服务端渲染(SSR)
总览
什么是 SSR?
SSR(Server-Side Rendering):服务器端渲染。Vue.js 是一个用于构建客户端应用的框架。默认情况下,Vue 组件在浏览器中生成和操作 DOM 作为输出。然而,我们也可以将相同的组件在服务端渲染成 HTML 字符串,直接返回给浏览器,最后再将静态的 HTML “激活”(hydrate)为完全交互式的客户端应用。
一个由服务端渲染的 Vue.js 应用也可以被认为是“同构的”或“通用的”,因为应用的大部分代码同时运行在服务端和客户端。
为什么要用 SSR?
与客户端的单页应用(SPA)相比,SSR 的优势主要在于:
- 更快的内容到达时间:这一点在慢网速或者运行缓慢的设备上尤为重要。服务端渲染的 HTML 无需等到所有的 JavaScript 都下载并执行完成之后才显示,所以你的用户将会更快地看到完整渲染的页面。除此之外,数据获取过程在首次访问时在服务端完成,相比于从客户端获取,可能有更快的数据库连接。这通常可以带来更高的核心 Web 指标评分、更好的用户体验,而对于那些“内容到达时间与转化率直接相关”的应用来说,这点可能至关重要。
- 统一的心智模型:你可以使用相同的语言以及相同的声明式、面向组件的心智模型来开发整个应用,而不需要在后端模板系统和前端框架之间来回切换。
更好的 SEO:搜索引擎爬虫可以直接看到完全渲染的页面。截至目前,Google 和 Bing 可以很好地对同步 JavaScript 应用进行索引。这里的“同步”是关键词。如果你的应用以一个 loading 动画开始,然后通过 Ajax 获取内容,爬虫并不会等到内容加载完成再抓取。也就是说,如果 SEO 对你的页面至关重要,而你的内容又是异步获取的,那么 SSR 可能是必需的。
使用 SSR 时还有一些权衡之处需要考量:
- 开发中的限制。浏览器端特定的代码只能在某些生命周期钩子中使用;一些外部库可能需要特殊处理才能在服务端渲染的应用中运行。
- 更多的与构建配置和部署相关的要求。服务端渲染的应用需要一个能让 Node.js 服务器运行的环境,不像完全静态的 SPA 那样可以部署在任意的静态文件服务器上。
- 更高的服务端负载。在 Node.js 中渲染一个完整的应用要比仅仅托管静态文件更加占用 CPU 资源,因此如果你预期有高流量,请为相应的服务器负载做好准备,并采用合理的缓存策略。
在为你的应用使用 SSR 之前,你首先应该问自己是否真的需要它。这主要取决于内容到达时间对应用的重要程度。例如,如果你正在构建一个内部的仪表盘,初始加载时的那额外几百毫秒对你来说并不重要,这种情况下使用 SSR 就有点小题大作了。然而,在内容到达时间极其重要的场景下,SSR 可以尽可能地帮你实现最优的初始加载性能。
SSR vs. SSG
SSG(static site generator),静态站点生成,也被称为预渲染,是另一种流行的构建快速网站的技术。如果用服务端渲染一个页面所需的数据对每个用户来说都是相同的,那么我们可以只渲染一次,提前在构建过程中完成,而不是每次请求进来就重新渲染页面。预渲染的页面生成后作为静态 HTML 文件被服务器托管。
SSG 保留了和 SSR 应用相同的性能表现:它带来了优秀的内容到达耗时性能。同时,它比 SSR 应用的花销更小,也更容易部署,因为它输出的是静态 HTML 和资源文件。这里的关键词是静态:SSG 仅可以用于消费静态数据的页面,即数据在构建期间就是已知的,并且在多次部署期间不会改变。每当数据变化时,都需要重新部署。
如果你调研 SSR 只是为了优化为数不多的营销页面的 SEO(例如/
、/about
和/contact
等),那么你可能需要 SSG 而不是 SSR。SSG 也非常适合构建基于内容的网站,比如文档站点或者博客。事实上,vue 官方文档,这个网站就是使用VitePress静态生成的,它是一个由 Vue 驱动的静态站点生成器。
渲染一个应用
让我们看看一个 Vue SSR 最基本骨架的实战示例。
- 创建一个新目录并
cd
进入其中 - 运行
npm init -y
,初始化 Node.js 项目,生成package.json配置文件。 - 添加
"type":"module"
到package.json中,这样 Node.js 就会以 ESM 模式运行。 - 运行
npm install vue
,安装 vue 框架。 - 创建一个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) })
接着 linux 命令行工具中,运行命令:node example.js。它应该会在命令行中打印出如下内容:
<button>1</button>
renderToString()接收一个 Vue 应用实例作为参数,返回一个Promise,当Promise
然后我们可以将 Vue SSR 代码移到服务器请求处理器中,它将应用程序的标记与整个页面的 HTML 包装起来。我们将在下一步中使用Express,它是一个 NodeJS 平台下面的框架,主要用于构于 Web 服务器项目。
在 linux 命令行工具中,运行命令:
# 安装 Express 依赖npm install express
然后创建server.js文件:
server.js import express from 'express' import { createSSRApp } from 'vue' import { renderToString } from 'vue/server-renderer' //创建 express 实例 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') })
最后,在 linux 命令行工具中,运行命令:
# 安装 开启服务npm server.js
在浏览器中,访问http://localhost:3000。你将最终能看到页面上按钮可以正常工作了。
注意事项:
上例 SSR 中,有几点需要注意:
客户端激活
如果你点击这个按钮,你会发现数字并没有变化。这是由于我们没有在浏览器中加载 Vue,所以 HTML 在客户端是完全静态的。
要让客户端应用重新恢复可交互,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 functioncreateApp() {return createSSRApp()({ data: () => ({ count: 1 }), template: `<div @click="count++">{{ count }}</div>` }) }
该文件及其依赖项在服务器和客户端之间共享——我们称它们为通用代码。编写通用代码时需要注意许多事项,我们将在下面讨论。
我们在客户端入口导入通用代码,创建应用程序并执行挂载:
// 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 => { // ... }) })
此外,为了在浏览器中加载客户端文件,我们还需要:
- 在server.js中添加
server.use(express.static('.'))
来托管客户端文件。 - 将
<script type="module" src="/client.js"></script>
添加到 HTML 外壳以加载客户端入口文件。 - 通过在 HTML 外壳中添加 Import Map 以支持在浏览器中使用
import * from 'vue'
。
书写 SSR 友好的代码
无论你的构建配置或顶层框架的选择如何,下面的原则在所有 Vue SSR 应用中都适用。
服务端的响应性
在 SSR 期间,每一个请求 URL 都会映射到我们应用中的一个期望状态。因为没有用户交互和 DOM 更新,所以响应性在服务端是不必要的。为了更好的性能,默认情况下响应性在 SSR 期间是禁用的。
组件生命周期钩子
因为没有任何动态更新,所以像onMounted
或者onUpdated
这样的生命周期钩子不会在 SSR 期间被调用,并且只会在客户端运行。
你应该避免在setup()
或者<script setup>
的根作用域中使用会产生副作用且需要被清理的代码。这类副作用的常见例子是使用setInterval
设置定时器。我们可能会在客户端特有的代码中设置定时器,然后在onBeforeUnmount
或onUnmounted
中清除。然而,由于unmount
钩子不会在 SSR 期间被调用,所以定时器会永远存在。为了避免这种情况,请将含有副作用的代码放到onMounted
中。
访问平台特有 API
通用代码不能访问平台特有的 API,如果你的代码直接使用了浏览器特有的全局变量,比如window
或document
,他们会在 Node.js 运行时报错,反过来也一样。
对于在服务器和客户端之间共享,但使用了不同的平台 API 的任务,建议将平台特定的实现封装在一个通用的 API 中,或者使用能为你做这件事的库。例如你可以使用node-fetch
在服务端和客户端使用相同的 fetch API。
对于浏览器特有的 API,通常的方法是在仅客户端特有的生命周期钩子中惰性地访问它们,例如onMounted
。
请注意,如果一个第三方库编写时没有考虑到普遍的使用。将它集成到一个 SSR 应用程序中可能会很棘手。你或许可以通过模仿一些全局变量来让它工作,但这可能会很麻烦,并且可能会干扰其他库的环境检测代码。
跨请求状态污染
在状态管理一章中,我们介绍了一种使用响应性 API 的简单状态管理模式。而在 SSR 环境中,这种模式需要一些额外的调整。
该模式将以单例模式共享状态声明。这意味着在我们应用程序的整个生命周期中,只有一个响应式对象的实例。这在纯客户端的 Vue 应用中是可以的,因为我们的应用代码在每个浏览器页面访问时都是全新初始化的。这在纯客户端的 Vue 应用中是可以正常工作的,因为我们应用中的模块在每次浏览器页面访问时都是全新初始化的。
然而,在 SSR 环境下,应用模块通常只在服务器启动时初始化一次。同一个应用模块会在多个服务器请求之间被复用,而我们的单例状态对象也一样。如果我们用单个用户特定的数据对共享的单例状态进行修改,那么这个状态可能会意外地泄露给另一个用户的请求。我们把这种情况称为跨请求状态污染。
从技术上讲,我们可以在每次请求时重新初始化所有的 JavaScript 模块,就像在浏览器中那样。然而初始化 JavaScript 模块的成本很高,所以这将大大影响服务器的性能。
推荐的解决方案是在每个请求中创建一个新的应用程序和共享对象的实例。然后,我们使用应用级的供给来提供共享状态,并将其注入给需要它的组件中,而不是直接在我们的组件中将其导入:
// 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) { // 服务端侧实现: // 返回要被渲染的 props // getSSRProps 只接受指令绑定 return { id: binding.value } } }
更高阶解决方案
从上面的例子到一个生产就绪的 SSR 应用还需要很多工作。我们将需要:
- 支持 Vue SFC 且满足其他构建步骤要求。事实上,我们需要为同一个应用协调两个构建过程:一个用于客户端,一个用于服务器。TIP:Vue 组件用在 SSR 时的编译产物不同——模板被编译为字符串而不是 render 函数,以此提高渲染性能。
- 在服务器请求处理函数中,确保返回的 HTML 包含正确的客户端资源链接和最优的资源加载提示(如 prefetch 和 preload)。我们可能还需要在 SSR 和 SSG 模式之间切换,甚至在同一个应用中混合使用这两种模式。
- 以一种通用的方式管理路由、数据获取和状态存储。
完整的实现会非常复杂,并且取决于你选择使用的构建工具链。因此,我们强烈建议你使用一种更通用的、偏好明显的(opinionated)解决方案,帮你抽象掉那些复杂的东西。下面推荐几个 Vue 生态中的 SSR 解决方案。
Nuxt,是一个构建于 Vue 生态系统之上的通用型框架,它为编写通用 Vue 应用提供了一种流线型的开发体验。更棒的是,你还可以把它当作一个静态站点生成器来用!我们强烈建议你试一试。
Quasar,是一个基于 Vue 的完整解决方案,它可以让你用同一套代码库构建不同目标的应用,如 SPA、SSR、PWA、移动端应用、桌面端应用以及浏览器插件。除此之外,它还提供了一整套 Material Design 风格的组件库。
Vite 内置的 SSR 服务端渲染,但它在设计上是偏底层的,是为制作库和框架准备的。
如果你的目标是构建一个应用程序,可以使用更上层的 SSR 插件。
插件 | 描述 | 适用 |
---|---|---|
vite-ssr | 类似 SPA 开发体验、简化了服的务器端渲染。 | react、vue3 |
vite-plugin-ssr | 类似Nuxt/Next.js,但是作为 do-one-thing-do-it-well 框架。 | react、vue3、vue2 |
ssr | 结合Webpack/Vite服务端框架。 | react、vue3、vue2 |
vavite | 用于开发和构建具有实时重载功能的服务器端应用程序的工具。 |