Vue同構(三): 狀態與數據

前言

  首先歡迎你們關注個人Github博客,也算是對個人一點鼓勵,畢竟寫東西無法變現,堅持下去也是靠的是本身的熱情和你們的鼓勵。各位讀者的Star是激勵我前進的動力,請不要吝惜。javascript

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

引子

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

Flux 架構就像眼鏡:你自會知道何時須要它。node

  其中很有幾分「只可意會不可言傳」的感受,咱們先來看看什麼狀況下咱們須要在服務端渲染中引入Vuex?ios

  前面的兩篇文章的例子都足夠的簡單,然而實際的業務場景並不會如此的簡單。好比咱們想要渲染的是文章的列表,那咱們確定須要向數據源請求數據。在客戶端渲染中,這一切太稀疏日常了。你可能立刻會想到在組件的生命週期mounted方法中去請求異步的數據接口,而後將請求的數據賦值給Vue的響應式數據,Vue會自動刷新界面,一切都是如此的完美,好比像下面的例子:git

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

  可是到了服務器渲染中,你想這麼幹是鐵定行不通了,由於在服務端壓根就不會執行到mounted的生命週期中,咱們以前說過在服務器端Vue的實例僅僅只會執行生命週期函數beforeCreatecreated,那麼咱們把數據請求的邏輯放置在這個兩個生命週期中是否可行呢?答案是不能夠的,由於數據請求的操做是異步的,咱們並不能預期何時數據能返回。而且咱們還須要考慮到,不只服務端在渲染界面的時候須要數據,客戶端也須要首屏頁面的數據,由於客戶端須要對其進行激活,難道咱們須要分別在服務端和服務端兩次請求同一份數據嗎?那麼不管是服務器仍是數據源都會壓力陡增,確定不是咱們所但願看到的。github

  其實解決方案仍是比較明確的:數據和組件分離,咱們在服務器渲染組件以前就將數據準備好並放置在容器中,所以服務器渲染的過程當中就能夠直接從容器中拿現成的數據渲染。不只如此,咱們能夠將該容器中的數據直接序列化,注入到請求的HTML中,這樣客戶端激活組件的時候,也能直接拿到相同的數據進行渲染,不只僅能減小相同的數據的請求而且還能夠防止由於請求數據的不相同致使的激活失敗從而客戶端從新渲染(開發模式下,生產模式下不會檢測,則激活就會出錯)。那誰來擔任數據容器的職責呢,顯然就是咱們今天講的Vuex了。vue-router

服務端數據預取

  咱們接着在上一篇文章中代碼的構建配置基礎上開始咱們的嘗試(文末會有代碼連接),首先咱們來講說咱們目標,咱們借用CNode提供的文章接口,而後在界面中渲染出不一樣標籤下的文章列表,不一樣路由標籤之間切換能夠加載不一樣的文章列表。咱們使用axios做爲Node服務端和瀏覽器客戶端通用的HTTP請求庫。先寫接口, CNode給咱們提供了以下的接口:vuex

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

參數: 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代碼保持一致,沒有其餘的修改。如今咱們打包看一下咱們程序的效果:

  咱們發現服務端獲取了數據渲染了文章列表而且點擊右側的按鈕能夠打開文章的連接,說明客戶端已經被正確的激活。可是當咱們在不一樣路由之間進行切換的時候,發現其餘的主題並無加載,這是由於咱們只寫了服務端渲染中的數據獲取,而在客戶端中不一樣的路由切換對應的數據加載應該是客戶端獨立請求的。所以咱們須要添加這部分的邏輯。

  以前咱們已經說過,咱們把數據請求的邏輯預置在組件的靜態函數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切換是無法加載數據的,以下圖:

  這是爲何呢?還記得咱們以前專門爲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博客以及接下來的系列文章。

相關文章
相關標籤/搜索