likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 16

在第 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。精读《Vuejs设计与实现》第 14 章(内建组件和模块)一个基本的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 的组件
      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 函数完成搬运工作:
// 卸载操作
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 函数代码中可以看出:
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 函数来激活它。接下来看一下组件激活和失活的函数:

const { move, createElement } = instance.keepAliveCtx

instance._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.__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 定义如下:

const KeepAlive = {
  __isKeepAlive: true,
  // 定义 include 和 exclude
  props: {
    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
      }
      // 获取“内部组件”的 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 对象实现对组件的缓存:

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.component
  rawVNode.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 } = 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 组件的定义如下:

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

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

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

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-from
  el.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 元素添加进场过渡的过程进行抽象为下图:精读《Vuejs设计与实现》第 14 章(内建组件和模块)

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

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

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

转载自:https://juejin.cn/post/7272276908380831755
评论
请登录