outline: deep

服务端渲染 (SSR) {#server-side-rendering-ssr}

总览 {#overview}

什么是 SSR? {#what-is-ssr}

Vue.js 是一个用于构建客户端应用的框架。默认情况下,Vue 组件在浏览器中生成和操作 DOM 作为输出。然而,我们也可以将相同的组件在服务端渲染成 HTML 字符串,直接返回给浏览器,最后再将静态的 HTML “激活” (hydrate) 为完全交互式的客户端应用。

一个由服务端渲染的 Vue.js 应用也可以被认为是“同构的”或“通用的”,因为应用的大部分代码同时运行在服务端客户端。

为什么要用 SSR? {#why-ssr}

与客户端的单页应用 (SPA) 相比,SSR 的优势主要在于:

  • 更快的内容到达时间:这一点在慢网速或者运行缓慢的设备上尤为重要。服务端渲染的 HTML 无需等到所有的 JavaScript 都下载并执行完成之后才显示,所以你的用户将会更快地看到完整渲染的页面。除此之外,数据获取过程在首次访问时在服务端完成,相比于从客户端获取,可能有更快的数据库连接。这通常可以带来更高的核心 Web 指标评分、更好的用户体验,而对于那些“内容到达时间与转化率直接相关”的应用来说,这点可能至关重要。

  • 统一的心智模型:你可以使用相同的语言以及相同的声明式、面向组件的心智模型来开发整个应用,而不需要在后端模板系统和前端框架之间来回切换。

  • 更好的 SEO:搜索引擎爬虫可以直接看到完全渲染的页面。

    :::tip 截至目前,Google 和 Bing 可以很好地对同步 JavaScript 应用进行索引。这里的“同步”是关键词。如果你的应用以一个 loading 动画开始,然后通过 Ajax 获取内容,爬虫并不会等到内容加载完成再抓取。也就是说,如果 SEO 对你的页面至关重要,而你的内容又是异步获取的,那么 SSR 可能是必需的。 :::

使用 SSR 时还有一些权衡之处需要考量:

  • 开发中的限制。浏览器端特定的代码只能在某些生命周期钩子中使用;一些外部库可能需要特殊处理才能在服务端渲染的应用中运行。

  • 更多的与构建配置和部署相关的要求。服务端渲染的应用需要一个能让 Node.js 服务器运行的环境,不像完全静态的 SPA 那样可以部署在任意的静态文件服务器上。

  • 更高的服务端负载。在 Node.js 中渲染一个完整的应用要比仅仅托管静态文件更加占用 CPU 资源,因此如果你预期有高流量,请为相应的服务器负载做好准备,并采用合理的缓存策略。

在为你的应用使用 SSR 之前,你首先应该问自己是否真的需要它。这主要取决于内容到达时间对应用的重要程度。例如,如果你正在构建一个内部的仪表盘,初始加载时的那额外几百毫秒对你来说并不重要,这种情况下使用 SSR 就有点小题大作了。然而,在内容到达时间极其重要的场景下,SSR 可以尽可能地帮你实现最优的初始加载性能。

SSR vs. SSG {#ssr-vs-ssg}

静态站点生成 (SSG),也被称为预渲染,是另一种流行的构建快速网站的技术。如果用服务端渲染一个页面所需的数据对每个用户来说都是相同的,那么我们可以只渲染一次,提前在构建过程中完成,而不是每次请求进来就重新渲染页面。预渲染的页面生成后作为静态 HTML 文件被服务器托管。

SSG 保留了和 SSR 应用相同的性能表现:它带来了优秀的内容到达耗时性能。同时,它比 SSR 应用的花销更小,也更容易部署,因为它输出的是静态 HTML 和资源文件。这里的关键词是静态:SSG 仅可以用于消费静态数据的页面,即数据在构建期间就是已知的,并且在多次部署期间不会改变。每当数据变化时,都需要重新部署。

如果你调研 SSR 只是为了优化为数不多的营销页面的 SEO (例如 //about/contact 等),那么你可能需要 SSG 而不是 SSR。SSG 也非常适合构建基于内容的网站,比如文档站点或者博客。事实上,你现在正在阅读的这个网站就是使用 VitePress 静态生成的,它是一个由 Vue 驱动的静态站点生成器。

基础教程 {#basic-tutorial}

渲染一个应用 {#rendering-an-app}

让我们来看一个 Vue SSR 最基础的实战示例。

  1. 创建一个新的文件夹,cd 进入
  2. 执行 npm init -y
  3. package.json 中添加 "type":"module" 使 Node.js 以 ES modules mode 运行
  4. 执行 npm install vue
  5. 创建一个 example.js 文件:
  1. // 此文件运行在 Node.js 服务器上
  2. import { createSSRApp } from 'vue'
  3. // Vue 的服务端渲染 API 位于 `vue/server-renderer` 路径下
  4. import { renderToString } from 'vue/server-renderer'
  5. const app = createSSRApp({
  6. data: () => ({ count: 1 }),
  7. template: `<button @click="count++">{{ count }}</button>`
  8. })
  9. renderToString(app).then((html) => {
  10. console.log(html)
  11. })

接着运行:

  1. > node example.js

它应该会在命令行中打印出如下内容:

  1. <button>1</button>

renderToString() 接收一个 Vue 应用实例作为参数,返回一个 Promise,当 Promise resolve 时得到应用渲染的 HTML。当然你也可以使用 Node.js Stream API 或者 Web Streams API 来执行流式渲染。查看 SSR API 参考获取完整的相关细节。

然后我们可以把 Vue SSR 的代码移动到一个服务器请求处理函数里,它将应用的 HTML 片段包装为完整的页面 HTML。接下来的几步我们将会使用 express

  • 执行 npm install express
  • 创建下面的 server.js 文件:
  1. import express from 'express'
  2. import { createSSRApp } from 'vue'
  3. import { renderToString } from 'vue/server-renderer'
  4. const server = express()
  5. server.get('/', (req, res) => {
  6. const app = createSSRApp({
  7. data: () => ({ count: 1 }),
  8. template: `<button @click="count++">{{ count }}</button>`
  9. })
  10. renderToString(app).then((html) => {
  11. res.send(`
  12. <!DOCTYPE html>
  13. <html>
  14. <head>
  15. <title>Vue SSR Example</title>
  16. </head>
  17. <body>
  18. <div id="app">${html}</div>
  19. </body>
  20. </html>
  21. `)
  22. })
  23. })
  24. server.listen(3000, () => {
  25. console.log('ready')
  26. })

最后,执行 node server.js,访问 http://localhost:3000。你应该可以看到页面中的按钮了。

在 StackBlitz 上试试

客户端激活 {#client-hydration}

如果你点击该按钮,你会发现数字并没有改变。这段 HTML 在客户端是完全静态的,因为我们没有在浏览器中加载 Vue。

为了使客户端的应用可交互,Vue 需要执行一个激活步骤。在激活过程中,Vue 会创建一个与服务端完全相同的应用实例,然后将每个组件与它应该控制的 DOM 节点相匹配,并添加 DOM 事件监听器。

为了在激活模式下挂载应用,我们应该使用 createSSRApp() 而不是 createApp():

  1. // 该文件运行在浏览器中
  2. import { createSSRApp } from 'vue'
  3. const app = createSSRApp({
  4. // ...和服务端完全一致的应用实例
  5. })
  6. // 在客户端挂载一个 SSR 应用时会假定
  7. // HTML 是预渲染的,然后执行激活过程,
  8. // 而不是挂载新的 DOM 节点
  9. app.mount('#app')

代码结构 {#code-structure}

想想我们该如何在客户端复用服务端的应用实现。这时我们就需要开始考虑 SSR 应用中的代码结构了——我们如何在服务器和客户端之间共享相同的应用代码呢?

这里我们将演示最基础的设置。首先,让我们将应用的创建逻辑拆分到一个单独的文件 app.js 中:

  1. // app.js (在服务器和客户端之间共享)
  2. import { createSSRApp } from 'vue'
  3. export function createApp() {
  4. return createSSRApp({
  5. data: () => ({ count: 1 }),
  6. template: `<button @click="count++">{{ count }}</button>`
  7. })
  8. }

该文件及其依赖项在服务器和客户端之间共享——我们称它们为通用代码。 编写通用代码时需要注意许多事项,我们将在下面讨论

我们在客户端入口导入通用代码,创建应用程序并执行挂载:

  1. // client.js
  2. import { createApp } from './app.js'
  3. createApp().mount('#app')

服务器在请求处理函数中使用相同的应用创建逻辑:

  1. // server.js (不相关的代码省略)
  2. import { createApp } from './app.js'
  3. server.get('/', (req, res) => {
  4. const app = createApp()
  5. renderToString(app).then(html => {
  6. // ...
  7. })
  8. })

此外,为了在浏览器中加载客户端文件,我们还需要:

  1. server.js 中添加 server.use(express.static('.')) 来托管客户端文件。
  2. <script type="module" src="/client.js"></script> 添加到 HTML 外壳以加载客户端入口文件。
  3. 通过在 HTML 外壳中添加 Import Map 以支持在浏览器中使用 import * from 'vue'

在 StackBlitz 上尝试完整的示例。按钮现在可以交互了!

更通用的解决方案 {#higher-level-solutions}

从上面的例子到一个生产就绪的 SSR 应用还需要很多工作。我们将需要:

  • 支持 Vue SFC 且满足其他构建步骤要求。事实上,我们需要为同一个应用协调两个构建过程:一个用于客户端,一个用于服务器。

    :::tip Vue 组件用在 SSR 时的编译产物不同——模板被编译为字符串而不是 render 函数,以此提高渲染性能。 :::

  • 在服务器请求处理函数中,确保返回的 HTML 包含正确的客户端资源链接和最优的资源加载提示 (如 prefetch 和 preload)。我们可能还需要在 SSR 和 SSG 模式之间切换,甚至在同一个应用中混合使用这两种模式。

  • 以一种通用的方式管理路由、数据获取和状态存储。

完整的实现会非常复杂,并且取决于你选择使用的构建工具链。因此,我们强烈建议你使用一种更通用的、偏好明显的 (opinionated) 解决方案,帮你抽象掉那些复杂的东西。下面推荐几个 Vue 生态中的 SSR 解决方案。

Nuxt {#nuxt}

Nuxt 是一个构建于 Vue 生态系统之上的通用型框架,它为编写通用 Vue 应用提供了一种流线型的开发体验。更棒的是,你还可以把它当作一个静态站点生成器来用!我们强烈建议你试一试。

Quasar {#quasar}

Quasar 是一个基于 Vue 的完整解决方案,它可以让你用同一套代码库构建不同目标的应用,如 SPA、SSR、PWA、移动端应用、桌面端应用以及浏览器插件。除此之外,它还提供了一整套 Material Design 风格的组件库。

Vite SSR {#vite-ssr}

Vite 提供了内置的 Vue 服务端渲染支持,但它在设计上是偏底层的。如果你想要直接使用 Vite,可以看看 vite-plugin-ssr,一个帮你抽象掉许多复杂细节的社区插件。

你也可以在这里查看一个使用手动配置的 Vue + Vite SSR 的示例项目,以它作为基础来构建。请注意,这种方式只有在你有丰富的 SSR 和构建工具经验,且相比于更顶层的架构,你更倾向于拥有完整控制权时,才推荐使用。

书写 SSR 友好的代码 {#writing-ssr-friendly-code}

无论你的构建配置或顶层框架的选择如何,下面的原则在所有 Vue SSR 应用中都适用。

服务端的响应性 {#reactivity-on-the-server}

在 SSR 期间,每一个请求 URL 都会映射到我们应用中的一个期望状态。因为没有用户交互和 DOM 更新,所以响应性在服务端是不必要的。为了更好的性能,默认情况下响应性在 SSR 期间是禁用的。

组件生命周期钩子 {#component-lifecycle-hooks}

因为没有任何动态更新,所以像 mountedonMounted 或者 updatedonUpdated 这样的生命周期钩子不会在 SSR 期间被调用,并且只会在客户端运行。只有 beforeCreatecreated 这两个钩子会在 SSR 期间被调用。

你应该避免在 beforeCreatecreatedsetup() 或者 <script setup> 的根作用域中使用会产生副作用且需要被清理的代码。这类副作用的常见例子是使用 setInterval 设置定时器。我们可能会在客户端特有的代码中设置定时器,然后在 beforeUnmountonBeforeUnmountunmountedonUnmounted 中清除。然而,由于 unmount 钩子不会在 SSR 期间被调用,所以定时器会永远存在。为了避免这种情况,请将含有副作用的代码放到 mountedonMounted 中。

访问平台特有 API {#access-to-platform-specific-apis}

通用代码不能访问平台特有的 API,如果你的代码直接使用了浏览器特有的全局变量,比如 windowdocument,他们会在 Node.js 运行时报错,反过来也一样。

对于在服务器和客户端之间共享,但使用了不同的平台 API 的任务,建议将平台特定的实现封装在一个通用的 API 中,或者使用能为你做这件事的库。例如你可以使用 node-fetch 在服务端和客户端使用相同的 fetch API。

对于浏览器特有的 API,通常的方法是在仅客户端特有的生命周期钩子中惰性地访问它们,例如 mountedonMounted

请注意,如果一个第三方库编写时没有考虑到通用性,那么要将它集成到一个 SSR 应用中可能会很棘手。你或许可以通过模拟一些全局变量来让它工作,但这只是一种 hack 手段并且可能会影响到其他库的环境检测代码。

跨请求状态污染 {#cross-request-state-pollution}

在状态管理一章中,我们介绍了一种使用响应性 API 的简单状态管理模式。而在 SSR 环境中,这种模式需要一些额外的调整。

上述模式在一个 JavaScript 模块的根作用域中声明共享的状态。这是一种单例模式——即在应用的整个生命周期中只有一个响应式对象的实例。这在纯客户端的 Vue 应用中是可以的,因为对于浏览器的每一个页面访问,应用模块都会重新初始化。

然而,在 SSR 环境下,应用模块通常只在服务器启动时初始化一次。同一个应用模块会在多个服务器请求之间被复用,而我们的单例状态对象也一样。如果我们用单个用户特定的数据对共享的单例状态进行修改,那么这个状态可能会意外地泄露给另一个用户的请求。我们把这种情况称为跨请求状态污染

从技术上讲,我们可以在每个请求上重新初始化所有 JavaScript 模块,就像我们在浏览器中所做的那样。但是,初始化 JavaScript 模块的成本可能很高,因此这会显著影响服务器性能。

推荐的解决方案是在每个请求中为整个应用创建一个全新的实例,包括 router 和全局 store。然后,我们使用应用层级的 provide 方法来提供共享状态,并将其注入到需要它的组件中,而不是直接在组件中将其导入:

  1. // app.js (在服务端和客户端间共享)
  2. import { createSSRApp } from 'vue'
  3. import { createStore } from './store.js'
  4. export function createApp() {
  5. const app = createSSRApp(/* ... */)
  6. // 对每个请求都创建新的 store 实例
  7. const store = createStore(/* ... */)
  8. // 提供应用级别的 store
  9. app.provide('store', store)
  10. // 也为激活过程暴露出 store
  11. return { app, store }
  12. }

像 Pinia 这样的状态管理库在设计时就考虑到了这一点。请参考 Pinia 的 SSR 指南以了解更多细节。

激活异常 {#hydration-mismatch}

如果预渲染的 HTML 的 DOM 结构不符合客户端应用的期望,就会出现激活异常。在大多数场景中,这是由于浏览器原生的 HTML 解析行为试图纠正 HTML 字符串中的非法结构。举个例子,一个常见的错误是 <div> 不能被放在 <p>

  1. <p><div>hi</div></p>

如果我们在服务器渲染的 HTML 中出现这样的代码,当遇到 <div> 时,浏览器会结束第一个 <p>,并解析为以下 DOM 结构:

  1. <p></p>
  2. <div>hi</div>
  3. <p></p>

当 Vue 遇到激活异常时,它将尝试自动恢复并调整预渲染的 DOM 以匹配客户端的状态。这将导致一些渲染性能的损失,因为不正确的节点被丢弃,新的节点被加载,但在大多数情况下,应用程序应该会如预期一样继续工作。尽管如此,最好还是在开发过程中去避免激活异常。

自定义指令 {#custom-directives}

因为大多数的自定义指令都包含了对 DOM 的直接操作,所以它们会在 SSR 时被忽略。但如果你想要自己控制一个自定义指令在 SSR 时应该如何被渲染 (即应该在渲染的元素上添加哪些 attribute),你可以使用 getSSRProps 指令钩子:

  1. const myDirective = {
  2. mounted(el, binding) {
  3. // 客户端实现:
  4. // 直接更新 DOM
  5. el.id = binding.value
  6. },
  7. getSSRProps(binding) {
  8. // 服务端实现:
  9. // 返回需要渲染的 prop
  10. // getSSRProps 只接收一个 binding 参数
  11. return {
  12. id: binding.value
  13. }
  14. }
  15. }