服务端渲染(SSR)
概览
什么是 SSR?
Vue.js 是一个用于构建客户端应用程序的框架。默认情况下,Vue 组件会在浏览器中生成并操作 DOM 作为输出。然而,也可以在服务器上将相同的组件渲染为 HTML 字符串,直接发送到浏览器,最后再将静态标记“激活(hydrate)”为一个完全可交互的客户端应用。
一个服务端渲染的 Vue.js 应用也可以被认为是“同构(isomorphic)”或“通用(universal)”的,因为你的应用大部分代码会同时在服务器和客户端运行。
为什么使用 SSR?
与客户端单页应用(SPA)相比,SSR 的优势主要体现在:
更快的内容呈现时间:在慢网速或低性能设备上这一点更明显。服务端渲染的标记不需要等到所有 JavaScript 下载并执行完成后才显示,因此用户会更早看到完整渲染的页面。此外,首次访问时的数据获取是在服务端完成的,而服务端通常与数据库的连接比客户端更快。这通常会带来更好的 Core Web Vitals 指标、更好的用户体验,并且对于内容呈现时间与转化率直接相关的应用来说,这可能至关重要。
统一的心智模型:你可以在整个应用开发中使用同一种语言和同一种声明式、组件化的心智模型,而不必在后端模板系统和前端框架之间来回切换。
更好的 SEO:搜索引擎爬虫会直接看到完整渲染后的页面。
TIP
截至目前,Google 和 Bing 都可以很好地索引同步的 JavaScript 应用。这里的关键词是“同步”。如果你的应用一开始显示的是加载中的转圈,然后再通过 Ajax 拉取内容,爬虫不会等待你完成。这意味着,如果你在 SEO 很重要的页面上使用异步获取内容,SSR 可能是必要的。
使用 SSR 也有一些需要权衡的地方:
开发约束。特定于浏览器的代码只能在某些生命周期钩子中使用;一些第三方库可能需要特殊处理才能在服务端渲染应用中运行。
构建配置和部署要求更复杂。不同于可以部署到任意静态文件服务器的纯静态 SPA,服务端渲染应用需要一个可以运行 Node.js 服务器的环境。
更高的服务端负载。在 Node.js 中渲染整个应用会比仅仅提供静态文件更消耗 CPU,因此如果你预期流量很高,就要准备好相应的服务器负载,并合理使用缓存策略。
在为你的应用使用 SSR 之前,你首先应该问自己的是:你是否真的需要它。它主要取决于内容呈现时间对你的应用有多重要。例如,如果你正在构建一个内部仪表盘,首次加载时多花几百毫秒并没有那么重要,那么 SSR 就有些过度了。然而,在内容呈现时间绝对关键的场景下,SSR 可以帮助你获得尽可能好的初始加载性能。
SSR 与 SSG
静态站点生成(SSG),也称为预渲染,是另一种流行的构建快速网站的技术。如果服务端渲染某个页面所需的数据对每个用户都相同,那么与其在每次请求到来时都渲染页面,我们可以只在构建过程中提前渲染一次。预渲染页面会被生成并以静态 HTML 文件的形式提供。
SSG 保留了与 SSR 应用相同的性能特征:它能提供很好的内容呈现时间性能。同时,由于输出的是静态 HTML 和资源文件,它比 SSR 应用更便宜,也更容易部署。这里的关键词是静态:SSG 只能用于提供静态数据的页面,也就是在构建时已知且在请求之间不会改变的数据。每当数据变化时,都需要进行一次新的部署。
如果你只是为了提升少数营销页面(例如 /、/about、/contact 等)的 SEO 而在研究 SSR,那么你可能更需要 SSG 而不是 SSR。SSG 也非常适合基于内容的网站,例如文档站点或博客。事实上,你现在正在阅读的这个网站就是使用 VitePress 静态生成的,这是一个由 Vue 驱动的静态站点生成器。
基础教程
渲染一个应用
让我们来看一个最简化的 Vue SSR 示例。
- 创建一个新目录并
cd进入 - 运行
npm init -y - 在
package.json中添加"type": "module",以便 Node.js 以 ES modules 模式运行。 - 运行
npm install vue - 创建一个
example.js文件:
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)
})然后运行:
sh
> node example.js它应该会在命令行中输出如下内容:
<button>1</button>renderToString() 接收一个 Vue 应用实例,并返回一个 Promise,该 Promise 会解析为应用渲染后的 HTML。也可以使用 Node.js Stream API 或 Web Streams API 进行流式渲染。完整细节请查看 SSR API 参考。
接下来,我们可以把 Vue SSR 代码放入服务端请求处理程序中,用完整的页面 HTML 包裹应用标记。下面的步骤将使用 express:
- 运行
npm install express - 创建如下
server.js文件:
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 示例</title>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`)
})
})
server.listen(3000, () => {
console.log('ready')
})最后,运行 node server.js 并访问 http://localhost:3000。你应该能看到页面正常工作并带有按钮。
客户端激活
如果你点击按钮,你会注意到数字没有变化。由于我们没有在浏览器中加载 Vue,客户端上的 HTML 是完全静态的。
为了让客户端应用具备交互性,Vue 需要执行 激活(hydration) 步骤。在激活过程中,它会创建与服务器上运行时相同的 Vue 应用,将每个组件与其应控制的 DOM 节点进行匹配,并附加 DOM 事件监听器。
要以激活模式挂载应用,我们需要使用 createSSRApp() 而不是 createApp():
js
// 这段代码运行在浏览器中。
import { createSSRApp } from 'vue'
const app = createSSRApp({
// ...与服务器上的应用相同
})
// 在客户端挂载 SSR 应用时,会假定
// HTML 已经预渲染,并会执行
// 激活而不是创建新的 DOM 节点。
app.mount('#app')代码结构
请注意,我们需要复用与服务器端相同的应用实现。这就是我们开始思考 SSR 应用中的代码结构的地方——我们该如何在服务器和客户端之间共享相同的应用代码?
这里我们将展示一个最简化的设置。首先,把应用创建逻辑拆分到一个独立文件 app.js 中:
js
// (服务器和客户端共享)
import { createSSRApp } from 'vue'
export function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<button @click="count++">{{ count }}</button>`
})
}这个文件及其依赖会在服务器和客户端之间共享——我们把它们称为通用代码(universal code)。编写通用代码时有很多需要注意的地方,我们会在下文中讨论。
我们的客户端入口会导入通用代码,创建应用并执行挂载:
js
import { createApp } from './app.js'
createApp().mount('#app')而服务器则在请求处理程序中使用相同的应用创建逻辑:
js
// (省略无关代码)
import { createApp } from './app.js'
server.get('/', (req, res) => {
const app = createApp()
renderToString(app).then(html => {
// ...
})
})此外,为了在浏览器中加载客户端文件,我们还需要:
- 通过在
server.js中添加server.use(express.static('.'))来提供客户端文件。 - 通过向 HTML 外壳中添加
<script type="module" src="/client.js"></script>来加载客户端入口。 - 通过向 HTML 外壳中添加 Import Map 来支持在浏览器中使用
import * from 'vue'之类的写法。
在 StackBlitz 上查看完整示例。现在按钮已经可以交互了!
更高级的方案
从示例过渡到一个可用于生产的 SSR 应用,会涉及更多内容。我们需要:
支持 Vue SFC 和其他构建步骤需求。实际上,我们需要为同一个应用协调两次构建:一次用于客户端,一次用于服务器。
TIP
Vue 组件在 SSR 场景下的编译方式不同——模板会被编译为字符串拼接,而不是虚拟 DOM 渲染函数,以获得更高效的渲染性能。
在服务端请求处理程序中,用正确的客户端资源链接和最优的资源提示来渲染 HTML。我们还可能需要在 SSR 和 SSG 模式之间切换,甚至在同一个应用中混用两者。
以通用的方式管理路由、数据获取和状态管理存储。
完整的实现会相当复杂,并且取决于你选择使用的构建工具链。因此,如果你需要 SSR,我们强烈建议使用 Vue 框架,因为它们通常内置了 SSR 支持。
编写适合 SSR 的代码
无论你的构建设置或更高层框架选择如何,在所有 Vue SSR 应用中都有一些通用原则适用。
服务端的响应式
在 SSR 期间,每个请求 URL 都映射到我们应用的某个期望状态。此时没有用户交互,也没有 DOM 更新,因此在服务端不需要响应式。默认情况下,为了获得更好的性能,SSR 期间会禁用响应式。
组件生命周期钩子
由于没有动态更新,诸如 onMounted 或 onUpdated 之类的生命周期钩子在 SSR 期间不会被调用,只会在客户端执行。
你应该避免在 setup() 或 <script setup> 的根作用域 中编写会产生副作用且需要清理的代码。此类副作用的一个例子是使用 setInterval 设置定时器。在仅客户端代码中,我们可能会设置一个定时器,然后在 onBeforeUnmount 或 onUnmounted 中将其清除。然而,由于卸载钩子在 SSR 期间永远不会被调用,定时器将会一直存在。为避免这种情况,请将你的副作用代码改为放在 onMounted 中。
访问平台特定的 API
通用代码不能假定能够访问平台特定的 API,因此如果你的代码直接使用了诸如 window 或 document 这类仅浏览器可用的全局对象,那么它们在 Node.js 中执行时会抛出错误,反之亦然。
对于服务端和客户端共享但平台 API 不同的任务,建议将平台特定的实现封装在一个通用 API 内部,或者使用能替你完成这件事的库。例如,你可以使用 node-fetch 在服务端和客户端都使用同样的 fetch API。
对于仅浏览器可用的 API,常见做法是在仅客户端的生命周期钩子中延迟访问它们,例如 onMounted。
请注意,如果某个第三方库并未考虑通用场景,那么将它集成到服务端渲染应用中可能会比较棘手。你 也许 能通过模拟一些全局对象让它工作,但这会比较取巧,并且可能会干扰其他库的环境检测代码。
跨请求状态污染
在状态管理章节中,我们介绍了一个使用响应式 API 的简单状态管理模式。在 SSR 场景下,这种模式需要一些额外调整。
这种模式将共享状态声明在 JavaScript 模块的根作用域中。这使它们成为单例——也就是说,在整个应用生命周期中,响应式对象只有一个实例。这在纯客户端的 Vue 应用中是符合预期的,因为我们的应用模块会在每次浏览器页面访问时重新初始化。
然而,在 SSR 场景中,应用模块通常只会在服务端启动时初始化一次。相同的模块实例会在多次服务请求之间复用,我们的单例状态对象也是如此。如果我们修改了包含某个用户特定数据的共享单例状态,这些数据可能会意外泄漏到另一个用户的请求中。我们称之为跨请求状态污染。
从技术上讲,我们可以像在浏览器中一样,在每个请求中重新初始化所有 JavaScript 模块。然而,初始化 JavaScript 模块可能会很昂贵,因此这会显著影响服务端性能。
推荐的解决方案是在每个请求中创建整个应用的新实例——包括路由器和全局 store。然后,不要在组件中直接导入它,而是通过应用级 provide 提供共享状态,并在需要它的组件中注入:
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 以用于 hydration
return { app, store }
}像 Pinia 这样的状态管理库就是基于这一点设计的。更多细节请参阅 Pinia 的 SSR 指南。
Hydration 不匹配
如果预渲染 HTML 的 DOM 结构与客户端应用期望输出不一致,就会出现 hydration mismatch 错误。Hydration mismatch 最常见的产生原因如下:
模板包含了无效的 HTML 嵌套结构,而渲染出来的 HTML 被浏览器原生的 HTML 解析行为“修正”了。例如,一个常见的坑是
<div>不能放在<p>内:html<p><div>hi</div></p>如果我们在服务端渲染的 HTML 中生成了这样的内容,浏览器在遇到
<div>时会终止第一个<p>,并将其解析为如下 DOM 结构:html<p></p> <div>hi</div> <p></p>渲染期间使用的数据包含随机生成的值。由于同一个应用会运行两次——一次在服务端,一次在客户端——随机值不能保证在两次运行之间相同。要避免由随机值引起的不匹配,有两种方式:
使用
v-if+onMounted让依赖随机值的部分只在客户端渲染。你的框架也可能提供内置功能来让这更容易,例如 VitePress 中的<ClientOnly>组件。使用支持种子生成的随机数生成库,并保证服务端运行和客户端运行使用相同的种子(例如,将种子包含在序列化状态中,并在客户端读取它)。
服务端和客户端处于不同的时区。有时,我们可能希望将时间戳转换为用户的本地时间。然而,服务端运行时的时区和客户端运行时的时区并不总是相同,而且在服务端运行时我们也不能可靠地知道用户的时区。在这种情况下,本地时间转换也应该作为仅客户端操作来执行。
当 Vue 遇到 hydration mismatch 时,它会尝试自动恢复并调整预渲染的 DOM,使其与客户端状态一致。这会因为丢弃错误节点并挂载新节点而导致一定的渲染性能损失,但在大多数情况下,应用应该仍然能按预期工作。尽管如此,最好还是在开发期间消除 hydration mismatch。
抑制 Hydration 不匹配
在 Vue 3.5+ 中,可以通过使用 data-allow-mismatch 属性有选择地抑制不可避免的 hydration mismatch。
自定义指令
由于大多数自定义指令都涉及直接的 DOM 操作,它们在 SSR 期间会被忽略。不过,如果你想指定自定义指令应如何渲染(即它应该向渲染后的元素添加哪些属性),可以使用 getSSRProps 指令钩子:
js
const myDirective = {
mounted(el, binding) {
// 客户端实现:
// 直接更新 DOM
el.id = binding.value
},
getSSRProps(binding) {
// 服务端实现:
// 返回要渲染的 props。
// getSSRProps 只接收指令绑定对象。
return {
id: binding.value
}
}
}Teleports
Teleport 在 SSR 期间需要特殊处理。如果渲染后的应用包含 Teleport,传送出去的内容不会成为渲染字符串的一部分。一个更简单的解决方案是在挂载时有条件地渲染 Teleport。
如果你确实需要对传送的内容进行 hydration,它们会暴露在 ssr context 对象的 teleports 属性中:
js
const ctx = {}
const html = await renderToString(app, ctx)
console.log(ctx.teleports) // { '#teleported': 'teleported content' }你需要将 teleport 的标记注入最终页面 HTML 中的正确位置,这与注入主应用标记的方式类似。
TIP
在将 Teleport 与 SSR 一起使用时,避免将目标指向 body——通常,<body> 中还会包含其他服务端渲染内容,这会使 Teleport 无法确定 hydration 的正确起始位置。
相反,建议使用一个专用容器,例如 <div id="teleported"></div>,其中只包含传送内容。