likes
comments
collection
share

这一篇,带你更深入的理解 Vite SSR

作者站长头像
站长
· 阅读数 18

这一篇,带你更深入的理解 Vite SSR

网上关于 Vite 的文章主要分为入门教程或者原理解析。本篇另辟蹊径,带大家了解一下作为 2021 最受关注的构建工具,Vite 是如何做 SSR 的

这篇文章具有时效性,当前 Vite 正式版本为 2.7.13

为什么用 SSR?

介绍 Vite SSR 之前,先对 SSR 做下简单介绍

市面上大部分 SPA 项目是客户端渲染的,客户端渲染也称 CSR(client side render)

CSR 采取前后端分离的架构,当客户端请求 HTML 时,服务端仅返回“空的” HTML

空的 HTML 指仅包含一个挂载点和 Vue runtime,不包含任何页面内容的 HTML

最终客户端通过运行 Vue runtime ,动态创建页面 DOM,从而渲染完整的 HTML

这一篇,带你更深入的理解 Vite SSR

服务端渲染也称 SSR(server side render)

客户端请求 HTML 时,服务端会返回完整的 HTML

完整的 HTML 包含挂载点,Vue runtime 和页面内容的 HTML

这一篇,带你更深入的理解 Vite SSR

这一篇,带你更深入的理解 Vite SSR

(上图引用自博客 SSR 和前端编译在代码生成上是一样的)

由于在服务端返回了完整的 HTML,Vue runtime 就不需要动态生成 DOM,一方面减轻了客户端渲染的压力(动态的创建大量 DOM 会造成一定卡顿),另一方面可以让用户更早看到内容

这两点最终让采用 SSR 渲染页面的项目首屏速度(First Paint)非常快

CSR

这一篇,带你更深入的理解 Vite SSR

SSR

这一篇,带你更深入的理解 Vite SSR

除了客户端渲染和服务端渲染,还有一种预渲染的技术,即 SSG(static site generate)。构建产物时直接生成包含页面内容的 HTML 产物

SSG 与 SSR 对首屏的优化效果一致,但比 SSR 更轻量,实现更简单。适用于博客,官网等首屏数据变化不大的项目

nextjs.org/docs/basic-…

CSR vs SSR

CSR

  • 优点
    • 开发简单,不需要关心代码运行在服务端产生的兼容问题
    • 部署简单,只涉及静态资源服务器
    • 获取数据方式单一,使用 ajax/fetch 获取数据
  • 缺点
    • 首屏速度慢
    • SEO 差

SSR

  • 优点

    • 首屏速度快(浏览器版本越低效果越好)
    • SEO 好
    • 可以获取请求上下文的额外信息
  • 缺点

    • 开发复杂,需要编写 SSR 的服务端代码

    • 部署复杂,需要 SSR 服务器,以及用于降级容灾的静态资源服务器,增加后期运维成本

    • 编写代码需要考虑平台兼容性,增加额外的心智负担

使用场景

对于后台管理系统、数据中台等 toB 项目,由于对首屏要求不高,且用户量较少,一般采取 CSR 简化开发流程,快速交付项目

对于官网首页、电商首页等 toC 项目,首屏速度直接关联用户的留存率,且良好的 SEO 也是提升转化率前提,因此需要依赖 SSR 挖掘进一步的可能

项目结构(开发环境)

以简化后的 Vite 官方仓库的 SSR 项目举例

Vite SSR 项目至少有以下几个文件

├── index.html // 模版文件,包含 SSR 客户端入口
├── package.json
├── server.js // 开发环境的服务器
├── src
│   ├── App.vue // 承载页面文件的 Vue 容器
│   ├── main.js // 公共入口文件
│   ├── entry-client.js // SSR 客户端入口
│   ├── entry-server.js // SSR 服务端入口
│   └── pages // 页面文件
│       └── Home.vue
└── vite.config.js

index.html

项目模版文件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <!--preload-links-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.js"></script>
  </body>
</html>

与 CSR 区别在于

  • 多了 <!--app-html--><!--preload-links--> 的注释
  • 入口文件变成了 /src/entry-client.js

其中 <!--app-html--> 是页面内容的占位符,服务端渲染出带有页面内容的 HTML 字符串后,会替换 <!--app-html--> 占位符

