20_第_14_章_内建组件和模块
约 18623 字大约 62 分钟
2026-10-05
前面第 12 章和第 13 章,我们把“渲染器”(renderer:负责把虚拟 DOM 变成真实 DOM 的那台机器)是怎么撑起 Vue.js 的组件化能力讲完了。
本章要登场的三位主角,是 Vue.js 自带、不用你安装任何东西就能直接用的内建组件和内建模块:KeepAlive 组件、Teleport 组件、Transition 组件。
内建组件(built-in component):
框架自己写好、直接内置在框架里的组件,像“工具箱里厂家配好的扳手”,你 <KeepAlive> 一写就能用,不用自己造。
它们和普通组件最大的区别在于:普通组件只负责“在什么位置画个什么东西”,而这三个组件还得管“这个元素什么时候从画面上拿走、该搬到哪儿去画”。
而“搬运节点”“推迟卸载”这类动作,只有渲染器这个层级才做得动,所以它们统统需要渲染器级别的底层支持——离开渲染器给它们开门,它们根本装不上。
最后再强调一次为什么值得学:这三个组件带来的能力,对开发者而言非常重要且实用——页签来回切不丢状态、弹窗盖住全屏、列表淡入淡出。
理解它们的工作原理,有助于我们在真正用到它们时用对。
14.1 KeepAlive 组件的实现原理
14.1.1 组件的激活与失活
KeepAlive 这个名字,是从 HTTP 协议那儿“借”来的。
在 HTTP 协议里,KeepAlive 还有一个正式叫法——HTTP 持久连接(HTTP persistent connection),它的作用是允许多个请求或响应共用同一条 TCP 连接。TCP 连接你可以理解成“两台机器之间拉起来的一根电话线”,拉一次线很费劲。
没有 KeepAlive 的时候,一条 HTTP 连接在每次请求/响应结束后就直接关闭了;等下一次请求发生,又得重新建立一条新的连接。
频繁地销毁、创建 HTTP 连接纯属白折腾(费了力气,没换来好处),KeepAlive 就是为了解决这个性能开销问题而生的。
HTTP 持久连接(HTTP KeepAlive):
浏览器和服务器之间“不断开”的连接。HTTP/1.0 每次请求都要新建一条 TCP 连接(很慢);HTTP/1.1 默认开启 KeepAlive,好几个请求复用同一条连接,省去反复握手的时间。
Vue 的 KeepAlive 组件(本章主角之一):
它管的不是网络,而是组件。你可以把它想成给组件准备的一台冰箱——某个 <Tab> 切走了,不等于被扔掉,只是被塞进冰箱冻着;哪天再切回来,拿出来还是原样。
滚动条停在哪、用户填了一半的表单,全都还在。这就是“避免一个组件被频繁地销毁/重建”在现实里的样子。
HTTP 中的 KeepAlive 可以避免连接频繁地销毁/创建。与 HTTP 中的 KeepAlive 类似,Vue.js 内建的 KeepAlive 组件可以避免一个组件被频繁地销毁/重建。
假设我们的页面中有一组 <Tab> 组件,如下面的代码所示:
<template>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</template>我们逐行看:
- 三个
<Tab>各带一个v-if:根据currentTab的值,只渲染对应那个 Tab。
规则一句话就能说清:currentTab 是几,就只渲染第几个 <Tab>;另外两个因为 v-if 条件不成立,连虚拟节点都不会生成。
麻烦也正出在这儿——用户“1 → 2 → 3 → 1”这样来回狂点页签,<Tab> 组件就跟着被反复地卸载并重建:里面的 setup 重跑一遍、子组件全重来一遍、data 全部回到初始值,切回来时滚动条又弹回最上面。
为了避免因此产生的性能开销,可以使用 KeepAlive 组件来解决这个问题,如下面的代码所示:
<template>
<!-- 使用 KeepAlive 组件包裹 -->
<KeepAlive>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</KeepAlive>
</template>用 <KeepAlive> 把三个 Tab 包起来。切换 Tab 时,已经显示过的 Tab 不会真的销毁——它被“缓存”住了,下次切换回来还是上次的状态。
挂载(mount)与卸载(unmount)这两个词前面章节已经解释过:前者是“把这个虚拟节点变成真实 DOM 插进页面”,后者是“把真实 DOM 从页面里拆掉”。
打个比方:挂载 = 盖房子(从图纸变成真房子,插进小区),卸载 = 拆房子(把真房子从小区里移除)。第 8 章专门讲这两个动作怎么实现;本章的 KeepAlive 之所以要“特殊处理卸载”,就是因为它想让某些房子别真被拆。
这样,无论用户怎样切换 <Tab> 组件,都不会发生频繁的创建和销毁,因而会极大地优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。
那么,KeepAlive 组件的实现原理是怎样的呢?其实 KeepAlive 的本质是缓存管理,再加上特殊的挂载/卸载逻辑。
首先,KeepAlive 组件的实现需要渲染器层面的支持。这是因为被 KeepAlive 的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。
正确的做法是,将被 KeepAlive 的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。
这个过程对应到组件的生命周期,其实就是 activated 和 deactivated。
假卸载:
字面上“卸载”了,但其实是把它搬到了另一个位置。对用户看上去是“隐藏了”,对框架内部其实“什么都没删”。
activated / deactivated:
组件的两个特殊生命周期钩子。激活时(从隐藏搬回屏幕)触发 activated,失活时(从屏幕搬到隐藏)触发 deactivated。注意:这两个钩子不会替代 mounted / unmounted,它们是额外触发的。
图 14-1 描述了“卸载”和“挂载”一个被 KeepAlive 的组件的过程。

