当前位置: 首页 > news >正文

《Vuejs设计与实现》第 14 章(内建组件和模块)

目录

14.1 KeepAlive 组件的实现原理

14.1.1 组件的激活与失活

14.1.2 include 和 exclude

14.1.3 缓存管理

14.2 Teleport 组件的实现原理

14.2.1 Teleport 组件要解决的问题

14.2.2 实现 Teleport 组件

14.3 Transition 组件的实现原理

14.3.1 原生 DOM 的过渡

14.3.2 实现 Transition 组件

14.4 总结


在第 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> 组件:

<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 组件:

<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。
 

image.png


一个基本的KeepAlive组件的实现如下所示:

const KeepAlive = {// KeepAlive 组件独有的属性,用作标识__isKeepAlive: true,setup(props, { slots }) {// 创建一个缓存对象// key: vnode.type// value: vnodeconst 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()// 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAliveif (typeof rawVNode.type !== 'object') {return rawVNode}// 在挂载时先获取缓存的组件 vnodeconst 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// 渲染组件 vnodereturn rawVNode}}
}

上述代码,首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,这就是我们所称之为的“内部组件”。
KeepAlive 组件会对这个“内部组件”进行操作,主要是在该“内部组件”的 vnode 对象上添加一些标记属性,这样渲染器就能执行特定的逻辑。
以下是这些标记属性的简要介绍:

  • shouldKeepAlive:该属性会被添加到“内部组件”的 vnode 对象上,这样当渲染器卸载“内部组件”时,就可以通过检查该属性得知“内部组件”需要被 KeepAlive,因此,渲染器就不会真正地卸载“内部组件”,而是会调用 _deActivate 函数完成搬运工作:
// 卸载操作
function unmount(vnode) {if (vnode.type === Fragment) {vnode.children.forEach(c => unmount(c))return} else if (typeof vnode.type === 'object') {// vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该被 KeepAliveif (vnode.shouldKeepAlive) {// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件,// 即 KeepAlive 组件的 _deActivate 函数使其失活vnode.keepAliveInstance._deActivate(vnode)} else {unmount(vnode.component.subTree)}return}const parent = vnode.el.parentNodeif (parent) {parent.removeChild(vnode.el)}
}
  • keepAliveInstance:这个属性使得“内部组件”的 vnode 对象能够引用到 KeepAlive 组件实例,因此,在 unmount 函数中,我们能够通过这个属性来访问 _deActivate 函数。
  • keptAlive:如果“内部组件”已经被缓存,那么就会为其添加一个 keptAlive 标记。这样当"内部组件"需要重新渲染时,渲染器不会重新挂载它,而是通过激活操作来实现,这在以下的 patch 函数代码中可以看出:
function patch(n1, n2, container, anchor) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (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 函数来激活它。
接下来看一下组件激活和失活的函数:

const { move, createElement } = instance.keepAliveCtxinstance._deActivate = (vnode) => {move(vnode, storageContainer)
}instance._activate = (vnode, container, anchor) => {move(vnode, container, anchor)
}
 

失活操作本质上是将组件渲染的内容移动到隐藏容器中,而激活操作则是将内容从隐藏容器中移回原来的容器。
这里的 move 函数由渲染器提供,如下所示:

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.__isKeepAliveif (isKeepAlive) {// 在 KeepAlive 组件实例上添加 keepAliveCtx 对象instance.keepAliveCtx = {// move 函数用来移动一段 vnodemove(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 定义如下:

const KeepAlive = {__isKeepAlive: true,// 定义 include 和 excludeprops: {include: RegExp,exclude: RegExp},setup(props, { slots }) {// 省略部分代码}
}

为简化问题,我们规定 include 和 exclude 的值应为正则表达式类型。
在挂载 KeepAlive 组件时,会根据内部组件的名称进行匹配,如下代码所示:

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}// 获取“内部组件”的 nameconst 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 对象实现对组件的缓存:

const cache = new Map()

此处的 Map 对象的键是组件的选项对象(即 vnode.type 属性的值),而值则是描述组件的 vnode 对象。
由于 vnode 对象包含了组件实例的引用(即 vnode.component 属性),因此,缓存 vnode 对象实际上就相当于缓存了组件实例。

在 KeepAlive 组件的渲染函数中,我们使用如下方式处理缓存:

// KeepAlive 组件的渲染函数中关于缓存的实现// 使用组件选项对象 rawVNode.type 作为键去缓存中查找
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {// 如果缓存存在,则无须重新创建组件实例,只需要继承即可rawVNode.component = cachedVNode.componentrawVNode.keptAlive = true
} else {// 如果缓存不存在,则设置缓存cache.set(rawVNode.type, rawVNode)
}

简单来说,如果缓存存在,我们就复用组件实例,并将 vnode 对象标记为 keptAlive,这样渲染器就不会创建新的组件实例。如果缓存不存在,我们则设置新的缓存。
然而,这种方式存在一个问题:当缓存不存在时,总是会添加新的缓存,导致缓存数量可能会无限增加,极端情况下可能占用大量内存。
为了解决这个问题,我们需要设置一个缓存阈值,当缓存数量超过这个阈值时,我们需要进行缓存修剪。
这又引发了另一个问题:我们应该如何进行缓存修剪?什么样的修剪策略是最优的?
Vue.js 的策略是"最新一次访问"。这意味着我们需要为缓存设置一个最大容量,可以通过 KeepAlive 组件的 max 属性来设置。例如:

<KeepAlive :max="2"><component :is="dynamicComp"/>
</KeepAlive>

上述代码,我们设置了缓存的最大容量为 2。假设有三个组件:Comp1,Comp2,Comp3,它们都会被缓存。当我们在组件之间切换时,会按照"最新一次访问"的策略进行缓存修剪。具体操作如下:

  1. 初始渲染 Comp1 并缓存,此时缓存队列:[Comp1]。
  2. 切换到 Comp2 并缓存,此时缓存队列:[Comp1, Comp2]。
  3. 再切换到 Comp3,此时缓存容量已满,需要修剪。由于 Comp2 是最后访问的组件,它是安全的,不会被修剪。所以,会被修剪的是 Comp1。此时,缓存队列:[Comp2, Comp3]。

我们也可以有不同的切换方式,如:

  1. 初始渲染 Comp1 并缓存,此时缓存队列:[Comp1]。
  2. 切换到 Comp2 并缓存,此时缓存队列:[Comp1, Comp2]。
  3. 再切换回 Comp1,此时,缓存队列无需修改。
  4. 切换到 Comp3,此时缓存容量已满,需要修剪。由于 Comp1 是最后访问的组件,它是安全的,不会被修剪。所以,会被修剪的是 Comp2。此时,缓存队列:[Comp1, Comp3]。

这就是 Vue.js 的缓存策略。但我们也可以自定义缓存策略,Vue.js的 RFCs 中已有相关提议。提议中引入了一个新的 cache 接口,允许用户指定缓存实例:

<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)}
}