<!--preload-links--> 是 preload 节点的占位符,用于生成预渲染的 HTML 字符串,开发环境没有实质作用

SSR 生产环境构建后可以选择生成 manifest.json 文件,记录了源文件和构建产物的依赖关系

{
  "src/App.vue": [
    "/assets/Inter-Italic.bab4e808.woff2",
    "/assets/Inter-Italic.7b187d57.woff"
  ],
  "vite/preload-helper": [
    "/assets/Inter-Italic.bab4e808.woff2",
    "/assets/Inter-Italic.7b187d57.woff"
  ],
  "src/router.js": [
    "/assets/Inter-Italic.bab4e808.woff2",
    "/assets/Inter-Italic.7b187d57.woff"
  ],
  ...
}

通过这份清单文件,生成用于预渲染的 HTML 字符串,并替换 <!--preload-links--> 占位符

function renderPreloadLink(file) {
  if (file.endsWith('.js')) {
    return `<link rel="modulepreload" crossorigin href="${file}">`
  } else if (file.endsWith('.css')) {
    return `<link rel="stylesheet" href="${file}">`
  } else if (file.endsWith('.woff')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
  } else if (file.endsWith('.woff2')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
  } else if (file.endsWith('.gif')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
  } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
  } else if (file.endsWith('.png')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/png">`
  } else {
    // TODO
    return ''
  }
}

为什么开发环境用不到 preload 占位符?

因为开发环境无法拿到构建的信息,例如构建 hash,也就无法生成 manifest 文件

同时开发环境生成 preload 节点几乎没有任何收益

为什么 CSR 时不需要考虑 preload,SSR 时需要额外处理?

因为 CSR 构建时会自动生成全量的 preload 代码

而 SSR 由于能获取到更多信息,例如用户具体访问了哪个页面,因此允许只加载入口所需的 preload 节点。虽然牺牲了一些便利性,但获得了灵活创建 preload 节点的能力,减少资源浪费

ssr.vuejs.org/zh/api/#sho…

也就说返回给客户端的完整 HTML 至少需要包含

  • 模版 index.html
  • 页面内容 app-html
  • preload 节点 preload-link (开发环境不需要)

其中模版是静态的,而页面内容和 preload 信息是每次请求时由服务端动态生成的

server.js

开发环境用于调试的 SSR 服务器

const app = express()
const vite = await require('vite').createServer(config)
// use vite's connect instance as middleware
app.use(vite.middlewares)
app.use('*', async (req, res) => {
  const url = req.url
  let template, render
  // always read fresh template in dev
  template = fs.readFileSync(resolve('index.html'), 'utf-8')
  template = await vite.transformIndexHtml(url, template)
  render = (await vite.ssrLoadModule('/src/entry-server.js')).render

  const [appHtml, preloadLinks] = await render(url, manifest)

  const html = template
  .replace(`<!--preload-links-->`, preloadLinks) // production only
  .replace(`<!--app-html-->`, appHtml)

  res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
})

此文件不会用于生产环境

此文件不会用于生产环境

此文件不会用于生产环境

重要的事情说三遍,打包后的产物不会有任何 server.js 相关的代码

为什么不将 server.js 放入产物中?

看下文 “生产环境无法开箱即用” 章节

server.js 的作用在于

  1. 收到页面请求时,借助 vite.ssrLoadModule 读取 SSR 服务端入口文件,返回渲染页面内容的函数(render 函数)

为什么需要用 vite.ssrLoadModule,而不是 require?

require 无法加载 ES 模块,vite.ssrLoadModule 背后采用了 dynamic import 同时能够兼容 commonJS 模块与 ES 模块

  1. 运行 render 函数得到页面内容的 HTML,并替换 index.html 中的 <!--app-html--> 占位符

其他静态资源(js/css/img)也会执行 vite.ssrLoadModule 嘛?

不会,只有作为入口的 html 会触发 vite.ssrLoadModule,其他的静态资源请求都会被 vite.middlewares 中内置的静态资源服务器提前拦截并返回

main.js

公共入口文件

import App from './App.vue'
import { createSSRApp } from 'vue'

// SSR requires a fresh app instance per request, therefore we export a function
// that creates a fresh app instance. If using Vuex, we'd also be creating a
// fresh store here.
export function bootstrap() {
  const app = createSSRApp(App)
  return { app }
}

在 SSR 中,客户端入口文件 entry-client.js 和服务端入口文件 entry-server.js 都会执行 main.js

与 CSR 区别在于,SSR 的 main.js 声明了一个名为 bootstrap 的工厂函数,替代原来立即执行

函数名没有规定,entry-client/entry-server 能正确找到函数即可

每次请求都创建了一个新的 Vue 实例,通过工厂函数保证每次请求的数据与状态互相独立

另外 SSR 用了 createSSRApp 来创建 Vue 实例,与 CSR 采用的 createApp 区别在于,createSSRApp 会根据当前所处的环境实现不同的功能

  • SSR 客户端:“激活” 静态的 HTML(下一节解释具体含义)
  • SSR 服务端:和 createApp 功能类似,区别在于,一个在客户端创建 Vue 实例,一个在服务端创建 Vue 实例

entry-client.js

SSR 客户端入口文件

import { bootstrap } from './main.js'
const { app, router } = bootstrap()

app.mount('#app')

服务端返回给客户端的 HTML 中,包含了一个 script 标签,指向 SSR 客户端入口 /src/entry-client.js

这一篇,带你更深入的理解 Vite SSR

客户端获取入口文件后,执行 main.js 里的 createSSRApp,“激活” 静态的 HTML

这一篇,带你更深入的理解 Vite SSR

由于服务端会将包含页面内容的完整 HTML 发送给客户端,因此 Vue 并不会重新创建 HTML 节点,而是借助 createSSRApp 为得到的 HTML 节点绑定事件、初始化背后 Vue 实例的状态等,从而看起来与 CSR 渲染出的页面行为相同

github.com/vuejs/core/…

这一篇,带你更深入的理解 Vite SSR

激活后的 HTML,后续完全由客户端接管, 并且行为与 CSR 一致,至此就和 SSR 服务器就没有任何关联了

本文只针对最简单的场景。对于 SSR 框架,例如 next.js, nuxt.js, remix.js,主张 SSR 客户端与 SSR 服务端一体化,hydrate 后可能还会和 SSR 服务端进行数据通讯

entry-server.js

SSR 服务端入口文件

import { bootstrap } from './main.js'
import { renderToString } from 'vue/server-renderer'

export async function render(url, manifest) {
  const { app, router } = bootstrap()
  // passing SSR context object which will be available via useSSRContext()
  // @vitejs/plugin-vue injects code into a component's setup() that registers
  // itself on ctx.modules. After the render, ctx.modules would contain all the
  // components that have been instantiated during this render call.
  const ctx = {
    url: url
  }
  const html = await renderToString(app, ctx)
  // the SSR manifest generated by Vite contains module -> chunk/asset mapping
  // which we can then use to determine what files need to be preloaded for this
  // request.
  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
  return [html, preloadLinks]
}

entry-server.js 需要导出一个生成页面内容的函数

Vue 提供了 renderToString 可以很方便的将 Vue 实例转换为页面内容的 HTML 字符串

此外,示例中的 entry-server.js 还负责动态生成 preload 节点(开发环境没有实质作用)

entry-server.js 的入参、出参、函数名都没有严格要求, server.js 能够从 entry-server.js 中正确获取 render 函数即可

render 函数中 ctx 对象的作用?

上图注释有详细解释,主要用于 SSR 服务端SSR 客户端无法使用)中服务器与 Vue 组件之间的数据通讯

ctx 可以存放一些只有 SSR 服务器才能获取的数据(cookie, header),并作为参数传入 renderToString

之后 Vue 组件可以通过 useSSRContext 返回 ctx 对象以获取这些数据

// App.vue
import { useSSRContext } from 'vue'
const isServer = typeof window === 'undefined'
const ctx = isServer ? useSSRContext() : {}

流程图

这一篇,带你更深入的理解 Vite SSR

项目结构(生产环境)

生产环境下的产物结构如下

├── client // SSR 客户端文件
│   ├── assets // 静态资源文件
│   │   ├── Home.149d59e2.css
│   │   ├── Home.c041839f.js
│   │   ├── index.b988c137.css
│   │   ├── index.cd252472.js
│   │   └── vendor.9ebeb296.js
│   ├── index.html // 模版文件,包含构建后的入口文件
│   └── ssr-manifest.json // 构建清单
└── server // SSR 服务端文件
    └── entry-server.js // SSR 服务端入口

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <!--preload-links-->
    <script type="module" crossorigin src="/assets/index.cd252472.js"></script>
    <link rel="modulepreload" href="/assets/vendor.9ebeb296.js">
    <link rel="stylesheet" href="/assets/index.b988c137.css">
  </head>
  <body>
    <div id="app"><!--app-html--></div>
  </body>
</html>

SSR 客户端入口文件 entry-client.js 经过 Vite 构建,演变成了静态资源文件

/assets/vendor.js 包含了 Vue runtime ,用于给客户端激活 HTML

/assets/index.js 包含了各个页面的 Vue 组件

<!--app-html--><!--preload-links--> 仍被保留,当 SSR 服务器收到页面请求后,会动态替换这两个占位符

ssr-manifest.json

生成预加载指令

这一篇,带你更深入的理解 Vite SSR

服务端在执行 render 函数时,会额外读取 ssr-manifest.json,动态生成 preload 节点

entry-server.js

构建时 Vite 会额外将所有 Vue 组件编译到 entry-server.js

这一篇,带你更深入的理解 Vite SSR

查看产物结构可以发现,构建时 SSR 客户端生成的静态资源文件和 entry-server.js 额外注入的代码,其实都代表相同的 Vue 组件

但考虑到 SSR 客户端与服务端之间的差异性,Vite 并没有给 entry-server.js 直接复用客户端的静态资源文件,而是额外打包了一次,个人认为还有一定的优化空间

构建后的产物里也并没有 server.js,以此验证 server.js 仅存在于开发环境

总的来说,entry-server.js 是一个只负责返回页面内容的函数,单靠 entry-server.js 并不能启动 SSR 服务器

那么如何启动生产环境的 SSR 服务器?

这里我们需要一个自定义的服务器,扮演着生产环境的 “server.js”,详情看下文 “生产环境无法开箱即用” 章节

流程图

这一篇,带你更深入的理解 Vite SSR

目前的问题

截止目前(Vite 2.7.13),Vite SSR 仍处于实验阶段

这一篇,带你更深入的理解 Vite SSR

Vite 官方单独在 issue 区开了一个讨论区

这一篇,带你更深入的理解 Vite SSR

为什么距离 Vue3 发布两年多, Vite 发布一年多的今天,Vite SSR 仍无法正式投入使用?

围绕 issue 区的讨论,主要分为以下几点问题

生产环境无法开箱即用

相关 issue: github.com/vitejs/vite…

前面提到 Vite 仅会在开发环境提供服务器,server.js 并不会存在于产物里。因此为了让代码在生产环境正常运行,还需要一个生产环境的服务器

这里肯定有人要问了,那为什么不打包 server.js 呢?

结合 issue 区的讨论,猜测 Vite 的动机

由于市面上基于 node 的服务端框架种类繁多(express, koa, nest, serverless),一旦 Vite 绑定了某种风格的服务端框架,其他风格开发者就不得不遵从

另外,提供开箱即用的服务器还会限制对其定制化的能力。为了增加框架通用性,Vite 干脆让产物不和任何服务端框架绑定,而是由开发者单独做适配

举个例子,想要在生产环境下部署 SSR 服务器,需要做这几件事:

  1. 构建源代码,将产物上传至 CDN 服务器

    这一篇,带你更深入的理解 Vite SSR
  2. 单独创建一个存放 SSR 服务器的代码仓库

  3. 创建一个自定义的服务器,可以是 express, koa 等任何技术栈

这一篇,带你更深入的理解 Vite SSR
  1. 以 express 框架举例,直接复制官方 demo(express 风格) 的 server.js 代码
这一篇,带你更深入的理解 Vite SSR
  1. 将 server.js require 的文件地址指向 CDN(也可以不用 CDN,只要确保能读取到第一步的产物即可)

    这一篇,带你更深入的理解 Vite SSR
  2. 将 process.env.NODE_ENV 设置为 "prod" ( server.js 通过 NODE_ENV 对环境作区分),运行 node server.js,启动服务器

