前面的几篇里,我们都没有涉及需要从后台抓取数据来渲染的页面,这块可能是最复杂的部分了,本文里我们就来讨论这个问题 – Vue SSR 如何做后台数据预取。
我们面临什么样的难题?
如果一个页面需要从数据后台的 api 获取数据来渲染页面,那么从 SSR 的架构来说就要处理以下2个场景:
-
这个页面不是首屏
数据应该由前端去数据后台获取,并渲染页面。这个场景和普通的 client side Vue 项目基本一样。
-
这个页面是首屏
数据应该由后台获取,因为这个作为用户访问的首屏,是要由 server-bundle 渲染生成 index.html (壳子 HTML) 的。HTML 页面和 client-bundle 一起返回给浏览器后, 在浏览器端做 hydrate。
这问题就又来了,client-bundle 并没有后台的那份数据。数据都不一致,hydrate 根本无从谈起。怎么办呢?可能 2 个思路:
- client-bundle 再去后台拿一次。细想一下完全不可行。首先,这是对数据后台的重复开销,能避免就应该避免。其次,对同一个 api 接口的2次调用,未必能得到同样的数据(比如获取论坛最新帖子的api),数据不同的话,hydrate 还是会失败。
- 把后台的这份数据想办法返回给前台。Bingo!Vue 的 SSR 就是这么做的。(我想可能所有的 SSR 方案都得这么做)
余文中我们主要分析场景2,即这个页面是首屏的场景。这个页面不是首屏的场景相对简单,我们下一篇再说。
Vue SSR 的方案
数据在 server-bundle 和 client-bundle 之间共享,看到数据共享几个字眼,可能大家就很自然的想起来 Vuex
. Vue 的 SSR 中也确实是以 Vuex 为基础的。如大致的流程,上图所示:
- 用户通过浏览器访问 SSR 网站时,server-bundle 会根据用户的 url 导航到相应页面,并从数据后台取到这个页面所需要的数据。(数据被存储在 Vuex store 里)
- server-bundle 根据 Vuex store 里的数据对 index.html(即壳子HTML) 做渲染. 这个 index.html 的特别之处在于 Vue store 的内容会在序列化后存在其中。(上图中选中的部分)
- 页面返回给浏览器后,client-bundle 开始初始化,它会读取 index.html 中这份序列化的 Vue store,并用这个值来初始化自己的 Vuex store,进而完成客户端的 hydrate.
方案很完美,但是为了做到前后台代码同构,实现还是挺复杂的。
Vue SSR 实现
-
创建一个 Vuex store,并按老规矩使用工厂函数。
// store/index.js import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) import { Order } from '../api' export function createStore() { return new Vuex.Store({ state: { orders: [] }, mutations: { setOrders(state, orders) { state.orders = orders } }, actions: { async getOrders({ commit }) { let orders = await Order.all() // http 的api commit('setOrders', orders) } } }) }
-
修改 app.js 以引入 Vuex
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router/index' + import { createStore } from './store/index' + import { sync } from 'vuex-router-sync' // 导出一个工厂函数,用于创建新的 // 应用程序、router 和 store 实例 export function createApp () { // create the router instance const router = createRouter() + const store = createStore() + sync(store, router) const app = new Vue({ // 根实例简单的渲染应用程序组件。 router, + store, render: h => h(App) }) + return { app, router, store } }
除了使用工厂函数外,这个和 client-only 的 Vue 项目并无区别,就不多解释啦。
-
改造需要获取异步数据的 Vue 组件
// Test.vue <template> <div> I am test <div v-for='order in orders' :key='order.id'> <p></p> <p></p> </div> </div> </template> <script> export default { ! asyncData({ store, route }) { ! return store.dispatch('getOrders') ! }, computed: { orders() { return this.$store.state.orders } } } </script>
Vue SSR 的处理方式是给页面的 Vue 对象加一个静态的 asyncData 函数,专门用于去获取数据。
-
改造 server Bundle
import { createApp } from './app' export default context => { return new Promise((resolve, reject) => { // *** router.onReady(() => { // *** + const matchedComponents = router.getMatchedComponents() + // 执行所有组件的 asyncData 方法, 从而预期数据 + Promise.all(matchedComponents.map(Component => { + if (Component.asyncData) { + return Component.asyncData({ + store, + route: router.currentRoute + }) + } else { + return new Promise(resolve => { + resolve() + }) + } + })).then(() => { + // 在所有预取钩子(preFetch hook) resolve 后, + // 我们的 store 现在已经填充入渲染应用程序所需的状态。 + // 当我们将状态附加到上下文, + // 并且 `template` 选项用于 renderer 时, + // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 + context.state = store.state + //Promise 应该 resolve 应用程序实例,以便它可以渲染 + resolve(app) + }) }, reject) }) }
路由 ready 之后,我们从路由对象里去除所有的组件,然后遍历所有的这些组件, 并检查组件是否有
asyncData
的静态方法,如果有,则用store
和currentRoute
调用之,等待所有的调用成功后,取出 Vuex store 里 state 对象,并赋值给函数的参数context
.state 对象里是啥? 所有的
asyncData
方法都是基于 Vuex 实现,那么这一番调用后,这个页面所有的组件需要的数据就都在 Vuex store 的 state 里了。- 改造 server.js
// server.js // *** createApp(context) // context 会被 createApp 添加一个 store 属性,renderToString 的时候 init store 会被注入到 html 页面中 .then(app => { renderer.renderToString(app, context, (err, html) => { // *** })
这个 context 经过
createApp
的处理后已经多了个state
属性. renderToString 会判断 context 是否有 state 的属性,如果有则把 state 做序列化然后在 index.html 里插入如下的代码:<script> window.__INITIAL_STATE__ = state序列化的值。 </script>
有兴趣的同学可以去看下 readerToString 的源码
- 改造 client-bundle
index.html
已经 inject 了服务器端的 store 的 state 了,client 端拿这个值去初始化自己的 store 就可以啦。// client-bundle.js const { app, router, store } = createApp() + if (window.__INITIAL_STATE__) { + store.replaceState(window.__INITIAL_STATE__) + }
至此,场景2,这个页面是首屏的场景,得到了彻底的解决。完整的代码请参看 commit.
总结
以上就是 Vue SSR 处理首屏需要加载数据问题的思路和方案。基本上就是用 Vuex 为容器,以 index.html (序列化 state) 为载体来共享前后台数据,这个过程被 Vue SSR 官方文档形象的称为服务端数据预取。不过目前我们只解决了场景1,并没有解决场景2(页面不是首屏)。
我们可以这样体验一些场景2的问题,运行上面提到的示例代码后,我们首先访问 Home 页,然后跳转到 Test 页,此时 Test 页面不会加载数据。
下一篇,我们再来讨论这个问题。
系列文章: