在第 12 章和第 13 章中,我们讨论了 Vue.js 是如何基于渲染器实现组件化能力的。
本章,我们将讨论 Vue.js 中几个非常重要的内建组件和模块,例如 KeepAlive 组件、Teleport 组件、Transition 组件等,它们都需要渲染器级别的底层支持。另外,这些内建组件所带来的能力,对开发者而言非常重要且实用,理解它们的工作原理有助于我们正确地使用它们。
14.1 KeepAlive 组件的实现原理
14.1.1 组件的激活与失活
KeepAlive 是 HTTP 协议中的一个概念,也被称为 HTTP 持久连接,用于多个请求或响应共享一个 TCP 连接。
没有 KeepAlive 时,HTTP 连接会在每次请求/响应后关闭,而下一次请求需要重新建立新的 HTTP 连接。由于频繁的连接销毁和创建会引入额外的性能开销,因此引入了 KeepAlive 的概念。
Vue.js内建的 KeepAlive 组件的原理与 HTTP 中的 KeepAlive 相似,可以避免一个组件被频繁地创建和销毁。考虑一个场景,我们的页面有一组 <Tab>
组件:
vue
<template>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</template>
根据 currentTab 的值,不同的 <Tab>
组件会被渲染。
用户频繁切换 Tab 会导致对应的<Tab>
组件频繁被创建和销毁,造成性能开销。此时,我们可以使用 KeepAlive 组件:
vue
<template>
<KeepAlive>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</KeepAlive>
</template>
使用 KeepAliv e后,无论用户如何切换 <Tab>
组件,频繁的创建和销毁都不会发生,极大优化了用户操作的响应。
尤其在处理大组件时,优势更明显。那么,KeepAlive是如何实现的呢?
KeepAlive 的实现主要是基于缓存管理以及特殊的挂载/卸载逻辑。
KeepAlive 组件在卸载时,我们不能真正卸载,否则无法维持组件的状态。
正确做法是,将 KeepAlive 的组件从原容器移到另一个隐藏的容器中,实现"假卸载"。
当需要再次"挂载"的时候,我们也不能执行真正的挂载逻辑,而应该将该组件从隐藏容器搬回原容器。
这个过程对应组件的生命周期,即 activated 和 deactivated。
一个基本的KeepAlive组件的实现如下所示:
javascript
const KeepAlive = {
// KeepAlive 组件独有的属性,用作标识
__isKeepAlive: true,
setup(props, { slots }) {
// 创建一个缓存对象
// key: vnode.type
// value: vnode
const cache = new Map()
// 当前 KeepAlive 组件的实例
const instance = currentInstance
// 对于 KeepAlive 组件来说,它的实例上存在特殊的 keepAliveCtx 对象,该对象由渲染器注入
// 该对象会暴露渲染器的一些内部方法,其中 move 函数用来将一段 DOM 移动到另一个容器中
const { move, createElement } = instance.keepAliveCtx
// 创建隐藏容器
const storageContainer = createElement('div')
// KeepAlive 组件的实例上会被添加两个内部函数,分别是 _deActivate 和 _activate
// 这两个函数会在渲染器中被调用
instance._deActivate = vnode => {
move(vnode, storageContainer)
}
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
}
return () => {
// KeepAlive 的默认插槽就是要被 KeepAlive 的组件
let rawVNode = slots.default()
// 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAlive
if (typeof rawVNode.type !== 'object') {
return rawVNode
}
// 在挂载时先获取缓存的组件 vnode
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {
// 如果有缓存的内容,则说明不应该执行挂载,而应该执行激活
// 继承组件实例
rawVNode.component = cachedVNode.component
// 在 vnode 上添加 keptAlive 属性,标记为 true,避免渲染器重新挂载它
rawVNode.keptAlive = true
} else {
// 如果没有缓存,则将其添加到缓存中,这样下次激活组件时就不会执行新的挂载动作了
cache.set(rawVNode.type, rawVNode)
}
// 在组件 vnode 上添加 shouldKeepAlive 属性,并标记为 true,避免渲染器真的将组件卸载
rawVNode.shouldKeepAlive = true
// 将 KeepAlive 组件的实例也添加到 vnode 上,以便在渲染器中访问
rawVNode.keepAliveInstance = instance
// 渲染组件 vnode
return rawVNode
}
}
}
上述代码,首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,这就是我们所称之为的"内部组件"。
KeepAlive 组件会对这个"内部组件"进行操作,主要是在该"内部组件"的 vnode 对象上添加一些标记属性,这样渲染器就能执行特定的逻辑。
以下是这些标记属性的简要介绍:
- shouldKeepAlive:该属性会被添加到"内部组件"的 vnode 对象上,这样当渲染器卸载"内部组件"时,就可以通过检查该属性得知"内部组件"需要被 KeepAlive,因此,渲染器就不会真正地卸载"内部组件",而是会调用 _deActivate 函数完成搬运工作:
javascript
// 卸载操作
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该被 KeepAlive
if (vnode.shouldKeepAlive) {
// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件,
// 即 KeepAlive 组件的 _deActivate 函数使其失活
vnode.keepAliveInstance._deActivate(vnode)
} else {
unmount(vnode.component.subTree)
}
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
- keepAliveInstance:这个属性使得"内部组件"的 vnode 对象能够引用到 KeepAlive 组件实例,因此,在 unmount 函数中,我们能够通过这个属性来访问 _deActivate 函数。
- keptAlive:如果"内部组件"已经被缓存,那么就会为其添加一个 keptAlive 标记。这样当"内部组件"需要重新渲染时,渲染器不会重新挂载它,而是通过激活操作来实现,这在以下的 patch 函数代码中可以看出:
javascript
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// Some codes here
} else if (type === Text) {
// Some codes here
} else if (type === Fragment) {
// Some codes here
} else if (typeof type === 'object' || typeof type === 'function') {
if (!n1) {
// 如果组件已经被 KeepAlive,则激活它,而不是重新挂载
if (n2.keptAlive) {
n2.keepAliveInstance._activate(n2, container, anchor)
} else {
mountComponent(n2, container, anchor)
}
} else {
patchComponent(n1, n2, anchor)
}
}
}
这里,如果组件的 vnode 对象中存在 keptAlive 标识,渲染器不会重新挂载它,而是会通过 keepAliveInstance._activate 函数来激活它。
接下来看一下组件激活和失活的函数:
javascript
const { move, createElement } = instance.keepAliveCtx
instance._deActivate = (vnode) => {
move(vnode, storageContainer)
}
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
}
失活操作本质上是将组件渲染的内容移动到隐藏容器中,而激活操作则是将内容从隐藏容器中移回原来的容器。
这里的 move 函数由渲染器提供,如下所示:
javascript
function mountComponent(vnode, container, anchor) {
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
mounted: [],
// 只有 KeepAlive 组件的实例下会有 keepAliveCtx 属性
keepAliveCtx: null
}
// 检查当前要挂载的组件是否是 KeepAlive 组件
const isKeepAlive = vnode.type.__isKeepAlive
if (isKeepAlive) {
// 在 KeepAlive 组件实例上添加 keepAliveCtx 对象
instance.keepAliveCtx = {
// move 函数用来移动一段 vnode
move(vnode, container, anchor) {
// 本质上是将组件渲染的内容移动到指定容器中,即隐藏容器中
insert(vnode.component.subTree.el, container, anchor)
},
createElement
}
}
// 省略部分代码
}
以上就是 KeepAlive 组件的基本实现。
14.1.2 include 和 exclude
在默认情况下,KeepAlive 组件会对所有"内部组件"进行缓存。但有时候,我们可能只希望缓存特定的组件。
为此,我们可以为 KeepAlive 组件添加两个 props: include 和 exclude,它们可以让用户自定义缓存规则。
include 用于明确指定需要被缓存的组件,而 exclude 则用于明确指定不应被缓存的组件。
KeepAlive 组件的 props 定义如下:
javascript
const KeepAlive = {
__isKeepAlive: true,
// 定义 include 和 exclude
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// 省略部分代码
}
}
为简化问题,我们规定 include 和 exclude 的值应为正则表达式类型。
在挂载 KeepAlive 组件时,会根据内部组件的名称进行匹配,如下代码所示:
javascript
const cache = new Map()
const KeepAlive = {
__isKeepAlive: true,
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// 省略部分代码
return () => {
let rawVNode = slots.default()
if (typeof rawVNode.type !== 'object') {
return rawVNode
}
// 获取"内部组件"的 name
const name = rawVNode.type.name
// 对 name 进行匹配
if (
name &&
// 如果 name 无法被 include 匹配
((props.include && !props.include.test(name)) ||
// 或者被 exclude 匹配
(props.exclude && props.exclude.test(name)))
) {
// 则直接渲染"内部组件",不对其进行后续的缓存操作
return rawVNode
}
// 省略部分代码
}
}
}
根据用户指定的 include 和 exclude 正则,我们匹配内部组件的名称,并据此决定是否缓存该组件。
在此基础上,可以灵活扩展匹配能力。例如,可以允许 include 和 exclude 接受多种类型的值,如字符串或函数,提供更多灵活的匹配机制。
同样,匹配条件不必局限于组件名称,用户也可以自定义其他的匹配条件。无论如何变化,其基本原理是保持不变的。
14.1.3 缓存管理
在之前的实现中,我们使用 Map 对象实现对组件的缓存:
javascript
const cache = new Map()
此处的 Map 对象的键是组件的选项对象(即 vnode.type 属性的值),而值则是描述组件的 vnode 对象。
由于 vnode 对象包含了组件实例的引用(即 vnode.component 属性),因此,缓存 vnode 对象实际上就相当于缓存了组件实例。
在 KeepAlive 组件的渲染函数中,我们使用如下方式处理缓存:
javascript
// KeepAlive 组件的渲染函数中关于缓存的实现
// 使用组件选项对象 rawVNode.type 作为键去缓存中查找
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {
// 如果缓存存在,则无须重新创建组件实例,只需要继承即可
rawVNode.component = cachedVNode.component
rawVNode.keptAlive = true
} else {
// 如果缓存不存在,则设置缓存
cache.set(rawVNode.type, rawVNode)
}
简单来说,如果缓存存在,我们就复用组件实例,并将 vnode 对象标记为 keptAlive,这样渲染器就不会创建新的组件实例。如果缓存不存在,我们则设置新的缓存。
然而,这种方式存在一个问题:当缓存不存在时,总是会添加新的缓存,导致缓存数量可能会无限增加,极端情况下可能占用大量内存。
为了解决这个问题,我们需要设置一个缓存阈值,当缓存数量超过这个阈值时,我们需要进行缓存修剪。
这又引发了另一个问题:我们应该如何进行缓存修剪?什么样的修剪策略是最优的?
Vue.js 的策略是"最新一次访问"。这意味着我们需要为缓存设置一个最大容量,可以通过 KeepAlive 组件的 max 属性来设置。例如:
vue
<KeepAlive :max="2">
<component :is="dynamicComp"/>
</KeepAlive>
上述代码,我们设置了缓存的最大容量为 2。假设有三个组件:Comp1,Comp2,Comp3,它们都会被缓存。当我们在组件之间切换时,会按照"最新一次访问"的策略进行缓存修剪。具体操作如下:
- 初始渲染 Comp1 并缓存,此时缓存队列:[Comp1]。
- 切换到 Comp2 并缓存,此时缓存队列:[Comp1, Comp2]。
- 再切换到 Comp3,此时缓存容量已满,需要修剪。由于 Comp2 是最后访问的组件,它是安全的,不会被修剪。所以,会被修剪的是 Comp1。此时,缓存队列:[Comp2, Comp3]。
我们也可以有不同的切换方式,如:
- 初始渲染 Comp1 并缓存,此时缓存队列:[Comp1]。
- 切换到 Comp2 并缓存,此时缓存队列:[Comp1, Comp2]。
- 再切换回 Comp1,此时,缓存队列无需修改。
- 切换到 Comp3,此时缓存容量已满,需要修剪。由于 Comp1 是最后访问的组件,它是安全的,不会被修剪。所以,会被修剪的是 Comp2。此时,缓存队列:[Comp1, Comp3]。
这就是 Vue.js 的缓存策略。但我们也可以自定义缓存策略,Vue.js的 RFCs 中已有相关提议。提议中引入了一个新的 cache 接口,允许用户指定缓存实例:
vue
<KeepAlive :cache="cache">
<Comp />
</KeepAlive>
缓存实例需要遵循一定的格式,例如:
javascript
const _cache = new Map()
const cache: KeepAliveCache = {
get(key) {
_cache.get(key)
},
set(key, value) {
_cache.set(key, value)
},
delete(key) {
_cache.delete(key)
},
forEach(fn) {
_cache.forEach(fn)
}
}
在这种设计下,如果用户提供了自定义的缓存实例,KeepAlive 组件将直接使用它来管理缓存。这实质上将缓存管理权限从 KeepAlive 组件转移到了用户手中。
14.2 Teleport 组件的实现原理
14.2.1 Teleport 组件要解决的问题
Vue3 引入了内建组件 Teleport,用以解决某些特殊渲染需求。
普遍情况下,虚拟 DOM 渲染为真实 DOM 时,两者的层级结构保持一致。例如,考虑以下模板:
vue
<template>
<div id="box" style="z-index: -1;">
<Overlay />
</div>
</template>
在这个模板中,<Overlay>
组件的内容将被渲染到 id 为 'box' 的 div 元素内。
但在某些场景,这种默认的行为可能不符合预期。例如,如果 <Overlay>
是一个蒙层组件,其要求蒙层覆盖所有页面元素。
如果 'box' div 的 z-index 设置 1,即使设置 <Overlay>
的 z-index 为极大值,也不能达到期望的遮挡效果。
在这种情况下,我们可能会选择在 <body>
下直接渲染蒙层内容。
Vue2 时代,需要使用原生 DOM API 手动处理 DOM 元素,但这样可能导致渲染与 Vue.js 渲染机制不同步,进而产生一系列问题。
因此,Vue3 中引入了 Teleport 组件,它能将特定内容渲染到指定的容器,不受 DOM 层级影响。
以下是一个使用 Teleport 的 <Overlay>
组件模板:
vue
<!-- Overlay.vue -->
<template>
<Teleport to="body">
<div class="overlay"></div>
</Teleport>
</template>
<style scoped>
.overlay {
z-index: 9999;
}
</style>
此处,<Overlay>
组件的内容被 Teleport 组件包裹,作为 Teleport 组件的插槽内容。
通过设置 Teleport 的 to 属性为 'body',这个组件就能直接将插槽内容渲染到 body 下,而不是按照模板的 DOM 层级进行渲染。
这样,<Overlay>
组件的 z-index 将按预期工作,遮挡页面的所有内容。
14.2.2 实现 Teleport 组件
Teleport 组件需要渲染器的底层支持,和 KeepAlive 组件类似。
首先,我们需要将 Teleport 组件的渲染逻辑从渲染器中抽离出来,这样可以避免代码膨胀,同时也有利于减小最终打包的体积,最终是通过 TreeShaking 机制实现的。这需要我们先修改 patch 函数,如下:
javascript
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object' && type.__isTeleport) {
// 组件选项中如果存在 __isTeleport 标识,则它是 Teleport 组件
// 调用 Teleport 组件选项中的 process 函数将控制权交接出去
// 传递给 process 函数的第五个参数是渲染器的一些内部方法
type.process(n1, n2, container, anchor, {
patch,
patchChildren,
unmount,
move(vnode, container, anchor) {
insert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
}
})
} else if (typeof type === 'object' || typeof type === 'function') {
// 省略部分代码
}
}
在这段代码中,我们判断组件是否为 Teleport 组件,如果是,我们将渲染控制权交给该组件的 process 函数,从而实现渲染逻辑的分离。
Teleport 组件的定义如下:
javascript
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor) {
// 处理渲染逻辑
}
}
Teleport 组件并非普通组件,它有特殊的选项 __isTeleport 和 process。
如果用户编写的模板如下:
html
<Teleport to="body">
<h1>Title</h1>
<p>content</p>
</Teleport>
那么它会被编译为如下的虚拟 DOM:
javascript
function render() {
return {
type: Teleport,
children: [
// 以普通 children 的形式代表要被 Teleport 的内容
{ type: 'h1', children: 'Title' },
{ type: 'p', children: 'content' }
]
}
}
对于 Teleport 组件,我们直接将其子节点编译为一个数组。现在我们可以开始实现 Teleport 组件了。首先是挂载操作:
javascript
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
// 通过 internals 参数取得渲染器的内部方法
const { patch } = internals
// 如果旧 VNode n1 不存在,则是全新的挂载,否则执行更新
if (!n1) {
// 获取容器,即挂载点
const target = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
n2.children.forEach(c => patch(null, c, target, anchor))
} else {
// 更新操作
}
}
}
挂载操作与渲染器的挂载思路保持一致,通过判断旧的虚拟节点(n1)是否存在,来决定是执行挂载还是执行更新。
对于更新操作,只需要调用 patchChildren 函数即可:
javascript
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
const { patch, patchChildren } = internals
if (!n1) {
// 省略部分代码
} else {
patchChildren(n1, n2, container)
// 如果新旧 to 参数的值不同,则需要对内容进行移动
if (n2.props.to !== n1.props.to) {
const newTarget = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
// 移动到新的容器
n2.children.forEach(c => move(c, newTarget))
}
}
}
}
更新操作可能由于 Teleport 组件的 to 属性值变化引起,所以在更新时我们需要处理这种情况。
执行移动操作的 move 函数如下:
javascript
else if (typeof type === 'object' && type.__isTeleport) {
type.process(n1, n2, container, anchor, {
patch,
patchChildren,
// 用来移动被 Teleport 包裹的内容
move(vnode, container, anchor) {
insert(
vnode.component
? vnode.component.subTree.el // 移动一个组件
: vnode.el, // 移动普通元素
container,
anchor
)
}
})
}
这里只考虑了移动组件和普通元素,一个完整的实现应考虑所有的虚拟节点类型,如文本类型(Text)和片段类型(Fragment)。
14.3 Transition 组件的实现原理
Transition组件的实现原理比想象中的更为简单,它主要基于以下两个步骤:
- 在DOM元素挂载时,附加动态效果到该元素;
- 在DOM元素卸载时,先让动态效果执行完再卸载元素。
尽管在具体实现时需要考虑许多边界情况,但了解这两个核心原理已经足够。细节部分可以在理解了基本实现后根据需要添加或完善。
14.3.1 原生 DOM 的过渡
让我们深入了解一下 Transition 组件的原理,首先,我们需要明确过渡效果是怎样在原生 DOM 中实现的。
过渡效果其实是 DOM 元素在两种状态间的转换,浏览器会根据指定的效果自动完成过渡。这些效果包括过渡的时长、运动路径和过渡的属性等。
以一个例子为例,假设我们有一个宽高为 100px 的 div 元素:
html
<div class="box"></div>
我们可以为这个 div 元素定义以下样式:
css
.box {
width: 100px;
height: 100px;
background-color: red;
}
现在,我们想给这个元素添加一个进场动效:从离左边 200px 的地方,经过 1 秒运动到离左边 0px 的位置。我们可以用下面的样式来描述这个过程:
初始状态(离左边200px):
css
.enter-from {
transform: translateX(200px);
}
结束状态(离左边0px):
css
.enter-to {
transform: translateX(0);
}
描述运动过程(例如持续时长和运动曲线):
css
.enter-active {
transition: transform 1s ease-in-out;
}
定义了过渡的初始状态、结束状态和运动过程后,我们就可以给DOM元素添加进场动效了:
javascript
// 创建 class 为 box 的 DOM 元素
const el = document.createElement('div')
el.classList.add('box')
// 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
el.classList.add('enter-from') // 初始状态
el.classList.add('enter-active') // 运动过程
// 将元素添加到页面
document.body.appendChild(el)
上述代码做了三件事情:
- 创建DOM元素;
- 将过渡的初始状态和运动过程定义到元素上,即添加'enter-from'和'enter-active'这两个类到元素上;
- 将元素添加到页面,即挂载。
执行这三步后,元素的初始状态就会生效,渲染时会按照初始状态定义的样式显示 DOM 元素。
下一步,我们需要改变元素的状态以启动动画。理论上,我们只需将 'enter-from' 类从DOM元素中删除,同时添加 'enter-to' 类即可,如下:
javascript
// 创建DOM元素
const el = document.createElement('div')
el.classList.add('box')
// 定义动画的初始状态和过程
el.classList.add('enter-from') // 初始状态
el.classList.add('enter-active') // 过程
// 添加元素到页面
document.body.appendChild(el)
// 改变元素状态
el.classList.remove('enter-from') // 删除 'enter-from'
el.classList.add('enter-to') // 添加 'enter-to'
然而,这段代码无法正常工作。浏览器在当前帧绘制 DOM 元素时,只会绘制 'enter-to' 类的样式,而忽略了 'enter-from' 类。要解决这个问题,我们需要在下一帧执行状态更改:
javascript
// 创建DOM元素
const el = document.createElement('div')
el.classList.add('box')
// 定义动画的初始状态和过程
el.classList.add('enter-from')
el.classList.add('enter-active')
// 添加元素到页面
document.body.appendChild(el)
// 在下一帧改变元素状态
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to
})
尽管理论上在下一帧改变元素状态应该有效,但在 Chrome 和 Safari 中,动画仍无法正常工作。这是由于浏览器的实现 bug。为解决此问题,我们可以嵌套调用 requestAnimationFrame:
javascript
// 创建DOM元素
const el = document.createElement('div')
el.classList.add('box')
// 定义动画的初始状态和过程
el.classList.add('enter-from')
el.classList.add('enter-active')
// 添加元素到页面
document.body.appendChild(el)
// 嵌套调用 requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('enter-from')
el.classList.add('enter-to')
})
})
现在,动画应该能够正常运行了。最后,我们需要在动画完成后移除 'enter-to' 和 'enter-active' 类:
javascript
// 创建DOM元素
const el = document.createElement('div')
el.classList.add('box')
// 定义动画的初始状态和过程
el.classList.add('enter-from')
el.classList.add('enter-active')
// 添加元素到页面
document.body.appendChild(el)
// 嵌套调用 requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('enter-from')
el.classList.add('enter-to')
// 监听 transitionend 事件以结束动画
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
})
通过监听元素的 transitionend 事件来完成收尾工作。
我们可以对上述为 DOM 元素添加进场过渡的过程进行抽象为下图:
- beforeEnter 阶段:添加 enter-from 和 enter-active 类。
- enter 阶段:在下一帧中移除 enter-from 类,添加 enter-to。
- 进场动效结束:移除 enter-to 和 enter-active 类。
同样地,对于 DOM 元素的离场动画效果,我们也需要定义动画的初始状态、结束状态以及过程:
css
/* 初始状态 */
.leave-from {
transform: translateX(0);
}
/* 结束状态 */
.leave-to {
transform: translateX(200px);
}
/* 过渡过程 */
.leave-active {
transition: transform 2s ease-out;
}
离场动画通常在 DOM 元素被卸载时执行。
此时,元素被点击时会被移除,但因为元素被立即卸载,动画无法执行。因此,我们需要在动画结束后才卸载元素:
javascript
el.addEventListener('click', () => {
// 定义卸载动作
const performRemove = () => el.parentNode.removeChild(el)
// 设置初始状态
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制重绘
document.body.offsetHeight
// 在下一帧改变状态
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 改变状态
el.classList.remove('leave-from')
el.classList.add('leave-to')
// 在动画结束后,移除元素
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
performRemove()
})
})
})
})
以上代码的处理方式与进场动画类似,只是在动画结束后需要执行 performRemove 函数来真正地卸载DOM元素。
14.3.2 实现 Transition 组件
Transition 组件的实现原理与 14.3.1 节中描述的原生 DOM 的过渡原理相同,只是它基于虚拟 DOM 实现。
在 14.3.1 节中,我们将原生 DOM 元素的过渡过程抽象为几个阶段,如 beforeEnter、enter、leave 等。
同样地,基于虚拟 DOM 的实现也需要将 DOM 元素的生命周期划分为类似的阶段,并在相应阶段执行对应的回调函数。
首先,我们需要在虚拟 DOM 层面定义 Transition 组件。例如,组件的模板内容可以是:
html
<template>
<Transition>
<div>我是需要过渡的元素</div>
</Transition>
</template>
此模板编译后的虚拟 DOM 可以设计如下:
javascript
function render() {
return {
type: Transition,
children: {
default() {
return { type: 'div', children: '我是需要过渡的元素' }
}
}
}
}
Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致。接下来,我们需要实现 Transition 组件,代码如下:
javascript
const Transition = {
name: 'Transition',
setup(props, { slots }) {
return () => {
const innerVNode = slots.default() // 获取需要过渡的元素
// 在过渡元素的 VNode 对象上添加 transition 钩子函数
innerVNode.transition = {
beforeEnter(el) { /* 省略部分代码 */ },
enter(el) { /* 省略部分代码 */ },
leave(el, performRemove) { /* 省略部分代码 */ }
}
return innerVNode // 渲染需要过渡的元素
}
}
}
上述代码告诉我们,Transition 组件并不渲染任何额外的内容,而只是通过默认插槽读取并渲染过渡元素。它的主要作用是在过渡元素的虚拟节点上添加 transition 钩子函数。
经过 Transition 组件的包装后,需要过渡的虚拟节点对象将会包含一个 vnode.transition 对象,其中包含与 DOM 元素过渡相关的钩子函数,如 beforeEnter、enter、leave 等。当渲染这些需要过渡的虚拟节点时,渲染器会在适当的时机调用这些钩子函数,具体如下:
javascript
function mountElement(vnode, container, anchor) {
const el = (vnode.el = createElement(vnode.type))
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
// 判断一个 VNode 是否需要过渡
const needTransition = vnode.transition
if (needTransition) {
// 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
vnode.transition.beforeEnter(el)
}
insert(el, container, anchor)
if (needTransition) {
// 调用 transition.enter 钩子,并将 DOM 元素作为参数传递
vnode.transition.enter(el)
}
}
以上是修改后的 mountElement 函数,我们增加了处理 transition 钩子的部分。在挂载 DOM 元素之前,会调用 beforeEnter 钩子;在挂载元素之后,会调用 enter 钩子。这两个钩子函数都接收需要过渡的 DOM 元素对象作为第一个参数。
除了挂载,卸载元素时我们也应该调用 transition.leave 钩子函数,如下所示:
javascript
function unmount(vnode) {
// 判断 VNode 是否需要过渡处理
const needTransition = vnode.transition
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
if (vnode.shouldKeepAlive) {
vnode.keepAliveInstance._deActivate(vnode)
} else {
unmount(vnode.component.subTree)
}
return
}
const parent = vnode.el.parentNode
if (parent) {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => parent.removeChild(vnode.el)
if (needTransition) {
// 如果需要过渡处理,则调用 transition.leave 钩子,
// 同时将 DOM 元素和 performRemove 函数作为参数传递
vnode.transition.leave(vnode.el, performRemove)
} else {
// 如果不需要过渡处理,则直接执行卸载操作
performRemove()
}
}
}
上述代码展示了增强后的 unmount 函数。
这里首先定义了 performRemove 函数,它包含了卸载 DOM 元素的操作。
如果需要进行过渡处理,我们会调用 vnode.transition.leave 钩子函数,只有在过渡结束后,才会执行 performRemove 完成卸载。
若不需要过渡处理,performRemove 会被直接调用。
有了这样增强后的 mountElement 和 unmount 函数,我们可以实现一个基本的 Transition 组件:
javascript
const Transition = {
name: 'Transition',
setup(props, { slots }) {
return () => {
const innerVNode = slots.default()
innerVNode.transition = {
beforeEnter(el) {
// 设置初始状态:添加 enter-from 和 enter-active 类
el.classList.add('enter-from')
el.classList.add('enter-active')
},
enter(el) {
// 在下一帧切换到结束状态
nextFrame(() => {
// 移除 enter-from 类,添加 enter-to 类
el.classList.remove('enter-from')
el.classList.add('enter-to')
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
},
leave(el, performRemove) {
// 设置离场过渡的初始状态:添加 leave-from 和 leave-active 类
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制 reflow,使得初始状态生效
document.body.offsetHeight
// 在下一帧修改状态
nextFrame(() => {
// 移除 leave-from 类,添加 leave-to 类
el.classList.remove('leave-from')
el.classList.add('leave-to')
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 调用 transition.leave 钩子函数的第二个参数,完成 DOM 元素的卸载
performRemove()
})
})
}
}
return innerVNode
}
}
}
这段代码中,我们实现了 vnode.transition 中各个过渡钩子函数。这里的实现与我们在原生 DOM 中讨论的过渡概念非常类似。
注意,我们硬编码了过渡状态的类名(如 'enter-from', 'enter-to')。实际上,我们可以轻松地通过 props 实现允许用户自定义类名,从而让 Transition 组件更加灵活。
另外,我们还没有实现过渡的"模式"概念(如 "in-out" 或 "out-in")。实际上,模式的概念只是对节点过渡时机的控制,原理上与将卸载动作封装到 performRemove 函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。
14.4 总结
在本章,我们探讨了 Vue.js 内建的三个核心组件:KeepAlive、Teleport 和 Transition。这些组件与渲染器紧密相关,需要框架提供底层实现。
KeepAlive 组件作用相似于 HTTP 的持久链接,能避免组件实例频繁地销毁和重建。它的实现原理并不复杂:当组件"卸载"时,渲染器并不会真正卸载,而是将它移到一个隐藏容器,以保持当前状态;当组件"挂载"时,渲染器会将它从隐藏容器移回原容器。我们也探讨了 KeepAlive 的额外功能,如匹配策略和缓存策略。默认情况下,include 和 exclude 选项决定哪些组件需要或不需要 KeepAlive。
接下来,我们分析了 Teleport 组件的作用和实现原理。Teleport 组件能跨 DOM 层级进行渲染,这在很多场景中都非常有用。我们将 Teleport 的渲染逻辑从渲染器中分离,这样做有两大优点:避免渲染器逻辑代码过于臃肿,以及利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关代码,从而减小最终构建包的体积。
最后,我们详解了 Transition 组件的原理和实现方式。我们以原生 DOM 的过渡为起点,说明如何通过 JavaScript 为 DOM 元素添加进场和离场动效。在此过程中,我们将动效实现分为多个阶段,包括 beforeEnter、enter、leave 等。Transition 组件的实现原理类似于为原生 DOM 添加过渡效果,我们将过渡相关的钩子函数定义在虚拟节点的 vnode.transition 对象中。在执行挂载和卸载操作时,渲染器会优先检查该虚拟节点是否需要过渡,并在合适的时机执行 vnode.transition 对象中定义的过渡相关钩子函数。