Vue同構(三): 狀態與資料

請叫我王磊同學發表於2018-08-22

前言

  首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法變現,堅持下去也是靠的是自己的熱情和大家的鼓勵。各位讀者的Star是激勵我前進的動力,請不要吝惜。

  Vue同構系列的文章已經出到第三篇了,前兩篇文章Vue同構(一): 快速上手Vue同構(二):路由與程式碼分割都取得了不錯的反響(可能是錯覺),前兩篇文章本質上講了如何在服務端渲染中使用Vue與Vue Router,基本的Vue全家桶中除了Vuex還沒有講,這篇文章也是圍繞這個主題來講的。

引子

  一直很認同Redux作者Dan Abramov的一句話:

Flux 架構就像眼鏡:你自會知道什麼時候需要它。

  其中頗有幾分“只可意會不可言傳”的感覺,我們先來看看什麼情況下我們需要在服務端渲染中引入Vuex?

  前面的兩篇文章的例子都足夠的簡單,然而實際的業務場景並不會如此的簡單。比如我們想要渲染的是文章的列表,那我們肯定需要向資料來源請求資料。在客戶端渲染中,這一切太稀疏平常了。你可能馬上會想到在元件的生命週期mounted方法中去請求非同步的資料介面,然後將請求的資料賦值給Vue的響應式資料,Vue會自動重新整理介面,一切都是如此的完美,比如像下面的例子:

<template>
    // ......省略
</template>
<script>
    export default {
        data: function(){
            return {
                items: []
            }
        },
        
        mounted: function(){
            // 我們並不關心請求介面的具體實現邏輯
            fetchAPI().then(data => {
                // 賦值
                this.items = data.items;
            })
        }
    }
</script>
複製程式碼

  但是到了伺服器渲染中,你想這麼幹是鐵定行不通了,因為在服務端壓根就不會執行到mounted的生命週期中,我們之前說過在伺服器端Vue的例項僅僅只會執行生命週期函式beforeCreatecreated,那麼我們把資料請求的邏輯放置在這個兩個生命週期中是否可行呢?答案是不可以的,因為資料請求的操作是非同步的,我們並不能預期什麼時候資料能返回。並且我們還需要考慮到,不僅服務端在渲染介面的時候需要資料,客戶端也需要首屏頁面的資料,因為客戶端需要對其進行啟用,難道我們需要分別在服務端和服務端兩次請求同一份資料嗎?那麼無論是伺服器還是資料來源都會壓力陡增,肯定不是我們所希望看到的。

  其實解決方案還是比較明確的:資料和元件分離,我們在伺服器渲染元件之前就將資料準備好並放置在容器中,因此伺服器渲染的過程中就可以直接從容器中拿現成的資料渲染。不僅如此,我們可以將該容器中的資料直接序列化,注入到請求的HTML中,這樣客戶端啟用元件的時候,也能直接拿到相同的資料進行渲染,不僅僅能減少相同的資料的請求並且還可以防止因為請求資料的不相同導致的啟用失敗從而客戶端重新渲染(開發模式下,生產模式下不會檢測,則啟用就會出錯)。那誰來擔任資料容器的職責呢,顯然就是我們今天講的Vuex了。

服務端資料預取

  我們接著在上一篇文章中程式碼的構建配置基礎上開始我們的嘗試(文末會有程式碼連結),首先我們來說說我們目標,我們借用CNode提供的文章介面,然後在介面中渲染出不同標籤下的文章列表,不同路由標籤之間切換可以載入不同的文章列表。我們使用axios作為Node服務端和瀏覽器客戶端通用的HTTP請求庫。先寫介面, CNode給我們提供瞭如下的介面:

GET URL: cnodejs.org/api/v1/topi…

引數: page Number 頁數 引數: tab 主題分類。目前有 ask share job good 引數: limit Number 每一頁的主題數量

  我們這次就選三個tab主題分別使用,分別是精華(good)、分享(share)、問答(ask)

  首先對元件提供介面:

// api/index.js
import axios from "axios";

export function fetchList(tab = "good") {
    const url = `https://cnodejs.org/api/v1/topics?limit=20&tab=${tab}`;
    return axios.get(url).then((data)=>{
        return data.data;
    })
}
複製程式碼

  作為演示我們僅渲染前20條資料。

  接下來我們引入Vuex,之前兩篇文章都提到了我們需要為每次請求都生成新的Vue與Vue Router例項,其根本原因是防止不同請求之間資料共享導致的狀態汙染。Vuex也是相同的原因,我們需要為每次請求都生成新的Vuex例項。

import Vue from 'vue'
import Vuex from 'vuex'

import { fetchList } from '../api'

Vue.use(Vuex)

export function createStore() {
    return new Vuex.Store({
        state: {
            good: [],
            ask: [],
            share: []
        },

        actions: {
            fetchItems: function ({commit}, key = "good") {
                return fetchList(key).then( res => {
                    if(res.success){
                        commit('addItems', {
                            key,
                            items: res.data
                        })
                    }
                })
            }
        },

        mutations: {
            addItems: function (state, payload) {
                const {key, items} = payload;
                state[key].push(...items);
            }
        }
    })
}
複製程式碼

  這裡我們假設你已經對Vuex有所瞭解,首先我們呼叫Vue.use(Vuex)將Vuex注入到Vue中,然後每次呼叫createStore都會返回新的Vuex例項,其中state中包含goodaskshare陣列用來儲存對應主題的文章資訊。 名為addItemsmutation負責向state中對應的陣列中增加資料,而名為fetchItemsaction則負責呼叫非同步介面請求資料並更新對應的mutation

  那我們什麼時候呼叫fetchItems是需要考慮一下。特定路由對應於特定的元件,而特定的元件則需要特定資料做渲染。我們說過的實現邏輯是在元件渲染前就獲取到所用的資料,在純客戶端渲染的程式中我們將請求的邏輯放置在對應元件的生命週期中,在服務端渲染中,我們仍然將該邏輯放置在元件內,這樣,不僅在服務端渲染的時候通過匹配的元件就能執行其請求資料的邏輯,並且在客戶端啟用後,元件內部也可以在必要的時刻中執行邏輯去請求或者更新資料。我們看例子:

// TopicList.vue
<template>
    <div>
        <div v-for="item in items">
            <span>{{ item.title }}</span>
            <button @click="openTopic(item.id)">開啟</button>
        </div>
    </div>
</template>

<script>
    export default {
        name: "topic-list",
        
        asyncData: function ({ store, route}) {
            // 演示邏輯,不想多次載入資料
            if(store.state[route.params.id].length <=0){
                return store.dispatch("fetchItems", route.params.id)
            }else {
                return Promise.resolve()
            }
        },

        computed: {
            items: function () {
                return this.$store.state[this.$route.params.id];
            }
        },

        methods: {
            openTopic: function (id) {
                window.open(`https://cnodejs.org/topic/${id}`)
            }
        }
    }
</script>

<style scoped>
</style>
複製程式碼

  Vue元件的模板不需要解釋,之所以增加button按鈕來開啟對應文章的連結主要是想驗證客戶端是否正確啟用。該元件從store中獲取資料,其中routeid表示文章的主題。最與眾不同的是,該元件我們對外暴露了一個自定義的靜態函式asyncData,因為是元件的靜態函式,因此我們可以在元件都沒建立例項之前就呼叫方法,但是因為還未建立例項,因此函式內部不能訪問thisasyncData內部邏輯是觸發store中的fetchItemsaction

  接下來我們看路由的配置:

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export function createRouter() {
    return new Router({
        mode: "history",
        routes: [{
            path: '/good',
            component: () => import('../components/TopicListCopy.vue')
        },{
            path: '/:id',
            component: () => import('../components/TopicList.vue')
        }]
    })
}
複製程式碼

  我們給good路由配置了特殊的TopicListCopy元件,他與TopicList除了名字之外,其他的全部一樣,其他的路由我們使用前面介紹的TopicList元件,之所以要這麼做主要是出於方便後面介紹其中的操作。

  然後我們看一下應用的入口app.js:

import Vue from 'vue'

import { createStore } from './store'
import { createRouter } from './router'

import App from './components/App.vue'

export function createApp() {

    const store = createStore()
    const router = createRouter()
    
    const app =  new Vue({
        store,
        router,
        render: h => h(App)
    })

    return {
        app,
        store,
        router
    }
}
複製程式碼

  和之前的程式碼大致相同,只不過在每次呼叫createApp函式的時候,建立Vuex的例項store,並給Vue例項注入store例項。

  接下來看服務端渲染的入口entry-server.js:

// entry-server.js
import { createApp } from './app'

export default function (context) {
    return new Promise((resolve, reject) => {
        const {app, store, router} = createApp()
        router.push(context.url)
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            if(matchedComponents.length <= 0){
                return reject({ code: 404 })
            }else {
                Promise.all(matchedComponents.map((component) => {
                    if(component.asyncData){
                    
                        return component.asyncData({
                            store,
                            route: router.currentRoute
                        })
                    }
                })).then(()=> {
                    context.state = store.state
                    resolve(app)
                })
            }
        }, reject)
    })
}
複製程式碼

  服務端的渲染入口檔案和之前的結構基本保持一致,onReady會在所有的非同步鉤子函式非同步元件載入完畢之後執行傳遞的回撥函式。上篇文章是在onReady回撥函式中直接執行了resolve(app)將對應的元件例項傳遞。但是在這裡我們做了一些其他的工作。首先我們呼叫了router.getMatchedComponents()獲取了當前路由匹配的路由元件,注意我們這裡匹配的路由元件並不是例項而僅僅只是配置物件,然後我們呼叫所有匹配的路由元件中的asyncData靜態方法,載入各個路由元件所需的資料,等到所有的路由元件的資料都載入完畢之後,將當前store中的state賦值給context.stateresolve了元件例項。需要注意的是,這時store中存有首屏渲染元件所需的所有資料,我們將其值賦值給context.state,renderer如果使用的是template的話,會將狀態序列化並通過注入HTML的方式儲存到window.__INITIAL_STATE__上。

  接下來我們看瀏覽器渲染入口entry-client.js:

//entry-client.js
import { createApp } from './app'

const {app, store, router} = createApp();


if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    app.$mount('#app')
})
複製程式碼

  瀏覽器啟用的邏輯也和上篇文章相類似,唯一不同的是,我們在一開始就呼叫replaceStatestore中的狀態state替換成window.__INITIAL_STATE__,這樣客戶端直接可以用此資料啟用避免二次請求。

  與上一篇文章中的程式碼相比,伺服器的server.js程式碼保持一致,沒有其他的修改。現在我們打包看一下我們程式的效果:

Vue同構(三): 狀態與資料

  我們發現服務端獲取了資料渲染了文章列表並且點選右側的按鈕可以開啟文章的連結,說明客戶端已經被正確的啟用。但是當我們在不同路由之間進行切換的時候,發現其他的主題並沒有載入,這是因為我們只寫了服務端渲染中的資料獲取,而在客戶端中不同的路由切換對應的資料載入應該是客戶端獨立請求的。因此我們需要新增這部分的邏輯。

  之前我們已經說過,我們把資料請求的邏輯預置在元件的靜態函式asyncData中,客戶端的請求的走這個邏輯,那麼客戶端應該在什麼時候去呼叫這個函式呢?

客戶端請求

  官方文件中給出兩個思路,一個是在路由導航之前就解析好資料。一個是在檢視渲染後再請求資料