图 14-1 “卸载”和“挂载”一个被 KeepAlive 的组件的过程
如图 14-1 所示,“卸载”一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动到一个隐藏容器中。当重新“挂载”该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再“放回”原来的容器中,即页面中。
我们再用一张图把整个搬运过程串起来:
前面第 12 章讲过插槽(slots):写在组件标签里面的那些内容。对 <KeepAlive> 来说,里面那三个 <Tab> 就是它的插槽内容,slots.default() 就是把默认插槽里的内容取出来(组件里没写具名插槽时,插槽就叫“默认插槽”)。
下面这几个名字会在代码里反复出现,先立个速查表,后面就不怕了:
patch(n1, n2, container, anchor):渲染器的主力函数。n1是“旧的虚拟节点”(没有就是null),n2是“新的虚拟节点”,container是“要挂到哪儿”,anchor是“插在这个参照物前面”。mountComponent(...):把一个组件真正挂到页面上(会跑setup、生成组件实例)。unmount(vnode):把一个虚拟节点对应的内容从页面上摘掉。insert(el, container, anchor):真正往 DOM 树上插元素的底层动作。DOM 的“移动”也是靠它实现的——浏览器会把已存在的节点“搬”到新位置,而不是复制一份。anchor(锚点):插入位置的参照物。比如“要插在<p>前面”,那anchor就是那个<p>。可以想成在门口贴张纸条写“新来的排这儿”,大家照着纸条排队。
一个最基本的 KeepAlive 组件实现起来并不复杂,如下面的代码所示:
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 的组件
const 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
}
}
}我们逐行看:
__isKeepAlive: true:这是个“身份证”,告诉渲染器“我是 KeepAlive”。渲染器看到它就走 KeepAlive 的特殊分支。
__is... 命名约定:
JS 里没有真正的“私有属性”,下划线开头的属性只是一种约定,提醒“别碰我,这是内部用的”。
setup(props, { slots }) { ... }:KeepAlive 组件的“内功”。const cache = new Map():用Map当缓存。键 = 组件的“身份证”(vnode.type),值 = 缓存的 vnode。
Map:
JS 里的“字典”,跟对象类似,但键可以是任意类型(不只是字符串),还有 size、has 这些方便的方法。这里用 vnode.type(组件对象本身)当键最合适。
const instance = currentInstance:拿到“当前正在 setup 的那个组件实例”。
currentInstance 就是 12.4 节(组件注册生命周期)里介绍过的那个全局变量:指向当前正在初始化的组件实例。KeepAlive 的 setup 跑的时候,它自然指向 KeepAlive 自己,其它模块也可以通过它“知道当前上下文”。
const { move, createElement } = instance.keepAliveCtx:从渲染器注入的上下文里取出“移动 DOM”的工具函数和“创建元素”的工具函数。const storageContainer = createElement('div'):创建隐藏容器。一个普通的<div>,不挂到 DOM 树上,只是临时仓库。instance._deActivate = (vnode) => move(vnode, storageContainer):失活 = 搬到隐藏容器。instance._activate = (vnode, container, anchor) => move(vnode, container, anchor):激活 = 搬到目标位置。return () => { ... }:渲染函数,每次重渲染都会跑。const rawVNode = slots.default():从默认插槽里拿到被 KeepAlive 的那个 vnode。if (typeof rawVNode.type !== 'object') return rawVNode:非组件(普通 HTML 标签)就原样返回,没必要缓存。const cachedVNode = cache.get(rawVNode.type):看看这个组件之前有没有被缓存过。if (cachedVNode) { rawVNode.component = cachedVNode.component; rawVNode.keptAlive = true }:有缓存!继承之前的组件实例,标上keptAlive = true让渲染器走“激活”分支。else { cache.set(rawVNode.type, rawVNode) }:没缓存过,先存一份,下次就能用上。rawVNode.shouldKeepAlive = true:告诉渲染器“卸载时不要真的删”。rawVNode.keepAliveInstance = instance:把自己(KeepAlive 实例)挂在 vnode 上,渲染器要失活时能找到_deActivate函数。
从上面的实现中可以看到,与普通组件的一个较大的区别在于,KeepAlive 组件与渲染器的结合非常深。
首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,我们把这个需要被 KeepAlive 的组件称为“内部组件”。
KeepAlive 组件会对“内部组件”进行操作,主要是在“内部组件”的 vnode 对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。这些标记属性包括如下几个。
shouldKeepAlive:该属性会被添加到“内部组件”的vnode对象上,这样当渲染器卸载“内部组件”时,可以通过检查该属性得知“内部组件”需要被 KeepAlive。于是,渲染器就不会真的卸载“内部组件”,而是会调用_deActivate函数完成搬运工作,如下面的代码所示:
_deActivate:
渲染器在“卸载内部组件”之前会先调用的特殊函数,KeepAlive 就是把真实 DOM 搬进隐藏容器的地方。
顺带回顾一下 Fragment(第 13 章讲过的片段类型):它是一种“只当容器、不渲染自己”的虚拟节点。模板里有多个并列的根元素时,Vue 就会用一个 Fragment 把它们包起来,页面里看不到任何额外标签,但框架内部需要这么个东西来“装”这一堆节点。打个比方,Fragment 就像一张透明胶带 —— 你把几张便签(子节点)并排放好,得用胶带把它们“归成一组”才能整组搬动,但胶带本身在页面上看不见。
打个比方:Fragment 就像一张透明胶带。你把几张便签(子节点)并排放好,需要用胶带把它们“归成一组”才能整组搬动,但胶带本身在页面上看不见。
// 卸载操作
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)
}
}结论:卸载组件之前先查一查“它要不要被 KeepAlive” —— 要就走失活(把 DOM 搬进隐藏容器),不要就走真卸载,两条路在这里分叉。
keepAliveInstance:“内部组件”的vnode对象会持有 KeepAlive 组件实例,在unmount函数中会通过keepAliveInstance来访问_deActivate函数。keptAlive:“内部组件”如果已经被缓存,则还会为其添加一个keptAlive标记。这样当“内部组件”需要重新渲染时,渲染器并不会重新挂载它,而会将其激活,如下面patch函数的代码所示: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' || typeof type === 'function') { // component if (!n1) { // 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用 _activate 来激活它 if (n2.keptAlive) { n2.keepAliveInstance._activate(n2, container, anchor) } else { mountComponent(n2, container, anchor) } } else { patchComponent(n1, n2, anchor) } } }
结论:只要 vnode 上带着 keptAlive 标记,渲染器就绝不重新挂载它,而是通过 keepAliveInstance._activate 函数把它从隐藏容器里激活(搬回原容器);没带这个标记,才走 mountComponent 做全新挂载。
我们再来看一下用于激活组件和失活组件的两个函数:
const { move, createElement } = instance.keepAliveCtx
instance._deActivate = (vnode) => {
move(vnode, storageContainer)
}
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor)
}失活(deactivate):
把 vnode 对应的 DOM 从当前位置搬到 storageContainer(隐藏 div)。
激活(activate):
把 vnode 对应的 DOM 从隐藏 div 搬到目标 container。
比喻:演员上台又下台。下台(失活)不是退出演播厅,而是走到后台的储藏间;再上台(激活)时,导演把他从储藏间叫回台前那个位置。
所以这里其实只记一句就够:失活的本质 = 把组件渲染出来的内容搬进隐藏容器;激活的本质 = 把它从隐藏容器搬回原来的容器。两件事共用同一个 move,只是目标不同。
storageContainer(隐藏容器):
KeepAlive 组件自己 createElement('div') 出来的一个普通 <div>,不挂到 DOM 树上,只是临时仓库。上面两个函数搬运的目标就是它。
而上面这段代码里的 move 不是 KeepAlive 自己写的,是渲染器注入进来的(它写在 mountComponent 里),下面这段 mountComponent 的代码就能看到:
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
}
}
// 省略部分代码
}我们逐行看关键行:
keepAliveCtx: null:实例上预埋一个字段。只有 KeepAlive 才会有值,其他组件都是null。const isKeepAlive = vnode.type.__isKeepAlive:通过组件选项上的“身份证”判断。instance.keepAliveCtx = { move, createElement }:如果当前挂载的是 KeepAlive,就给它“注入”两个工具函数。move内部调的是浏览器原生的insert(DOM API),把subTree.el插到新容器。
subTree:
组件真正渲染出来的“那棵树”。KeepAlive 搬的是它,不是组件实例本身。
至此,一个最基本的 KeepAlive 组件就完成了。
14.1.2 include 和 exclude
在默认情况下,KeepAlive 组件会对所有“内部组件”进行缓存。但有时候用户期望只缓存特定组件。为了使用户能够自定义缓存规则,我们需要让 KeepAlive 组件支持两个 props,分别是 include 和 exclude。其中,include 用来显式地配置应该被缓存组件,而 exclude 用来显式地配置不应该被缓存组件。
include / exclude:
KeepAlive 的两个 prop,用正则指定“白名单 / 黑名单”。只有名称匹配 include 且不匹配 exclude 的组件才会被缓存。本节的实现里,这两个 prop 只接受正则——props 选项里声明的类型就是 RegExp。
KeepAlive 组件的 props 定义如下:
const KeepAlive = {
__isKeepAlive: true,
// 定义 include 和 exclude
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// 省略部分代码
}
}为了简化问题,我们只允许为 include 和 exclude 设置正则类型的值。在 KeepAlive 组件被挂载时,它会根据“内部组件”的名称(即 name 选项)进行匹配,如下面的代码所示:
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
}
// 省略部分代码
}
}
}我们逐行看:
const cache = new Map():声明缓存容器。注意它在KeepAlive组件对象的外面 —— 意味着这个 Map 跟着整个 KeepAlive 组件活,组件卸载了缓存也还在。__isKeepAlive: true:给组件挂一个标记,渲染器看到它就知道“这个组件启用 KeepAlive 逻辑”。include: RegExp, exclude: RegExp:声明这两个 prop 接收的是正则对象。这也是本节“只允许正则”这条约定在代码里的落点。setup(props, { slots }):接收 props 和插槽上下文。return () => { ... }:返回渲染函数。let rawVNode = slots.default():从默认插槽拿到被 KeepAlive 的那个 vnode。这里用let而不是const,因为下面会修改它的属性。if (typeof rawVNode.type !== 'object') return rawVNode:如果这不是组件(比如就是一个<div>),直接原样返回,没有缓存的必要。const name = rawVNode.type.name:拿到“内部组件”的name选项,靠它来判断该不该缓存。props.include && !props.include.test(name):用户指定了include,但组件名匹配不上 → 不缓存。props.exclude && props.exclude.test(name):组件名被exclude匹配上了 → 不缓存。- 两个条件用
||连接:任意一条成立就return rawVNode提前返回,跳过后面所有缓存逻辑。 // 省略部分代码:这就是本节讲过的“拿缓存、存缓存、挂shouldKeepAlive”那几步。
可以看到,我们根据用户指定的 include 和 exclude 正则,对“内部组件”的名称进行匹配,并根据匹配结果判断是否要对“内部组件”进行缓存。在此基础上,我们可以任意扩充匹配能力。例如,可以将 include 和 exclude 设计成多种类型值,允许用户指定字符串或函数,从而提供更加灵活的匹配机制。另外,在做匹配时,也可以不限于“内部组件”的名称,我们甚至可以让用户自行指定匹配要素。但无论如何,其原理都是不变的。
14.1.3 缓存管理
在前文给出的实现中,我们使用一个 Map 对象来实现对组件的缓存:
const cache = new Map()这个 Map 的键是组件的选项对象,也就是 vnode.type 的值(组件的“身份证”);值是用来描述这个组件的 vnode 对象。
这里有个特别关键的地方:vnode 身上挂着 vnode.component,而它指向的就是组件实例本身。因此缓存用于描述组件的 vnode 对象,就等价于缓存了组件实例。
打个比方:Map 像一排抽屉,抽屉外贴的标签就是键(组件的 type),抽屉里装的是那个组件的 vnode。因为抽屉里那份 vnode 手里攥着组件实例的名牌,所以谁拿到这个抽屉,谁就等于把那个还活着的组件整个攥在了手里。
回顾一下:到目前为止,KeepAlive 渲染函数里跟缓存有关的代码就下面这么几句,我们把它单独拎出来,复习一遍:
// 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,这样渲染器就不会重新创建新的组件实例; - 如果缓存不存在,则设置缓存。
这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量内存。
为了解决这个问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。但是这又引出了另外一个问题:我们应该如何对缓存进行修剪呢?换句话说,当需要对缓存进行修剪时,应该以怎样的策略修剪?优先修剪掉哪一部分?
缓存修剪(prune):
缓存满了以后踢掉一部分老条目,给新组件腾出位置。踢谁、按什么顺序踢,就是本节要回答的问题。
Vue.js 当前所采用的修剪策略叫作“最新一次访问”。首先,你需要为缓存设置最大容量,也就是通过 KeepAlive 组件的 max 属性来设置,例如:
<KeepAlive :max="2">
<component :is="dynamicComp"/>
</KeepAlive>max:
KeepAlive 的 prop,限制“最多缓存几个组件实例”。上面 :max="2" 的意思就是最多缓存 2 个组件实例。
现在假设有三个组件 Comp1、Comp2、Comp3,它们都会被缓存。然后,我们开始模拟组件切换过程中缓存的变化——你可以把每个组件想成一盘录像带:每切一个页签就等于往播放器里塞一盘新带子,塞不下时就必须倒掉一盘旧的腾地方。
缓存队列:
KeepAlive 把“当前缓存着哪些组件”按顺序摆成一队,本章里我们把它写成 [Comp1, Comp2] 这样的形式来称呼。队列满了就要修剪(prune)——踢掉一个,腾出位置给新的。
LRU(Least Recently Used,最久最少使用):
一种常见的缓存淘汰策略——谁最久没被用到,就先请谁出门。就像书架只放得下 10 本书,新书来了就得挤掉一本,那就挤掉“最久没翻过”的那本。下面场景 B 里,你会看到 KeepAlive 是怎么用上它的。
⚠️ 关于“最新一次访问”这个叫法:本节用的是一套简化模型 —— 缓存队列只进不出、顺序不变,另有一个“最新访问”指针(下面图上的 ★)标着当前访问的是谁;修剪时保住 ★、淘汰队首。这套模型推导出来的结论是对的。Vue 的真实实现则是标准 LRU:内部用一个
Set存条目的 key,每次命中缓存就把 key 移到队尾重新排队,超出max时淘汰队首。官方文档的原话是 behaves like an LRU cache。两者机制描述不同,但结论一致 —— 被淘汰的始终是“最久没被用到”的那个。
下面是第一种模拟方式,如下所示。
- 初始渲染
Comp1并缓存它。此时缓存队列为:[Comp1],并且最新一次访问(或渲染)的组件是Comp1。 - 切换到
Comp2并缓存它。此时缓存队列为:[Comp1, Comp2],并且最新一次访问(或渲染)的组件是Comp2。 - 切换到
Comp3,此时缓存容量已满,需要修剪,应该修剪谁呢?因为当前最新一次访问(或渲染)的组件是Comp2,所以它是“安全”的,即不会被修剪。因此被修剪掉的将会是Comp1。当缓存修剪完毕后,将会出现空余的缓存空间用来存储Comp3。所以,现在的缓存队列是:[Comp2, Comp3],并且最新一次渲染的组件变成了Comp3。
我们还可以换一种切换组件的方式,如下所示。
- 初始渲染
Comp1并缓存它。此时,缓存队列为:[Comp1],并且最新一次访问(或渲染)的组件是Comp1。 - 切换到
Comp2并缓存它。此时,缓存队列:[Comp1, Comp2],并且最新一次访问(或渲染)的组件是Comp2。 - 再切换回
Comp1,由于Comp1已经在缓存队列中,所以不需要修剪缓存,只需要激活组件即可,但要将最新一次渲染的组件设置为Comp1。 - 切换到
Comp3,此时缓存容量已满,需要修剪。应该修剪谁呢?由于Comp1是最新一次被渲染的,所以它是“安全”的,即不会被修剪掉,所以最终会被修剪掉的是Comp2。于是,现在的缓存队列是:[Comp1, Comp3],并且最新一次渲染的组件变成了Comp3。
我们用这张图把“最新一次访问”策略画出来(注意看清:方格里是缓存队列,“最新访问”是另一个指针,两者不是一回事):
⚠️ 场景 B 第三步最容易看错:切回
Comp1时,按上面那套简化模型,队列[Comp1, Comp2]原样不变,只是“最新访问”这个指针从Comp2挪到了Comp1。正因为指针换了人,第四步被淘汰的才是Comp2而不是Comp1。真实 Vue 实现虽然会在命中时把Comp1重排到队尾,但淘汰结果完全一样。
对比两次模拟就能看出,最终留在缓存里的组件是不一样的:场景 A 留下的是 Comp2 和 Comp3,场景 B 留下的是 Comp1 和 Comp3。
结论:“最新一次访问”策略的核心在于,需要把当前访问(或渲染)的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。
实现 Vue.js 内建的缓存策略并不难,本质上等同于一个小小的算法题目。我们的关注点在于,缓存策略能否改变?甚至允许用户自定义缓存策略?实际上,在 Vue.js 官方的 RFCs 中已经有相关提议¹。该提议允许用户实现自定义的缓存策略,在用户接口层面,则体现在 KeepAlive 组件新增了 cache 接口,允许用户指定缓存实例:
¹ 参见 Custom cache strategy and matching rules for KeepAlive #284。
<KeepAlive :cache="cache">
<Comp />
</KeepAlive>缓存实例需要满足固定的格式,一个基本的缓存实例的实现如下:
// 自定义实现
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)
}
}get / set / delete / forEach 这四个方法就是 KeepAlive 调用缓存时的“约定接口”:内部用 Map 实现,外面套一层对象,KeepAlive 就能把它当成普通缓存来用。
结论:在 KeepAlive 的内部实现中,只要用户提供了自定义的缓存实例,就直接使用该缓存实例来管理缓存。 从本质上来说,这等价于将缓存的管理权限从 KeepAlive 组件转交给用户了 —— 你用自己的 Map、自己的淘汰规则都行,KeepAlive 只管按约定去调那四个方法。
14.2 Teleport 组件的实现原理
14.2.1 Teleport 组件要解决的问题
Teleport 组件是 Vue.js 3 才新增的一个内建组件,我们先弄清楚它到底要解决什么麻烦事。
这里有一条需要先记住的默认规矩:通常情况下,在将虚拟 DOM 渲染为真实 DOM 时,最终渲染出来的真实 DOM 的层级结构,与虚拟 DOM 的层级结构是一致的。你可以理解成“照着积木图纸搭积木”——图纸上 A 里面套着 B,那么搭出来 B 就一定躺在 A 里面。下面这个模板就是个例子:
<template>
<div id="box" style="position: relative; z-index: -1;">
<Overlay />
</div>
</template>我们逐行看:
id="box"、position: relative; z-index: -1:这个div被故意压到最底层。<Overlay />套在里面:蒙层组件。
在这段模板中,<Overlay> 组件的内容会被渲染到 id 为 box 的 div 标签下。然而,有时这并不是我们所期望的。假设 <Overlay> 是一个“蒙层”组件,该组件会渲染一个“蒙层”,并要求“蒙层”能够遮挡页面上的任何元素。换句话说,我们要求 <Overlay> 组件的 z-index 的层级最高,从而实现遮挡。但问题是,如果 <Overlay> 组件的内容无法跨越 DOM 层级渲染,就无法实现这个目标。还是拿上面这段模板来说,id 为 box 的 div 标签拥有一段内联样式:position: relative; z-index: -1,这导致即使我们将 <Overlay> 组件所渲染内容的 z-index 值设置为无穷大,也无法实现遮挡功能。
z-index:
CSS 的“层级”。数值越大,越盖在别人上面。如果父元素是 -1,子元素无论多大都会被父元素一起压下去。
通常,我们在面对上述场景时,会选择直接在 <body> 标签下渲染“蒙层”内容。在 Vue.js 2 中我们只能通过原生 DOM API 来手动搬运 DOM 元素实现需求。这么做的缺点在于,手动操作 DOM 元素会使得元素的渲染与 Vue.js 的渲染机制脱节,并导致各种可预见或不可预见的问题。考虑到该需求的确非常常见,用户对此也抱有迫切的期待,于是 Vue.js 3 内建了 Teleport 组件。该组件可以将指定内容渲染到特定容器中,而不受 DOM 层级的限制。
Teleport(传送门):
Vue.js 3 新出的内建组件。它把“你写在 A 位置”的 DOM 传送到 B 位置去渲染。
B 位置可以是 <body>、某个 id,甚至一个 DOM 节点对象。原模板位置不变,只是不在那里真正渲染了。
我们先来看看 Teleport 组件是如何解决这个问题的。下面就是基于 Teleport 组件实现的 <Overlay> 组件的模板:
<template>
<Teleport to="body">
<div class="overlay"></div>
</Teleport>
</template>
<style scoped>
.overlay {
z-index: 9999;
}
</style>我们逐行看:
<Teleport to="body">:声明一个 Teleport,to指明传送目的地。- 里面写真正要渲染的内容:蒙层 div。
- CSS 里
z-index: 9999:现在因为蒙层直接挂在body下,再也不会被父div的-1压住了。
可以看到,<Overlay> 组件要渲染的内容都包含在 Teleport 组件内,即作为 Teleport 组件的插槽。通过为 Teleport 组件指定渲染目标 body,即 to 属性的值,该组件就会直接把它的插槽内容渲染到 body 下,而不会按照模板的 DOM 层级来渲染,于是就实现了跨 DOM 层级的渲染。最终 <Overlay> 组件的 z-index 值也会按预期工作,并遮挡页面中的所有内容。
14.2.2 实现 Teleport 组件
与 KeepAlive 组件一样,Teleport 组件也需要渲染器的底层支持。首先我们要将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:
- 可以避免渲染器逻辑代码“膨胀”;
- 当用户没有使用 Teleport 组件时,由于 Teleport 的渲染逻辑被分离,因此可以利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关的代码,使得最终构建包的体积变小。
为了完成逻辑分离的工作,要先修改 patch 函数,如下面的代码所示:
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') {
// 省略部分代码
}
}我们逐行看新分支:
else if (typeof type === 'object' && type.__isTeleport):注意判断顺序——Teleport 必须放在“普通对象/函数组件”分支之前。否则会被前面的else if抢走。
分支顺序:
在 if / else if 链里,先匹配先生效。所以一定要把“更特殊的”放前面。
type.process(n1, n2, container, anchor, { patch, patchChildren, unmount, move }):把渲染权完整交接给 Teleport 自己的process。第五个参数是渲染器主动“借”给它的工具函数。move(vnode, container, anchor) { insert(...subTree.el : vnode.el, ...) }:移动节点时区分两种情况——是组件(搬它的subTree.el)还是普通元素(搬vnode.el即可)。
结论:判断依据就是组件选项上的 __isTeleport 标识。 只要带着这个标记,就认定“这是 Teleport 组件”,然后直接调用组件选项中定义的 process 函数,把渲染控制权完全交接出去——渲染器自己一个 Teleport 专属分支都不写。这就是“渲染逻辑分离”的实现方式。
Teleport 组件的定义如下:
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor) {
// 在这里处理渲染逻辑
}
}我们逐行看:
__isTeleport: true:Teleport 的“身份证”,被 patch 函数认出来的标志。process(...):渲染逻辑全在这一个函数里。这跟普通组件的“setup + render”完全不一样。
所以要记住:Teleport 组件并非普通组件。普通组件的入口是 setup + 渲染函数,而它有特殊的选项 __isTeleport 和 process——一个用来“报名”,一个用来“干活”。
接下来我们设计虚拟 DOM 的结构。假设用户编写的模板如下:
<Teleport to="body">
<h1>Title</h1>
<p>content</p>
</Teleport>那么它应该被编译为怎样的虚拟 DOM 呢?这里有个容易困惑的地方:虽然在用户看来 Teleport 也是一个内建组件,但实际上,Teleport 是否拥有“组件”的性质,是由框架本身决定的。
通常情况下,一个组件的子节点会被编译为插槽内容;不过对于 Teleport 组件来说,我们直接将其子节点编译为一个数组即可,如下面的代码所示:
function render() {
return {
type: Teleport,
// 以普通 children 的形式代表被 Teleport 的内容
children: [
{ type: 'h1', children: 'Title' },
{ type: 'p', children: 'content' }
]
}
}这段代码是全章最该盯住的一段,我们逐行看:
function render() { ... }:一个渲染函数。渲染函数的作用就是凭空造出一个虚拟 DOM 节点对象并返回。return { ... }:返回的这就是 Teleport 的虚拟节点(vnode)。和所有 vnode 一样,它至少有type和children。type: Teleport:type不是字符串'div'这种标签名,而是 Teleport 组件自己那个对象。前面说过,渲染器就是靠判断vnode.type.__isTeleport来认出它的。children: [ { type: 'h1', ... }, { type: 'p', ... } ]:这就是和普通组件最大的差别所在——- 普通组件的
children会是插槽对象(像{ default() {...} }那样,是个“函数包起来的内容”); - 而 Teleport 的
children是一个普通的数组,数组里装的就是要被传送的那两个虚拟节点。
为什么不也用插槽?因为传送门没有“内部组件实例”那回事——它不需要跑
setup、不需要自己的状态,它只负责“把这一堆节点搬到别处去渲染”。所以直接摊成一个数组,Teleport 的process就能拿到它们,挨个扔给props.to指定的位置。- 普通组件的
{ type: 'h1', children: 'Title' }:最普通的一个元素虚拟节点——type是标签名'h1',children是文本'Title'。注意它和 Teleport 那个 vnode 长得很像:结构一样,只是type的含义不同。
我们把这段结构画成图,对着看更清楚 —— 右边拿来做对比的那个 Transition,是本章后面 14.3 节要讲的内建组件,这里先并排看一眼:
把虚拟 DOM 的结构设计好之后,我们就可以着手实现 Teleport 组件了。首先来完成 Teleport 组件的挂载动作,如下的代码所示:
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
const { patch } = internals
// 如果旧节点不存在,则属于挂载阶段
if (!n1) {
// 获取目标挂载点
const target = typeof n2.props.to === 'string'
? document.querySelector(n2.props.to)
: n2.props.to
// 将 n2.children 渲染到指定挂载点即可
n2.children.forEach(c => patch(null, c, target, anchor))
} else {
// 更新
}
}
}我们逐行看:
process(n1, n2, container, anchor, internals):五个参数,前四个和patch一样(n1旧节点、n2新节点、container原来的容器、anchor插入参照物)。第五个internals是渲染器“借”给它的内部方法包——渲染器能做的它都能做,但它自己的特殊逻辑写在process里。想成“外包工人自带全套工具,接到的活儿按自己的图纸干”。const { patch } = internals:先把这把最常用的工具从internals里取出来用。if (!n1):没有旧节点 → 说明这是第一次出现,走挂载分支。const target = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to:to属性支持两种写法——写字符串(当成 CSS 选择器)就document.querySelector查一遍;写的就是一个 DOM 节点对象,那就直接拿来用。target变量就是真正的挂载点(门牌号被换算成了具体地址)。n2.children.forEach(c => patch(null, c, target, anchor)):把 Teleport 的children数组挨个遍历,每个子节点都调patch挂到target里。else { /* 更新 */ }:更新阶段留空,下面接着写。
⚠️ 注意这里用的是
target而不是container——container是“模板原本所在的位置”,target才是“真正该出现的位置”,这正是 Teleport 跨层级渲染的秘密所在。
你会发现,即使 Teleport 的渲染逻辑被单独分离了出来,它的渲染思路仍然与渲染器本身的渲染思路保持一致:
- 通过判断旧的虚拟节点(
n1)是否存在,来决定是执行挂载还是执行更新; - 如果要执行挂载,则需要根据
props.to属性的值来取得真正的挂载点; - 最后遍历 Teleport 组件的
children属性,并逐一调用patch函数完成子节点的挂载。
一句话记忆:挂载 = 找到 to 那个门牌号 → 挨个把孩子挂上去。
更新的处理更加细,如下面的代码所示:
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
const { patch, patchChildren } = internals
if (!n1) {
// 省略部分代码
} else {
// 更新
patchChildren(n1, n2, container)
}
}
}我们逐行看:
patchChildren(n1, n2, container):更新走标准patchChildren。注意这里container用的是“原始 container”——Teleport 内部其实可以理解为“从一个容器搬到另一个容器”。
只需要调用 patchChildren 函数就能完成更新操作了。⚠️ 不过有一点需要额外注意:更新操作可能是由于 Teleport 组件的 to 属性值的变化引起的,因此在更新时我们应该考虑这种情况。具体的处理方式如下:
const Teleport = {
__isTeleport: true,
process(n1, n2, container, anchor, internals) {
const { patch, patchChildren, move } = 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))
}
}
}
}我们把“更新”这个场景连起来看一遍:
用来执行移动操作的 move 函数的实现如下:
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
)
}
})
}我们逐行看 move:
move(vnode, container, anchor):把“一个 vnode 对应的 DOM”搬进container,插在anchor前面。注意整个搬家最终只靠一句insert——因为浏览器对待“已存在的节点”和新节点一视同仁:把老节点insert到新位置,浏览器就会自动把它从原位置摘下来搬过去(而不是复制一份)。就像把书架上的书直接搬到另一个书架,原位自然就空出来了。vnode.component ? vnode.component.subTree.el : vnode.el:这是整个函数最关键的一行,做的是分情况取“要搬的那个真实 DOM 元素”:- 如果这个 vnode 是普通元素(
type是字符串),它自己身上就挂着真实 DOM —— 那个vnode.el,直接搬它。 - 如果这个 vnode 是组件,
vnode.el并不是最终那块内容(组件的el可能是个注释节点或空壳),真正渲染出来的是它的subTree.el(组件渲染结果那棵树的根元素)——所以要往里再挖一层才拿得到。
- 如果这个 vnode 是普通元素(
vnode.component:指代该 vnode 的组件实例。组件 vnode 才有它,普通元素 vnode 上是undefined。container, anchor:新家地址和插入参照物,和insert的后两个参数完全一致。
结论:上面这段代码只考虑了移动组件和普通元素这两种情况。 虚拟节点的类型有很多种,例如文本类型(Text)、片段类型(Fragment)等。一个完善的实现应该考虑所有这些虚拟节点的类型。
顺带一提,Fragment(第 13 章讲过的片段类型)没有单个 el,所以要搬它得遍历它的 children 一个个搬,不能一句 insert 搞定 —— 比如 <template> 或多个并列的根节点,编译出来就是它。这正是上面说“完善的实现要考虑所有类型”的原因。
Text:
表示纯文本的虚拟节点类型。它的“DOM”是一个文本节点,也同样需要单独处理。
14.3 Transition 组件的实现原理
KeepAlive 和 Teleport 讲完,一个规律就浮出来了:Vue.js 内建的组件通常与渲染器的核心逻辑结合得非常紧密——因为它们要干的事(搬运节点、推迟卸载)全都发生在“渲染/卸载”这一层,普通组件根本碰不到。
本节将要讨论的 Transition 组件也不例外,甚至它与渲染器的结合更加紧密。
不过实际上,Transition 组件的实现比想象中简单得多。它的核心原理就两条:
- 当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
- 当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM 元素上的动效执行完成后再卸载它。
先认下来:Transition 到底是什么
Transition(过渡):
DOM 进入或离开时让用户看到的“动画过程”。本质是 CSS 类的“挂上 → 切下一帧 → 挂上 → 监听 transitionend → 清掉”的循环。
比喻:演员上下台。上场(进场)时要先摆好起始姿势,再“动起来”走到指定位置;下场(离场)时要先动起来走到幕布那边,走到了才真的下台,不能站在台上一挥手就消失。
规则上主要遵循的就是上述两个要素,但具体实现时要考虑的边界情况还有很多(比如过渡播到一半又被新的更新打断、元素在过渡期间就被移出文档等等)。
不过我们只要理解它的核心原理即可,至于细节,可以在基本实现的基础上按需添加或完善。
14.3.1 原生 DOM 的过渡
为了更好地理解 Transition 组件的实现原理,我们有必要先讨论如何为原生 DOM 创建过渡动效。
过渡效果本质上就是一个 DOM 元素在两种状态间的切换,浏览器会根据过渡效果自行完成 DOM 元素的过渡。这里的“过渡效果”指的是三样东西:持续时长、运动曲线、要过渡的属性。
进场过渡 / 离场过渡:
元素出现在页面上叫进场(enter),从页面上消失叫离场(leave)。这两件事都需要三份 CSS:初始状态、结束状态、过渡过程。下面我们就照着这个“三件套”把两种过渡都搭出来。
我们从一个例子开始。假设我们有一个 div 元素,宽高各 100px,如下面的代码所示:
<div class="box"></div>一眼就懂:页面上就一个空的 <div>,靠 class="box" 挂着样式。它现在是“静止”的——接下来我们要给它安上过渡。
接着,为其添加对应的 CSS 样式:
.box {
width: 100px;
height: 100px;
background-color: red;
}我们逐行看:
- 宽高 100px:固定大小的方块。
background-color: red:红色背景,便于观察。
现在,假设我们要为元素添加一个进场动效。我们可以这样描述该动效:从距离左边 200px 的位置在 1 秒内运动到距离左边 0px 的位置。在这句描述中,初始状态是“距离左边 200px”,因此我们可以用下面的样式来描述初始状态:
.enter-from {
transform: translateX(200px);
}我们逐行看:
translateX(200px):把元素沿 X 轴向右挪 200 像素。
而结束状态是“距离左边 0px”,也就是初始位置,可以用下面的 CSS 代码来描述:
.enter-to {
transform: translateX(0);
}初始状态和结束状态都已经描述完毕了。最后,我们还要描述运动过程,例如持续时长、运动曲线等。对此,我们可以用如下的 CSS 代码来描述:
.enter-active {
transition: transform 1s ease-in-out;
}我们逐行看:
transition: transform 1s ease-in-out:transform:要过渡的属性。1s:持续时长。ease-in-out:运动曲线(开始慢、中间快、结束慢)。
所以这段 CSS 表达的是:运动的属性是 transform,持续时长为 1s,并且运动曲线是 ease-in-out(也就是“慢慢启动 → 逐渐加快 → 再慢慢减速停下来”,像汽车起步和刹车)。
至此,初始状态、结束状态、运动过程三样都齐了。接下来我们就可以为这个 DOM 元素添加进场动效了,如下面的代码所示:
// 创建 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)我们逐行看:
document.createElement('div'):创建一个空的div。el.classList.add('box'):把基础样式加上。el.classList.add('enter-from'):初始状态——元素一上场就站在 200px 处。el.classList.add('enter-active'):运动过程——告诉浏览器“如果你之后改transform,请用 1 秒的过渡”。document.body.appendChild(el):把元素塞进页面。
上面这段代码主要做了三件事:
- 创建 DOM 元素;
- 将过渡的初始状态和运动过程定义到元素上,即把
enter-from、enter-active这两个类添加到元素上; - 将元素添加到页面,即挂载。
经过这三个步骤之后,元素的初始状态会生效——页面渲染的时候,会将 DOM 元素以初始状态所定义的样式进行展示(也就是一露面就站在 200px 处)。
接下来我们需要切换元素的状态,使得元素开始运动。那么,应该怎么做呢?理论上,我们只需要将 enter-from 类从 DOM 元素上移除,并将 enter-to 这个类添加到 DOM 元素上即可,如下面的代码所示:
// 创建 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)
// 切换元素的状态
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to我们逐行看:
el.classList.remove('enter-from'):丢掉“起点”。el.classList.add('enter-to'):加上“终点”。
结论:上面这段代码无法按预期执行。 这是因为浏览器会在当前帧绘制 DOM 元素,最终结果是:浏览器将 enter-to 这个类所具有的样式绘制出来,而不会绘制 enter-from 类所具有的样式。起点样式压根没被画到屏幕上,浏览器自然也就“看不到变化”,过渡也就无从谈起。
帧(frame):
浏览器更新画面的最小单位,一秒钟大约要画 60 帧。同一帧里刚刚加上的类,要等到下一次绘制才看得见。所以“先摆好起点姿势,再换个姿势让它动起来”这两件事必须分到两帧去,不能挤在同一帧里做。
为了解决这个问题,我们需要在下一帧执行状态切换,如下的代码所示:
// 创建 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)
// 在下一帧切换元素的状态
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to
})requestAnimationFrame(简称 rAF):
告诉浏览器“下一帧(重绘前)帮我跑这段代码”。它是浏览器在每一帧绘制前会调用的回调队列。
可以看到,我们使用 requestAnimationFrame 注册了一个回调函数,该回调函数理论上会在下一帧执行。这样,浏览器就会在当前帧绘制元素的初始状态,然后在下一帧切换元素的状态,从而使得过渡生效。
但如果你尝试在 Chrome 或 Safari 浏览器中运行上面这段代码,会发现过渡仍未生效——这是为什么呢?
实际上,这是浏览器的实现 bug 所致。该 bug 的具体描述参见 Issue 675795: Interop: mismatch in when animations are started between different browsers。其大意是:使用 requestAnimationFrame 函数注册回调会在当前帧执行,除非其他代码已经调用了一次 requestAnimationFrame 函数。这明显是不正确的,因此我们需要一个变通方案,如下的代码所示:
// 创建 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)
// 嵌套调用 requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to
})
})通过嵌套一层 requestAnimationFrame 函数的调用即可解决上述问题 —— 第一次让浏览器“先记住”,第二次才是真正的“下一帧”,这也是 Vue.js Transition 内部用的标准做法。现在,如果你再次尝试在浏览器中运行代码,会发现进场动效能够正常显示了。
最后我们需要做的是:当过渡完成后,将 enter-to 和 enter-active 这两个类从 DOM 元素上移除,如下的代码所示:
// 创建 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)
// 嵌套调用 requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
})这个 transitionend 是整段进场过渡的“结束铃铛”,值得单独解释一下:
transitionend(过渡结束事件):浏览器在一条 CSStransition跑完的那一瞬间会触发的事件。谁的transition跑完,谁就响一次。- 我们在
el上addEventListener('transitionend', ...),就是把耳朵贴在元素上等这声铃响。 - 铃一响,就说明“动画播完了”,这时候把
enter-to和enter-active这两个类摘掉——它们只是过渡期间的临时化妆,播完就该卸妆,不然会一直污染元素的后续样式。
到这里,一个完整的进场过渡就闭环了:
结论:通过监听元素的 transitionend 事件来完成收尾工作。 实际上,我们可以对上述为 DOM 元素添加进场过渡的整个过程进行抽象,就如图 14-2 所示。

