服务端渲染SSR及实现原理
前言
在日常前端开发中,在需要首屏渲染速度优化的场景下,大家或多或少都听到过服务端渲染( SSR )。本文将结合 Vue 来对 SSR 的实现逻辑来进行解读。通过阅读本文你将了解到:
-
服务端渲染的使用场景
-
Vue SSR 的实现原理
-
可开箱即用的 SSR 脚手架
服务端渲染
服务端渲染 SSR (Server-Side Rendering),是指在服务端完成页面的 html 拼接处理, 然后再发送给浏览器,将不具有交互能力的 html 结构绑定事件和状态,在客户端展示为具有完整交互能力的应用程序。
适用场景
以下两种情况 SSR 可以提供很好的场景支持
-
需更好的支持 SEO
优势在于同步。搜索引擎爬虫是不会等待异步请求数据结束后再抓取信息的,如果 SEO 对应用程序至关重要,但你的页面又是异步请求数据,那 SSR 可以帮助你很好的解决这个问题。
-
需更快的到达时间
优势在于慢网络和运行缓慢的设备场景。传统 SPA 需完整的 JS 下载完成才可执行,而SSR 服务器渲染标记在服务端渲染 html 后即可显示,用户会更快的看到首屏渲染页面。如果首屏渲染时间转化率对应用程序至关重要,那可以使用 SSR 来优化。
不适用场景
以下三种场景 SSR 使用需要慎重
-
同构资源的处理
劣势在于程序需要具有通用性。结合 Vue 的钩子来说,能在 SSR 中调用的生命周期只有 beforeCreate 和 created,这就导致在使用三方 API 时必须保证运行不报错。在三方库的引用时需要特殊处理使其支持服务端和客户端都可运行。
-
部署构建配置资源的支持
劣势在于运行环境单一。程序需处于 node.js server 运行环境。
-
服务器更多的缓存准备
劣势在于高流量场景需采用缓存策略。应用代码需在双端运行解析,cpu 性能消耗更大,负载均衡和多场景缓存处理比 SPA 做更多准备。
我们来结合 Vue.js 来看看 Vue 是如何实现 SSR 的。
Vue SSR 的实现原理
先决条件
组件基于 Vnode 来实现渲染
VNode 本身是 js 对象,兼容性极强,不依赖当前的执行的环境,从而可以在服务端渲染及原生渲染。虚拟 DOM 频繁修改,最后比较出真实 DOM 需要更改的地方,可以达到局部渲染的目的,减少性能损耗。
vue-server-renderer
是一个具有独立渲染应用程序能力的包,是 Vue 服务端渲染的核心代码。
本文下面的源码也结合这个包展开,此处不多冗余介绍。
SSR 渲染架构
我们结合官网图和项目架构两个维度来整体了解一下 SSR 全貌
项目架构
src
├── components
├── App.vue
├── app.js ----通用 entry
├── entry-client.js ----仅运行于浏览器
└── entry-server.js ----仅运行于服务器
app.js导出 createApp 函数工厂,此函数是可以被重复执行的,从根 Vue 实例注入,用于创建 router,store 以及应用程序实例。
import Vue from 'vue'
import App from './App.vue'
// 导出一个工厂函数,用于创建新的应用程序、router 和 store 实例
export function createApp () {const app = new Vue({render: h => h(App)})return { app }
}
entry-client.js负责创建应用程序,挂载实例 DOM ,仅运行于浏览器。
import { createApp } from './app'
const { app } = createApp()
// #app 为根元素,名称可替换
app.$mount('#app')
entry-server.js创建返回应用实例,同时还会进行路由匹配和数据的预处理,仅运行于服务器。
import { createApp } from './app'
export default context => {const { app } = createApp()return app
}
服务端和客户端代码编写原则
作为同构框架,应用代码编译过程 Vue SSR 提供了两个编译入口,来作为抹平由于环境不同的代码差异。Client entry 和 Server entry 中编写代码逻辑的区分有两条原则
-
通用型代码 可通用性的代码,由于鉴权逻辑和网关配置不同,需要在 webpack resolve.alias 中配置不同的模块环境应用。
-
非通用性代码 Client entry 负责挂载 DOM 节点代码,以及三方包引入和具有兼容性库的加载。
Server entry 只生成 Vue 对象。
两个编译产物
经过 webpack 打包之后会有两个 bundle 产物
server bundle 用于生成 vue-ssr-server-bundle.json,我们熟悉的 sourceMap 和需要在服务端运行的代码列表都在这个产物中。
vue-SSR-server-bundle.json
{ "entry": , "files": {A:包含了所有要在服务端运行的代码列表B:入口文件}
}
client Bundle 用于生成 vue-SSR-client-manifest.json,包含所有的静态资源,首次渲染需要加载的 script 标签,以及需要在客户端运行的代码。
vue-SSR-client-manifest.json
{ "publicPath": 公共资源路径文件地址, "all": 资源列表"initial":输出 html 字符串"async": 异步加载组件集合"modules": moduleIdentifier 和 all 数组中文件的映射关系
}
在先决条件中我们提到了一个重要的包 vue-server-renderer,那我们来重点看看这个包里面的值得我们学习关注的内容。
vue-server-renderer
是 Vue SSR 的核心代码,值得我们关注的是应用初始化和应用输出。两个阶段提供了完整的应用层代码编译和组装逻辑。
应用初始化
在应用初始化过程中,重点展开介绍实例化流程和防止交叉污染。
首先我们先来看看一个 Vue SSR 的应用是如何被初始化的。
实例化流程
-
生成 Vue 对象
const Vue = require('vue')
const app = new Vue()
-
生成 renderer,值得关注的两个对象 render 和 templateRenderer
const renderer = require('vue-server-renderer').createRenderer()
// createRenderer 函数中有两个重要的对象:render 和 templateRenderer
function createRenderer (ref) {// render: 渲染 html 组件var render = createRenderFunction(modules, directives, isUnaryTag, cache);// templateRenderer: 模版渲染,clientManifest 文件var templateRenderer = new TemplateRenderer({template: template,inject: inject,shouldPreload: shouldPreload,shouldPrefetch: shouldPrefetch,clientManifest: clientManifest,serializer: serializer});
经过这个过程的 render 和 templateRenderer 并没有被调用,这两个函数真正的调用是在项目实例化 createBundleRenderer 函数的时候,即第三步创建的函数。
-
创建沙盒 vm,实例化 Vue 的入口文件
var vm = require('vm');
// 调用 createBundleRunner 函数实例对象,rendererOptions 支持可配置
var run = createBundleRunner( entry, ----入口文件集合files, ----打包文件集合basedir, rendererOptions.runInNewContext。
);}
在 createBundleRunner 方法的源码到其实举例了一个叫 compileModule 的一个方法,这个方法中有两个函数:getCompiledScript 和 evaluateModule
function createBundleRunner (entry, files, basedir, runInNewContext) {//触发 compileModule 方法,找到 webpack 编译形成的 codevar evaluate = compileModule(files, basedir, runInNewContext);
}
getCompiledScript:编译 wrapper ,找到入口文件的 files 文件名及 script 脚本的编译执行
function getCompiledScript (filename) {if (compiledScripts[filename]) {return compiledScripts[filename]}// 在入口文件 files 中找到对应的文件名称var code = files[filename];var wrapper = NativeModule.wrap(code);// 在沙盒上下文中执行构建 script 脚本var script = new vm.Script(wrapper, {filename: filename,displayErrors: true});compiledScripts[filename] = script;return script}
evaluateModule:根据 runInThisContext 中的配置项来决定是在当前上下文执行还是单独上下文执行。
function evaluateModule (filename, sandbox, evaluatedFiles) {if ( evaluatedFiles === void 0 ) evaluatedFiles = {};if (evaluatedFiles[filename]) {return evaluatedFiles[filename]}var script = getCompiledScript(filename);// 用于判断是在当前的那种模式下面执行沙盒上下文,此时存在两个函数的相互调用var compiledWrapper = runInNewContext === false? script.runInThisContext(): script.runInNewContext(sandbox);// m: 函数导出的 exports 数据var m = { exports: {}};// r: 替代原生 require 用来解析 bundle 中通过 require 函数引用的模块var r = function (file) {...return require(file)};}
上述的函数执行完成之后会调用 compiledWrapper.call,传参对应上面的 exports、require、module, 我们就能拿到入口函数。
-
错误抛出容错和全局错误监听 renderToString: 在没有 cb 函数时做了 promise 的返回,那说明我们在调用次函数的时候可以直接做 try catch的处理,用于全局错误的抛出容错。
renderToString: function (context, cb) {var assign;if (typeof context === 'function') {cb = context;context = {};}var promise;if (!cb) {((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));}...return promise},
}
renderToStream:对抛错做了监听机制, 抛错的钩子函数将在这个方法中触发。
renderToStream: function (context) {var res = new PassThrough();run(context).catch(function (err) {rewriteErrorTrace(err, maps);// 此处做了监听器的容错process.nextTick(function () {res.emit('error', err);});}).then(function (app) {if (app) {var renderStream = renderer.renderToStream(app, context);... }}}
防止交叉污染
Node.js 服务器是一个长期运行的进程,在客户端编写的代码在进入进程时,变量的上下文将会被保留,导致交叉请求状态污染。因此不可共享一个实例,所以说 createApp 是一个可被重复执行的函数。其实在包内部,变量之间也存在防止交叉污染的能力。
防止交叉污染的能力是由 rendererOptions.runInNewContext 这个配置项来提供的,这个配置支持 true, false,和 once 三种配置项传入。
// rendererOptions.runInNewContext 可配置项如下true: 新上下文模式:创建新上下文并重新评估捆绑包在每个渲染上。确保每个应用程序的整个应用程序状态都是新的渲染,但会产生额外的评估成本。false:直接模式:每次渲染时,它只调用导出的函数。而不是在上重新评估整个捆绑包模块评估成本较高,但需要结构化源代码once: 初始上下文模式仅用于收集可能的非组件 vue 样式加载程序注入的样式。
特别说明一下 false 和 once 的场景, 为了防止交叉污染,在渲染的过程中对作用域要求很严格,以此来保证在不同的对象彼此之间不会形成污染。
if (!runner) {var sandbox = runInNewContext === 'once'? createSandbox(): global;initialContext = sandbox.__VUE_SSR_CONTEXT__ = {};runner = evaluate(entry, sandbox);//在后续渲染中,_VUE_SSR_CONTEXT_uu 将不可用//防止交叉污染delete sandbox.__VUE_SSR_CONTEXT__;if (typeof runner !== 'function') {throw new Error('bundle export should be a function when using ' +'{ runInNewContext: false }.')}}
应用输出
在应用输出这个阶段中,SSR 将更多侧重加载脚本内容和模版渲染,在模版渲染时在代码中是否定义过模版引擎源码将提供不同的 html 拼接结构。
加载脚本内容
此过程会将上个阶段构造的 reader 和 templateRender 方法实现数据绑定。
templateRenderer:负责 html 封装,其原型上会有如下几个方法, 这些函数的作用如下图。值得一提的是:bindRenderFns 函数是将 4 个 render 函数绑定到用户上下文的 context 中,用户在拿到这些内容之后就可以做内容的自定义组装和渲染。
render: 函数会被递归调用按照从父到子的顺序,将组件全部转化为 html。
function createRenderFunction (modules,directives,isUnaryTag,cache
) {return function render (component,write,userContext,done) {warned = Object.create(null);var context = new RenderContext({activeInstance: component,userContext: userContext,write: write, done: done, renderNode: renderNode,isUnaryTag: isUnaryTag, modules: modules, directives: directives,cache: cache});installSSRHelpers(component);normalizeRender(component);// 渲染 node 节点,绑定用户作用上下文var resolve = function () {renderNode(component._render(), true, context);};// 等待组件 serverPrefetch 执行完成之后,_render 生成子节点的 vnode 进行渲染waitForServerPrefetch(component, resolve, done);}
}
在经过上面的编译流程之后,我们已经拿到了 html 字符串,但如果要在浏览器中展示页面还需js, css 等标签与这个 html 组装成一个完整的报文输出到浏览器中, 因此需要模版渲染阶段来将这些元素实现组装。
模版渲染
经过应用初始化阶段,代码被编译获取了 html 字符串,context 渲染需要依赖的 templateRenderer.prototype.bindRenderFns 中绑定的 state, script , styles 等资源。
TemplateRenderer.prototype.bindRenderFns = function bindRenderFns (context) {var renderer = this;['ResourceHints', 'State', 'Scripts', 'Styles'].forEach(function (type) {context[("render" + type)] = renderer[("render" + type)].bind(renderer, context);});context.getPreloadFiles = r**erer.ge****:**reloadFiles.bind(renderer, context);
};
在具体渲染模版时,会有以下两种情况:
-
未定义模版引擎 渲染结果会被直接返回给 renderToString 的回调函数,而页面所需要的脚本依赖我们通过用户上下文 context 的 renderStyles,renderResourceHints、renderState、renderScripts 这些函数分别获得。
-
定义了模版引擎 templateRender 会帮助我们进行 html 组装
TemplateRenderer.prototype.render = function render (content, context) {
// parsedTemplate 用于解析函数得到的包含三个部分的 compile 对象,
// 按照顺序进行字符串模版的拼接var template = this.parsedTemplate;if (!template) {throw new Error('render cannot be called without a template.')}context = context || {};if (typeof template === 'function') {return template(content, context)}if (this.inject) {return (template.head(context) +(context.head || '') +this.renderResourceHints(context) +this.renderStyles(context) +template.neck(context) +content +this.renderState(context) +this.renderScripts(context) +template.tail(context))} else {...}
};
至此我们了解了 Vue SSR 的整体架构逻辑和 vue-server-renderer 的核心代码,当然 SSR 也是有很多开箱即用的脚手架来供我们选择的。
开箱即用的SSR脚手架
目前前端流行的三种技术栈 React, Vue 和 Angula,已经孵化出对应的服务端渲染框架,开箱即用,感兴趣的同学可以自主学习使用。
-
React: Next.js
-
Vue: Nuxt.js
-
Angula: Nest.js