可以发现部署生产环境的服务器还是有一定成本的,社区也意识到了这个痛点,提出了解决方案 vite-plugin-node

这一篇,带你更深入的理解 Vite SSR

该插件会为 Vite 提供多个主流的服务端框架适配器,开发者只需选择其中一个服务端框架,使得在开发环境中不再需要 server.js(目仅作用于开发环境,作者称未来会衍生到构建侧)

import { defineConfig } from 'vite';
import { VitePluginNode } from 'vite-plugin-node';

export default defineConfig({
  plugins: [
    ...VitePluginNode({
      // Nodejs native Request adapter
      // currently this plugin support 'express', 'nest', 'koa' and 'fastify' out of box,
      // you can also pass a function if you are using other frameworks, see Custom Adapter section
      adapter: 'express',
    })
  ]
});

具体 Vite 官方如何响应,还有待考证

关于外部化的争议

相关 issue:

通过两者产物的区别来理解什么是 “外部化”

外部化产物

// dist/server/entry-server.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports[Symbol.toStringTag] = "Module";
var vue = require("vue");
var serverRenderer = require("vue/server-renderer");
var vueRouter = require("vue-router");
var path = require("path");
var vuex = require("vuex");
var App_vue_vue_type_style_index_0_lang = "";
var _export_sfc = (sfc, props) => {
  const target = sfc.__vccOpts || sfc;
  for (const [key, val] of props) {
    target[key] = val;
  }
  return target;
};
// ...

非外部化产物

// dist/server/entry-server.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports[Symbol.toStringTag] = "Module";
function getAugmentedNamespace(n) {
  if (n.__esModule)
    return n;
  var a = Object.defineProperty({}, "__esModule", { value: true });
  Object.keys(n).forEach(function(k) {
    var d = Object.getOwnPropertyDescriptor(n, k);
    Object.defineProperty(a, k, d.get ? d : {
      enumerable: true,
      get: function() {
        return n[k];
      }
    });
  });
  return a;
}
var runtimeDom_cjs = {};
var runtimeCore_cjs = {};
function makeMap(str, expectsLowerCase) {}
// ...

它们的区别在于是否对代码进行打包

外部化后的产物,通过 require 形式引入依赖包(vue,vue-router,vuex),非外部化会将依赖进行打包(类似 webpack)

外部化的优点在于,不会对模块作任何处理,直接引用模块。因此尽可能外部化模块对构建速度与产物体积都会有明显优化

BUT,对于一些特殊环境,例如 worker,serverless,deno,docker ,可能对 node 原生加载模块(require/import)的能力支持有限。此时需要对项目和依赖重新打包,生成一个不依赖任何宿主环境的 js 文件

这一篇,带你更深入的理解 Vite SSR

有人觉得生产环境下,所有模块都应该外部化,提升性能,以及避免打包模块时遇到的问题

而有人觉得应该打包所有模块以确保任何平台都能够完美运行

所以目前还没有一个完美的解决方案,甚至对外部化的判断条件还没有稳定

这一篇,带你更深入的理解 Vite SSR

不兼容 commonJS 模块

相关 issue:

Vite 的模块加载器 (vite.ssrLoadModule) 加载的模块,一旦项目本身引入了 commonJS 模块,会出现以下报错

// src/entry-server.js
import { bootstrap } from './main.js'
import { renderToString } from 'vue/server-renderer'
+ const path = require("path")

export async function render(url, manifest) {
  const { app, router } = bootstrap()
  // passing SSR context object which will be available via useSSRContext()
  // @vitejs/plugin-vue injects code into a component's setup() that registers
  // itself on ctx.modules. After the render, ctx.modules would contain all the
  // components that have been instantiated during this render call.
  const ctx = {}
  const html = await renderToString(app, ctx)
  // the SSR manifest generated by Vite contains module -> chunk/asset mapping
  // which we can then use to determine what files need to be preloaded for this
  // request.
  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
  return [html, preloadLinks]
}

这一篇,带你更深入的理解 Vite SSR

原因是 Vite 的加载器由 new AsyncFunction + dynamic import 组成

这一篇,带你更深入的理解 Vite SSR