图 14-2 对进场过渡过程的抽象
对着图 14-2 划分阶段就清楚了:从创建 DOM 元素完成后、到把 DOM 元素添加到 body 前,整个过程可以视作 beforeEnter 阶段;在把 DOM 元素添加到 body 之后,则可以视作 enter 阶段;等到 transitionend 响起、过渡收尾之后,则可以视作 enter 结束阶段。
结论:在不同的阶段执行不同的操作,即可完成整个进场过渡的实现。
我们用这张图重新画一遍这个时序:
理解了进场过渡的实现原理后,接下来我们讨论 DOM 元素的离场过渡效果。与进场过渡一样,我们也需要定义离场过渡的初始状态、结束状态以及过渡过程,如下的 CSS 代码所示:
/* 初始状态 */
.leave-from {
transform: translateX(0);
}
/* 结束状态 */
.leave-to {
transform: translateX(200px);
}
/* 过渡过程 */
.leave-active {
transition: transform 2s ease-out;
}这三个类和进场那三个类是一一对应的,我们逐个拆开看:
.leave-from(初始状态):transform: translateX(0)——离场的“起点”是 0px,也就是原位。元素还没开始动。.leave-to(结束状态):transform: translateX(200px)——离场的“终点”是 200px,也就是元素要滑到的地方。.leave-active(过渡过程):transition: transform 2s ease-out——要过渡的属性是transform,持续2s,运动曲线是ease-out(先快后慢,像被推出去之后自己滑停下来)。- 类名的记忆窍门:
enter-开头的管“进场(Enter)”,leave-开头的管“离场(Leave)”;中间的from是起点、to是终点、active是过程。四个词把过渡的“起点 / 过程 / 终点”和“进场 / 离场”两组维度全占了。
把进场的和离场的放在一起对比,镜像关系就非常直观了:
⚠️ 另外注意时长和曲线:进场是
1s ease-in-out(1 秒、先慢后快再慢),离场是2s ease-out(2 秒、先快后慢)。这两个数值在下面Transition组件的实现里也会原样出现。
可以看到,离场过渡的初始状态与结束状态,正好对应进场过渡的结束状态与初始状态(进场终点 0px ↔ 离场起点 0px,进场起点 200px ↔ 离场终点 200px)。
⚠️ 不过,我们完全可以打破这种对应关系,可以采用任意过渡效果——上面这种“镜像”只是最常见、最好理解的一种默认写法,用户完全可以自己写一套不这么对称的离场动画。
离场动效一般发生在 DOM 元素被卸载的时候,如下面的代码所示:
// 卸载元素
el.addEventListener('click', () => {
el.parentNode.removeChild(el)
})一眼就懂:用户一点击,就把元素从 DOM 树里直接 removeChild 删了。
当点击元素的时候,该元素会被移除,这样就实现了卸载。然而,从代码中可以看出,元素被点击的瞬间就会被卸载,所以如果仅仅这样做,元素根本就没有执行过渡的机会。因此,一个很自然的思路就产生了:当元素被卸载时,不要将其立即卸载,而是等待过渡效果结束后再卸载它。为了实现这个目标,我们需要把用于卸载 DOM 元素的代码封装到一个函数中,该函数会等待过渡结束后被调用,如下面的代码所示:
el.addEventListener('click', () => {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => el.parentNode.removeChild(el)
})结论:这段代码干的事就一件——把“卸载”这个动作打包进 performRemove 函数。 现在卸载不再由 removeChild 直接触发,而是由“调用 performRemove”决定;这样一来,什么时候过渡结束,就什么时候调用它,这个函数会等到过渡效果结束后再执行。
具体的离场动效的实现如下:
el.addEventListener('click', () => {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => el.parentNode.removeChild(el)
// 设置初始状态:添加 leave-from 和 leave-active 类
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制 reflow:使初始状态生效
document.body.offsetHeight
// 在下一帧切换状态
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 切换到结束状态
el.classList.remove('leave-from')
el.classList.add('leave-to')
// 监听 transitionend 事件做收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 当过渡完成后,记得调用 performRemove 函数将 DOM 元素移除
performRemove()
})
})
})
})我们逐行看关键行:
el.classList.add('leave-from')+el.classList.add('leave-active'):把“离场起点 + 运动过程”加上。document.body.offsetHeight:强制 reflow(重排)。这是一个“读 DOM”操作,会让浏览器马上把当前样式画上去(不会等到下一帧)。如果不读一下,下面的“下一帧”会看不到起点。
强制 reflow:
浏览器对 DOM 的“读”操作(offsetHeight、getBoundingClientRect 等)会强制重排——也就是立刻应用样式。Vue.js 用它“骗”浏览器把初始状态落实下来。
- 嵌套 rAF 切换到
leave-to:跟进场同样的“双 rAF”小套路。 transitionend后performRemove():过渡完才真正卸载元素。
从上面这段代码中可以看到,离场过渡的处理与进场过渡的处理方式非常相似:即首先设置初始状态,然后在下一帧中切换为结束状态,从而使得过渡生效。
⚠️ 需要注意的是:当离场过渡完成之后,需要执行
performRemove函数来真正地将 DOM 元素卸载——进场结束后只是“卸妆”,离场结束后还得下台。
14.3.2 实现 Transition 组件
Transition 组件的实现原理,与 14.3.1 节介绍的原生 DOM 的过渡原理一样。唯一的区别是:Transition 组件是基于虚拟 DOM 实现的。
在 14.3.1 节中,我们在为原生 DOM 元素创建进场动效和离场动效时能注意到,整个过渡过程可以抽象为几个阶段(摆起点、切换到终点、等动画播完收尾……),而这些阶段可以抽象为特定的回调函数,例如 beforeEnter、enter、leave 等。
实际上,基于虚拟 DOM 的实现也需要将 DOM 元素的生命周期分割为这样几个阶段,并在特定阶段执行对应的回调函数——框架不会因为你换成了虚拟 DOM 就免掉这些阶段,它只是在每个阶段多问一句“这个元素要过渡吗”,要的话就把钩子叫起来。
Transition 用的也是生命周期钩子(lifecycle hook,第 12 章讲过)那套思路 —— 在组件“一生”的关键节点上预留回调,只不过它自己定义的是 beforeEnter、enter、leave 这几个钩子,让用户在里面写“动效代码”。
为了实现 Transition 组件,我们需要先设计它在虚拟 DOM 层面的表现形式。假设组件的模板内容如下:
<template>
<Transition>
<div>我是需要过渡的元素</div>
</Transition>
</template>一行字看:<Transition> 包着一个 div。过渡的目标就是这个 div。
我们可以将这段模板被编译后的虚拟 DOM 设计为:
function render() {
return {
type: Transition,
children: {
default() {
return { type: 'div', children: '我是需要过渡的元素' }
}
}
}
}我们逐行看:
type: Transition:根节点是 Transition 组件。children: { default() {...} }:子节点用“默认插槽”包起来,调用default()才返回真正的 vnode。
可以看到,Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致(对比一下 Teleport——它把子节点摊成了普通数组)。虚拟 DOM 层面的表示已经设计完了,接下来,我们着手实现 Transition 组件,如下面的代码所示:
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
}
}
}我们逐行看:
const innerVNode = slots.default():拿到要过渡的那个 vnode。innerVNode.transition = { beforeEnter, enter, leave }:核心操作——在 vnode 上挂三个钩子函数。这相当于告诉渲染器:“这个元素要过渡,请按钩子的指示办”。return innerVNode:Transition 本身不渲染任何额外东西,只是把内层 vnode 透传出去。
观察上面的代码,可以发现几点重要信息:
- Transition 组件本身不会渲染任何额外的内容,它只是通过默认插槽读取过渡元素,并渲染需要过渡的元素;
- Transition 组件的作用,就是在过渡元素的虚拟节点上添加
transition相关的钩子函数。
可以看到,经过 Transition 组件的“包装”后,内部需要过渡的虚拟节点对象会被添加一个 vnode.transition 对象。这个对象下存在一些与 DOM 元素过渡相关的钩子函数,例如 beforeEnter、enter、leave 等。
这些钩子函数与我们在 14.3.1 节中介绍的钩子函数完全相同。渲染器在渲染需要过渡的虚拟节点时,会在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,具体体现在 mountElement 函数以及 unmount 函数中,如下面的代码所示:
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)
}
}我们逐行看关键行:
const needTransition = vnode.transition:判断“这个 vnode 要不要过渡”。if (needTransition) { vnode.transition.beforeEnter(el) }:插入之前调beforeEnter——这里是用户动手设置“初始样式”的机会。insert(el, container, anchor):把元素塞进 DOM。if (needTransition) { vnode.transition.enter(el) }:插入之后调enter——这里负责“切到目标样式”。
结论:上面这段代码是修改后的 mountElement 函数,我们为它增加了 transition 钩子的处理。 可以看到:在挂载 DOM 元素之前,会调用 transition.beforeEnter 钩子;在挂载元素之后,会调用 transition.enter 钩子,并且这两个钩子函数都接收需要过渡的 DOM 元素对象作为第一个参数。
⚠️ 注意这个前后顺序不能颠倒:
beforeEnter必须在元素还没进页面时执行(这时才能把元素藏在“起点”上),enter必须在元素进页面之后执行(这时才能触发“从起点走向终点”)。至于为什么不能在同一帧里做完两件事,14.3.1 节已经解释过了。
除了挂载之外,卸载元素时我们也应该调用 transition.leave 钩子函数,如下面的代码所示:
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()
}
}
}我们逐行看关键行:
const performRemove = () => parent.removeChild(vnode.el):把“卸载”封装成函数。if (needTransition) { vnode.transition.leave(vnode.el, performRemove) }:关键技巧——卸载权“交出去”了。Transition 的leave钩子内部会等过渡结束再调performRemove。else { performRemove() }:无过渡?直接卸载。
结论:上面这段代码是修改后的 unmount 函数的实现,我们同样为其增加了关于过渡的处理。 首先,需要将卸载动作封装到 performRemove 函数内;然后分两种情况——如果 DOM 元素需要过渡处理,那么就需要等待过渡结束后再执行 performRemove 函数完成卸载,否则直接调用该函数完成卸载即可。
结论:这就是 Transition 组件和渲染器之间的全部“契约”。 mountElement 负责“进场前后各敲一次门”,unmount 负责“把卸载的决定权递出去”。剩下的动画怎么播,渲染器不关心,全交给 vnode.transition 里那几个钩子。
有了 mountElement 函数和 unmount 函数的支持后,我们就可以轻松地实现一个最基本的 Transition 组件了,如下的代码所示:
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
}
}
}我们逐行看:
beforeEnter(el):挂 DOM 之前调用。添加enter-from+enter-active,让元素一开始就站在“起点”。enter(el):挂 DOM 之后调用。嵌套 rAF 后切到enter-to,并transitionend后清场。leave(el, performRemove):卸载时调用。先加leave-from、leave-active,强制 reflow 后下一帧切到leave-to,transitionend后清掉样式 + 调performRemove()。document.body.offsetHeight:那条“强制 reflow”——读 offsetHeight 让浏览器立即应用初始状态。
nextFrame:
Vue 内部对“嵌套 rAF”的封装(上面那段是伪代码)。代码里只调一次 nextFrame,其实是两次 requestAnimationFrame。
结论:上面这段代码补全了 vnode.transition 中各个钩子函数的具体实现,其实现思路与我们在 14.3.1 节中讨论的关于原生 DOM 过渡的思路一样——beforeEnter 挂起点和过程、enter 下一帧切终点、leave 同理但最后多调一次 performRemove。同一套“加类 → 等一帧 → 换类 → 等 transitionend → 清类”的流程,从原生 DOM 原封不动地搬到了虚拟 DOM 这一层。
在上面的实现中,我们硬编码了过渡状态的类名,例如 enter-from、enter-to 等。实际上,我们可以轻松地通过 props 来实现允许用户自定义类名的能力,从而实现一个更加灵活的 Transition 组件。另外,我们也没有实现“模式”的概念,即先进后出(in-out)或后进先出(out-in)。实际上,模式的概念只是增加了对节点过渡时机的控制,原理上与将卸载动作封装到 performRemove 函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。
in-out / out-in:
Transition 的两种“出场模式”。
out-in(默认):先让旧元素离开过渡完,再让新元素进入。in-out:让新元素先进来,再让旧元素离开。
14.4 总结
在本章中,我们介绍了 Vue.js 内建的三个组件,即 KeepAlive 组件、Teleport 组件和 Transition 组件。
结论:它们的共同特点是与渲染器的结合非常紧密,因此需要框架提供底层的实现与支持。
KeepAlive 组件的作用类似于 HTTP 中的持久连接:它可以避免组件实例不断地被销毁和重建。它的基本实现并不复杂——当被 KeepAlive 的组件“卸载”时,渲染器并不会真的将其卸载掉,而是会将该组件搬运到一个隐藏容器中,从而使得组件可以维持当前状态;当被 KeepAlive 的组件“挂载”时,渲染器也不会真的挂载它,而是将它从隐藏容器搬运回原容器。
我们还讨论了 KeepAlive 的其他能力,如匹配策略和缓存策略。include 和 exclude 这两个选项用来指定哪些组件需要被 KeepAlive,哪些组件不需要被 KeepAlive。默认情况下,include 和 exclude 会匹配组件的 name 选项。但是在具体实现中,我们可以扩展匹配能力。对于缓存策略,Vue.js 默认采用“最新一次访问”。为了让用户能自行实现缓存策略,我们还介绍了正在讨论中的提案。
接着,我们讨论了 Teleport 组件所要解决的问题和它的实现原理。Teleport 组件可以跨越 DOM 层级完成渲染,这在很多场景下非常有用。
在实现 Teleport 时,我们将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:
- 可以避免渲染器逻辑代码“膨胀”;
- 可以利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关的代码,使得最终构建包的体积变小。
Teleport 组件是一个特殊的组件。与普通组件相比,它的组件选项非常特殊,例如 __isTeleport 选项和 process 选项等。这是因为 Teleport 本质上是渲染器逻辑的合理抽象,它完全可以作为渲染器的一部分而存在——它并不需要“变成组件”,它天生就是渲染流程里的一环。
最后,我们讨论了 Transition 组件的原理与实现。我们从原生 DOM 过渡开始,讲解了如何使用 JavaScript 为 DOM 元素添加进场动效和离场动效。在此过程中,我们将实现动效的过程分为多个阶段,即 beforeEnter、enter、leave 等。
结论:Transition 组件的实现原理与为原生 DOM 添加过渡效果的原理类似。 我们将过渡相关的钩子函数定义到虚拟节点的 vnode.transition 对象中。渲染器在执行挂载和卸载操作时,会优先检查该虚拟节点是否需要进行过渡,如果需要,则会在合适的时机执行 vnode.transition 对象中定义的过渡相关钩子函数。
本章小结
- KeepAlive:让组件“假卸载、真缓存”。本质是把组件 DOM 从原容器搬到隐藏
div,激活时再搬回来。靠三个标记属性shouldKeepAlive/keptAlive/keepAliveInstance与渲染器深度联动。 - KeepAlive 的
include/exclude:用正则配置“白名单 / 黑名单”,只有名称匹配上的组件才缓存(本节实现只接受正则值)。 - KeepAlive 的缓存策略:默认“最新一次访问”,
max控制容量上限;RFC 提案允许用户传入自定义缓存实例。 - Teleport:把内容“传送”到
to指定的位置(body、选择器或 DOM 节点)。关键设计是__isTeleport标志 +process选项,把渲染逻辑从渲染器里剥离出去,既避免代码膨胀,又能配合 Tree-Shaking。 - Teleport 更新:
to变了要move到新容器;move函数区分“搬组件”和“搬普通元素”。 - Transition:本质是挂载时挂初始类 + 卸载时把卸载权交给钩子。
- Transition 的“双 rAF”技巧:嵌套
requestAnimationFrame是为了避开浏览器“rAF 回调在当前帧执行”的 bug。 - 强制 reflow(
document.body.offsetHeight)用于“立刻应用初始状态”,否则下一帧看不到起点。 performRemove:离场过渡的关键函数。把“卸载”封装成回调,等transitionend触发才真正执行。mountElement在前后各调一次钩子(beforeEnter、enter),unmount调leave并把performRemove传过去——这是 Transition 与渲染器协作的核心流程。in-out/out-in模式:只决定新旧元素过渡的先后顺序,原理仍然是performRemove的“延迟卸载”。
