梗概
- VUE的SSR不仅需要服务端把VUE实例渲染成HTML,还要给前端发送对应的VUE实例,形成混合
- 服务端和客户端都需要创建VUE实例,两个平台上能使用的API不同,创建的过程可能有些许不同,需要做做区分
流程
在实际的开发过程中,使用Vue进行服务器端渲染(SSR)的流程会更加复杂,需要处理路由、状态管理、模板、异步数据等问题。以下是一个更详尽的流程:
- 安装必要的依赖
安装
vue-server-renderer
等必要库,以及服务器框架如Express。
npm install vue vue-server-renderer express vue-router vuex --save
- 创建Vue实例 第一步是创建一个Vue实例。这个实例通常会使用Vue Router和Vuex进行路由和状态管理。
// app.js
const Vue = require('vue');
const Router = require('vue-router');
const Vuex = require('vuex');
Vue.use(Router);
Vue.use(Vuex);
const router = new Router({
// 定义路由规则
});
const store = new Vuex.Store({
// 定义状态管理规则
});
const app = new Vue({
router,
store,
template: `<div>App</div>`
});
module.exports = app;
- 创建服务器
服务器端,我们会创建服务器,并在每个请求到来时创建一个新的Vue实例和路由器实例,然后使用
vue-server-renderer
的renderToString
方法将Vue实例渲染成HTML字符串。
// server.js
const server = require('express')();
const renderer = require('vue-server-renderer').createRenderer();
const createApp = require('./app');
server.get('*', (req, res) => {
const context = { url: req.url };
createApp(context).then(app => {
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error');
return;
}
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>My App</title></head>
<body>${html}</body>
</html>
`);
});
});
});
server.listen(8080);
- 处理异步操作
在服务器端渲染中,我们需要先解析出当前路由需要的所有异步组件和异步数据,等待这些异步操作完成后再进行渲染。这可以通过
vue-router
的router.getMatchedComponents
方法和组件内的asyncData
方法来完成。 - child::混合 以上就是Vue SSR的基本流程。由于涉及到许多细节,实际操作可能会更加复杂。具体的实现方法可以参考Vue.js官方文档的服务器端渲染指南。
SSR需要处理的问题
- 这些问题有些是能被上层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 应用中可能会很棘手。你_或许_可以通过模拟一些全局变量来让它工作,但这只是一种 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 被浏览器原生的 HTML 解析行为纠正导致不匹配。举例来说,一个常见的错误是
<div>
不能被放在<p>
中:<p><div>hi</div></p>
如果我们在服务器渲染的 HTML 中出现这样的代码,当遇到
<div>
时,浏览器会结束第一个<p>
,并解析为以下 DOM 结构:<p></p> <div>hi</div> <p></p>
-
渲染所用的数据中包含随机生成的值。由于同一个应用会在服务端和客户端执行两次,每次执行生成的随机数都不能保证相同。避免随机数不匹配有两种选择:
-
利用
v-if
+onMounted
让需要用到随机数的模板只在客户端渲染。你所用的上层框架可能也会提供简化这个用例的内置 API,比如 VitePress 的<ClientOnly>
组件。 -
使用一个能够接受随机种子的随机数生成库,并确保服务端和客户端使用同样的随机数种子 (比如把种子包含在序列化的状态中,然后在客户端取回)。
-
-
服务端和客户端的时区不一致。有时候我们可能会想要把一个时间转换为用户的当地时间,但在服务端的时区跟用户的时区可能并不一致,我们也并不能可靠的在服务端预先知道用户的时区。这种情况下,当地时间的转换也应该作为纯客户端逻辑去执行。
当 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
}
}
}
Teleports
在 SSR 的过程中 Teleport 需要特殊处理。如果渲染的应用包含 Teleport,那么其传送的内容将不会包含在主应用渲染出的字符串中。在大多数情况下,更推荐的方案是在客户端挂载时条件式地渲染 Teleport。
如果你需要激活 Teleport 内容,它们会暴露在服务端渲染上下文对象的 teleports
属性下:
const ctx = {}
const html = await renderToString(app, ctx)
console.log(ctx.teleports) // { '#teleported': 'teleported content' }
跟主应用的 HTML 一样,你需要自己将 Teleport 对应的 HTML 嵌入到最终页面上的正确位置处。