Vue-SSR踩坑小记

写在前面:本文只是Vue-SSR的踩坑记录,Vue-SSR的具体介绍情看官方文档及官方HackerNews demo.

前言

服务端渲染技术其实早已存在,早期的静态内容站点(如博客等)大多采用后端模板+上下文来进行服务端渲染。随着前端框架的兴起(React、Vue、Angular等)和网络性能的提升,趋势又逐渐转移到客户端渲染。然而,服务端渲染还是有着两个重要的优势:SEO友好首屏渲染速度快

踩坑小记

让人又爱又恨的官方文档与示例:

截止到目前,Vue-SSR的官方详细文档的中文版已经出炉,配合官方的HackerNews示例基本可以解决大部分的SPA问题。主要问题出在数据预取上。(不过其实Vue-SSR的核心用法已在基本用法中说明清楚,而数据预取的方案可以有很多,官方方案只是其中的一个可选的解决方案而已)

1.官方数据预取(preFetch)方案严重依赖于vue-router和vuex
官方文档的思路是,通过vue-router找到匹配的component,从而调用对应的asyncData方法(也可以叫别的名字)。
因为页面比较简单的缘故,一开始不想引入vue-router和vuex。如果不配合vue-router的话则需要手动指定调用哪些组件的数据请求方法(asyncData)。
之后发现页面还是引入vue-router比较好,不过vuex还是算了吧。然后又踩到了另一个坑—数据预取时组件尚未实例化(this无法使用),也就是说一般的在createdbeforeMounted等生命周期钩子中调用methods里的方法请求数据并格式化的思路走不通,数据请求及格式化等操作都应该独立于组件(如通过vuex来完成)。不过这也比较符合组件专注于展示的哲学就是了。如果实在不想使用vuex,我当时的思路是可以自己实现一个数据处理的中心,获取完数据后调用根组件的方法注入根组件的data中,子组件通过计算属性(computed)获取根组件的data(this.$root.data)。不过试着这样写了一下之后,嫌太麻烦,并且考虑到未来页面可能复杂度会提高,最后还是“不争气”地用上了vuex(笑)

