讨论 Vue.js 中几个非常重要的内建组件和模块,例如 KeepAlive 组件、Teleport 组件、Transition 组件等,它们都需要渲染器级别的底层支持。另外,这些内建组件所带来的能力,对开发者而言非常重要且实用,理解它们的工作原理有助于我们正确地使用它们。
KeepAlive 一词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive 又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。在没有 KeepAlive 的情况下,一个 HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的HTTP 连接。频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive 就是为了解决这个问题而生的。
HTTP 中的 KeepAlive 可以避免连接频繁地销毁/创建,与HTTP 中的 KeepAlive 类似,Vue.js 内建的 KeepAlive 组件可以避免一个组件被频繁地销毁/重建。假设我们的页面中有一组
组件,如下面的代码所示:
01 <template>
02 <Tab v-if="currentTab === 1">...</Tab>
03 <Tab v-if="currentTab === 2">...</Tab>
04 <Tab v-if="currentTab === 3">...</Tab>
05 </template>
可以看到,根据变量 currentTab 值的不同,会渲染不同的
组件。当用户频繁地切换 Tab 时,会导致不停地卸载并重建对应的
组件。为了避免因此产生的性能开销,可以使用 KeepAlive 组件来解决这个问题,如下面的代码所示:
01 <template>
02 <!-- 使用 KeepAlive 组件包裹 -->
03 <KeepAlive>
04 <Tab v-if="currentTab === 1">...</Tab>
05 <Tab v-if="currentTab === 2">...</Tab>
06 <Tab v-if="currentTab === 3">...</Tab>
07 </KeepAlive>
08 </template>
这样,无论用户怎样切换
组件,都不会发生频繁的创建和销毁,因而会极大地优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。那么,KeepAlive 组件的实现原理是怎样的呢?其实 KeepAlive 的本质是缓存管理,再加上特殊的挂载/卸载逻辑。
首先,KeepAlive 组件的实现需要渲染器层面的支持。这是因为被 KeepAlive 的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。正确的做法是,将被KeepAlive 的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。这个过程对应到组件的生命周期,其实就是 activated 和 deactivated。
下图描述了“卸载”和“挂载”一个被 KeepAlive 的组件的过程:
“卸载”一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动到一个隐藏容器中。当重新“挂载”该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再“放回”原来的容器中,即页面中。
一个最基本的 KeepAlive 组件实现起来并不复杂,如下面的代码所示:
01 const KeepAlive = {
02 // KeepAlive 组件独有的属性,用作标识
03 __isKeepAlive: true,
04 setup(props, { slots }) {
05 // 创建一个缓存对象
06 // key: vnode.type
07 // value: vnode
08 const cache = new Map()
09 // 当前 KeepAlive 组件的实例
10 const instance = currentInstance
11 // 对于 KeepAlive 组件来说,它的实例上存在特殊的 keepAliveCtx 对象,该对象由渲染器注入
12 // 该对象会暴露渲染器的一些内部方法,其中 move 函数用来将一段 DOM 移动到另一个容器中
13 const { move, createElement } = instance.keepAliveCtx
14
15 // 创建隐藏容器
16 const storageContainer = createElement('div')
17
18 // KeepAlive 组件的实例上会被添加两个内部函数,分别是 _deActivate 和 _activate
19 // 这两个函数会在渲染器中被调用
20 instance._deActivate = (vnode) => {
21 move(vnode, storageContainer)
22 }
23 instance._activate = (vnode, container, anchor) => {
24 move(vnode, container, anchor)
25 }
26
27 return () => {
28 // KeepAlive 的默认插槽就是要被 KeepAlive 的组件
29 let rawVNode = slots.default()
30 // 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAlive
31 if (typeof rawVNode.type !== 'object') {
32 return rawVNode
33 }
34
35 // 在挂载时先获取缓存的组件 vnode
36 const cachedVNode = cache.get(rawVNode.type)
37 if (cachedVNode) {
38 // 如果有缓存的内容,则说明不应该执行挂载,而应该执行激活
39 // 继承组件实例
40 rawVNode.component = cachedVNode.component
41 // 在 vnode 上添加 keptAlive 属性,标记为 true,避免渲染器重新挂载它
42 rawVNode.keptAlive = true
43 } else {
44 // 如果没有缓存,则将其添加到缓存中,这样下次激活组件时就不会执行新的挂载动作了
45 cache.set(rawVNode.type, rawVNode)
46 }
47
48 // 在组件 vnode 上添加 shouldKeepAlive 属性,并标记为 true,避免渲染器真的将组件卸载
49 rawVNode.shouldKeepAlive = true
50 // 将 KeepAlive 组件的实例也添加到 vnode 上,以便在渲染器中访问
51 rawVNode.keepAliveInstance = instance
52
53 // 渲染组件 vnode
54 return rawVNode
55 }
56 }
57 }
从上面的实现中可以看到,与普通组件的一个较大的区别在于,KeepAlive 组件与渲染器的结合非常深。首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,我们把这个需要被 KeepAlive 的组件称为“内部组件”。KeepAlive 组件会对“内部组件”进行操作,主要是在“内部组件”的 vnode 对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。这些标记属性包括如下几个:
01 // 卸载操作
02 function unmount(vnode) {
03 if (vnode.type === Fragment) {
04 vnode.children.forEach(c => unmount(c))
05 return
06 } else if (typeof vnode.type === 'object') {
07 // vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该被 KeepAlive
08 if (vnode.shouldKeepAlive) {
09 // 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件,
10 // 即 KeepAlive 组件的 _deActivate 函数使其失活
11 vnode.keepAliveInstance._deActivate(vnode)
12 } else {
13 unmount(vnode.component.subTree)
14 }
15 return
16 }
17 const parent = vnode.el.parentNode
18 if (parent) {
19 parent.removeChild(vnode.el)
20 }
21 }
可以看到,unmount 函数在卸载组件时,会检测组件是否应该被 KeepAlive,从而执行不同的操作。
01 function patch(n1, n2, container, anchor) {
02 if (n1 && n1.type !== n2.type) {
03 unmount(n1)
04 n1 = null
05 }
06
07 const { type } = n2
08
09 if (typeof type === 'string') {
10 // 省略部分代码
11 } else if (type === Text) {
12 // 省略部分代码
13 } else if (type === Fragment) {
14 // 省略部分代码
15 } else if (typeof type === 'object' || typeof type === 'function') {
16 // component
17 if (!n1) {
18 // 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用 _activate 来激活它
19 if (n2.keptAlive) {
20 n2.keepAliveInstance._activate(n2, container, anchor)
21 } else {
22 mountComponent(n2, container, anchor)
23 }
24 } else {
25 patchComponent(n1, n2, anchor)
26 }
27 }
28 }
可以看到,如果组件的 vnode 对象中存在 keptAlive 标识,则渲染器不会重新挂载它,而是会通过keepAliveInstance._activate 函数来激活它。
我们再来看一下用于激活组件和失活组件的两个函数:
01 const { move, createElement } = instance.keepAliveCtx
02
03 instance._deActivate = (vnode) => {
04 move(vnode, storageContainer)
05 }
06 instance._activate = (vnode, container, anchor) => {
07 move(vnode, container, anchor)
08 }
可以看到,失活的本质就是将组件所渲染的内容移动到隐藏容器中,而激活的本质是将组件所渲染的内容从隐藏容器中搬运回原来的容器。另外,上面这段代码中涉及的 move 函数是由渲染器注入的,如下面 mountComponent 函数的代码所示:
01 function mountComponent(vnode, container, anchor) {
02 // 省略部分代码
03
04 const instance = {
05 state,
06 props: shallowReactive(props),
07 isMounted: false,
08 subTree: null,
09 slots,
10 mounted: [],
11 // 只有 KeepAlive 组件的实例下会有 keepAliveCtx 属性
12 keepAliveCtx: null
13 }
14
15 // 检查当前要挂载的组件是否是 KeepAlive 组件
16 const isKeepAlive = vnode.type.__isKeepAlive
17 if (isKeepAlive) {
18 // 在 KeepAlive 组件实例上添加 keepAliveCtx 对象
19 instance.keepAliveCtx = {
20 // move 函数用来移动一段 vnode
21 move(vnode, container, anchor) {
22 // 本质上是将组件渲染的内容移动到指定容器中,即隐藏容器中
23 insert(vnode.component.subTree.el, container, anchor)
24 },
25 createElement
26 }
27 }
28
29 // 省略部分代码
30 }
至此,一个最基本的 KeepAlive 组件就完成了。
在默认情况下,KeepAlive 组件会对所有“内部组件”进行缓存。但有时候用户期望只缓存特定组件。为了使用户能够自定义缓存规则,我们需要让 KeepAlive 组件支持两个 props,分别是 include 和 exclude。其中,include 用来显式地配置应该被缓存组件,而 exclude 用来显式地配置不应该被缓存组件。
KeepAlive 组件的 props 定义如下:
01 const KeepAlive = {
02 __isKeepAlive: true,
03 // 定义 include 和 exclude
04 props: {
05 include: RegExp,
06 exclude: RegExp
07 },
08 setup(props, { slots }) {
09 // 省略部分代码
10 }
11 }
为了简化问题,我们只允许为 include 和 exclude 设置正则类型的值。在 KeepAlive 组件被挂载时,它会根据“内部组件”的名称(即 name 选项)进行匹配,如下面的代码所示:
01 const cache = new Map()
02 const KeepAlive = {
03 __isKeepAlive: true,
04 props: {
05 include: RegExp,
06 exclude: RegExp
07 },
08 setup(props, { slots }) {
09 // 省略部分代码
10
11 return () => {
12 let rawVNode = slots.default()
13 if (typeof rawVNode.type !== 'object') {
14 return rawVNode
15 }
16 // 获取“内部组件”的 name
17 const name = rawVNode.type.name
18 // 对 name 进行匹配
19 if (
20 name &&
21 (
22 // 如果 name 无法被 include 匹配
23 (props.include && !props.include.test(name)) ||
24 // 或者被 exclude 匹配
25 (props.exclude && props.exclude.test(name))
26 )
27 ) {
28 // 则直接渲染“内部组件”,不对其进行后续的缓存操作
29 return rawVNode
30 }
31
32 // 省略部分代码
33 }
34 }
35 }
可以看到,我们根据用户指定的 include 和 exclude 正则,对“内部组件”的名称进行匹配,并根据匹配结果判断是否要对“内部组件”进行缓存。在此基础上,我们可以任意扩充匹配能力。例如,可以将 include 和 exclude 设计成多种类型值,允许用户指定字符串或函数,从而提供更加灵活的匹配机制。另外,在做匹配时,也可以不限于“内部组件”的名称,我们甚至可以让用户自行指定匹配要素。但无论如何,其原理都是不变的。
在前文给出的实现中,我们使用一个 Map 对象来实现对组件的缓存:
01 const cache = new Map()
该 Map 对象的键是组件选项对象,即 vnode.type 属性的值,而该 Map 对象的值是用于描述组件的 vnode 对象。由于用于描述组件的 vnode 对象存在对组件实例的引用(即vnode.component 属性),所以缓存用于描述组件的 vnode 对象,就等价于缓存了组件实例。
回顾一下目前 KeepAlive 组件中关于缓存的实现,如下是该组件渲染函数的部分代码:
01 // KeepAlive 组件的渲染函数中关于缓存的实现
02
03 // 使用组件选项对象 rawVNode.type 作为键去缓存中查找
04 const cachedVNode = cache.get(rawVNode.type)
05 if (cachedVNode) {
06 // 如果缓存存在,则无须重新创建组件实例,只需要继承即可
07 rawVNode.component = cachedVNode.component
08 rawVNode.keptAlive = true
09 } else {
10 // 如果缓存不存在,则设置缓存
11 cache.set(rawVNode.type, rawVNode)
12 }
缓存的处理逻辑可以总结为:
这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量内存。为了解决这个问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。但是这又引出了另外一个问题:我们应该如何对缓存进行修剪呢?换句话说,当需要对缓存进行修剪时,应该以怎样的策略修剪?优先修剪掉哪一部分?
Vue.js 当前所采用的修剪策略叫作“最新一次访问”。首先,你需要为缓存设置最大容量,也就是通过 KeepAlive 组件的max 属性来设置,例如:
01 <KeepAlive :max="2">
02 <component :is="dynamicComp"/>
03 </KeepAlive>
在上面这段代码中,我们设置缓存的容量为 2。假设我们有三个组件 Comp1、Comp2、Comp3,并且它们都会被缓存。然后,我们开始模拟组件切换过程中缓存的变化,如下所示:
我们还可以换一种切换组件的方式,如下所示:
可以看到,在不同的模拟策略下,最终的缓存结果会有所不同。“最新一次访问”的缓存修剪策略的核心在于,需要把当前访问(或渲染)的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。
实现 Vue.js 内建的缓存策略并不难,本质上等同于一个小小的算法题目。我们的关注点在于,缓存策略能否改变?甚至允许用户自定义缓存策略?实际上,在 Vue.js 官方的 RFCs 中已经有相关提议[插图]。该提议允许用户实现自定义的缓存策略,在用户接口层面,则体现在 KeepAlive 组件新增了 cache 接口,允许用户指定缓存实例:
01 <KeepAlive :cache="cache">
02 <Comp />
03 </KeepAlive>
缓存实例需要满足固定的格式,一个基本的缓存实例的实现如下:
01 // 自定义实现
02 const _cache = new Map()
03 const cache: KeepAliveCache = {
04 get(key) {
05 _cache.get(key)
06 },
07 set(key, value) {
08 _cache.set(key, value)
09 },
10 delete(key) {
11 _cache.delete(key)
12 },
13 forEach(fn) {
14 _cache.forEach(fn)
15 }
16 }
在 KeepAlive 组件的内部实现中,如果用户提供了自定义的缓存实例,则直接使用该缓存实例来管理缓存。从本质上来说,这等价于将缓存的管理权限从 KeepAlive 组件转交给用户了。
Teleport 组件是 Vue.js 3 新增的一个内建组件,我们首先讨论它要解决的问题是什么。通常情况下,在将虚拟 DOM 渲染为真实 DOM 时,最终渲染出来的真实 DOM 的层级结构与虚拟DOM 的层级结构一致。以下面的模板为例:
01 <template>
02 <div id="box" style="z-index: -1;">
03 <Overlay />
04 </div>
05 </template>
在这段模板中,
组件的内容会被渲染到 id 为 box 的 div 标签下。然而,有时这并不是我们所期望的。假设
是一个“蒙层”组件,该组件会渲染一个“蒙层”,并要求“蒙层”能够遮挡页面上的任何元素。换句话说,我们要求
组件的 z-index 的层级最高,从而实现遮挡。但问题是,如果
组件的内容无法跨越DOM 层级渲染,就无法实现这个目标。还是拿上面这段模板来说,id 为 box 的 div 标签拥有一段内联样式:z-index: -1,这导致即使我们将
组件所渲染内容的 z-index 值设置为无穷大,也无法实现遮挡功能。
通常,我们在面对上述场景时,会选择直接在 标签下渲染“蒙层”内容。在 Vue.js 2 中我们只能通过原生 DOM API 来手动搬运 DOM 元素实现需求。这么做的缺点在于,手动操作 DOM 元素会使得元素的渲染与 Vue.js 的渲染机制脱节,并导致各种可预见或不可预见的问题。考虑到该需求的确非常常见,用户对此也抱有迫切的期待,于是 Vue.js 3 内建了Teleport 组件。该组件可以将指定内容渲染到特定容器中,而不受 DOM 层级的限制。
我们先来看看 Teleport 组件是如何解决这个问题的。如下是基于 Teleport 组件实现的
组件的模板:
01 <template>
02 <Teleport to="body">
03 <div class="overlay"></div>
04 </Teleport>
05 </template>
06 <style scoped>
07 .overlay {
08 z-index: 9999;
09 }
10 </style>
可以看到,
组件要渲染的内容都包含在 Teleport 组件内,即作为 Teleport 组件的插槽。通过为 Teleport 组件指定渲染目标 body,即 to 属性的值,该组件就会直接把它的插槽内容渲染到 body 下,而不会按照模板的 DOM 层级来渲染,于是就实现了跨 DOM 层级的渲染。最终
组件的 z-index 值也会按预期工作,并遮挡页面中的所有内容。
与 KeepAlive 组件一样,Teleport 组件也需要渲染器的底层支持。首先我们要将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:
为了完成逻辑分离的工作,要先修改 patch 函数,如下面的代码所示:
01 function patch(n1, n2, container, anchor) {
02 if (n1 && n1.type !== n2.type) {
03 unmount(n1)
04 n1 = null
05 }
06
07 const { type } = n2
08
09 if (typeof type === 'string') {
10 // 省略部分代码
11 } else if (type === Text) {
12 // 省略部分代码
13 } else if (type === Fragment) {
14 // 省略部分代码
15 } else if (typeof type === 'object' && type.__isTeleport) {
16 // 组件选项中如果存在 __isTeleport 标识,则它是 Teleport 组件,
17 // 调用 Teleport 组件选项中的 process 函数将控制权交接出去
18 // 传递给 process 函数的第五个参数是渲染器的一些内部方法
19 type.process(n1, n2, container, anchor, {
20 patch,
21 patchChildren,
22 unmount,
23 move(vnode, container, anchor) {
24 insert(vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor)
25 }
26 })
27 } else if (typeof type === 'object' || typeof type === 'function') {
28 // 省略部分代码
29 }
30 }
可以看到,我们通过组件选项的 __isTeleport 标识来判断该组件是否是 Teleport 组件。如果是,则直接调用组件选项中定义的 process 函数将渲染控制权完全交接出去,这样就实现了渲染逻辑的分离。
Teleport 组件的定义如下:
01 const Teleport = {
02 __isTeleport: true,
03 process(n1, n2, container, anchor) {
04 // 在这里处理渲染逻辑
05 }
06 }
可以看到,Teleport 组件并非普通组件,它有特殊的选项__isTeleport 和 process。
接下来我们设计虚拟 DOM 的结构。假设用户编写的模板如下:
01 <Teleport to="body">
02 <h1>Title</h1>
03 <p>content</p>
04 </Teleport>
那么它应该被编译为怎样的虚拟 DOM 呢?虽然在用户看来Teleport 是一个内建组件,但实际上,Teleport 是否拥有组件的性质是由框架本身决定的。通常,一个组件的子节点会被编译为插槽内容,不过对于 Teleport 组件来说,直接将其子节点编译为一个数组即可,如下面的代码所示:
01 function render() {
02 return {
03 type: Teleport,
04 // 以普通 children 的形式代表被 Teleport 的内容
05 children: [
06 { type: 'h1', children: 'Title' },
07 { type: 'p', children: 'content' }
08 ]
09 }
10 }
设计好虚拟 DOM 的结构后,我们就可以着手实现 Teleport 组件了。首先,我们来完成 Teleport 组件的挂载动作,如下面的代码所示:
01 const Teleport = {
02 __isTeleport: true,
03 process(n1, n2, container, anchor, internals) {
04 // 通过 internals 参数取得渲染器的内部方法
05 const { patch } = internals
06 // 如果旧 VNode n1 不存在,则是全新的挂载,否则执行更新
07 if (!n1) {
08 // 挂载
09 // 获取容器,即挂载点
10 const target = typeof n2.props.to === 'string'
11 ? document.querySelector(n2.props.to)
12 : n2.props.to
13 // 将 n2.children 渲染到指定挂载点即可
14 n2.children.forEach(c => patch(null, c, target, anchor))
15 } else {
16 // 更新
17 }
18 }
19 }
可以看到,即使 Teleport 渲染逻辑被单独分离出来,它的渲染思路仍然与渲染器本身的渲染思路保持一致。通过判断旧的虚拟节点(n1)是否存在,来决定是执行挂载还是执行更新。如果要执行挂载,则需要根据 props.to 属性的值来取得真正的挂载点。最后,遍历 Teleport 组件的 children 属性,并逐一调用 patch 函数完成子节点的挂载。
更新的处理更加简单,如下面的代码所示:
01 const Teleport = {
02 __isTeleport: true,
03 process(n1, n2, container, anchor, internals) {
04 const { patch, patchChildren } = internals
05 if (!n1) {
06 // 省略部分代码
07 } else {
08 // 更新
09 patchChildren(n1, n2, container)
10 }
11 }
12 }
只需要调用 patchChildren 函数完成更新操作即可。不过有一点需要额外注意,更新操作可能是由于 Teleport 组件的 to 属性值的变化引起的,因此,在更新时我们应该考虑这种情况。具体的处理方式如下:
01 const Teleport = {
02 __isTeleport: true,
03 process(n1, n2, container, anchor, internals) {
04 const { patch, patchChildren, move } = internals
05 if (!n1) {
06 // 省略部分代码
07 } else {
08 // 更新
09 patchChildren(n1, n2, container)
10 // 如果新旧 to 参数的值不同,则需要对内容进行移动
11 if (n2.props.to !== n1.props.to) {
12 // 获取新的容器
13 const newTarget = typeof n2.props.to === 'string'
14 ? document.querySelector(n2.props.to)
15 : n2.props.to
16 // 移动到新的容器
17 n2.children.forEach(c => move(c, newTarget))
18 }
19 }
20 }
21 }
用来执行移动操作的 move 函数的实现如下:
01 else if (typeof type === 'object' && type.__isTeleport) {
02 type.process(n1, n2, container, anchor, {
03 patch,
04 patchChildren,
05 // 用来移动被 Teleport 的内容
06 move(vnode, container, anchor) {
07 insert(
08 vnode.component
09 ? vnode.component.subTree.el // 移动一个组件
10 : vnode.el, // 移动普通元素
11 container,
12 anchor
13 )
14 }
15 })
16 }
在上面的代码中,我们只考虑了移动组件和普通元素。我们知道,虚拟节点的类型有很多种,例如文本类型(Text)、片段类型(Fragment)等。一个完善的实现应该考虑所有这些虚拟节点的类型。
通过对 KeepAlive 组件和 Teleport 组件的讲解,我们能够意识到,Vue.js 内建的组件通常与渲染器的核心逻辑结合得非常紧密。本节将要讨论的 Transition 组件也不例外,甚至它与渲染器的结合更加紧密。
实际上,Transition 组件的实现比想象中简单得多,它的核心原理是:
当然,规则上主要遵循上述两个要素,但具体实现时要考虑的边界情况还有很多。不过,我们只要理解它的核心原理即可,至于细节,可以在基本实现的基础上按需添加或完善。
为了更好地理解 Transition 组件的实现原理,我们有必要先讨论如何为原生 DOM 创建过渡动效。过渡效果本质上是一个DOM 元素在两种状态间的切换,浏览器会根据过渡效果自行完成 DOM 元素的过渡。这里的过渡效果指的是持续时长、运动曲线、要过渡的属性等。
我们从一个例子开始。假设我们有一个 div 元素,宽高各100px,如下面的代码所示:
01 <div class="box"></div>
接着,为其添加对应的 CSS 样式:
01 .box {
02 width: 100px;
03 height: 100px;
04 background-color: red;
05 }
现在,假设我们要为元素添加一个进场动效。我们可以这样描述该动效:从距离左边 200px 的位置在 1 秒内运动到距离左边0px 的位置。在这句描述中,初始状态是“距离左边200px”,因此我们可以用下面的样式来描述初始状态:
01 .enter-from {
02 transform: translateX(200px);
03 }
而结束状态是“距离左边 0px”,也就是初始位置,可以用下面的 CSS 代码来描述:
01 .enter-to {
02 transform: translateX(0);
03 }
初始状态和结束状态都已经描述完毕了。最后,我们还要描述运动过程,例如持续时长、运动曲线等。对此,我们可以用如下 CSS 代码来描述:
01 .enter-active {
02 transition: transform 1s ease-in-out;
03 }
这里我们指定了运动的属性是 transform,持续时长为 1s,并且运动曲线是 ease-in-out。
定义好了运动的初始状态、结束状态以及运动过程之后,接下来我们就可以为 DOM 元素添加进场动效了,如下面的代码所示:
01 // 创建 class 为 box 的 DOM 元素
02 const el = document.createElement('div')
03 el.classList.add('box')
04
05 // 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
06 el.classList.add('enter-from') // 初始状态
07 el.classList.add('enter-active') // 运动过程
08
09 // 将元素添加到页面
10 document.body.appendChild(el)
上面这段代码主要做了三件事:
经过这三个步骤之后,元素的初始状态会生效,页面渲染的时候会将 DOM 元素以初始状态所定义的样式进行展示。接下来我们需要切换元素的状态,使得元素开始运动。那么,应该怎么做呢?理论上,我们只需要将 enter-from 类从 DOM 元素上移除,并将 enter-to 这个类添加到 DOM 元素上即可,如下面的代码所示:
01 // 创建 class 为 box 的 DOM 元素
02 const el = document.createElement('div')
03 el.classList.add('box')
04
05 // 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
06 el.classList.add('enter-from') // 初始状态
07 el.classList.add('enter-active') // 运动过程
08
09 // 将元素添加到页面
10 document.body.appendChild(el)
11
12 // 切换元素的状态
13 el.classList.remove('enter-from') // 移除 enter-from
14 el.classList.add('enter-to') // 添加 enter-to
然而,上面这段代码无法按预期执行。这是因为浏览器会在当前帧绘制 DOM 元素,最终结果是,浏览器将 enter-to 这个类所具有的样式绘制出来,而不会绘制 enter-from 类所具有的样式。为了解决这个问题,我们需要在下一帧执行状态切换,如下面的代码所示:
01 // 创建 class 为 box 的 DOM 元素
02 const el = document.createElement('div')
03 el.classList.add('box')
04
05 // 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
06 el.classList.add('enter-from') // 初始状态
07 el.classList.add('enter-active') // 运动过程
08
09 // 将元素添加到页面
10 document.body.appendChild(el)
11
12 // 在下一帧切换元素的状态
13 requestAnimationFrame(() => {
14 el.classList.remove('enter-from') // 移除 enter-from
15 el.classList.add('enter-to') // 添加 enter-to
16 })
可以看到,我们使用 requestAnimationFrame 注册了一个回调函数,该回调函数理论上会在下一帧执行。这样,浏览器就会在当前帧绘制元素的初始状态,然后在下一帧切换元素的状态,从而使得过渡生效。但如果你尝试在 Chrome 或 Safari 浏览器中运行上面这段代码,会发现过渡仍未生效,这是为什么呢?实际上,这是浏览器的实现 bug 所致。该 bug 的具体描述参见 Issue 675795: Interop: mismatch in when animations are started between different browsers。其大意是,使用 requestAnimationFrame 函数注册回调会在当前帧执行,除非其他代码已经调用了一次requestAnimationFrame 函数。这明显是不正确的,因此我们需要一个变通方案,如下面的代码所示:
01 // 创建 class 为 box 的 DOM 元素
02 const el = document.createElement('div')
03 el.classList.add('box')
04
05 // 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
06 el.classList.add('enter-from') // 初始状态
07 el.classList.add('enter-active') // 运动过程
08
09 // 将元素添加到页面
10 document.body.appendChild(el)
11
12 // 嵌套调用 requestAnimationFrame
13 requestAnimationFrame(() => {
14 requestAnimationFrame(() => {
15 el.classList.remove('enter-from') // 移除 enter-from
16 el.classList.add('enter-to') // 添加 enter-to
17 })
18 })
通过嵌套一层 requestAnimationFrame 函数的调用即可解决上述问题。现在,如果你再次尝试在浏览器中运行代码,会发现进场动效能够正常显示了。
最后我们需要做的是,当过渡完成后,将 enter-to 和 enter-active 这两个类从 DOM 元素上移除,如下面的代码所示:
01 // 创建 class 为 box 的 DOM 元素
02 const el = document.createElement('div')
03 el.classList.add('box')
04
05 // 在 DOM 元素被添加到页面之前,将初始状态和运动过程定义到元素上
06 el.classList.add('enter-from') // 初始状态
07 el.classList.add('enter-active') // 运动过程
08
09 // 将元素添加到页面
10 document.body.appendChild(el)
11
12 // 嵌套调用 requestAnimationFrame
13 requestAnimationFrame(() => {
14 requestAnimationFrame(() => {
15 el.classList.remove('enter-from') // 移除 enter-from
16 el.classList.add('enter-to') // 添加 enter-to
17
18 // 监听 transitionend 事件完成收尾工作
19 el.addEventListener('transitionend', () => {
20 el.classList.remove('enter-to')
21 el.classList.remove('enter-active')
22 })
23 })
24 })
通过监听元素的 transitionend 事件来完成收尾工作。实际上,我们可以对上述为 DOM 元素添加进场过渡的过程进行抽象,如下图所示:
从创建 DOM 元素完成后,到把 DOM 元素添加到 body 前,整个过程可以视作 beforeEnter 阶段。在把 DOM 元素添加到body 之后,则可以视作 enter 阶段。在不同的阶段执行不同的操作,即可完成整个进场过渡的实现。
理解了进场过渡的实现原理后,接下来我们讨论 DOM 元素的离场过渡效果。与进场过渡一样,我们需要定义离场过渡的初始状态、结束状态以及过渡过程,如下面的 CSS 代码所示:
01 /* 初始状态 */
02 .leave-from {
03 transform: translateX(0);
04 }
05 /* 结束状态 */
06 .leave-to {
07 transform: translateX(200px);
08 }
09 /* 过渡过程 */
10 .leave-active {
11 transition: transform 2s ease-out;
12 }
可以看到,离场过渡的初始状态与结束状态正好对应进场过渡的结束状态与初始状态。当然,我们完全可以打破这种对应关系,你可以采用任意过渡效果。
离场动效一般发生在 DOM 元素被卸载的时候,如下面的代码所示:
01 // 卸载元素
02 el.addEventListener('click', () => {
03 el.parentNode.removeChild(el)
04 })
当点击元素的时候,该元素会被移除,这样就实现了卸载。然而,从代码中可以看出,元素被点击的瞬间就会被卸载,所以如果仅仅这样做,元素根本就没有执行过渡的机会。因此,一个很自然的思路就产生了:当元素被卸载时,不要将其立即卸载,而是等待过渡效果结束后再卸载它。为了实现这个目标,我们需要把用于卸载 DOM 元素的代码封装到一个函数中,该函数会等待过渡结束后被调用,如下面的代码所示:
01 el.addEventListener('click', () => {
02 // 将卸载动作封装到 performRemove 函数中
03 const performRemove = () => el.parentNode.removeChild(el)
04 })
在上面这段代码中,我们将卸载动作封装到 performRemove 函数中,这个函数会等待过渡效果结束后再执行。
具体的离场动效的实现如下:
01 el.addEventListener('click', () => {
02 // 将卸载动作封装到 performRemove 函数中
03 const performRemove = () => el.parentNode.removeChild(el)
04
05 // 设置初始状态:添加 leave-from 和 leave-active 类
06 el.classList.add('leave-from')
07 el.classList.add('leave-active')
08
09 // 强制 reflow:使初始状态生效
10 document.body.offsetHeight
11
12 // 在下一帧切换状态
13 requestAnimationFrame(() => {
14 requestAnimationFrame(() => {
15 // 切换到结束状态
16 el.classList.remove('leave-from')
17 el.classList.add('leave-to')
18
19 // 监听 transitionend 事件做收尾工作
20 el.addEventListener('transitionend', () => {
21 el.classList.remove('leave-to')
22 el.classList.remove('leave-active')
23 // 当过渡完成后,记得调用 performRemove 函数将 DOM 元素移除
24 performRemove()
25 })
26 })
27 })
28 })
从上面这段代码中可以看到,离场过渡的处理与进场过渡的处理方式非常相似,即首先设置初始状态,然后在下一帧中切换为结束状态,从而使得过渡生效。需要注意的是,当离场过渡完成之后,需要执行 performRemove 函数来真正地将 DOM 元素卸载。
在为原生 DOM 元素创建进场动效和离场动效时能注意到,整个过渡过程可以抽象为几个阶段,这些阶段可以抽象为特定的回调函数。例如 beforeEnter、enter、leave 等。实际上,基于虚拟 DOM 的实现也需要将DOM 元素的生命周期分割为这样几个阶段,并在特定阶段执行对应的回调函数。
为了实现 Transition 组件,我们需要先设计它在虚拟 DOM 层面的表现形式。假设组件的模板内容如下:
01 <template>
02 <Transition>
03 <div>我是需要过渡的元素</div>
04 </Transition>
05 </template>
我们可以将这段模板被编译后的虚拟 DOM 设计为:
01 function render() {
02 return {
03 type: Transition,
04 children: {
05 default() {
06 return { type: 'div', children: '我是需要过渡的元素' }
07 }
08 }
09 }
10 }
可以看到,Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致。虚拟 DOM 层面的表示已经设计完了,接下来,我们着手实现 Transition 组件,如下面的代码所示:
01 const Transition = {
02 name: 'Transition',
03 setup(props, { slots }) {
04 return () => {
05 // 通过默认插槽获取需要过渡的元素
06 const innerVNode = slots.default()
07
08 // 在过渡元素的 VNode 对象上添加 transition 相应的钩子函数
09 innerVNode.transition = {
10 beforeEnter(el) {
11 // 省略部分代码
12 },
13 enter(el) {
14 // 省略部分代码
15 },
16 leave(el, performRemove) {
17 // 省略部分代码
18 }
19 }
20
21 // 渲染需要过渡的元素
22 return innerVNode
23 }
24 }
25 }
观察上面的代码,可以发现几点重要信息:
可以看到,经过 Transition 组件的包装后,内部需要过渡的虚拟节点对象会被添加一个 vnode.transition 对象。这个对象下存在一些与 DOM 元素过渡相关的钩子函数,例如beforeEnter、enter、leave 等。这些钩子函数与我们在上节中介绍的钩子函数相同,渲染器在渲染需要过渡的虚拟节点时,会在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,具体体现在 mountElement 函数以及unmount 函数中,如下面的代码所示:
01 function mountElement(vnode, container, anchor) {
02 const el = vnode.el = createElement(vnode.type)
03
04 if (typeof vnode.children === 'string') {
05 setElementText(el, vnode.children)
06 } else if (Array.isArray(vnode.children)) {
07 vnode.children.forEach(child => {
08 patch(null, child, el)
09 })
10 }
11
12 if (vnode.props) {
13 for (const key in vnode.props) {
14 patchProps(el, key, null, vnode.props[key])
15 }
16 }
17
18 // 判断一个 VNode 是否需要过渡
19 const needTransition = vnode.transition
20 if (needTransition) {
21 // 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
22 vnode.transition.beforeEnter(el)
23 }
24
25 insert(el, container, anchor)
26 if (needTransition) {
27 // 调用 transition.enter 钩子,并将 DOM 元素作为参数传递
28 vnode.transition.enter(el)
29 }
30 }
上面这段代码是修改后的 mountElement 函数,我们为它增加了 transition 钩子的处理。可以看到,在挂载 DOM 元素之前,会调用 transition.beforeEnter 钩子;在挂载元素之后,会调用 transition.enter 钩子,并且这两个钩子函数都接收需要过渡的 DOM 元素对象作为第一个参数。除了挂载之外,卸载元素时我们也应该调用 transition.leave 钩子函数,如下面的代码所示:
01 function unmount(vnode) {
02 // 判断 VNode 是否需要过渡处理
03 const needTransition = vnode.transition
04 if (vnode.type === Fragment) {
05 vnode.children.forEach(c => unmount(c))
06 return
07 } else if (typeof vnode.type === 'object') {
08 if (vnode.shouldKeepAlive) {
09 vnode.keepAliveInstance._deActivate(vnode)
10 } else {
11 unmount(vnode.component.subTree)
12 }
13 return
14 }
15 const parent = vnode.el.parentNode
16 if (parent) {
17 // 将卸载动作封装到 performRemove 函数中
18 const performRemove = () => parent.removeChild(vnode.el)
19 if (needTransition) {
20 // 如果需要过渡处理,则调用 transition.leave 钩子,
21 // 同时将 DOM 元素和 performRemove 函数作为参数传递
22 vnode.transition.leave(vnode.el, performRemove)
23 } else {
24 // 如果不需要过渡处理,则直接执行卸载操作
25 performRemove()
26 }
27 }
28 }
上面这段代码是修改后的 unmount 函数的实现,我们同样为其增加了关于过渡的处理。首先,需要将卸载动作封装到performRemove 函数内。如果 DOM 元素需要过渡处理,那么就需要等待过渡结束后再执行 performRemove 函数完成卸载,否则直接调用该函数完成卸载即可。
有了 mountElement 函数和 unmount 函数的支持后,我们可以轻松地实现一个最基本的 Transition 组件了,如下面的代码所示:
01 const Transition = {
02 name: 'Transition',
03 setup(props, { slots }) {
04 return () => {
05 const innerVNode = slots.default()
06
07 innerVNode.transition = {
08 beforeEnter(el) {
09 // 设置初始状态:添加 enter-from 和 enter-active 类
10 el.classList.add('enter-from')
11 el.classList.add('enter-active')
12 },
13 enter(el) {
14 // 在下一帧切换到结束状态
15 nextFrame(() => {
16 // 移除 enter-from 类,添加 enter-to 类
17 el.classList.remove('enter-from')
18 el.classList.add('enter-to')
19 // 监听 transitionend 事件完成收尾工作
20 el.addEventListener('transitionend', () => {
21 el.classList.remove('enter-to')
22 el.classList.remove('enter-active')
23 })
24 })
25 },
26 leave(el, performRemove) {
27 // 设置离场过渡的初始状态:添加 leave-from 和 leave-active 类
28 el.classList.add('leave-from')
29 el.classList.add('leave-active')
30 // 强制 reflow,使得初始状态生效
31 document.body.offsetHeight
32 // 在下一帧修改状态
33 nextFrame(() => {
34 // 移除 leave-from 类,添加 leave-to 类
35 el.classList.remove('leave-from')
36 el.classList.add('leave-to')
37
38 // 监听 transitionend 事件完成收尾工作
39 el.addEventListener('transitionend', () => {
40 el.classList.remove('leave-to')
41 el.classList.remove('leave-active')
42 // 调用 transition.leave 钩子函数的第二个参数,完成 DOM 元素的卸载
43 performRemove()
44 })
45 })
46 }
47 }
48
49 return innerVNode
50 }
51 }
52 }
在上面这段代码中,我们补全了 vnode.transition 中各个钩子函数的具体实现。可以看到,其实现思路与原生 DOM 过渡的思路一样。
在上面的实现中,我们硬编码了过渡状态的类名,例如 enter-from、enter-to 等。实际上,我们可以轻松地通过 props 来实现允许用户自定义类名的能力,从而实现一个更加灵活的Transition 组件。另外,我们也没有实现“模式”的概念,即先进后出(in-out)或后进先出(out-in)。实际上,模式的概念只是增加了对节点过渡时机的控制,原理上与将卸载动作封装到 performRemove 函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。