为了追求更好的用户体验,很多公司的业务页面都会采用SSR进行渲染,将渲染的结果返回给浏览器,浏览器能够不经过实例化Vue,直接解析HTML代码展示,这样可以减少首屏时间,提高用户体验。
Vue
提供一个npm
包叫做vue-server-renderer
,它是在vue
源码中的server
目录中单独打包出来作为服务端渲染的依赖包,包的名称已经讲清楚了它的作用:作为一个服务端渲染器,将Vue
实例中所有需要展示的内容进行渲染。
举一个很简单的服务端渲染例子:
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer();
server.get('*', (req, res) => {
// 实例化一个Vue
const app = new Vue({
template: `Server Render`,
})
// 将vue实例进行渲染生成html,并且拼凑为HTML代码返回
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
Hello
${
html}
`)
})
})
server.listen(8080)
使用renderer
在普通实例上面进行渲染成为一个字符串并且返回。
ssr就这么简单?肯定不是。
要知道在Server环境下,一个服务会接收很多请求。那么上面例子会导致很多请求都共享一个Vue实例。现在的程序比较复杂,需要借用到store来进行保存状态:
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer();
const store = new Vuex.Store({
state: {
count: 1
},
mutations: {
increment(state) {
state.count++;
}
}
})
server.get('*', (req, res) => {
// 实例化一个Vue
const app = new Vue({
store,
template: `Server Render{
{$store.state.count}}`,
created() {
this.$store.commit('increment');
}
})
// 将vue实例进行渲染生成html,并且拼凑为HTML代码返回
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
Hello
${
html}
`)
})
})
server.listen(8080)
那么此时就要小心了,在这个例子中,启动服务的时候会创建一个store实例,请求的时候,会去更新store的值。由于store实例只有一个,而每次请求的时候都会自增count,这样会导致同样的请求得到不同的结果(同样的请求访问两次的结果不一致)。
那么如何解决以上问题呢?
造成上面问题的原因是全局只有一个store实例,那么解决的办法是在每次实例化Vue
的时候,顺带创建一个新的store即可。
// 工厂函数,返回一个新的store实例
function createStore() {
return new Vuex.Store({
state: {
count: 1
},
mutations: {
increment(state) {
state.count++;
}
}
});
}
function createApp() {
let store = createStore();
let vue = new Vue({
store,
template: `Server Render{
{$store.state.count}}`,
created() {
this.$store.commit('increment');
}
});
return {
vue,
store
}
}
上面举了一个简单的例子,主要想要表达的一点是在写SSR的时候,与编写浏览器的应用是不一样的。在编写SSR代码的时候,需要去考虑到编写的代码会不会对环境造成影响、生成的Store、Router是不是单例?如果是,那么要注意会不会互相影响。因为在服务端中会长时间运行程序,避免不了多次访问服务。而每次服务都很有可能进行更改数据。如果多个不同的应用访问同一个共享内存,那么很有可能产生难以预估的后果。
先提出几个问题先:
beforeCreate
和created
生命周期钩子函数呢?先来一段官方文档:
SSR文档中讲到在服务端渲染中只调用这两个生命周期函数。为什么不会调用后面的生命周期呢?是因为Vue对象留出接口来阻止调用还是SSR进行了特殊化处理?
都不是!为什么呢?我们从Vue源码中生命周期看起!
这里进行回答问题:
为什么会调用beforeCreate
和created
生命周期钩子函数呢?
callHook
函数是调用实例生命周期的函数。上面的代码在调用new Vue()
的时候直接执行,那么理所当然会调用beforeCreate
和created
生命周期钩子函数。
为什么不会调用到后面的生命周期钩子函数呢?
看图中的最后一段代码
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
先进行判断options
中的el选项是否定义,从而决定是否执行$mount
函数。而beforeMount
和mounted
钩子函数是在调用vm.$mount
函数的时候才能被调用。而在Node环境或者其他环境下是没有document
对象的,自然也不会在选项中配置el
,所以这个判断条件为false
,也就是说不会执行到后面两个生命周期钩子函数。下面的代码可以说明:
在$mount
函数中调用了生命周期钩子函数beforeMount
和mounted
。而ssr的vue实例是没有进行设置el属性的,即不会执行到这个函数,那么自然就没有执行后面的生命周期钩子函数。
先回答是如何获取数据、以及什么时候获取数据:
如何获取数据?
Vue的所有组件的配置都容纳在对象中,并且组件配置实例化成Vue实例的时候是可以拿到组件配置的,那么可以在组件的配置中添加一个选项。这样可以在配置中添加一个asyncData
选项,路由匹配到组件后先进行请求数据,请求完毕后再进行初始化,代码如下:
import {
createApp } from './app.js';
// 下面是组件配置
export default {
asyncData({
store}) {
/// code....
}
}
// 下面是服务端入口文件内容
export default context => {
return new Promise((resolve, reject) => {
const {
app, store, router, App } = 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 });
}
})).then(() => {
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
context.state = store.state;
// 返回根组件
resolve(app);
});
}, reject);
});
}
上面的例子是先在路由层级(即路由配置中直接指向的组件)组件中定义asyncData
。当然你也可以定义其他属性。根据路由获取组件配置后,通过获取配置的asyncData
属性并且执行代码,这样就可以执行数据的获取。
那么服务端在什么时候进行请求数据?
请求刚进来,并且vue实例已经初始化后。根据请求的url进行匹配路由,匹配后拿到匹配到的组件配置进行请求数据。相关的代码如下:
有些组件在渲染之前是需要获取数据的,而获取数据之后,应用的状态就改变的,如果在服务端更改状态后,浏览器初始化的时候状态就与服务端渲染的状态不一致了,那么要怎么做到服务端与浏览器状态统一?
window
中;
,在解析页面的时候会进行设置全局变量;window
对象进行获取数据在服务端的状态,并且将其注入到store.state
状态中,这样能够实现状态统一。(window对象作为中间媒介进行传递数据)可以从ssr渲染出来的html代码看到:
以上就是SSR一种保存状态的方式的流程,不过我们还需要初始化store的时候执行以下这段代码,进行数据同步:
要讲清楚两个端渲染之间的区别,那么就要先从为什么要使用SSR入手:SSR能够在服务端先进行请求渲染,由于服务端进行请求数据的时延较小,能够快速拿到数据并且返回HTML代码。在客户端可以直接渲染数据而不需要花费一些请求数据的时间,这是服务端渲染的好处。
返回内容SSR会比普通的SPA在HTML代码中多出首次渲染的结果,这样在初始化的时候直接将页面进行渲染,无需花费时间去请求数据再次渲染。
SSR并不是说只在服务端进行渲染,而是说SSR会比普通的客户端渲染多一次在服务端渲染。到浏览器这边,SSR还是需要进行再次初始化Vue,并且经过beforeCreate、created、beforeMount、mounted
生命周期,但是在客户端VNode进行patch的时候,如果遇到服务端渲染过的节点,那么会跳过
,所以在浏览器端渲染的时候可以减少一些工作,从而提高了页面体验。
Vue的SSR与浏览器端的执行流程有很大相似度,但是实现的时候还需要有一些比较关键的点:
在编写vue应用的时候,难免会在代码中对环境变量(即全局变量)进行修改。在浏览器端,每次刷新页面的时候都会开启一个新的应用,很少会发生全局变量被莫名其妙修改。而在服务端上跑着的不仅有vue应用,还有接收请求的Server,那么要小心会不会发生因为修改全局变量而产生的错误:
而在服务端的node环境下有一个共享的global
对象。Vue的SSR的Renderer可以配置是否每次执行代码的时候创建一个新的执行环境sandbox
全局环境(也就是global),以达到每次创建vue实例的时候创建出新的状态。
const renderer = createBundleRenderer('', {
runInNewContext: true // 是否开启每次都创建新的沙箱模式
});
如果你的应用很依赖全局变量进行通信的话,那么建议你开启这个功能。
对于没有进行编译操作(不是通过脚手架进行编写代码的应用)的组件渲染,在SSR中会进行编译优化,具体的思想如下:
这是因为服务端渲染的实质是直接生成HTML字符串,在执行render函数后并不会进行patch操作,而是重写了渲染层,直接转为字符串,所以在编译的时候一些静态的内容,可以跳过转为渲染函数的步骤直接处理成为字符串。但是如果是webpack
中使用到vue-template-compiler
已经处理成为渲染函数的组件,那么会跳过编译优化。
Web端的渲染层是进行dom操作,而服务端的渲染层是进行生成字符串操作。我们来看一下ssr中的渲染层做了什么事情,首先找到渲染层的入口:
export function createRenderFunction (
modules: Array<(node: VNode) => ?string>,
directives: Object,
isUnaryTag: Function,
cache: any
) {
// 渲染函数
return function render (
component: Component,
write: (text: string, next: Function) => void,
userContext: ?Object,
done: Function
) {
warned = Object.create(null)
// 执行上下文,包括活跃的vm实例
const context = new RenderContext({
activeInstance: component,
userContext,
write, done, renderNode,
isUnaryTag, modules, directives,
cache
});
// 混入一些工具,比如说标签的class注入、style注入
installSSRHelpers(component)
// 判断是否具有渲染函数,如果没有的话就进行编译操作
normalizeRender(component)
const resolve = () => {
// 执行渲染函数,这是渲染层的开端
renderNode(component._render(), true, context)
}
// 在文档中有讲到在每个组件可以配置serverPrefetch,会先调用这个钩子函数再进行渲染
waitForServerPrefetch(component, resolve, done)
}
}
接下来看到renderNode
函数:
/**
* @desc 在这里的时候,访问到组件实例中的变量已经换成值了,所以不需要考虑说遇到变量怎么处理,而是将考虑的重点放到node的种类中
* @param node
* @param isRoot
* @param context
*/
function renderNode (node, isRoot, context) {
if (node.isString) {
renderStringNode(node, context)
} else if (isDef(node.componentOptions)) {
renderComponent(node, isRoot, context)
} else if (isDef(node.tag)) {
// 渲染组件或者真实的dom节点
renderElement(node, isRoot, context)
} else if (isTrue(node.isComment)) {
if (isDef(node.asyncFactory)) {
// async component
renderAsyncComponent(node, isRoot, context)
} else {
// 注释
context.write(``, context.next)
}
} else {
// 进行转义操作
context.write(
node.raw ? node.text : escape(String(node.text)),
context.next
)
}
}
到这里进行渲染的过程也比较清晰了,就不再展开。不过我们来讲清楚一下几个常见问题:
对于事件监听、双向绑定在SSR中是如何处理的?
ssr是页面在某个条件下的快照,并不会根据数据的改变进行更新操作(因为在执行_render
函数的时候没有创建渲染函数观察者,所以更改数据的时候并不会更新视图,所以说是一个快照),并且服务端渲染出字符串,并不存在dom这个概念,那么事件监听无从说起。事件监听是在浏览器再次进行patch的时候挂载上去的。