Vuejs设计与实现 —— 同构渲染

3,397 阅读10分钟

前言

Vue.js 是一个构建客户端应用的框架,组件的代码会在浏览器中运行,然后向页面输出 DOM 元素,也就是我们最常用的方式,即 客户端渲染(client-side rendering,CSR).

实际上 Vue.js 还可以在 Node.js 环境中运行,即可将相同组件渲染成相应的字符串,并发送给浏览器进行渲染,这就是 服务端渲染(server-side rendering,SSR).

Vue.js 作为现代前端框架,除了能够分别支持 CSRSSR 渲染之外,还能够同时支持 CSRSSR,这就是所谓的 同构渲染(isomorphic rendering).

客户端渲染(CSR)

渲染流程

客户端渲染大致流程

对应的 performance 面板的快照

CSR 优点

通常 客户端渲染 伴随着 单页面应用(single-page application,SPA)前端路由 等,相比于早期的 服务端路由 的渲染方式带来了一定的优势:

  • 用户体验更好
    • 早期的 服务端路由 方式,会导致从 A 页面跳转到 B 页面时,页面会重新刷新并对整个页面重新进行渲染,这个过程会让用户感觉不够流畅,基于 前端路由 的方式并不会真正进行 页面跳转,带来了更高的流畅度
  • 占用服务端资源少
    • 早期的 服务端路由 方式,会将完整的页面返回给客户端,意味着要在 服务端 访问数据库,并且需要将对应的数据和页面进行融合,所以对服务端而言,一次路由访问就需要做这两件事,若访问的并发量高,会导致服务端需要额外处理这些计算,自然会占用服务端有限的资源
    • CSR 渲染则是交由客户端进行处理,服务端不需要关心渲染计算的过程,减轻了服务端的压力

CSR 缺陷

客户端渲染 仍是目前使用最多得渲染模式,除非一些特殊场景下 CSR 无法满足对应的需求:

  • "白屏" 时间较长
    • 主要是因为 CSR 渲染需要 *.js 的支持,而 *.js 又必须保证 *.html 被接收和解析, *.html 又强依赖于当前的 网络环境,因此,在差网环境下回导致 白屏时间过长,特别是在移动网络环境下
  • 对 SEO 的支持不友好
    • 这一点也很好理解,因为 白屏时间较长 导致在一段时间内没有重要的内容能够交由 搜索引擎 进行分析、分类、打标签等,并且 搜索引擎 并不会等待页面渲染完成,因此对 SEO 优化并不友好

服务端渲染(SSR)

渲染流程

简单的渲染流程

搭建 node 服务

搭建一个简单的 node 服务来观察 SSR 的效果,内容比较简单不过多赘述,其中需要注意的是:

  • Node.js 服务器是长期运行的进程,当代码第一次被导入进程时,它会被执行一次然后 保留在内存里
  • 如果只创建了一个 vue 的单例对象,它将被 每次发来的请求共享,这是不符合实际需求的,因此,需要为每个请求重新生成一个 vue 实例,避免相互影响

效果演示

以下是 node 环境相关代码:

const express = require("express");
const { createSSRApp } = require("vue");
const { renderToString } = require("@vue/server-renderer");

const app = express();

// Node.js 服务器是长期运行的进程,当代码第一次被导入进程时,它会被执行一次然后保留在内存里
// 如果只创建了一个 vue 的单例对象,它将被每次发来的请求共享,这是不符合实际需求的
// 因此,需要为每个请求,重新生成一个 vue 实例,避免相互影响
function createApp(msg) {
  return createSSRApp({
    data() {
      return {
        msg,
      };
    },
    template: `<h1>{{ msg }}</h1>`,
  });
}

function getHtmlStrWrap(contentStr) {
  return `
    <!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <h3><a href="/home"> home </a></h3>
    <h3><a href="/about"> about </a></h3>
    <h3><a href="/test"> error path </a></h3>
    <div id="app">${contentStr}</div>
</html>
    `;
}

app.get("/home", async (req, res, next) => {
  const vueStr = await renderToString(createApp("Home Page!!"));
  const htmlStr = getHtmlStrWrap(vueStr);
  res.end(htmlStr);
});

app.get("/about", async (req, res, next) => {
  const vueStr = await renderToString(createApp("About Page!!"));
  const htmlStr = getHtmlStrWrap(vueStr);
  res.end(htmlStr);
});

app.get("*", async (req, res, next) => {
  const vueStr = await renderToString(createApp("Not Found Page!!"));
  const htmlStr = getHtmlStrWrap(vueStr);
  res.end(htmlStr);
});

app.listen(8000, (err) => {
  if (err) {
    console.error("server fail:", err);
    return;
  }
  console.log("server is runing at http://localhost:8000");
});

SSR 优势

  • 不存在 白屏时间过长 问题
    • 更快的内容呈现,尤其是网络连接缓慢或设备运行速度缓慢的时候,服务端标记 不需要等待所有的 JavaScript 都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容
  • 更好的搜索引擎优化 (SEO)
    • 搜索引擎爬虫会直接读取 完整渲染 出来的页面
    • 通过 API 调用获取的内容,爬虫是不会等待页面加载完成

SSR 缺点

  • 需要保证开发一致性
    • 浏览器特有的代码只能在特定的生命周期钩子中使用
    • 一些外部库在服务端渲染应用中可能需要经过特殊处理
  • 需要更多的构建设定和部署要求
    • 不同于一个完全静态的 SPA 可以部署在任意的静态文件服务器,服务端渲染应用需要一个能够运行 Node.js 服务器的环境
  • 更多的服务端负载
    • Node.js 中渲染一个完整的应用,会比仅供应静态文件产生更密集的 CPU 运算
    • 若访问流量很高,就必须要准备好与其负载相对应的服务器,以及采取 合理的缓存策略

