V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
1340641314
V2EX  ›  分享创造

Vue.js 项目重构,轻松实现上拉加载滚动位置还原

  •  
  •   1340641314 ·
    lzxb · 2017-06-19 08:11:15 +08:00 · 3237 次点击
    这是一个创建于 2716 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    上一篇《Vue.js 轻松实现页面后退时,还原滚动位置》只是简单的实现了路由切换时进行的滚动位置还原,很多朋友就来问上拉加载怎么实现啊!于是我想起了以前做过一个叫vue-cnode的项目,于是花了两天时间进行了重构,完全的移除了 Vuex,使用了Vuet来做为状态的管理工具。如果关注Vuet的朋友就会发现,版本更新得好快,简直就是版本帝啊!!!其实Vuet的版本升级,都是向下兼容的,每次的版本发布都会经过完整的单元测试和 e2e 测试,极大的保证了发布版本的稳定性。

    项目源码

    需求分析

    • 记录上拉请求时的页数
    • 页面后退时,还原之前列表页面的状态
    • 列表分类切换时,进行状态重置
    • 从列表 A 点击详情 A,页面后退,重新打开详情 A,还原之前访问详情 A 状态
    • 从列表 A 点击详情 A,页面后退,重新打开详情 B,清除详情 A 的状态,初始化详情 B 的状态

    安装

    npm install --save vuet
    

    Vuet 实例

    import Vue from 'vue'
    import Vuet from 'vuet'
    import utils from 'utils'
    import http from 'http'
    
    Vue.use(Vuet)
    
    export default new Vuet({
      pathJoin: '-', // 定义模块的连接符
      modules: {
        topic: {
          create: {
            data () {
              return {
                title: '', // 标题
                tab: '', // 发表的板块
                content: '' // 发表的内容
              }
            },
            manuals: {
              async create ({ state }) {
                if (!state.title) {
                  return utils.toast('标题不能为空')
                } else if (!state.tab) {
                  return utils.toast('选项不能为空')
                } else if (!state.content) {
                  return utils.toast('内容不能为空')
                }
                const res = await http.post(`/topics`, {
                  ...state
                })
                if (res.success) {
                  this.reset()
                } else {
                  utils.toast(res.error_msg)
                }
                return res
              }
            }
          },
          /********* 实现列表上拉加载滚动位置还原的核心代码开始 *************/
          list: {
            data () {
              return {
                data: [], // 列表存储的数据
                loading: true, // 数据正在加载中
                done: false, // 数据是否已经全部加载完成
                page: 1 // 加载的页数
              }
            },
            async fetch ({ state, route, params, path }) {
              // 注,在 vuet 0.1.2 以上版本,会多带一个 params.routeWatch 参数,我们可以根据这个来判断页面是否发生了变化
              if (params.routeWatch === true) { // 路由发生了变化,重置模块状态
                this.reset(path)
              } else if (params.routeWatch === false) { // 路由没有变化触发的请求,可能是从详情返回到列表
                return {}
              }
              // params.routeWatch 没有参数,则是上拉加载触发的调用
              const { tab = '' } = route.query
              const query = {
                tab,
                mdrender: false,
                limit: 20,
                page: state.page
              }
              const res = await http.get('/topics', query)
              const data = params.routeWatch ? res.data : [...state.data, ...res.data]
              return {
                data, // 更新模块的列表数据
                page: ++state.page, // 每次请求成功后,页数+1
                loading: false, // 数据加载完成
                done: res.data.length < 20 // 判断列表的页数是否全部加载完成
              }
            }
          },
          /********* 实现列表上拉加载滚动位置还原的核心代码结束 *************/
          detail: {
            data () {
              return {
                data: {
                  id: null,
                  author_id: null,
                  tab: null,
                  content: null,
                  title: null,
                  last_reply_at: null,
                  good: false,
                  top: false,
                  reply_count: 0,
                  visit_count: 0,
                  create_at: null,
                  author: {
                    loginname: null,
                    avatar_url: null
                  },
                  replies: [],
                  is_collect: false
                },
                existence: true,
                loading: true,
                commentId: null
              }
            },
            async fetch ({ route }) {
              const { data } = await http.get(`/topic/${route.params.id}`)
              if (data) {
                return {
                  data,
                  loading: false
                }
              }
              return {
                existence: false,
                loading: false
              }
            }
          }
        },
        user: { // 登录用户的模块
          self: {
            data () {
              return {
                data: JSON.parse(localStorage.getItem('vue_cnode_self')) || {
                  avatar_url: null,
                  id: null,
                  loginname: null,
                  success: false
                }
              }
            },
            manuals: {
              async login ({ state }, accesstoken) { // 用户登录方法
                const res = await http.post(`/accesstoken`, { accesstoken })
                if (typeof res === 'object' && res.success) {
                  state.data = res
                  localStorage.setItem('vue_cnode_self', JSON.stringify(res))
                  localStorage.setItem('vue_cnode_accesstoken', accesstoken)
                }
                return res
              },
              signout () { // 用户退出方法
                localStorage.removeItem('vue_cnode_self')
                localStorage.removeItem('vue_cnode_accesstoken')
                this.reset()
              }
            }
          },
          detail: {
            data () {
              return {
                data: {
                  loginname: null,
                  avatar_url: null,
                  githubUsername: null,
                  create_at: null,
                  score: 0,
                  recent_topics: [],
                  recent_replies: []
                },
                existence: true,
                loading: true,
                tabIndex: 0
              }
            },
            async fetch ({ route }) {
              const { data } = await http.get(`/user/${route.params.username}`)
              if (data) {
                return {
                  data,
                  loading: false
                }
              }
              return {
                existence: false,
                loading: false
              }
            }
          },
          messages: {
            data () {
              return {
                data: {
                  has_read_messages: [],
                  hasnot_read_messages: []
                },
                loading: true
              }
            },
            async fetch () {
                // 用户未登录,拦截请求
              if (!this.getState('user-self').data.id) return
              const { data } = await http.get(`/messages`, { mdrender: true })
              return {
                data
              }
            },
            count: {
              data () {
                return {
                  data: 0
                }
              },
              async fetch () {
                // 用户未登录,拦截请求
                if (!this.getState('user-self').data.id) return
                const res = await http.get('/message/count')
                if (!res.data) return
                return {
                  data: res.data
                }
              }
            }
          }
        }
      }
    })
    
    

    Vuet实例创建完成后,我们就可以在组件中连接我们的Vuet了。

    • 首页列表
    <template>
      <div>
        <nav class="nav">
          <ul flex="box:mean">
    
            <li v-for="item in tabs" :class="{ active: item.tab === ($route.query.tab || '') }">
              <router-link :to="{ name: 'index', query: { tab: item.tab } }">{{ item.title }}</router-link>
            </li>
          </ul>
        </nav>
        <!-- 
            注意了,由于我的页面布局是一个局部滚动条,所以需要指定一个 name
            如果你的页面是全局滚动条,设置指令为
            v-route-scroll.window="{ path: 'topic-list' }"
        -->
        <v-content v-route-scroll="{ path: 'topic-list', name: 'content' }">
          <ul class="list">
            <li v-for="item in list.data" key="item.id">
              <router-link :to="{ name: 'topic-detail', params: { id: item.id } }">
                <div class="top" flex="box:first">
                  <div class="headimg" :style="{ backgroundImage: 'url(' + item.author.avatar_url + ')' }"></div>
                  <div class="box" flex="dir:top">
                    <strong>{{ item.author.loginname }}</strong>
                    <div flex>
                      <time>{{ item.create_at | formatDate }}</time>
                      <span class="tag">#分享#</span>
                    </div>
                  </div>
                </div>
                <div class="common-typeicon" flex v-if="item.top || item.good">
                  <div class="icon" v-if="item.good">
                    <i class="iconfont icon-topic-good"></i>
                  </div>
                  <div class="icon" v-if="item.top">
                    <i class="iconfont icon-topic-top"></i>
                  </div>
                </div>
                <div class="tit">{{ item.title }}</div>
                <div class="expand" flex="box:mean">
                  <div class="item click" flex="main:center cross:center">
                    <i class="iconfont icon-click"></i>
                    <div class="num">{{ item.visit_count > 0 ? item.visit_count : '暂无阅读' }}</div>
                  </div>
                  <div class="item reply" flex="main:center cross:center">
                    <i class="iconfont icon-comment"></i>
                    <div class="num">{{ item.reply_count > 0 ? item.reply_count : '暂无评论' }}</div>
                  </div>
                  <div class="item last-reply" flex="main:center cross:center">
                    <time class="time">{{ item.last_reply_at | formatDate }}</time>
                  </div>
                </div>
              </router-link>
            </li>
          </ul>
          <v-loading :done="list.done" :loading="list.loading" @seeing="$vuet.fetch('topic-list')"></v-loading>
        </v-content>
        <v-footer></v-footer>
      </div>
    </template>
    <script>
      import { mapModules, mapRules } from 'vuet'
    
      export default {
        mixins: [
          mapModules({ list: 'topic-list' }), // 连接我们定义的 Vuet.js 的状态
          mapRules({ route: 'topic-list' }) // 使用 Vuet.js 内置的 route 规则来对页面数据和滚动位置进行管理
        ],
        data () {
          return {
            tabs: [
              {
                title: '全部',
                tab: ''
              },
              {
                title: '精华',
                tab: 'good'
              },
              {
                title: '分享',
                tab: 'share'
              },
              {
                title: '问答',
                tab: 'ask'
              },
              {
                title: '招聘',
                tab: 'job'
              }
            ]
          }
        }
      }
    </script>
    
    • 页面详情
    <template>
      <div>
        <v-header title="主题">
          <div slot="left" class="item" flex="main:center cross:center" v-on:click="$router.go(-1)">
            <i class="iconfont icon-back"></i>
          </div>
        </v-header>
        <!--
            设置详情的局部滚动条
        -->
        <v-content style="bottom: 0;" v-route-scroll="{ path: 'topic-detail', name: 'content' }">
          <v-loading v-if="detail.loading"></v-loading>
          <v-data-null v-if="!detail.existence" msg="话题不存在"></v-data-null>
          <template v-if="!detail.loading && detail.existence">
            <div class="common-typeicon" flex v-if="data.top || data.good">
              <div class="icon" v-if="data.good">
                <i class="iconfont icon-topic-good"></i>
              </div>
              <div class="icon" v-if="data.top">
                <i class="iconfont icon-topic-top"></i>
              </div>
            </div>
    
            <ul class="re-list">
              <!-- 楼主信息 start -->
              <li flex="box:first">
                <div class="headimg">
                  <router-link class="pic" :to="{ name: 'user-detail', params: { username: author.loginname } }" :style="{ backgroundImage: 'url(' + author.avatar_url + ')' }"></router-link>
                </div>
                <div class="bd">
                  <div flex>
                    <router-link flex-box="0" :to="{ name: 'user-detail', params: { username: author.loginname } }">{{ author.loginname }}</router-link>
                    <time flex-box="1">{{ data.create_at | formatDate }}</time>
                    <div flex-box="0" class="num">#楼主</div>
                  </div>
                </div>
              </li>
              <!-- 楼主信息 end -->
              <!-- 主题信息 start -->
              <li>
                <div class="datas">
                  <div class="tit">{{ data.title }}</div>
                  <div class="bottom" flex="main:center">
                    <div class="item click" flex="main:center cross:center">
                      <i class="iconfont icon-click"></i>
                      <div class="num">{{ data.visit_count }}</div>
                    </div>
                    <div class="item reply" flex="main:center cross:center">
                      <i class="iconfont icon-comment"></i>
                      <div class="num">{{ data.reply_count }}</div>
                    </div>
                  </div>
                </div>
                <div class="markdown-body" v-html="data.content"></div>
              </li>
              <!-- 主题信息 end -->
              <li class="replies-count" v-if="replies.length">
                共(<em>{{ replies.length }}</em>)条回复
              </li>
              <!-- 主题评论 start -->
              <li v-for="(item, $index) in replies">
                <div flex="box:first">
                  <div class="headimg">
                    <router-link class="pic" :to="{ name: 'user-detail', params: { username: item.author.loginname } }" :style="{ backgroundImage: 'url(' + item.author.avatar_url + ')' }"></router-link>
                  </div>
                  <div class="bd">
                    <div flex>
                      <router-link flex-box="0" :to="{ name: 'user-detail', params: { username: item.author.loginname } }">{{ item.author.loginname }}</router-link>
                      <time flex-box="1">{{ item.create_at | formatDate }}</time>
                      <div flex-box="0" class="num">#{{ $index + 1 }}</div>
                    </div>
                    <div class="markdown-body" v-html="item.content"></div>
                    <div class="bottom" flex="dir:right cross:center">
                      <div class="icon" @click="commentShow(item, $index)">
                        <i class="iconfont icon-comment-topic"></i>
                      </div>
                      <div class="icon" :class="{ fabulous: testThing(item.ups) }" v-if="item.author.loginname !== user.data.loginname" @click="fabulousItem(item)">
                        <i class="iconfont icon-comment-fabulous"></i>
                        <em v-if="item.ups.length">{{ item.ups.length }}</em>
                      </div>
                    </div>
                  </div>
                </div>
                <reply-box v-if="detail.commentId === item.id" :loginname="item.author.loginname" :replyId="item.id"></reply-box>
              </li>
              <!-- 主题评论 end -->
            </ul>
            <div class="reply" v-if="user.data.id">
              <reply-box @success="$vuet.fetch('topic-detail')"></reply-box>
            </div>
            <div class="tip-login" v-if="!user.data.id">
              你还未登录,请先
              <router-link to="/login">登录</router-link>
            </div>
          </template>
        </v-content>
      </div>
    </template>
    <script>
      import http from 'http'
      import replyBox from './reply-box'
      import { mapModules, mapRules } from 'vuet'
    
      export default {
        mixins: [
          // 连接详情和登录用户模块
          mapModules({ detail: 'topic-detail', user: 'user-self' }),
          // 一样是使用 route 规则对页面的数据进行管理
          mapRules({ route: 'topic-detail' })
        ],
        components: { replyBox },
        computed: {
          data () {
            return this.detail.data
          },
          author () {
            return this.detail.data.author
          },
          replies () {
            return this.detail.data.replies
          }
        },
        methods: {
          testThing (ups) { // 验证是否点赞
            return ups.indexOf(this.user.data.id || '') > -1
          },
          fabulousItem ({ ups, id }) { // 点赞
            if (!this.user.data.id) return this.$router.push('/login')
            var index = ups.indexOf(this.user.data.id)
            if (index > -1) {
              ups.splice(index, 1)
            } else {
              ups.push(this.user.data.id)
            }
            http.post(`/reply/${id}/ups`)
          },
          commentShow (item) { // 显示隐藏回复框
            if (!this.user.data.id) return this.$router.push('/login')
            this.detail.commentId = this.detail.commentId === item.id ? null : item.id
          }
        }
      }
    
    </script>
    

    总结

    因为篇幅有限,所以只列出了列表和详情的代码,大家有兴趣深入的话,可以看下vue-cnode的代码。这是基于Vuet进行状态管理的完整项目,包含了用户的登录退出,路由页面,滚动位置还原,帖子编辑状态保存等等,麻雀虽小,却是五脏俱全。

    1 条回复    2017-06-19 08:16:32 +08:00
    zaxlct
        1
    zaxlct  
       2017-06-19 08:16:32 +08:00 via Android
    Good
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5885 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 06:06 · PVG 14:06 · LAX 22:06 · JFK 01:06
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.