Vue同构(三): 状态与数据

2,116 阅读9分钟

前言

  首先欢迎大家关注我的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代码保持一致,没有其他的修改。现在我们打包看一下我们程序的效果:

  我们发现服务端获取了数据渲染了文章列表并且点击右侧的按钮可以打开文章的链接,说明客户端已经被正确的激活。但是当我们在不同路由之间进行切换的时候,发现其他的主题并没有加载,这是因为我们只写了服务端渲染中的数据获取,而在客户端中不同的路由切换对应的数据加载应该是客户端独立请求的。因此我们需要添加这部分的逻辑。

  之前我们已经说过,我们把数据请求的逻辑预置在组件的静态函数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博客以及接下来的系列文章。