在这种设计下,如果用户提供了自定义的缓存实例,KeepAlive 组件将直接使用它来管理缓存。这实质上将缓存管理权限从 KeepAlive 组件转移到了用户手中。

14.2 Teleport 组件的实现原理

14.2.1 Teleport 组件要解决的问题

Vue3 引入了内建组件 Teleport,用以解决某些特殊渲染需求。
普遍情况下,虚拟 DOM 渲染为真实 DOM 时,两者的层级结构保持一致。例如,考虑以下模板:

<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> 组件模板

<!-- 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 函数,如下:

function patch(n1, n2, container, anchor) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (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 组件的定义如下:

const Teleport = {__isTeleport: true,process(n1, n2, container, anchor) {// 处理渲染逻辑}
}

Teleport 组件并非普通组件,它有特殊的选项 __isTeleport 和 process。
如果用户编写的模板如下:

<Teleport to="body"><h1>Title</h1><p>content</p>
</Teleport>

那么它会被编译为如下的虚拟 DOM:

function render() {return {type: Teleport,children: [// 以普通 children 的形式代表要被 Teleport 的内容{ type: 'h1', children: 'Title' },{ type: 'p', children: 'content' }]}

对于 Teleport 组件,我们直接将其子节点编译为一个数组。现在我们可以开始实现 Teleport 组件了。首先是挂载操作:

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.ton2.children.forEach(c => patch(null, c, target, anchor))} else {// 更新操作}}

挂载操作与渲染器的挂载思路保持一致,通过判断旧的虚拟节点(n1)是否存在,来决定是执行挂载还是执行更新。
对于更新操作,只需要调用 patchChildren 函数即可:

const Teleport = {__isTeleport: true,process(n1, n2, container, anchor, internals) {const { patch, patchChildren } = internalsif (!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 函数如下:

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组件的实现原理比想象中的更为简单,它主要基于以下两个步骤:

  1. 在DOM元素挂载时,附加动态效果到该元素;
  2. 在DOM元素卸载时,先让动态效果执行完再卸载元素。

尽管在具体实现时需要考虑许多边界情况,但了解这两个核心原理已经足够。细节部分可以在理解了基本实现后根据需要添加或完善。

14.3.1 原生 DOM 的过渡

让我们深入了解一下 Transition 组件的原理,首先,我们需要明确过渡效果是怎样在原生 DOM 中实现的。
过渡效果其实是 DOM 元素在两种状态间的转换,浏览器会根据指定的效果自动完成过渡。这些效果包括过渡的时长、运动路径和过渡的属性等。
以一个例子为例,假设我们有一个宽高为 100px 的 div 元素:

<div class="box"></div>

我们可以为这个 div 元素定义以下样式:

.box {width: 100px;height: 100px;background-color: red;
}

现在,我们想给这个元素添加一个进场动效:从离左边 200px 的地方,经过 1 秒运动到离左边 0px 的位置。我们可以用下面的样式来描述这个过程:
初始状态(离左边200px):

.enter-from {transform: translateX(200px);
}

结束状态(离左边0px):

.enter-to {transform: translateX(0);
}

描述运动过程(例如持续时长和运动曲线):

.enter-active {transition: 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)

上述代码做了三件事情:

  1. 创建DOM元素;
  2. 将过渡的初始状态和运动过程定义到元素上,即添加'enter-from'和'enter-active'这两个类到元素上;
  3. 将元素添加到页面,即挂载。

执行这三步后,元素的初始状态就会生效,渲染时会按照初始状态定义的样式显示 DOM 元素。
下一步,我们需要改变元素的状态以启动动画。理论上,我们只需将 'enter-from' 类从DOM元素中删除,同时添加 'enter-to' 类即可,如下:

// 创建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' 类。要解决这个问题,我们需要在下一帧执行状态更改:

// 创建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-fromel.classList.add('enter-to') // 添加 enter-to
})

尽管理论上在下一帧改变元素状态应该有效,但在 Chrome 和 Safari 中,动画仍无法正常工作。这是由于浏览器的实现 bug。为解决此问题,我们可以嵌套调用 requestAnimationFrame:

// 创建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' 类:

// 创建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 元素添加进场过渡的过程进行抽象为下图:

image.png

  1. beforeEnter 阶段:添加 enter-from 和 enter-active 类。
  2. enter 阶段:在下一帧中移除 enter-from 类,添加 enter-to。
  3. 进场动效结束:移除 enter-to 和 enter-active 类。

同样地,对于 DOM 元素的离场动画效果,我们也需要定义动画的初始状态、结束状态以及过程:

/* 初始状态 */
.leave-from {transform: translateX(0);
}
/* 结束状态 */
.leave-to {transform: translateX(200px);
}
/* 过渡过程 */
.leave-active {transition: transform 2s ease-out;
}

离场动画通常在 DOM 元素被卸载时执行。
此时,元素被点击时会被移除,但因为元素被立即卸载,动画无法执行。因此,我们需要在动画结束后才卸载元素:

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 组件。例如,组件的模板内容可以是:

<template><Transition><div>我是需要过渡的元素</div></Transition>
</template>

此模板编译后的虚拟 DOM 可以设计如下:

function render() {return {type: Transition,children: {default() {return { type: 'div', children: '我是需要过渡的元素' }}}}
}

Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致。接下来,我们需要实现 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  // 渲染需要过渡的元素}}
}

上述代码告诉我们,Transition 组件并不渲染任何额外的内容,而只是通过默认插槽读取并渲染过渡元素。它的主要作用是在过渡元素的虚拟节点上添加 transition 钩子函数。
经过 Transition 组件的包装后,需要过渡的虚拟节点对象将会包含一个 vnode.transition 对象,其中包含与 DOM 元素过渡相关的钩子函数,如 beforeEnter、enter、leave 等。当渲染这些需要过渡的虚拟节点时,渲染器会在适当的时机调用这些钩子函数,具体如下:

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.transitionif (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 钩子函数,如下所示:

function unmount(vnode) {// 判断 VNode 是否需要过渡处理const needTransition = vnode.transitionif (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.parentNodeif (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 组件:

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 对象中定义的过渡相关钩子函数。

http://www.dtcms.com/a/336322.html

相关文章:

  • 概率dp|math
  • Android中切换语言的方法
  • 基于Netty的高并发WebSocket连接管理与性能优化实践指南
  • ReactNode 类型
  • 第12章《学以致用》—PowerShell 自学闭环与实战笔记
  • “让机器人更智慧 让具身体更智能”北京世界机器人大会行业洞察
  • Python 调试工具的高级用法
  • OJ目录饿
  • Python 基础语法(二)
  • Kubernetes存储迁移实战:从NFS到阿里云NAS完整指南
  • 【踩坑笔记】50系显卡适配的 PyTorch 安装
  • XF 306-2025 阻燃耐火电线电缆检测
  • JavaScript 性能优化实战:从评估到落地的全链路指南
  • Docker Compose 安装 Neo4j 的详细步骤
  • 福彩双色球第2025094期号码分析
  • Jenkins - CICD 注入环境变量避免明文密码暴露
  • 用MTEB对Embedding模型进行benchmark
  • Pell数列
  • 基本的设计原则
  • SONiC (4) - redis的介绍以及应用
  • 远程协作绘图:借助 cpolar 内网穿透服务访问 Excalidraw
  • 用PaddleDetection套件训练自己的数据集,PP-YOLO-SOD训练全流程
  • 领域快速入门过程记录之--电力网络
  • ROS常用命令手册
  • # C++ 中的 `string_view` 和 `span`:现代安全视图指南
  • GaussDB常用术语缩写及释义
  • 【Linux】IO多路复用
  • nodejs 错误处理
  • Shell脚本-条件判断相关参数
  • 任务型Agent架构简介