目录
[18.4 客户端激活的原理](#18.4 客户端激活的原理)
[18.5 编写同构的代码](#18.5 编写同构的代码)
[18.5.1 组件的生命周期](#18.5.1 组件的生命周期)
[18.5.2 使用跨平台的 API](#18.5.2 使用跨平台的 API)
[18.5.3 只在某一端引入模块](#18.5.3 只在某一端引入模块)
[18.5.4 避免交叉请求引起的状态污染](#18.5.4 避免交叉请求引起的状态污染)
[18.5.5 ClientOnly 组件](#18.5.5 ClientOnly 组件)
[18.6 总结](#18.6 总结)
18.4 客户端激活的原理
什么是客户端激活呢?我们知道,对于同构渲染来说,组件的代码会在服务端和客户端分别执行一次。
在服务端,组件会被渲染为静态的 HTML 字符串,然后发送给浏览器,浏览器再把这段纯静态的 HTML 渲染出来。
此时页面中已经存在对应的 DOM 元素。同时,该组件还会被打包到一个 JavaScript 文件中,最终在浏览器解释并执行。
这时问题来了,当组件的代码在客户端执行时,会再次创建 DOM 元素吗?答案是"不会"。
由于浏览器在渲染了由服务端发送过来的 HTML 字符串之后,页面中已经存在对应的 DOM 元素了,所以组件代码在客户端运行时,不需要再次创建相应的 DOM 元素。
但是,组件代码在客户端运行时,仍然需要做两件重要的事:
- 在页面中的 DOM 元素与虚拟节点对象之间建立联系。
- 为页面中的 DOM 元素添加事件绑定。
我们知道,一个虚拟节点被挂载之后,为了保证更新程序能正确运行,需要通过该虚拟节点的 vnode.el 属性存储对真实 DOM 对象的引用。
而同构渲染也是一样,为了应用程序在后续更新过程中能够正确运行,我们需要在页面中已经存在的 DOM 对象与虚拟节点对象之间建立正确的联系。
另外,在服务端渲染的过程中,会忽略虚拟节点中与事件相关的 props。所以,当组件代码在客户端运行时,我们需要将这些事件正确地绑定到元素上。
这两个步骤就体现了客户端激活的含义。
我们来看下客户端激活的具体实现。当组件进行纯客户端渲染时,我们通过渲染器的 renderer.render 函数来完成渲染,例如:
renderer.render(vnode, container)
而对于同构应用,我们将使用独立的 renderer.hydrate 函数来完成激活:
renderer.hydrate(vnode, container)
实际上,我们可以用代码模拟从服务端渲染到客户端激活的整个过程,如下所示:
// html 代表由服务端渲染的字符串
const html = renderComponentVNode(compVNode)
// 假设客户端已经拿到了由服务端渲染的字符串
// 获取挂载点
const container = document.querySelector('#app')
// 设置挂载点的 innerHTML,模拟由服务端渲染的内容
container.innerHTML = html
// 接着调用 hydrate 函数完成激活
renderer.hydrate(compVNode, container)
其中 CompVNode 的代码如下:
const MyComponent = {
name: 'App',
setup() {
const str = ref('foo')
return () => {
return {
type: 'div',
children: [
{
type: 'span',
children: str.value,
props: {
onClick: () => {
str.value = 'bar'
}
}
},
{ type: 'span', children: 'baz' }
]
}
}
}
}
const CompVNode = {
type: MyComponent
}
接下来,我们着手实现 renderer.hydrate 函数。
与 renderer.render 函数一样,renderer.hydrate 函数也是渲染器的一部分,因此它也会作为 createRenderer 函数的返回值,如下面的代码所示:
function createRenderer(options) {
function hydrate(node, vnode) {
// ...
}
return {
render,
// 作为 createRenderer 函数的返回值
hydrate
}
}
这样,我们就可以通过 renderer.hydrate 函数来完成客户端激活了。
在具体实现其函数之前,我们先来看一下页面中已经存在的真实 DOM 元素与虚拟 DOM 对象之间的关系,如下图:

可以看到,真实 DOM 元素与虚拟 DOM 对象都是树型结构,并且节点之间存在一一对应的关系。
因此,我们可以认为它们是"同构"的。而激活的原理就是基于这一事实,递归地在真实 DOM 元素与虚拟 DOM 节点之间建立关系。
另外,在虚拟 DOM 中并不存在与容器元素(挂载点)对应的节点。
因此,在激活的时候,应该从容器元素的第一个子节点开始,如下面的代码所示:
function hydrate(vnode, container) {
// 从容器元素的第一个子节点开始
hydrateNode(container.firstChild, vnode)
}
其中,hydrateNode 函数接收两个参数,分别是真实 DOM 元素和虚拟 DOM 元素。hydrateNode 函数的具体实现如下
function hydrateNode(node, vnode) {
const { type } = vnode
// 1. 让 vnode.el 引用真实 DOM
vnode.el = node
// 2. 检查虚拟 DOM 的类型,如果是组件,则调用 mountComponent 函数完成激活
if (typeof type === 'object') {
mountComponent(vnode, container, null)
} else if (typeof type === 'string') {
// 3. 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配
if (node.nodeType !== 1) {
console.error('mismatch')
console.error('服务端渲染的真实 DOM 节点是:', node)
console.error('客户端渲染的虚拟 DOM 节点是:', vnode)
} else {
// 4. 如果是普通元素,则调用 hydrateElement 完成激活
hydrateElement(node, vnode)
}
}
// 5. 重要:hydrateNode 函数需要返回当前节点的下一个兄弟节点,以便继续进行后续的激活操作
return node.nextSibling
}
上述代码关键:首先,要在真实 DOM 元素与虚拟 DOM 元素之间建立联系,即 vnode.el = node。这样才能保证后续更新操作正常进行。
其次,我们需要检测虚拟 DOM 的类型,并据此判断应该执行怎样的激活操作。
在上面的代码中,我们展示了对组件和普通元素类型的虚拟节点的处理。
可以看到,在激活普通元素类型的节点时,我们检查真实 DOM 元素的类型与虚拟 DOM 的类型是否相同,如果不同,则需要打印 mismatch 错误,即客户端渲染的节点与服务端渲染的节点不匹配。
同时,为了能够让用户快速定位问题节点,保证开发体验,我们最好将客户端渲染的虚拟节点与服务端渲染的真实 DOM 节点都打印出来,供用户参考。
对于组件类型节点的激活操作,则可以直接通过 mountComponent 函数来完成。
对于普通元素的激活操作,则可以通过 hydrateElement 函数来完成。
最后,hydrateNode 函数需要返回当前激活节点的下一个兄弟节点,以便进行后续的激活操作。
hydrateNode 函数的返回值非常重要,它的用途体现在hydrateElement 函数内,如下所示:
// 用来激活普通元素类型的节点
function hydrateElement(el, vnode) {
// 1. 为 DOM 元素添加事件
if (vnode.props) {
for (const key in vnode.props) {
// 只有事件类型的 props 需要处理
if (/^on/.test(key)) {
patchProps(el, key, null, vnode.props[key])
}
}
}
// 递归地激活子节点
if (Array.isArray(vnode.children)) {
// 从第一个子节点开始
let nextNode = el.firstChild
const len = vnode.children.length
for (let i = 0; i < len; i++) {
// 激活子节点,注意,每当激活一个子节点,hydrateNode 函数都会返回当前子节点的下一个兄弟节点,
// 于是可以进行后续的激活了
nextNode = hydrateNode(nextNode, vnode.children[i])
}
}
}
hydrateElement 函数有两个关键点:
- 因为服务端渲染是忽略事件的,浏览器只是渲染了静态的 HTML 而已,所以激活 DOM 元素的操作之一就是为其添加事件处理程序。
- 递归地激活当前元素的子节点,从第一个子节点 el.firstChild 开始,递归地调用 hydrateNode 函数完成激活。注意这里的小技巧,hydrateNode 函数会返回当前节点的下一个兄弟节点,利用这个特点即可完成所有子节点的处理。
对于组件的激活,我们还需要针对性地处理 mountComponent 函数。
由于服务端渲染的页面中已经存在真实 DOM 元素,所以当调用 mountComponent 函数进行组件的挂载时,无须再次创建真实 DOM 元素。
基于此,我们需要对mountComponent 函数做一些调整,如下所示:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
instance.update = effect(
() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
beforeMount && beforeMount.call(renderContext)
// 如果 vnode.el 存在,则意味着要执行激活
if (vnode.el) {
// 直接调用 hydrateNode 完成激活
hydrateNode(vnode.el, subTree)
} else {
// 正常挂载
patch(null, subTree, container, anchor)
}
instance.isMounted = true
mounted && mounted.call(renderContext)
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
beforeUpdate && beforeUpdate.call(renderContext)
patch(instance.subTree, subTree, container, anchor)
updated && updated.call(renderContext)
}
instance.subTree = subTree
},
{
scheduler: queueJob
}
)
}
可以看到,hydrateNode 函数所做的第一件事是什么吗?是在真实 DOM 与虚拟 DOM 之间建立联系,即 vnode.el = node。
所以,当渲染副作用执行挂载操作时,我们优先检查虚拟节点的 vnode.el 属性是否已经存在,如果存在,则意味着无须进行全新的挂载,只需要进行激活操作即可,否则仍然按照之前的逻辑进行全新的挂载。
最后一个关键点是,组件的激活操作需要在真实 DOM 与 subTree 之间进行。
18.5 编写同构的代码
"同构"一词指的是一份代码既在服务端运行,又在客户端运行。因此,在编写组件代码时,应该额外注意因代码运行环境的不同所导致的差异。
18.5.1 组件的生命周期
我们知道,当组件的代码在服务端运行时,由于不会对组件进行真正的挂载操作,即不会把虚拟 DOM 渲染为真实 DOM 元素,所以组件的 beforeMount 与mounted 这两个钩子函数不会执行。
又因为服务端渲染的是应用的快照,所以不存在数据变化后的重新渲染,因此,组件的 beforeUpdate 与 updated 这两个钩子函数也不会执行。
另外,在服务端渲染时,也不会发生组件被卸载的情况,所以组件的 beforeUnmount 与 unmounted 这两个钩子函数也不会执行。
实际上,只有 beforeCreate 与 created 这两个钩子函数会在服务端执行,所以当你编写组件代码时需要额外注意。如下是一段常见的问题代码:
<script>
export default {
created() {
this.timer = setInterval(() => {
// 做一些事情
}, 1000)
},
beforeUnmount() {
// 清除定时器
clearInterval(this.timer)
}
}
</script>
观察上面这段组件代码,我们在 created 钩子函数中设置了一个定时器,并尝试在组件被卸载之前将其清除,即在 beforeUnmount 钩子函数执行时将其清除。
如果在客户端运行这段代码,并不会产生任何问题;但如果在服务端运行,则会造成内存泄漏。因为 beforeUnmount 钩子函数不会在服务端运行,所以这个定时器将永远不会被清除。
实际上,在 created 钩子函数中设置定时器对于服务端渲染没有任何意义。
这是因为服务端渲染的是应用程序的快照,所谓快照,指的是在当前数据状态下页面应该呈现的内容。
所以,在定时器到时,修改数据状态之前,应用程序的快照已经渲染完毕了。
所以我们说,在服务端渲染时,定时器内的代码没有任何意义。遇到这类问题时,我们通常有两个解决方案:
- 方案一:将创建定时器的代码移动到 mounted 钩子中,即只在客户端执行定时器。
- 方案二:使用环境变量包裹这段代码,让其不在服务端运行。
方案二依赖项目的环境变量。例如,在通过 webpack 或 Vite 等构建工具搭建的同构项目中,通常带有这种环境变量。
以Vite 为例,我们可以使用 import.meta.env.SSR 来判断当前代码的运行环境:
<script>
export default {
created() {
// 只在非服务端渲染时执行,即只在客户端执行
if (!import.meta.env.SSR) {
this.timer = setInterval(() => {
// 做一些事情
}, 1000)
}
},
beforeUnmount() {
clearInterval(this.timer)
}
}
</script>
可以看到,我们通过 import.meta.env.SSR 来使代码只在 SSR 环境运行。
实际上,构建工具会分别为客户端和服务端输出两个独立的包。
构建工具在为客户端打包资源的时候,会在资源中排除被 import.meta.env.SSR 包裹的代码。上面的代码中被 !import.meta.env.SSR 包裹的代码只会在客户端包中存在。
18.5.2 使用跨平台的 API
编写同构代码的另一个关键点是使用跨平台的 API。
由于组件的代码既运行于浏览器,又运行于服务器,所以在编写代码的时候要避免使用平台特有的API。
例如,仅在浏览器环境中才存在的 window、document 等对象。
然而,有时不得不使用这些平台特有的 API。这时可以使用诸如 import.meta.env.SSR 这样的环境变量来做代码守卫:
<script>
if (!import.meta.env.SSR) {
// 使用浏览器平台特有的 API
window.xxx
}
export default {
// ...
}
</script>
类似地,Node.js 中特有的 API 也无法在浏览器中运行。
因此,为了减轻开发时的心智负担,我们可以选择跨平台的第三方库。例如,使用 Axios 作为网络请求库。
18.5.3 只在某一端引入模块
通常情况下,我们自己编写的组件的代码是可控的,这时我们可以使用跨平台的 API 来保证代码"同构"。
然而,第三方模块的代码非常不可控。假设我们有如下组件:
<script>
import storage from './storage.js'
export default {
// ...
}
</script>
上面这段组件代码本身没有任何问题,但它依赖了 ./storage.js 模块。
如果该模块中存在非同构的代码,则仍然会发生错误。假设 ./storage.js 模块的代码如下:
// storage.js
export const storage = window.localStorage
可以看到,./storage.js 模块中依赖了浏览器环境下特有的 API,即window.localStorage。因此,当进行服务端渲染时会发生错误。
对于这个问题,有两种解决方案:
方案一是使用 import.meta.env.SSR 来做代码守卫:
// storage.js
export const storage = !import.meta.env.SSR ? window.localStorage : {}
这样做虽然能解决问题,但是在大多数情况下我们无法修改第三方模块的代码。
因此,更多时候我们会采用接下来介绍的方案二来解决问题,即条件引入:
<script>
let storage
// 只有在非 SSR 下才引入 ./storage.js 模块
if (!import.meta.env.SSR) {
storage = import('./storage.js')
}
export default {
// ...
}
</script>
上面这段代码是修改后的组件代码。可以看到,我们通过 import.meta.env.SSR 做了代码守卫,实现了特定环境下的模块加载。
但是在上面的代码中,./storage.js 模板的代码仅会在客户端生效。也就是说,服务端将会缺失该模块的功能。
为了弥补这个缺陷,我们通常需要根据实际情况,再实现一个具有同样功能并且可运行于服务端的模块,如下面的代码所示:
<script>
let storage
if (!import.meta.env.SSR) {
// 用于客户端
storage = import('./storage.js')
} else {
// 用于服务端
storage = import('./storage-server.js')
}
export default {
// ...
}
</script>
可以看到,我们根据环境的不同,引入不用的模块实现。
18.5.4 避免交叉请求引起的状态污染
编写同构代码时,额外需要注意的是,避免交叉请求引起的状态污染。
在服务端渲染时,我们会为每一个请求创建一个全新的应用实例,例如:
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from 'App.vue'
// 每个请求到来,都会执行一次 render 函数
async function render(url, manifest) {
// 为当前请求创建应用实例
const app = createSSRApp(App)
const ctx = {}
const html = await renderToString(app, ctx)
return html
}
可以看到,每次调用 render 函数进行服务端渲染时,都会为当前请求调用 createSSRApp 函数来创建一个新的应用实例。
这是为了避免不同请求共用同一个应用实例所导致的状态污染。
除了要为每一个请求创建独立的应用实例之外,状态污染的情况还可能发生在单个组件的代码中,如下所示:
<script>
// 模块级别的全局变量
let count = 0
export default {
create() {
count++
},
}
</script>
如果上面这段组件的代码在浏览器中运行,则不会产生任何问题,因为浏览器与用户是一对一的关系,每一个浏览器都是独立的。
但如果这段代码在服务器中运行,因为服务器与用户是一对多的关系。
当用户 A 发送请求到服务器时,服务器会执行上面这段组件的代码,即执行 count++。
接着,用户 B 也发送请求到服务器,服务器再次执行上面这段组件的代码,此时的 count 已经因用户 A 的请求自增了一次,因此对于用户 B 而言,用户A 的请求会影响到他,于是就会造成请求间的交叉污染。所以,在编写组件代码时,要额外注意组件中出现的全局变量。
18.5.5 ClientOnly 组件
最后,我们再来介绍一个对编写同构代码非常有帮助的组件,即 <ClientOnly>
组件。
在日常开发中,我们经常会使用第三方模块。而它们不一定对 SSR 友好,例如:
<template>
<SsrIncompatibleComp />
</template>
假设 <SsrIncompatibleComp />
是一个不兼容 SSR 的第三方组件,我们没有办法修改它的源代码,这时应该怎么办呢?
这时我们会想,既然这个组件不兼容 SSR,那么能否只在客户端渲染该组件呢?
其实是可以的,我们可以自行实现一个 <ClientOnly>
的组件,该组件可以让模板的一部分内容仅在客户端渲染,如下面这段模板所示:
<template>
<ClientOnly>
<SsrIncompatibleComp />
</ClientOnly>
</template>
可以看到,我们使用 <ClientOnly>
组件包裹了不兼容 SSR 的<SsrIncompatibleComp/>
组件。
这样,在服务端渲染时就会忽略该组件,且该组件仅会在客户端被渲染。
那么,<ClientOnly>
组件是如何做到这一点的呢?这其实是利用了 CSR 与 SSR 的差异。如下是 <ClientOnly>
组件的实现:
import { ref, onMounted, defineComponent } from 'vue'
export const ClientOnly = defineComponent({
setup(_, { slots }) {
// 标记变量,仅在客户端渲染时为 true
const show = ref(false)
// onMounted 钩子只会在客户端执行
onMounted(() => {
show.value = true
})
// 在服务端什么都不渲染,在客户端才会渲染 <ClientOnly> 组件的插槽内容
return () => (show.value && slots.default ? slots.default() : null)
},
})
可以看到,整体实现非常简单。其原理是利用了 onMounted 钩子只会在客户端执行的特性。
注意 <ClientOnly>
组件并不会导致客户端激活失败。因为在客户端激活的时候,mounted 钩子还没有触发,所以服务端与客户端渲染的内容一致,即什么都不渲染。等到激活完成,且 mounted 钩子触发执行之后,才会在客户端将 <ClientOnly>
组件的插槽内容渲染出来。
18.6 总结
在本章中,我们首先讨论了 CSR、SSR 和同构渲染的工作机制,以及它们各自的优缺点。
当我们为应用程序选择渲染架构时,需要结合软件的需求及场景,选择合适的渲染方案。
接着,我们讨论了 Vue.js 是如何把虚拟节点渲染为字符串的。以普通标签节点为例,在将其渲染为字符串时,要考虑以下内容。
- 自闭合标签的处理。对于自闭合标签,无须为其渲染闭合标签部分,也无须处理其子节点。
- 属性名称的合法性,以及属性值的转义。
- 文本子节点的转义。
具体的转义规则如下。
- 对于普通内容,应该对文本中的以下字符进行转义。
- 将字符 & 转义为实体 &。
- 将字符 < 转义为实体 <。
- 将字符 > 转义为实体 >。
- 对于属性值,除了上述三个字符应该转义之外,还应该转义下面两个字符。
- 将字符 " 转义为实体 "。
- 将字符 ' 转义为实体 '。
然后,我们讨论了如何将组件渲染为 HTML 字符串。在服务端渲染组件与渲染普通标签并没有本质区别。
我们只需要通过执行组件的 render 函数,得到该组件所渲染的 subTree 并将其渲染为 HTML 字符串即可。
另外,在渲染组件时,需要考虑以下几点:
- 服务端渲染不存在数据变更后的重新渲染,所以无须调用 reactive 函数对 data 等数据进行包装,也无须使用 shallowReactive 函数对 props 数据进行包装。正因如此,我们也无须调用 beforeUpdate 和 updated 钩子。
- 服务端渲染时,由于不需要渲染真实 DOM 元素,所以无须调用组件的 beforeMount 和 mounted 钩子。
之后,我们讨论了客户端激活的原理。在同构渲染过程中,组件的代码会分别在服务端和浏览器中执行一次。
在服务端,组件会被渲染为静态的 HTML 字符串,并发送给浏览器。浏览器则会渲染由服务端返回的静态的 HTML 内容,并下载打包在静态资源中的组件代码。当下载完毕后,浏览器会解释并执行该组件代码。
当组件代码在客户端执行时,由于页面中已经存在对应的DOM 元素,所以渲染器并不会执行创建 DOM 元素的逻辑,而是会执行激活操作。激活操作可以总结为两个步骤:
- 在虚拟节点与真实 DOM 元素之间建立联系,即 vnode.el = el。这样才能保证后续更新程序正确运行。
- 为 DOM 元素添加事件绑定。
最后,我们讨论了如何编写同构的组件代码。由于组件代码既运行于服务端,也运行于客户端,所以当我们编写组件代码时要额外注意。具体可以总结为以下几点:
- 注意组件的生命周期。beforeUpdate、updated、beforeMount、mounted、beforeUnmount、unmounted 等生命周期钩子函数不会在服务端执行。
- 使用跨平台的 API。由于组件的代码既要在浏览器中运行,也要在服务器中运行,所以编写组件代码时,要额外注意代码的跨平台性。通常我们在选择第三方库的时候,会选择支持跨平台的库,例如使用 Axios 作为网络请求库。
- 特定端的实现。无论在客户端还是在服务端,都应该保证功能的一致性。例如,组件需要读取 cookie 信息。在客户端,我们可以通过 document.cookie 来实现读取;而在服务端,则需要根据请求头来实现读取。所以,很多功能模块需要我们为客户端和服务端分别实现。
- 避免交叉请求引起的状态污染。状态污染既可以是应用级的,也可以是模块级的。对于应用,我们应该为每一个请求创建一个独立的应用实例。对于模块,我们应该避免使用模块级的全局变量。这是因为在不做特殊处理的情况下,多个请求会共用模块级的全局变量,造成请求间的交叉污染。
- 仅在客户端渲染组件中的部分内容。这需要我们自行封装
<ClientOnly>
组件,被该组件包裹的内容仅在客户端才会被渲染。