PS: 写这篇文章时无意中发现大概一年前题叶也吐槽过服务端抓取数据的方案还不够优雅。(见这里

2.使用了vue-router后,后端路由记得作出相应的配合。
如果使用了vue-router的history模式,注意给后端也配上相应的路由,以免找不到相应的页面。
另外,如果前端路由用了base选项,数据预取时要记得去掉base部分。通过前端路由寻找匹配的组件时,用到了url,而该url是对应后端路由的,直接router.push会找不到相应的组件。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 后端路由
// ...省略无关代码
app.get('/baseURL/a', someController);
// 前端路由
export default function createRouter() {
return new Router({
base: '/baseURL',
routes: [
{ path: '/a', component: aComponent },
],
});
}
// entry-service.js
// 此处若直接使用请求的url(即'/baseURL/a')会报错,要用'/a'才能成功匹配
router.push(context.url.replace('/baseURL', '');
router.onReady(() => {
// 找到路由对应组件后的操作
});

3.前后端请求的差异化处理
虽然使用了官方推荐的可前后端复用的axios,但是因为api写在同一个node层里,前后端的请求也就有所不同了。例如,前端请求的是相对路径'/api?key=value',后端如果直接这样请求就会变成'http://null/api?key=value',显然这样是会请求失败的。
看了vue-hackernews的处理:调用的是firebase封装的api(emmmm….),避开了这个问题
解决方案自然就是在服务器端请求api时加上相应的host和port(甚至header)了。我的做法是在state里面加上reqHost和header,服务端渲染时通过commit设置reqHost和header,然后数据预取完之后重置reqHost和header为空。

尽量减少对Vue-SSR的依赖

SSR虽然有着优化SEO和加快首屏渲染等优点,但对服务端的压力也相当的大。虽然Vue能服务端渲染,但不一定要用它来进行服务端渲染。而且作为新技术,生产环境的使用还有待考验。作为一名优秀的搬砖工,应该在编码时就做好一键切换SSR的准备(误),以便于突发情况下的紧急回退。
主要要注意的有下面几点:
1.数据预取方法的进一步抽离和复用
关掉SSR之后,数据的初始化可能就要放在mounted等生命周期钩子里了。为了不在开了SSR的时候重复获取数据,我的做法是把asyncData的内容抽出来,放在一个函数里,并检查是否已获取过数据(判断依据视数据不同而不同)。然后分别在asyncData和mounted中调用同一方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
function fetchData(store) {
// 如果未获取过数据,则dispatch action
if (...) {
return store.dispatch(xxx);
}
// 获取过则直接return
return Promise.resolve();
}
export default {
asyncData({ store }) {
return fetchData(store);
},
// ...省略无关代码
mounted() {
return fetchData(this.$store);
},
}
<script>

2.手动完成state的初始化
为了保证store的一致性,Vue-SSR会将服务端渲染的state挂载在window.__INITIAL_STATE__上,在client-entry.js中调用store.replaceState(window.__INITIAL_STATE__);,保证客户端和服务端的state一致。
要减少对Vue-SSR的依赖的话,应该是把Vue-SSR渲染出的html插入到后端模板里,再进一步渲染出html:

1
2
3
4
5
6
7
8
9
<!-- 以nunjucks模板为例 -->
<body>
{% if SSRHtml%}
{{SSRHtml|safe}}
{% else %}
<!-- 用于在关闭SSR后挂载Vue实例 -->
<div id="app"></div>
{% endif %}
</body>

这里有个问题就是,需要renderer使用了template,state才会自动注入windows.__INITIAL_STATE__。然而我们又想保持用普通的后端模板渲染html,这样windows.__INITIAL_STATE__又会为空,该怎么办呢?
答案其实看一眼Vue-hackernews 的head就知道了。
其实所谓的自动注入,其实也是直接写在html里拼进去的…

1
2
3
+`<script>
window.__INITIAL_STATE__=${serialize(context.initialState, { isJSON: true })
</script>`

这里的serialize()其实和JSON.stringify()差不多,不过能把正则表达式和函数也序列化,在某些时候(例如路由匹配的正则)会需要这些能力。一定程度上可以直接用JSON.stringify()代替。
知道了原理之后,我们就可以在renderer不用template的情况下手动将初始化的动态数据注入到html中啦~

数据预取时的异常处理

官方文档给出的服务端数据预取方案如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// entry-server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}

注意到在异常时,这里直接把错误reject了出去:.catch(reject), 然而这里的Promise.all只进行数据预取操作,所以这里的错误只会是请求数据错误(接口错误)。而此时app仍旧是能用的,只是数据取不到了。
为了避免接口错误就导致整个页面崩溃,我们可以仍旧将没问题的app resolve出去进行render,然后在store中放入缺省数据,保证页面有内容显示:

1
2
3
4
5
6
7
8
9
10
11
12
Promise.all().then(() => {
context.state = store.state;
resolve({
app,
context, // 如果需要手动挂在store,要在此处将挂载了state的context返回
})
}).catch(() => {
resolve({
app, // app不受影响,正常返回用作render
context, // 外层检查是否有context.state, 没有则代表预取失败,采用缺省数据
});
})

多页面应用的解决方案?

最后的还有一个问题就是,官方推荐的优点多多的createBundleRenderer似乎只能生成单个json文件,但对于多页面应用,出于选择性渲染的考虑,显然生成单个bundle.json文件是不划算的。但目前暂时没找到根据页面生成多个bundle的方法,只能暂时采用普通的createRenderer来配合App实例渲染了。
(官方推荐的Nuxt.js就是一个多页面服务端渲染的解决方案,说明应该还是有办法解决这个问题的,还有待进一步研究)

结语

目前只是把SSR用在了普通的页面而不是SPA上,也没有应用缓存,路由级代码分割等特性,所以踩到的坑暂时就只有这些(相信不止这些)。但解决方案都是一致的,就是理解其原理,解决方案就自动出来了(感觉好废话)。但像其对vm模块的应用这些更深层次的原理还有待进一步研究,相信如果进一步使用Vue-SSR的话,这部分的内容也需要进一步的了解。