Vue.js專案重構,輕鬆實現上拉載入滾動位置還原

狼族小狽發表於2017-06-19

前言

上一篇《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進行狀態管理的完整專案,包含了使用者的登入退出,路由頁面,滾動位置還原,帖子編輯狀態儲存等等,麻雀雖小,卻是五臟俱全。

相關文章