服务端渲染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 中调用的生命周期只有 beforeCreatecreated,这就导致在使用三方 API 时必须保证运行不报错。在三方库的引用时需要特殊处理使其支持服务端和客户端都可运行。

  • 部署构建配置资源的支持

劣势在于运行环境单一。程序需处于 node.js server 运行环境。

  • 服务器更多的缓存准备

劣势在于高流量场景需采用缓存策略。应用代码需在双端运行解析,cpu 性能消耗更大,负载均衡和多场景缓存处理比 SPA 做更多准备。

我们来结合 Vue.js 来看看 Vue 是如何实现 SSR 的。

Vue SSR 的实现原理

先决条件

组件基于 VNode 来实现渲染

VNode 本身是 js 对象,兼容性极强,不依赖当前的执行的环境,从而可以在服务端渲染及原生渲染。虚拟 DOM 频繁修改,最后比较出真实 DOM 需要更改的地方,可以达到局部渲染的目的,减少性能损耗

vue-server-renderer

是一个具有独立渲染应用程序能力的包,是 Vue 服务端渲染的核心代码。

本文下面的源码也结合这个包展开,此处不多冗余介绍。

SSR 渲染架构

我们结合官网图项目架构两个维度来整体了解一下 SSR 全貌

项目架构

1
2
3
4
5
6
src
├── components
├── App.vue
├── app.js ----通用 entry
├── entry-client.js ----仅运行于浏览器
└── entry-server.js ----仅运行于服务器

app.js 导出 createApp 函数工厂,此函数是可以被重复执行的,从根 Vue 实例注入,用于创建router,store 以及应用程序实例。

1
2
3
4
5
6
7
8
9
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 ,仅运行于浏览器。

1
2
3
4
import { createApp } from './app'
const { app } = createApp()
// #app 为根元素,名称可替换
app.$mount('#app')

entry-server.js 创建返回应用实例,同时还会进行路由匹配和数据的预处理,仅运行于服务器。

1
2
3
4
5
import { createApp } from './app'
export default context => {
const { app } = createApp()
return app
}

服务端和客户端代码编写原则

作为同构框架,应用代码编译过程 Vue SSR 提供了两个编译入口,来作为抹平由于环境不同的代码差异。Client entry 和 Server entry 中编写代码逻辑的区分有两条原则

  1. 通用型代码

可通用性的代码,由于鉴权逻辑和网关配置不同,需要在 webpack resolve.alias 中配置不同的模块环境应用。

  1. 非通用性代码

Client entry  负责挂载 DOM 节点代码,以及三方包引入和具有兼容性库的加载。 Server entry 只生成 Vue 对象。

两个编译产物

经过 webpack 打包之后会有两个 bundle 产物

server bundle 用于生成 vue-ssr-server-bundle.json,我们熟悉的 sourceMap 和需要在服务端运行的代码列表都在这个产物中。

1
2
3
4
5
6
7
8
vue-SSR-server-bundle.json

  "entry": , 
  "files": {
    A:包含了所有要在服务端运行的代码列表
    B:入口文件
  } 
}

client Bundle 用于生成 vue-SSR-client-manifest.json,包含所有的静态资源,首次渲染需要加载的 script 标签,以及需要在客户端运行的代码。

1
2
3
4
5
6
7
8
vue-SSR-client-manifest.json

  "publicPath": 公共资源路径文件地址, 
  "all": 资源列表
  "initial":输出 html 字符串
  "async": 异步加载组件集合
  "modules": moduleIdentifier 和 all 数组中文件的映射关系
}

先决条件中我们提到了一个重要的包 vue-server-renderer,那我们来重点看看这个包里面的值得我们学习关注的内容。

vue-server-renderer

是 Vue SSR 的核心代码,值得我们关注的是应用初始化应用输出。两个阶段提供了完整的应用层代码编译和组装逻辑。

应用初始化

在应用初始化过程中,重点展开介绍实例化流程防止交叉污染。

首先我们先来看看一个 Vue SSR 的应用是如何被初始化的。

实例化流程

  1. 生成 Vue 对象
1
2
const Vue = require('vue')
const app = new Vue()
  1. 生成 renderer,值得关注的两个对象 render 和 templateRenderer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 函数的时候,即第三步创建的函数。

  1. 创建沙盒vm,实例化 Vue 的入口文件
1
2
3
4
5
6
7
8
var vm = require('vm');
// 调用 createBundleRunner 函数实例对象,rendererOptions 支持可配置
var run = createBundleRunner( 
  entry, ----入口文件集合
  files, ----打包文件集合
  basedir, 
  rendererOptions.runInNewContext。
);}

在 createBundleRunner 方法的源码到其实例了一个叫 compileModule 的一个方法,这个方法做了中有两个函数:getCompiledScriptevaluateModule

1
2
3
4
function createBundleRunner (entry, files, basedir, runInNewContext) {
  //触发 compileModule 方法,找到 webpack 编译形成的 code
  var evaluate = compileModule(files, basedir, runInNewContext);
}

getCompiledScript: 编译 wrapper ,找到入口文件的 files 文件名及 script 脚本的编译执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 中的配置项来决定是在当前上下文执行还是单独上下文执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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, 我们就能拿到入口函数。

  1. 错误抛出容错和全局错误监听

renderToString: 在没有 cb 函数时做了 promise 的返回,那说明我们在调用次函数的时候可以直接做 try catch的处理,用于全局错误的抛出容错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:对抛错做了监听机制, 抛错的钩子函数将在这个方法中触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 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 三种配置项传入。

1
2
3
4
5
6
7
8
9
10
11
// rendererOptions.runInNewContext 可配置项如下
  true: 
  新上下文模式:创建新上下文并重新评估捆绑包在每个渲染上。
  确保每个应用程序的整个应用程序状态都是新的渲染,但会产生额外的评估成本。
  false:
  直接模式:
  每次渲染时,它只调用导出的函数。而不是在上重新评估整个捆绑包
  模块评估成本较高,但需要结构化源代码
  once: 
  初始上下文模式
  仅用于收集可能的非组件vue样式加载程序注入的样式。

特别说明一下 false 和 once 的场景, 为了防止交叉污染,在渲染的过程中对作用域要求很严格,以此来保证在不同的对象彼此之间不会形成污染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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。

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
26
27
28
29
30
31
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 等资源。

1
2
3
4
5
6
7
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 组装

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
26
27
28
29
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

总结

服务端渲染 ( SSR ) 是一个同构程序,是否使用 SSR 取决于内容到达时间对应用程序的重要程度。如果对初始加载的几百毫秒可接受,SSR 的使用就有点小题大做了。

对于源码的学习可以帮助更好借鉴优秀的程序写法和激发对日常代码编程架构的思考,如果你更倾向箱即用的解决方案,那可以使用现有的 SSR 脚手架来搭建项目,这些脚手架的模版抽象和额外的功能扩展可以提供平滑的开箱体验。

参考文献

作者:政采云前端团队
链接:https://juejin.cn/post/7046898330000949285