内建组件和模块

前几篇文章,我们讨论了 Vue.js 是如何基于渲染器实现组件化能力的。本篇文章我们将继续讨论 Vue.js 中几个非常重要的内建组件和模块,例如 KeeyAlive 组件、Teleport 组件、Transition 组件等,它们都需要渲染器级别的底层支持。另外,这些内建组件所带来的能力,对开发者而言非常重要且使用,理解它们的工作原理有助于我们正确地使用它们。

KeepAlive 组件的实现原理

组件的激活与失活

KeepAlive 一词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive 又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。在没有 KeepAlive 的情况下,一个 HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。频繁地销毁、创建 HTTP 链接会带来额外的性能开销,KeepAlive 就是为了解决这个问题而生的。

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>

可以看到,根据变量 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>

这样,无论用户怎么切换 <Tab> 组件,都不会发生频繁地创建和销毁,因而会极大地优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。那么,KeepAlive 组件的实现原理是怎样的呢?其实 KeepAlive 组件的本质是缓存管理,再加上特殊的挂载/卸载逻辑。

首先,KeepAlive 组件的实现需要渲染器层面的支持。这是因为被 KeepAlive 的组件在卸载时,并不是真正卸载,否则就无法维持组件的当前状态。正确的做法是,将被 KeepAlive 的组件从原容器搬运到另一个隐藏的容器中,实现 “假卸载”。当被搬运到隐藏容器中的组件需要再次被 “挂载” 时,我们也不能执行真正的挂载逻辑,而是应该把该组件从隐藏容器中再搬运到原容器中。这个过程对应组件的生命周期分别是 activateddeactivated

一个最基本的 KeepAlive 组件实现起来并不复杂,如下面的代码所示:

const KeepAlive = {
  // KeepAlive 组件独有的属性,用作标识
  _isKeepAlive: true,
  setup(props, { slots }) {
    // 创建一个缓存对象
    // key:vnode.type
    // value:vnode
    const cache = new Map()
    // 当前 KeepAlive 组件的实例
    const instance = curentInstance
    // 对于 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
    }
  }
}
js

从上面的实现中可以看到,与普通组件的一个较大的区别在于,KeepAlive 组件与渲染器的结合非常深。首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 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') {
        if (vnode.shouldKeepAlive) {
          // 对于需要被 KeepAlive 的组件,不应该真正卸载它,而是调用该组件的父组件
          // 即 KeepAlive 组件的 _deActivate 函数使其失活
          vnode.keepAliveInstance._deActivate(vnode)
        } else {
          // 对于组件卸载,本质上是要卸载组件所渲染的内容,即 subTree
          unmount(vnode.component.subTree)
        }
        return
      }
      const parent = vnode.el.parentNode
      if (parent) {
        parent.removeChild(vnode.el)
      }
    }
    js

    可以看到,unmount 函数在卸载组件时,会检测组件是否应该被 KeepAlive,从而执行不同的操作。

  • 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') {
        // vnode.type 的值是选项对象,作为组件处理
        if (!n1) {
          if (n2.keptAlive) {
            // 如果该组件已经被 KeptAlive,则不会重新挂载,而是调用 _activate 激活组件
            n2.keepAliveInstance._activate(n2, container, anchor)
          } else {
            // 挂载组件
            mountComponent(n2, container, anchor)
          }
        } else {
          // 更新组件
          patchComponent(n1, n2, anchor)
        }
      }
    }
    
    js

可以看到,如果组件的 vnode 对象中存在 KeptAlive 标识,渲染器不会重新挂载它,而是会通过 keepAliveInstance._activate 函数来激活它。

我们再来看一下用于激活组件和失活组件的两个函数:

// 对于 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)
}
js

可以看到,失活的本质就是将组件所渲染的内容移动到隐藏容器中,激活的本质是将组件所渲染的内容从隐藏容器中搬运回原来的容器。另外,上面这段代码所涉及的 move 函数是由渲染器注入的。


function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions
	
  // ...

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
    // 将插槽添加到组件实例上
    slots,
    // 组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
    mounted: [],
    // 只有 KeepAlive 组件的实例下会有 keepAliveCtx 属性
    keepAliveCtx: null
  }

  // 检查当前要挂载的组件是否是 KeepAlive 组件
  const isKeepAlive = vnode.type._isKeepAlive
  if (isKeepAlive) {
    // 在 KeepAlive 组件实例上添加 KeepAliveCtx 对象
    instance.keepAliveCtx = [
      // 用来移动 vnode
      move(vnode, container, anchor) {
        // 本质上是将组件渲染的内容移动到指定容器中,隐藏在容器中
        insert(vnode.component.subTree.el, container, anchor)
      },
      createElement
    ]
  }
	
	// ...
}
js

至此,一个最基本的 KeepAlive 组件就完成了。

include 和 exclude

在默认情况下,KeepAlive 组件会对所有 ”内部组件“ 进行缓存。但有时候用户期望只缓存特定组件。为了使用户能够自定义缓存规则,我们需要让 KeepAlive 组件支持两个 props,分别是 include 和 exclude。其中,include 用来显式地配置应该被缓存的组件,exclude 用来显式地配置不应该被缓存的组件。

KeepAlive 组件的 props 定义如下:

const KeepAlive = {
  // KeepAlive 组件独有的属性,用作标识
  _isKeepAlive: true,
  // 定义 include 和 exclude
  props: {
    include: RegExp,
    exclude: RegExp
  },
  setup(props, { slots }) {
		// ...
  }
}
js

为了简化问题,我们只允许为 include 和 exclude 设置正则类型的值。在 KeepAlive 组件被挂载时,它会根据 “内部组件” 的名称(name 选项)进行匹配。

const KeepAlive = {
  // KeepAlive 组件独有的属性,用作标识
  _isKeepAlive: true,
  // 定义 include 和 exclude
  props: {
    include: RegExp,
    exclude: RegExp
  },
  setup(props, { slots }) {
    // ...

    return () => {
      // KeepAlive 的默认插槽就是要被 KeepAlive 的组件
      let rawVNode = slots.default()
      // 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAlive
      if (typeof rawVNode.type !== 'object') {
        return rawVNode
      }

      // 获取 “内部组件” 的 name
      const name = rawVNode.type.name

      if (
        name &&
        (
          // 如果 name 无法被 include 匹配
          (props.include && !props.include.test(name)) || 
          // 或者被 exclude 匹配
          (props.exclude && props.exclude.test(name))
        )
      ) {
        // 直接渲染 “内部组件”,不需要进行缓存操作
        return rawVNode
      }

    	// ...
    }
  }
}
js

可以看到,我们根据用户指定的 include 和 exclude 正则,对 “内部组件” 的名称进行匹配,并根据匹配结果判断是否要对 “内部组件” 进行缓存。在此基础上,我们可以任意扩充匹配能力。例如,可以将 include 和 exclude 设计成多种类型值,允许用户指定字符串或者函数从而提供更加灵活的机制。另外,在做匹配时,也可以不限于 “内部组件” 的名称,甚至可以让用户自行指定匹配要素。但无论如何,原理都是不变的。

缓存管理

在之前的实现中,我们使用一个 Map 对象来实现对组件的缓存。

const cache = new Map()
js

该 Map 对象的键是组件选项对象,即 vnode.type 属性的值,该 Map 对象的值是用于描述组件的 vnode 对象。由于用于描述组件的 vnode 对象存在对组件实例的引用(即 vnode.component)属性,所以缓存用户描述组件的 vnode 对象,就等价于缓存组件实例。

回顾一下目前 KeepAlive 组件中关于缓存的实现:

// 挂载时先获取缓存的组件 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)
}
js

缓存的处理逻辑可以总结为:

  • 如果缓存存在,则继承组件实例,并将用于描述组件的 vnode 对象标记为 keptAlive,这样渲染器就不会重新创建新的组件实例;
  • 如果缓存不存在,则设置缓存。

这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量缓存。为了解决这个问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。但是这样又会引出另一个问题:我们应该对缓存如何修剪?应该采用怎样的策略修剪?

Vue.js 当前所采用的修剪策略叫做 “最新一次访问(LRU 缓存淘汰策略)”。首先,你需要为缓存设置最大容量,也就是通过 KeepAlive 组件的 max 属性来设置。

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

在上面这段代码中,我们设置缓存的容量为 2。假设我们有三那个组件 Comp1Comp2Comp3,并且它们都会缓存。然后,我们模拟一下组件切换过程中缓存的变化。

  • 初始渲染 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

可以看到,在不同的模拟策略下,最终的缓存结果会有所不同。“最新一次访问” 的缓存策略的核心在于,需要把当前访问(或渲染)的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的。

实现 Vue.js 内建的缓存策略并不难。我们的关注点在于,缓存策略是否可以改变?甚至允许用户自定义缓存策略?实际上,在 Vue.js 官方的 RFCs 中已经有相关提议。该提议允许用户实现自定义的缓存策略,在用户接口层面,则体现在 KeepAlive 组件新增了 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)
  }
}
typescript

在 KeepAlive 组件的内部实现中,如果用户提供了自定义的缓存实例,则直接使用该缓存实例来管理缓存。从本质上来说,这等价于将缓存的管理权限从 KeepAlive 组件转交给用户了。

Teleport 组件的实现原理

Teleport 组件要解决的问题

Teleport 组件是 Vue.js 3 新增的一个内建组件,我们首先讨论它要解决的问题是什么。通常情况下,在将虚拟 DOM 渲染为真实 DOM 时,最终渲染出来的真实 DOM 的层级结构与虚拟 DOM 的层级结构一致。以下面的模板为例:

<template>
	<div id="box" style="z-index: -1;">
    <Overlay />
  </div>
</template>

在这段模板中,<Overlay> 组件的内容会被渲染到 id 为 box 的 div 标签下。然而,有时这并不是我们所期望的。假设 <Overlay> 组件是一个 “蒙层” 组件,该组件会渲染一个 “蒙层”,并要求 “蒙层” 能够遮挡页面上的任何元素。但问题是,如果 <Overlay> 组件的内容无法跨越 DOM 层级渲染,就无法实现这个目标。还是拿上面这段模板来说,id 为 box 的 div 标签拥有一段内联样式:z-index: -1, 这会导致即使我们将 <Overlay> 组件所渲染内容的 z-index 值设置为无穷大,也无法实现遮挡功能。

通常,我们在面对上述长江,会选择直接在 <body> 标签下渲染 “蒙层” 内容。在 Vue.js 2 中我们只能通过原生 DOM API 来手动搬运 DOM 元素实现需求。这么做的缺点在于,手动操作 DOM 元素会使得元素的渲染与 Vue.js 的渲染机制脱节,并导致各种可预见或不可预见的问题。考虑到该需求确实非常常见,用户也对此抱有迫切的期待,于是 Vue.js 3 内建了 Teleport 组件。该组件可以将指定内容渲染到特定容器中,而不受 DOM 层级的限制。

我们先来看看 Teleport 组件是如何解决这个问题的。以下是基于 Teleport 组件实现的 <Overlay> 组件的模板。

<template>
	<Teleport to="body">
  	<div class="overlay"></div>
  </Teleport>
</template>

<style scoped>
  .overlay {
    z-index: 9999;
  }
</style>

可以看到,<Overlay> 组件要渲染的内容都包含在 Teleport 组件内,即作为 Teleport 组件的插槽。通过为 Teleport 组件指定渲染目标 body,即 to 属性的值,该组件就会直接把它的插槽内容渲染到 body 下,而不会按照模板的 DOM 层级来渲染,于是就实现了跨 DOM 层级的渲染。最终,<Overlay> 组件的 z-index 值也会按预期工作,并遮挡页面中的所有内容。

实现 Teleport 组件

与 KeepAlive 组件一样,Teleport 组件也需要渲染器的底层支持。首先我们要将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:

  • 可以避免渲染器代码 “膨胀”;
  • 当用户没有使用 Teleport 组件时,由于 Teleport 的渲染逻辑被分离,因此可以利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关的diamagnetic,使得最终构建包的体积变小。

为了完成逻辑分离的操作,我们需要修改 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) {
    // 组件选项中如果存在 __isiTeleport 标识,则它是 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') {
    // ...
  }
}
js

可以看到,我们通过组件选项的 __isTeleport 标识来判断该组件是否是 Teleport 组件。如果是,则直接调用组件选项中定义的 process 函数将渲染控制权完全交接出去,这样就实现了渲染逻辑的分离。

Teleport 组件的定义如下:

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

可以看到,Teleport 组件并非普通组件,它有特殊的选项 __isTeleportprocess

接下来我们设计虚拟 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' }
    ]
  }
}
js

设计好虚拟 DOM 的结构后,我们就可以着手实现 Teleport 组件了。首先,我们来完成 Teleport 组件的挂载动作。

const Teleport = {
  __isTeleport: tree,
  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 渲染到指定挂载点即可
      n2.children.forEach(c => patch(null, c, target, anchor))
    } else {
      // 更新
    }
  }
}
js

可以看到,即使 Teleport 渲染逻辑被单独分离分出,它的渲染思路仍然与渲染器本身的渲染思路保持一致。通过判断旧的虚拟节点(n1)是否存在,来决定执行挂载还是执行更新。如果要执行挂载,则需要根据 props.to 属性的值来取得真正的挂载点。最后,遍历 Teleport 组件的 children 属性,并逐一调用 patch 函数完成子节点的挂载。

更新的处理更加简单,如下面的代码所示:

const Teleport = {
  __isTeleport: tree,
  process(n1, n2, container, anchor, internals) {
    // 通过 internals 参数取得渲染器的内部方法
    const { patch, patchChildren } = internals
    // 如果旧 VNode n1 不存在,则是全新的挂载,否则执行更新
    if (!n1) {
      // 挂载
			// ...
    } else {
      // 更新
      patchChildren(n1, n2, container)
    }
  }
}
js

只需要调用 patchChildren 函数来完成更新操作即可。不过有一点需要额外注意,更新操作可能是由于 Teleport 组件的 to 属性值的变化引起的,因此,在更新时我们应该考虑这种情况。

const Teleport = {
  __isTeleport: tree,
  process(n1, n2, container, anchor, internals) {
    // 通过 internals 参数取得渲染器的内部方法
    const { patch, patchChildren } = internals
    // 如果旧 VNode n1 不存在,则是全新的挂载,否则执行更新
    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 {
      // 更新
      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))
      }
    }
  }
}
js

用来执行移动操作的 move 函数的实现如下:

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) {
		// ...
  } else if (typeof type === 'object' || typeof type === 'function') {
    // vnode.type 的值是选项对象,作为组件处理
    if (!n1) {
      if (n2.keptAlive) {
        // 如果该组件已经被 KeptAlive,则不会重新挂载,而是调用 _activate 激活组件
        n2.keepAliveInstance._activate(n2, container, anchor)
      } else {
        // 挂载组件
        mountComponent(n2, container, anchor)
      }
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor)
    }
  }
}
js

在上面的代码中,我们只考虑了移动组件和普通元素。我们知道,虚拟节点的类型有很多种,例如文本类型(Text)、片段类型(Fragment)等。一个完善的实现应该考虑所有这些虚拟节点的类型。

Transition 组件的实现原理

通过对 KeepAlive 组件和 Teleport 组件的讲解,我们能够意识到,Vue.js 内建的组件通常与渲染器的核心逻辑结合的非常紧密。本节将要讨论的 Transition 组件也不例外,甚至它与渲染器结合更加紧密。

实际上,Transition 组件的实现要比想象中简单的多,它的核心原理是:

  • 当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
  • 当 DOM 元素被卸载时,不要理解卸载 DOM 元素,而是等到附加到该 DOM 元素伤的动效执行完成之后再卸载它。

当然,规则上主要遵循上述两个要素,但具体实现要考虑的边界情况还有很多。不过,我们只需要了解它的核心原理即可,具体细节可以在基本实现的基础上按需添加或完善。

原生 DOM 的过渡

为了更好地理解 Transition 组件的实现原理,我们有必要先讨论如何为原生 DOM 创建过渡动效。过渡效果本质上是一个 DOM 元素在两种状态间的切换,浏览器会根据过渡效果自行完成 DOM 元素的过渡。这里的过渡效果指的是持续时长、运动曲线、要过渡的属性等。

我们从一个例子开始。假设我们有一个 div 元素,宽高各 100px,如下面的代码所示:

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

接着,为其添加对应的 CSS 样式:

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

现在,假设我们要为元素添加一个进场动效。我们可以这样描述该动效:从距离左边 200px 的位置在 1 秒内运动到距离左边 0px 的位置。在这句描述中,初始状态是 “距离左边 200px” ,因此我们可以用下面的样式描述初始状态。

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

而结束状态是 "距离左边 0px",也就是初始位置,可以用下面的 CSS 代码来描述:

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

初始状态和结束状态都已经描述完毕。最后,我们还要描述运动过程,例如持续时长、运动曲线等。对此,我们可以用以下 CSS 代码来描述。

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

上面这段代码主要做了三件事:

  • 创建 DOM 元素;
  • 将过渡的初始状态和运动过程定义到元素上,即把 enter-fromenter-active 这两个类添加到元素伤;
  • 将元素添加到页面中,即挂载。

经过这三个步骤之后,元素的初始化状态会生效,页面渲染的时候会将 DOM 元素以初始状态所定义的样式进行展示。接下来我们需要切换元素的状态,使得元素开始运动。那么,应该怎么做呢?理论上,我们只需要将 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')
el.classList.add('enter-to')
js

然而,上面这段代码无法按预期执行。这是因为浏览器会在当前帧绘制 DOM 元素,最终结果是,浏览器将 enter-to 这个类所具有的样式绘制出来,而不会绘制 enter-from 类所具有的样式。为了解决这个问题,我们需要在下一帧执行状态切换,如下面的代码所示。

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

现在你会发现进场动效能够正常显示了。

最后我们需要做的是,当过渡完成后,将 enter-fromenter-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(() => {
  el.classList.remove('enter-from')
  el.classList.add('enter-to')

  // 监听 transitionend 事件完成
  el.addEventListener('transitionend', () => {
    el.classList.remove('enter-to')
    el.classList.remove('enter-active')
  })
}) 
js

通过监听元素的 transitionend 事件来完成收尾工作。实际上,我们可以对上述 DOM 元素添加进场过渡的过程进行抽象。

transition01.png

从创建 DOM 元素完成后,到把 DOM 元素添加到 body 前,整个过程可以视作 beforeEnter 阶段。在把 DOM 元素添加到 body 之后,则可以视作 enter 阶段。在不同的阶段执行不同的操作,即可完成整个进场过渡的实现。

  • beforeEnter 阶段:添加 enter-fromenter-active 类。
  • enter 阶段:在下一帧中移除 enter-from 类,添加 enter-to
  • 进场动效结束:移除 enter-toenter-active 类。

理解了进场过渡的实现原理后,接下来我们讨论 DOM 元素的离场过渡效果。与进场过渡效果一样,我们需要定义离场过渡的初始状态、结束状态以及过渡过程。