我们知道,当使用 node 运行 js 文件时,node runtime 会将文件包裹为一个函数,并注入一些特定参数

// https://nodejs.org/api/modules.html#the-module-wrapper
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
})

Vite SSR 借鉴了 node 的实现,通过一个 new AsyncFunction 包裹文件,使用 global 作为上下文,并传入了 ssrModule , ssrImportMeta 等参数

 const initModule = new AsyncFunction(
      `global`,
      ssrModuleExportsKey,
      ssrImportMetaKey,
      ssrImportKey,
      ssrDynamicImportKey,
      ssrExportAllKey,
      result.code + `\n//# sourceURL=${mod.url}`
 )

但 Vite 并没有注入 require 函数,因此一旦执行 require 函数,会提示找不到该变量

server.js 并不属于 Vite 接管的代码,可以用 require

官方解释为,Vite 应该运行在一个 ESM 模块的环境中,而 ESM 模块默认不会支持 commonJS 的语法

这一篇,带你更深入的理解 Vite SSR

这一篇,带你更深入的理解 Vite SSR

所以 Vite 理论上也不会允许例如 require, module.exports 之类的语法出现

但为了适配 npm 社区中大量的 commonJS 模块,对于 npm 包, Vite 还是会通过 pre-bundling 将 commonJS 转为 ESM,所以上述场景只针对项目本身的代码

当然 ESM 为 commonJS 预留了一个“后门”,通过 createRequire 可以在 ESM 模块中动态创建一个 commonJS 的模块加载器

import { createRequire } from 'module';
const require = createRequire(import.meta.url);

// sibling-module.js is a CommonJS module.
const siblingModule = require('./sibling-module');

SSR 开发指南

本章节节选自《Vue.Js 设计与实现》第 18 章 同构渲染

列举了一些开发中比较常见的错误,帮助大家更清楚的在 SSR 开发过程中遇到的问题以及解决方法

平台兼容性

SSR 由于同时涉及到客户端和服务端,所以需要保证项目源代码能够双端兼容

编写双端兼容的代码,也称同构代码

举个例子

案例

// storage.js
export default storage = window.localStorage

// index.vue
import storage from "./storage.js"

export default {
  // ...
}

上述代码由于存在服务端不支持的语法,并不是同构代码,服务端渲染时会发生错误

这一篇,带你更深入的理解 Vite SSR

解决方案:

通过环境变量对服务端和客户端做区分

// storage.js
export default storage = import.meta.env.SSR ? {} : window.localStorage

// index.vue
import storage from "./storage.js"
export default {
   // ...
}

若是第三方无法直接修改的模块代码,则还需要借助 dynamic import,根据环境变量,加载适合在当前环境下运行的模块

// index.vue
export default {
  async setup(){
    const storage = import.meta.env.SSR ? 
          await import("my-localStorage-server") : 
          await import("my-localStorage-client")
  }
}

同时需要补充该模块在另一端的实现,防止双端不统一造成代码逻辑上的错误

内存泄漏

大部分开发者都是从 CSR 项目转移到 SSR 项目的开发,从而没有意识到服务端开发一个非常严重的问题

“内存泄漏”

这一篇,带你更深入的理解 Vite SSR

不像浏览器和用户是一对一的关系。作为服务端,尤其是 toC 的网站,qps 可能达到成百上千的流量,一台服务器每秒会服务几千名用户,执行几千次服务端渲染

一旦将数据遗漏在服务器内存某个地方,没有及时清理,就会出现以下情况

这一篇,带你更深入的理解 Vite SSR

上图是一个内存泄漏的 SSR 服务器的内存占用率,随着时间的推移,占用率会不断变高,到达顶点后就会当前进程会崩溃重启,并再次回到原点,如此往复...

为了避免出现这样的错误,首先先要知道什么样的代码会造成内存泄漏?

案例 1

export default {
  created(){
    this.timer = setInterval(() => {
      // do something
    },1000)
  },
  beforeUnmount(){
    // clear timer
    clearInterval(this.timer)
  }
}

服务端渲染时并不会触发 beforeUnmount 事件,因此这个定时器会一直滞留在服务器的内存中

这一篇,带你更深入的理解 Vite SSR

不止 beforeUnmount,unmount/beforeUpdate/updated/beforeMount/mounted 这些钩子都不会在服务端触发

