前言
之前 使用 Vue + Koa + Node 搭建了服务器端单页面应用程序。后续维护的时候发现对整体流程不太清楚了😄,所以花了点时间整理了每个模块的功能如上图。接下来按流程来分析下 Vue SSR。
介绍
什么是 Server-Side Rendering
拿 Vue 举例,在服务端 Vue 组件可以被渲染为 HTML 字符串,将 HTML 字符串 ( markup 标记 ) 返回给客户端,最终将这些静态标记激活为完全可以交互的应用。
为什么使用服务端渲染
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。采用服务端渲染返回给浏览器的时候已经有完整的 HTML 结构了。
- 更快的内容到达时间 (time-to-content)
- 右键点击 View Page Source 查看纯客户端的 Vue 程序源代码。像这样:
- 点击查看 SSR 应用的源代码。像这样:
由于我们已经在服务端将 HTML 结构渲染好了,所以在浏览器中无需下载这茫茫多的 js 文件,所以用户可以更快的看到页面内容。
- 右键点击 View Page Source 查看纯客户端的 Vue 程序源代码。像这样:
为什么使用 Vue
通过上面的介绍,我们知道 SSR 大致的流程就是,服务器生成 html -> 浏览器中激活这些 html 标记。
其实开始我采用的是 Node + Koa + 模版引擎的方式,后来感觉开发效率和体验都不好。还是习惯用 Vue 语法来写页面。 Vue 组件可以在服务器上被渲染成 HTML 的这种特性解决了使用模版引擎开发的痛点。
流程
vue-server-renderer
Vue 是通过 vue-server-renderer 依赖来将 Vue 组件编译成 HTML 的。该依赖暴露出两个方法: createRenderer 和 createBundleRenderer .
实际项目中基本都使用 createBundleRenderer 所以我们来看下这个方法。
createBundleRenderer
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: fs.readFileSync(resolve('../src/index.temp.html'), 'utf-8'),
clientManifest
})
先看下 createBundleRenderer 的使用方法。来说明一下流程并介绍下几个重要的参数。
- 首先 vue-server-renderer 的暴露的两个方法 createRenderer 和 createBundleRenderer 是用于创建 renderer 用的。
- createBundleRenderer(serverBundle,{ template:temp.html,clientManifest }) 通过这些参数来生成了 renderer 对象。(这些参数后面详细说,先忽略)
- 而 renderer.renderToString() 就是返回我们想要的 HTML 字符串。这样服务端的流程就结束了。
重点来看下第二步:
- serverBundle webpack 以 entry-server.js 入口打包生成的 bundle。
- cientManifest webpack 以 entry-client.js 入口打包生成的 manifest。
- runInNewContext 推荐使用 false 具体查看官网
- template 使用指定模版
打包构建
首先我们来看下项目结构
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器
vue.config.js
公共部分 ( 服务器和浏览器都会使用的 ):
app.js、App.vue 以及 components 内的组件都会在服务端和客户端渲染,都属于公共部分,这些书写和纯客户端的 Vue 项目基本相同。
服务器入口:
我们会在服务器上启动一个 Node.js 的进程处理对应端口的请求。每次请求的会经过 entry-client.js 进行处理。(先忽略内部实现)。
浏览器入口:
当浏览器接收到服务器返回的 HTML 标记后,会进入到 entry-client.js 进行处理。(先忽略内部实现)。
vue.config.js:
举几个 webpack 配置项,例如 entry、taget、node、output.libraryTarget 等都需要根据环境 ( 服务器 / 浏览器 ) 来进行配置。具体配置
module.exports = {
configureWebpack: () => ({
// 将 entry 指向应用程序的 server / client 文件
entry: `./src/entry-${target}.js`,
target: TARGET_NODE ? 'node' : 'web',
node: TARGET_NODE ? undefined : false,
output: {
libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
}
// ...
})
}
了解了整体的构建流程以后,我们来看下公共部分 app.js 实现以及 entry-client.js 和 entry-server.js 的内部逻辑。
app.js 视图渲染
功能就是和纯客户端的应用的 Vue 实例化相同,由于这里是公共的逻辑,我们需要考虑的点是服务器渲染 Vue 实例和浏览器渲染 Vue 实例有什么不同。
平时我们项目通过 webpack 打包成一些静态文件。例如下图
当一个用户点击了一个地址 https://juejin.cn/post/6844904099733848072 访问的是服务器上的 html 文件,然后再去加载一些 js、css、资源等。 所以每个用户都是独立的,每个用户点击都会去单独生成 Vue 实例。因为他们在各自的浏览器中执行了这些代码。
和浏览器中不同,我们在服务器上启动了一个 Node.js 进程。无论那个用户访问该端口下的进程都是同一个进程。为了防止数据交叉污染,我们对于每次请求都需要重新创建一个 Vue 实例。
之前我们创建 Vue 实例像下面这样:
const app = new Vue({
router,
store,
render: h => h(App)
})
现在要暴露一个工厂函数出来,对于每次请求都去重新创建 Vue 实例,同理应用在 Vue-Router 和 Vue-Store 上。代码如下:
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from 'src/store/index'
import { sync } from 'vuex-router-sync'
export function createApp() {
const router = createRouter()
const store = createStore()
sync(store, router)
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
entry-server.js
前面我们讲到会在服务器上启动一个 Node.js 进程。来看下示例代码。
const app = new Koa()
const renderer = createBundleRenderer(bundle,/* 省略*/)
router.get('index', ctx => {
const context = { title: '标题' }
ctx.body = await renderer.renderToString(context)
})
app.listen(9000)
例如我们监听了 9000 端口,当有请求访问了该端口会调用 renderer.renderToString(context) 方法去渲染出 HTML 字符串。这里就是调用了我们在 entry-server.js 中定义的方法。来看下该方法:
// 结构: context => Promise<CombinedVueInstance>
// 该函数返回了一个 Promise, Promise resolve 出 Vue 实例。
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
// 其他逻辑...
}
}
由于代码量比较多,这里不完整将所有代码的功能,完整地址可以看 github demo。这里接收了服务器传递的 context 对象 ( 你可以自己定义想传的数据 ) ,我们拿到访问 Node.js 的 url,并且这个 url 和我们在 routes 中定义的路由数组中的 ( 如果没有,就拿不到对应页面 ) 。我们使用 router.push 命令式的跳转到指定的路由组件中。这样我们可以拿到组件中的信息,进行数据预取等其他操作。
下图是服务器返回给浏览器的 HTML 字符串,会带上 data-server-rendered 标记。让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。
entry-client.js
该文件代码比较简单,同样创建 Vue 实例、Store 实例,在 entry-server.js 中我们将数据预取的数据挂载到 window.__INITIAL_STATE__ 上
- 使用 store.replaceState() 将根状态替换为 window.__INITIAL_STATE__
- app.$mount('#app') 将返回的 HTML 结构激活并 #app 节点上。
import { createApp } from './app'
const { app, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
app.$mount('#app')
激活后查看浏览器中dom结构,data-server-rendered 标记就不存在了,并且 Store 数据也以及填充了。
vue-server-renderer 源码
查看 vue-server-renderer/build.dev.js 可以看到暴露的两个方法
var createBundleRenderer = createBundleRendererCreator(createRenderer$1);
exports.createRenderer = createRenderer$1;
exports.createBundleRenderer = createBundleRenderer;
来看下 createBundleRendererCreator 方法结构,
createRenderer => (bundle,renderOptions) => {}
function createBundleRendererCreator (
createRenderer
) {
return function createBundleRenderer (
bundle,
rendererOptions
) {
// 拿到 bundle 相关信息
if (typeof bundle === 'object') {
entry = bundle.entry;
files = bundle.files;
basedir = basedir || bundle.basedir;
maps = createSourceMapConsumers(bundle.maps);
if (typeof entry !== 'string' || typeof files !== 'object') {
throw new Error(INVALID_MSG)
}
}
// 创建 createRenderer 实例
var renderer = createRenderer(rendererOptions);
// 将 bundle 信息传递给 createBundleRunner 去创建 run 实例
var run = createBundleRunner(
entry,
files,
basedir,
rendererOptions.runInNewContext
);
// 返回包含 renderToString 方法的对象
return {
renderToString:function (context, cb) {
var promise;
if (!cb) {
((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));
}
run(context).catch(function (err) {
rewriteErrorTrace(err, maps);
cb(err);
}).then(function (app) {
if (app) {
renderer.renderToString(app, context, function (err, res) {
rewriteErrorTrace(err, maps);
cb(err, res);
});
}
});
return promise
}
}
}
}
通过传入的 bundle 文件读取该文件的 entry、files、maps 信息。来看下 vue-ssr-server-bundle.json 文件的结构
调用 createBundleRunner 创建 run 实例,返回一个对象 包含了 renderToString、renderToStream 两个方法。这里我们就关注 renderToString 这个方法。
renderToString
renderToString 接收两个参数,一个 context 上下文对象,一个 cb 回调函数。这里对 cb 进行判断,如果未传入 cb 就返回一个 promise 这样调用 renderer.renderToString(context) 通过 .then 链式操作。
调用 run(context).then(function(app){}) 这里的 app 就是在 entry-server.js 函数中暴露出来的,还记得该函数的结构,context => Promise<CombinedVueInstance> 返回一个 Promise 并 resolve(app) Vue 实例出来的。
关键的是 renderer.renderToString(app, context,function(err,res){})
这个 renderer 是调用传入的 createRenderer$1 生成的
/**
* Mix properties into target object.
*/
function extend (to, _from) {
for (var key in _from) {
to[key] = _from[key];
}
return to
}
function createRenderer$1 (options) {
if ( options === void 0 ) options = {};
return createRenderer(extend(extend({}, options), {
isUnaryTag: isUnaryTag,
canBeLeftOpenTag: canBeLeftOpenTag,
modules: modules,
// user can provide server-side implementations for custom directives
// when creating the renderer.
directives: extend(baseDirectives, options.directives)
}))
}
这里两个 extend 其实是 copy 对象的值,可以看作传入一个对象包含 options 和后面定的属性的对象。相当于
function createRenderer$1 (options) {
var newOptions = Object.assign({},options,{//...})
return createRenderer(newOptions)
}
createRenderer
function createRenderer (ref) {
// ...
var render = createRenderFunction(modules, directives, isUnaryTag, cache);
return {
// 这里 component 传的就是 app Vue 实例
renderToString: function renderToString (
component,
context,
cb
) {
// ...
try {
render(component, write, context,function (err) {
// ...
})
} catch (e) {
cb(e);
}
}
}
}
createRenderFunction
createRenderFunction 函数返回一个 render 函数,该函数中关键的步骤为
- normalizeRender(component) 编译的时候优化大小
- waitForServerPrefetch(component, resolve, done);调用组件中的 serverPrefetch 方法用于获取数据
所以我们组件中定义的定义的 asyncData 方法可以改为:
serverPrefetch(){
return this.$store.dispatch('getNormal')
},
并且删除 entry-server.js 中定义的调用组件中 asyncData 的逻辑,这样使得代码简单清晰了很多。
- 调用 renderNode 去渲染行内模版,最终组成 HTML 字符串。
function createRenderFunction (
modules,
directives,
isUnaryTag,
cache
) {
return function render (
component,
write,
userContext,
done
) {
// 实例渲染上下文对象
var context = new RenderContext({
activeInstance: component,
userContext: userContext,
write: write, done: done, renderNode: renderNode,
isUnaryTag: isUnaryTag, modules: modules, directives: directives,
cache: cache
});
// 在编译阶段通过分析模版来尝试优化大小
normalizeRender(component);
// 将 Vue 实例渲染为 HTML 模版
var resolve = function () {
renderNode(component._render(), true, context);
};
// 调用组件中的 serverPrefetch 函数,
waitForServerPrefetch(component, resolve, done);
}
}
waitForServerPrefetch 函数 vm 就是我传入的 Vue 实例,判断实例当前组件中是否定义了 serverPrefetch 函数。
function waitForServerPrefetch (vm, resolve, reject) {
var handlers = vm.$options.serverPrefetch;
if (isDef(handlers)) {
if (!Array.isArray(handlers)) { handlers = [handlers]; }
try {
var promises = [];
for (var i = 0, j = handlers.length; i < j; i++) {
var result = handlers[i].call(vm, vm);
if (result && typeof result.then === 'function') {
promises.push(result);
}
}
Promise.all(promises).then(resolve).catch(reject);
return
} catch (e) {
reject(e);
}
}
// 调用传入的 resolve 去执行 renderNode 函数。
resolve();
}
根据传入节点类型去生成对应的行内模版。
function renderNode (node, isRoot, context) {
if (node.isString) {
renderStringNode$1(node, context);
} else if (isDef(node.componentOptions)) {
renderComponent(node, isRoot, context);
} else if (isDef(node.tag)) {
renderElement(node, isRoot, context);
}
// ...
}
总结
梳理了 Vue SSR 的整体流程以及各个模块都具体做了哪些事,并且查看了 vue-server-renderer 源码后了解了大致的运作流程,并且简化了数据预取的逻辑,使得整个逻辑更加简单清晰。