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

前言

  首先欢迎你们关注个人Github博客,也算是对个人一点鼓励,毕竟写东西无法变现,坚持下去也是靠的是本身的热情和你们的鼓励。各位读者的Star是激励我前进的动力,请不要吝惜。javascript

  Vue同构系列的文章已经出到第三篇了,前两篇文章Vue同构(一): 快速上手Vue同构(二):路由与代码分割都取得了不错的反响(多是错觉),前两篇文章本质上讲了如何在服务端渲染中使用Vue与Vue Router,基本的Vue全家桶中除了Vuex尚未讲,这篇文章也是围绕这个主题来说的。vue

引子

  一直很认同Redux做者Dan Abramov的一句话:java

Flux 架构就像眼镜:你自会知道何时须要它。

  其中很有几分“只可意会不可言传”的感受,咱们先来看看什么状况下咱们须要在服务端渲染中引入Vuex?node

  前面的两篇文章的例子都足够的简单,然而实际的业务场景并不会如此的简单。好比咱们想要渲染的是文章的列表,那咱们确定须要向数据源请求数据。在客户端渲染中,这一切太稀疏日常了。你可能立刻会想到在组件的生命周期mounted方法中去请求异步的数据接口,而后将请求的数据赋值给Vue的响应式数据,Vue会自动刷新界面,一切都是如此的完美,好比像下面的例子:ios

<template>
    // ......省略
</template>
<script>
    export default {
        data: function(){
            return {
                items: []
            }
        },
        
        mounted: function(){
            // 咱们并不关心请求接口的具体实现逻辑
            fetchAPI().then(data => {
                // 赋值
                this.items = data.items;
            })
        }
    }
</script>

  可是到了服务器渲染中,你想这么干是铁定行不通了,由于在服务端压根就不会执行到mounted的生命周期中,咱们以前说过在服务器端Vue的实例仅仅只会执行生命周期函数beforeCreatecreated,那么咱们把数据请求的逻辑放置在这个两个生命周期中是否可行呢?答案是不能够的,由于数据请求的操做是异步的,咱们并不能预期何时数据能返回。而且咱们还须要考虑到,不只服务端在渲染界面的时候须要数据,客户端也须要首屏页面的数据,由于客户端须要对其进行激活,难道咱们须要分别在服务端和服务端两次请求同一份数据吗?那么不管是服务器仍是数据源都会压力陡增,确定不是咱们所但愿看到的。git

  其实解决方案仍是比较明确的:数据和组件分离,咱们在服务器渲染组件以前就将数据准备好并放置在容器中,所以服务器渲染的过程当中就能够直接从容器中拿现成的数据渲染。不只如此,咱们能够将该容器中的数据直接序列化,注入到请求的HTML中,这样客户端激活组件的时候,也能直接拿到相同的数据进行渲染,不只仅能减小相同的数据的请求而且还能够防止由于请求数据的不相同致使的激活失败从而客户端从新渲染(开发模式下,生产模式下不会检测,则激活就会出错)。那谁来担任数据容器的职责呢,显然就是咱们今天讲的Vuex了。github

服务端数据预取

  咱们接着在上一篇文章中代码的构建配置基础上开始咱们的尝试(文末会有代码连接),首先咱们来讲说咱们目标,咱们借用CNode提供的文章接口,而后在界面中渲染出不一样标签下的文章列表,不一样路由标签之间切换能够加载不一样的文章列表。咱们使用axios做为Node服务端和浏览器客户端通用的HTTP请求库。先写接口, CNode给咱们提供了以下的接口:vue-router

GET
URL: https://cnodejs.org/api/v1/to...

参数: page Number 页数
参数: tab 主题分类。目前有 ask share job good
参数: limit Number 每一页的主题数量vuex

  咱们此次就选三个tab主题分别使用,分别是精华(good)、分享(share)、问答(ask)axios

  首先对组件提供接口:

// 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博客以及接下来的系列文章。

相关文章
相关标签/搜索