Skip to content

服务端渲染 (SSR)

总览

什么是 SSR?

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

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

为什么要用 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

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

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

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

基础教程

渲染一个应用

让我们来看一个 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 文件:
// 此文件运行在 Node.js 服务器上
import { createSSRApp } from 'vue'
// Vue 的服务端渲染 API 位于 `vue/server-renderer` 路径下
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})

接着运行:

> node example.js

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

<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 文件:
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const server = express()

server.get('/', (req, res) => {
  const app = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('ready')
})

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

在 StackBlitz 上试试

客户端激活

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

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

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


 










// 该文件运行在浏览器中
import { createSSRApp } from 'vue'

const app = createSSRApp({
  // ...和服务端完全一致的应用实例
})

// 在客户端挂载一个 SSR 应用时会假定
// HTML 是预渲染的,然后执行激活过程,
// 而不是挂载新的 DOM 节点
app.mount('#app')

代码结构

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

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

// app.js (在服务器和客户端之间共享)
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })
}

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

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

// client.js
import { createApp } from './app.js'

createApp().mount('#app')

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


 


 





// server.js (不相关的代码省略)
import { createApp } from './app.js'

server.get('/', (req, res) => {
  const app = createApp()
  renderToString(app).then(html => {
    // ...
  })
})

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

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

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

更通用的解决方案

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

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

    TIP

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

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

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

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

Nuxt

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

Quasar

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

Vite SSR

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

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

书写 SSR 友好的代码

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

服务端的响应性

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

组件生命周期钩子

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

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

访问平台特有 API

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

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

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

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

跨请求状态污染

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

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

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

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

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

// app.js (在服务端和客户端间共享)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'

export function createApp() {
  const app = createSSRApp(/* ... */)
  // 对每个请求都创建新的 store 实例
  const store = createStore(/* ... */)
  // 提供应用级别的 store
  app.provide('store', store)
  // 也为激活过程暴露出 store
  return { app, store }
}

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

激活异常

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

<p><div>hi</div></p>

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

<p></p>
<div>hi</div>
<p></p>

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

自定义指令

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

const myDirective = {
  mounted(el, binding) {
    // 客户端实现:
    // 直接更新 DOM
    el.id = binding.value
  },
  getSSRProps(binding) {
    // 服务端实现:
    // 返回需要渲染的 prop
    // getSSRProps 只接收一个 binding 参数
    return {
      id: binding.value
    }
  }
}

Teleport

在 SSR 的过程中 Teleport 需要特殊处理。如果渲染的应用包含 Teleport,那么 teleport 的内容将不会作为渲染字符串的一部分。在大多数情况下,最佳方案是在挂载时条件式地渲染 Teleport。

如果你需要激活 teleport 内容,服务端渲染上下文对象将它们暴露在了 teleports property 下:

const ctx = {}
const html = await renderToString(app, ctx)

console.log(ctx.teleports) // { '#teleported': 'teleported content' }
You need to inject the teleport markup into the correct location in your final page HTML similar to how you need to inject the main app markup.

TIP

Avoid targeting body when using Teleports and SSR together - usually, <body> will contain other server-rendered content which makes it impossible for Teleports to determine the correct starting location for hydration.

Instead, prefer a dedicated container, e.g. <div id="teleported"></div> which contains only teleported content.

服务端渲染 (SSR) has loaded