回头来看,服务端使用定时器没有任何意义,服务端渲染的是应用程序的快照,即在当前数据状态下页面应该呈现的内容。而在定时器的回调触发前,应用程序的快照已经渲染完毕了,服务端渲染并不会等待定时器回调的执行

解决方案:

  1. 将 created 中的定时器,移动到 mounted 里,只在客户端创建/销毁定时器
  2. 通过环境变量对服务端和客户端做区分
export default {
  created(){
    // set timer on client side
    if(!import.meta.env.SSR){
      this.timer = setInterval(() => {
       // do something
      },1000)
    }
  },
  beforeUnmount(){
    // clear timer on client side
    clearInterval(this.timer)
  }
}

案例 2

// my-utils-package
import mitt from 'mitt'
const emitter = mitt()
export default emitter

// index.vue
import { emitter } from 'my-utils-package'
export default {
  setup(){
    // ...
     emitter.on('click', (value) => {
      foo.value = value
    })
  }
}

服务端每次收到请求,都会向 emitter 添加一个监听事件,emitter 中存储的监听事件会随着用户请求的增加而增加

问题的原因在于 my-utils-package 导出的不是一个 mitt 函数,而是 mitt 的实例

且服务端接收请求时,并不是所有的代码都会重新执行一次

// server.js
app.use('*', async (req, res) => {
  try {
    const url = req.url
    let template, render
    if (!isProd) {
      // always read fresh template in dev
      template = fs.readFileSync(resolve('index.html'), 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      render = (await vite.ssrLoadModule('/src/entry-server.js')).render
    } else {
      template = indexProd
+     render = require('./dist/server/entry-server.js').render
    }
    const [appHtml, preloadLinks] = await render(url, manifest)
    const html = template
      .replace(`<!--preload-links-->`, preloadLinks)
      .replace(`<!--app-html-->`, appHtml)
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    vite && vite.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})

以 server.js 举例,上图 12 行代表了生产环境服务端渲染的行为

我们知道 nodejs 对 require 的模块是有缓存

因此每次请求时,只有 render 函数会重新执行,其他代码都会在第一次加载后,复用第一次的数据

这一篇,带你更深入的理解 Vite SSR

查看 entry-server 的产物,发现案例中的 emiiter 变量在 render 函数外部,并不会随着 render 函数的执行而重新创建

因此尽可能避免修改由单个文件导入的变量,或者做好清洗数据的准备

解决方案:

将 emitter “塞进” render 函数,可以使用 provide 在最顶部注入 emitter 实例,子组件使用 inject 获取

// App.vue
import mitt from 'mitt'
import { provide } from "vue"
export default {
  setup(){
    provide("emitter", mitt())
  }
}

// child.vue
import { inject } from "vue"
export default {
  setup() {
    const emitter = inject("emitter")
  }
}

服务端每次收到请求渲染页面时,都会创建新的 provide 供子孙组件消费数据,请求与请求之间就不会存在状态污染了

案例 3

<script>
  let count = 0
  export default {
    created() {
      count++     
    }
  }
</script>

和案例 2 的问题相同,count 会被打包到 render 函数以外,并且随着服务端请求次数的增加,count 会不断增加

解决方案:

使用 setup 语法糖

<script setup>
  let count = 0
  count++     
</script>

Vue3 的 setup 语法糖可以让 script 的内容运行在 setup (对应 Vue2 created 生命周期)里

每次组件渲染时都会重新执行 setup,创建新的 count 变量

总结

SSR 是指在服务端生成完整 HTML 内容的技术,能提高首屏的时间,增加 SEO,适用于用户量大,对首屏要求高的系统

SSR 同时涉及到客户端和服务端,需要编写兼容双端的代码,部署时还需要额外的 SSR 服务器

Vite SSR 目前还处于实验性质,对产物的结构,如何兼容 npm 生态还需要进一步讨论

做好平台兼容性和防止内存泄漏是同构时的重点

参考资料

为什么选择 SSR?

Vite SSR 踩坑记录

从 Next.js 看企业级框架的 SSR 支持

SSR 和前端编译在代码生成上是一样的

從你的 Node.js 專案裡找出 Memory leak,及早發現、及早治療!