SSR 和 预渲染

基于以上 SSR 的优缺点对比,只有明确具体页面的具体需求才能更好的决定是否需要使用 SSR,如果只是希望通过 SSR 来改善一些 推广页面 (如 //about/contact 等) 的 SEO,那么应该优先考虑 预渲染 的方式.

SSR 是一个 动态编译 HTML 的 web 服务器,而 预渲染 可以在 构建时 为指定的路由 生成静态 HTML 文件,且预渲染的设置比 SSR 更加简单,也支持生成为一个完全静态的 HTML 文件.

预渲染 需要和 打包构建工具(webpack、rollup 等) 进行配合,如 webpack,就可通过 prerender-spa-plugin 来支持 预渲染.

同构渲染(isomorphic rendering)

基于 CSRSSR 各自的优缺点,如果可以将它们进行结合,那么就可以实现互补,而这也就是 同构渲染 需要做的事,其中的 同构 就是指 应用代码的主体 可以同时运行在 服务端客户端.

同构流程

服务端渲染应用快照

在服务端,Vue 组件会被渲染为静态的 HTML 字符串,然后发送给客户端浏览器,服务端生成的 HTML 内容是在当前数据状态下应用的快照:

  • 生成应用快照的同时,还会生成当前数据状态的 初始数据,用于提供给客户端做初始化处理
  • 应用快照不具备事件绑定能力,即定义好的事件不会被注册到对应的 DOM
  • 应用快照不具备数据响应式的能力,即不具备和用户进行数据交互的能力,不会执行 beforeUpdate、updated 生命周期
  • 应用快照不具备节点挂载的能力,即不需要在服务端运行时进行节点挂载操作,不会执行 beforeMount、mounted 生命周期钩子
  • 应用快照不具备组件销毁的能力,即不会执行组件的 beforeUnMount、unMount 生命周期钩子

服务端渲染时不提供上述的功能是因为在服务端渲染根本不需要关注这些,另外也是为了使服务端的渲染压力更小,关注更少的内容。

客户端激活

在浏览器端,需要渲染这段从服务端返回的 HTML 内容,即此时页面中已经存在 组件对应的 DOM 元素,除此之外该组件还会被打包到一个 JavaScript 文件中,并在客户端被 下载、解析、执行,也就是进入 客户端激活,后续页面内容的渲染都不需要服务器进行处理动态编译处理。

客户端的 JavaScript 脚本处理核心内容:

  • 将当前页面已渲染的 DOM 元素与 Vue.js 所渲染的 虚拟 DOM 之间建立联系
    • 由于 真实 DOM虚拟 DOM 对象都是树形结构,并且节点间存在相互对应关系,激活 就可以通过递归地在 真实 DOM虚拟 DOM 之间建立联系,即 vnode.el = el,并保证是从容器元素的第一个子节点开始,即 el.firstChild
  • 为页面中的 DOM 元素添加事件绑定,使得页面本身支持事件交互
  • Vue.jsHTML 页面中提取由服务端序列化后发送过来的数据,用于初始化整个 Vue.js 的应用程序

同构编码注意点

组件生命周期

当组件代码在服务端运行时,由于不会对组件进行真正的挂载,即不会把虚拟 DOM 渲染为真实 DOM 并且服务端渲染只是一个应用的快照,不存在数据变化后的更新渲染,因此只有 beforeCreate、created 会被执行,因此涉及相关逻辑需要注意编写生命周期钩子。

还需要注意的是,在服务端渲染时和定义器相关的一些操作是没有任何意义的,因此一般需要通过 环境变量 的方式控制对应逻辑是否需要被执行,又或者将定时器移动到只有客户端才会执行的生命周期钩子中。

跨平台 API

由于组件代码既可以运行在浏览器端,也可以运行在服务端,因此,在编码时要特别注意特定平台特有的 API,如浏览器特有的 window、document 等对象,涉及到特定 API 要么使用跨平台的第三方库来作为兼容处理,如 axios,否则仍然需要通过上述的环境变量和调动生命周期的方式达到目的。

不可控的第三方模块

通常,自己编写的组件代码是可控的,但代码中的第三方模块代码确不能保证其可控性,而很多时候我们本身也不能去修改第三方模块的代码,因此可以通过动态引入模块的方式实现加载,如 import(...),这样在配合环境变量的方式就能实现只在某一端引入模块。

避免应用状态污染

在服务端渲染时,需要为每个请求创建一个全新的应用实例,主要是为了避免不同请求共用同一个应用实例导致应用状态被污染,同时要注意在编写组件代码中可能会导出的全局变量。

构建同构渲染

服务端 要渲染 Vue 组件 意味着需要处理 *.vue*.css*.ts 等依赖模块,而这些是 node 本身就不能处理的内容,也不是 renderToString 能够处理的,因此需要借助 打包构建工具(如 webpack) 进行处理.

客户端 实际也需要一个独立的客户端构建版本,虽然最新版本的 Node.js 完全支持 ES2015 特性,但对于旧的浏览器仍然需要对代码进行转译、兼容处理.

基本思路,使用 webpack 同时打包客户端和服务端应用,其中服务端的包会被引入到服务端用来渲染 HTML,同时客户端的包会被送到浏览器用于 激活静态标记.

与之对应的两个入口文件就是:entry-client.jsentry-server.js

篇幅有限,更多具体的配置可参见 官方文档

效果演示

以下是根据官方文档配置得到运行效果:

源代码

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