.leave-from {
  transform: translateX(0);
}
.leave-to {
  transform: translateX(200px);
}
.leave-active {
  transition: transform 2s ease-out;
}
css

可以看到,离场过渡的初始状态与结束状态正好对应进场过渡的结束状态与初始状态。

离场动效一般发生在 DOM 元素被卸载的时候。

// 卸载元素
el.addEventListener('click', () => {
  el.parentNode.removeChild(el)
})
js

当点击元素时,该元素会被移除,这样就实现了卸载。如果仅仅这样做,元素根本没有执行过渡的机会。因此,一个很自然的思路就产生了:当元素被卸载时,不要将其立即卸载,而是等待过渡效果结束后再卸载它。为了实现这个目标,我们需要把用于卸载 DOM 元素的代码封装到一个函数中,该函数会等待过渡结束后被调用,如下面的代码所示:

el.addEventListener('click', () => {
  // 将卸载动作封装到 performRemove 函数中
  const performRemove = () => el.parentNode.removeChild(el)
})
js

在上面这段代码中,我们将卸载动作封装到 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(() => {
    // 切换到结束状态
    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()
  })
})
js

从上面这段代码中可以看到,离场过渡的处理与进场过渡的处理方式非常相似,即首先设置初始状态,然后在下一帧中切换为结束状态,从而使得过渡失效。需要注意的是,当离场过渡完成之后,需要执行 performRemove 函数来真正地将 DOM 元素卸载。

实现 Transition 组件

Transition 组件的实现原理与原生 DOM 的过渡原理一样。只不过,Transition 组件是基于虚拟 DOM 实现的。我们在为 DOM 元素创建进场动效和离场动效时能注意到,整个过渡过程可以抽象为几个阶段,这些阶段可以抽象为特定的回调函数。例如 beforeEnterenterleave 等。实际上,基于虚拟 DOM 的实现也需要将 DOM 元素的生命周期分割为这样几个阶段,并在特定阶段执行对应的回调函数。

为了实现 Transition 组件,我们需要先设计它在虚拟 DOM 层面的表现形式。假设组件的模板内容如下:

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

我们可以将这段模板被编译后的虚拟 DOM 设计为:

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

可以看到,Transition 组件的子节点被编译为默认插槽,这与普通组件的行为一致。虚拟 DOM 层面的表示已经设计完了,接下来,我们着手实现 Transition 组件,如下面的代码所示:

const Transtion = {
  name: 'Transition',
  setup(props, { slots }) {
    return () => {
      // 通过默认插槽获取需要过渡的元素
      const innerVNode = slots.default()

      // 在过渡元素的 VNode 对象上添加 transition 相应的钩子函数
      innerVNode.transtion = {
        beforEnter(el) {
          // 
        },
        enter(el) {
          // 
        },
        leave(el, performRemove) {
          // 
        }
      }

      // 返回需要过渡的元素
      return innerVNode
    }
  }
}
js

观察上面的代码,可以发现几点重要信息:

  • Transition 组件本身不会渲染任何额外内容,它只是通过默认插槽读取过渡元素,并渲染需要过渡的元素;
  • Transition 组件的作用,就是在过渡元素的虚拟节点上添加 transition 相关的钩子函数。

可以看到,经过 Transition 组件的包装后,内部需要过渡的虚拟节点会被添加一个 vnode.transition 对象。这个对象下存在一些与 DOM 元素过渡相关的钩子函数,例如 beforeEnterenterleave 等。这些钩子函数与我们之前介绍的钩子函数相同,渲染器在渲染需要过渡的虚拟节点时,会在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,具体体现在 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.transtion
  if (needTransition) {
    // 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
    vnode.transtion.beforEnter(el)
  }

  insert(el, container, anchor)

  if (needTransition) {
    // 调用 transition.enter 钩子,并将 DOM 元素作为参数传递
    vnode.transtion.enter(el)
  }
}
js