先請求再渲染

  先請求資料,等到資料請求完畢之後,再渲染元件,要實現這個邏輯我們要藉助Vue Router中的beforeResolve解析守衛,在所有元件內守衛和非同步路由元件被解析之後,beforeResolve解析守衛就被呼叫。讓我們改造一下客戶端渲染入口邏輯:

import { createApp } from './app'

const {app, store, router} = createApp();

if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    router.beforeResolve((to, from, next) => {
        const matched = router.getMatchedComponents(to)
        const prevMatched = router.getMatchedComponents(from)
        // 我們只關心非預渲染的元件
        // 所以我們對比它們,找出兩個匹配列表的差異元件
        let diffed = false
        const activated = matched.filter((c, i) => {
            return diffed || (diffed = (prevMatched[i] !== c))
        })
        if (!activated.length) {
            return next()
        }
        // 這裡如果有載入指示器(loading indicator),就觸發
        Promise.all(activated.map(c => {
            if (c.asyncData) {
                return c.asyncData({ store, route: to })
            }
        })).then(() => {
            // 停止載入指示器(loading indicator)
            next()
        }).catch(next)
    })
    app.$mount('#app')
})
複製程式碼

  上面的beforeResolve中的程式碼邏輯,首先比較tofrom路由的匹配路由元件,然後找出兩個匹配列表的差異元件,再呼叫所有差異元件中的asyncData去獲取資料,待所有資料獲取到後,呼叫next繼續執行。

  這時候我們打包並執行程式,我們發現good切換到ask或者share是可以載入資料的,但是askshare切換是沒法載入資料的,如下圖:

Vue同構(三): 狀態與資料

  這是為什麼呢?還記得我們之前專門為good路由設定了TopicListCpoy路由元件,為shareask路由設定了TopicList路由元件,因此shareask切換過程中而且並不存在差異元件,只是路由引數發生了變化。為了解決這個問題,我們增加元件內守衛解決這個問題:

beforeRouteUpdate: function (to, from, next) {
    this.$options.asyncData({
        store: this.$store,
        route: to
    });
    next()
}
複製程式碼

  元件守衛beforeRouteUpdate會在當前路由改變,但是仍然屬於該元件被複用時呼叫,比如動態引數發生改變的時候,beforeRouteUpdate就會被呼叫。這時我們執行載入資料的邏輯,問題就會得到解決。在使用先預取資料,再載入元件的方式存在一個易見的問題就是會感受到明顯的卡頓感,因為你不能保證資料什麼時候能請求結束,如果請求資料時間過長而導致元件遲遲不能渲染,使用者體驗就會大打折扣,因此建議在載入的過程中提供一個統一的載入指示器,來儘量降低帶來的互動體驗下降。

先渲染再請求

  先渲染元件再請求資料的邏輯比較接近與純客戶端渲染的邏輯,我們將資料預取的邏輯放置在元件的beforeMount或者mounted生命週期函式中,路由切換之後,元件會被立即渲染,但是會存在渲染元件時不存在完整資料,因此這個元件內部自身需要提供相應載入狀態。資料預取的邏輯可以在每個路由元件單獨呼叫,當然也可以通過Vue.mixin的方式全域性實現:

Vue.mixin({
    beforeMount () {
        const { asyncData } = this.$options
        if (asyncData) {
            asyncData({
                store: this.$store,
                route: this.$route
            })
        }
    }
})
複製程式碼

  當然這種也會存在我們前面說過的,路由切換但是元件複用的情況,因此僅僅只在beforeMount做操作做資料獲取是不夠的,我們在路由引數發生改變但是元件複用的情況下,也應該去請求資料,這個問題仍然可以通過元件守衛beforeRouteUpdate來處理。

  到此為止我們已經介紹瞭如何在伺服器渲染中處理資料和預覽的問題,需要看原始碼的同學請移步到這裡。如果有表達不正確的地方,歡迎指出,希望大家關注我的Github部落格以及接下來的系列文章。

相關文章