上面这段代码是修改后的 mountElement 函数,我们为它增加了 transition 钩子的处理。可以看到,在挂载 DOM 元素之前,会调用 transition.beforeEnter 钩子;在挂载元素之后,会调用 transition.enter 钩子,并且这两个钩子函数都接收需要过渡的 DOM 元素对象作为第一个参数。除了挂载之外,卸载元素我们也应该调用 transition.leave 钩子函数,如下面的代码所示:

function unmount(vnode) {
  // 判断 vnode 是否需要过渡处理
  const needTransition = vnode.transtion

  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c))
    return
  } else if (typeof vnode.type === 'object') {
    if (vnode.shouldKeepAlive) {
      // 对于需要被 KeepAlive 的组件,不应该真正卸载它,而是调用该组件的父组件
      // 即 KeepAlive 组件的 _deActivate 函数使其失活
      vnode.keepAliveInstance._deActivate(vnode)
    } else {
      // 对于组件卸载,本质上是要卸载组件所渲染的内容,即 subTree
      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.transtion.leave(vnode.el, performRemove)
    } else {
      // 如果不需要过渡处理,直接执行卸载操作
      performRemove()
    }
  }
}
js

上面这段代码是修改后的 unmount 函数的实现,我们同样为其增加了关于过渡的处理。首先,需要将卸载动作封装到 performRemove 函数内。如果 DOM 元素需要过渡处理,那么就需要等待过渡结束后再执行 performRemove 函数完成卸载,否则直接调用该函数完成卸载即可。

有了 mountElement 函数和 unmount 函数的支持后,我们就可以轻松地实现一个最基本的 Transition 组件了。

const Transtion = {
  name: 'Transition',
  setup(props, { slots }) {
    return () => {
      // 通过默认插槽获取需要过渡的元素
      const innerVNode = slots.default()

      // 在过渡元素的 VNode 对象上添加 transition 相应的钩子函数
      innerVNode.transtion = {
        beforEnter(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) {
          // 设置离场过渡的初始状态:添加 leve-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')
                // 当过渡完成后,调用 performRemove 函数将 DOM 元素移除
                performRemove()
              })
          })
        }
      }

      // 返回需要过渡的元素
      return innerVNode
    }
  }
}
js

在上面这段代码中,我们补全了 vnode.transition 中各个钩子函数的具体实现。可以看到,其实现思路和我们之前讨论的原生 DOM 过渡的思路一样。

在上面的实现中,我们硬编码了过渡状态的类名,例如 enter-fromenter-to 等。实际上,我们可以轻松地通过 props 来实现允许用户自定义类型的能力,从而实现一个更加灵活的 Transition 组件。另外,我们也没有实现 “模式” 的概念,即先进后出(in-out)或后进先出(out-in)。实际上,模式的概念只是增加了对节点过渡时机的控制,原理上与将卸载动作封装到 performRemove 函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。

总结

本篇文章中,我们介绍了 Vue.js 内建的三个组件,即 KeepAlive 组件、Teleport 组件和 Transition 组件。它们共同的特点是,与渲染器的集合非常紧密,因此需要框架提供底层的实现与支持。

KeepAlive 组件的作用类似于 HTTP 的持久连接。它可以避免组件实例不断地被销毁和重建。KeepAlive 组件的基本实现并不复杂。当被 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 元素添加进场动销和离场动效。在此过程中,我们将实现动效的过程分为多个阶段,即 beforeEneterenterleave 等。Transition 组件的实现原理与原生 DOM 添加过渡效果的原理类似,我们将过渡相关的钩子函数定义到虚拟节点 vnode.transition 对象中。渲染器在执行挂载和卸载操作时,会优先检查该虚拟节点是否需要进行过渡,如果需要,则会在合适的时机执行 vnode.transition 对象中定义的过渡相关钩子函数。