Skip to content

14. 内建组件和模块

14.1   KeepAlive 组件的实现原理

1. 组件的激活与失活

“卸载”一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动到一个隐藏容器中。当重新“挂载”该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再“放回”原来的容器中

js
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;
    };
  },
};

// 卸载操作
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);
  }
}

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

上面这段代码中涉及的 move 函数是由渲染器注入的

js
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,
    };
  }
  // 省略部分代码
}

2. includeexclude

include 用来显式地配置应该被缓存组件,而 exclude 用来显式地配置不应该被缓存组件

js
const cache = new Map();
const KeepAlive = {
  __isKeepAlive: true,
  // 定义 include 和 exclude
  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;
      }

      // 省略部分代码
    };
  },
};

3. 缓存管理

Vue.js 当前所采用的修剪策略叫作“最新一次访问”​。首先,需要为缓存设置最大容量,也就是通过 KeepAlive 组件的 max 属性来设置。”最新一次访问”的缓存修剪策略的核心在于,需要把当前访问(或渲染)的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪

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

在 KeepAlive 组件的内部实现中,如果用户提供了自定义的缓存实例,则直接使用该缓存实例来管理缓存

html
<KeepAlive :cache="cache">
  <Comp />
</KeepAlive>
ts
// 自定义实现
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);
  },
};

14.2   Teleport 组件的实现原理

1. Teleport 组件要解决的问题

在将虚拟 DOM 渲染为真实 DOM 时,最终渲染出来的真实 DOM 的层级结构与虚拟 DOM 的层级结构一致,但假如需要渲染一个全屏遮罩组件到 body 元素上,这时就需要用到 Teleport 组件。

html
<template>
  <Teleport to="body">
    <div class="overlay"></div>
  </Teleport>
</template>
<style scoped>
  .overlay {
    z-index: 9999;
  }
</style>

2. 实现 Teleport 组件

Teleport 组件也需要渲染器的底层支持。首先要将 Teleport 组件的渲染逻辑从渲染器中分离出来

js
const Teleport = {
  __isTeleport: true,
  process(n1, n2, container, anchor) {
    // 在这里处理渲染逻辑
  },
};
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") {
    // 省略部分代码
  }
}

先设计虚拟 DOM 的结构。通常,一个组件的子节点会被编译为插槽内容,不过对于 Teleport 组件来说,直接将其子节点编译为一个数组即可

html
<Teleport to="body">
  <h1>Title</h1>
  <p>content</p>
</Teleport>
js
function render() {
  return {
    type: Teleport,
    // 以普通 children 的形式代表被 Teleport 的内容
    children: [
      { type: "h1", children: "Title" },
      { type: "p", children: "content" },
    ],
  };
}
js
const Teleport = {
  __isTeleport: true,
  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));
      }
    }
  },
};

14.3   Transition 组件的实现原理

Transition 核心原理是:

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

1. 原生 DOM 的过渡

过渡效果本质上是一个 DOM 元素在两种状态间的切换,浏览器会根据过渡效果自行完成 DOM 元素的过渡。这里的过渡效果指的是持续时长、运动曲线、要过渡的属性等

html
<div class="box"></div>
css
.box {
  width: 100px;
  height: 100px;
  background-color: red;
}
/* 初始状态 */
.enter-from {
  transform: translateX(200px);
}
/* 初始状态 */
.enter-to {
  transform: translateX(0);
}
/* 运动过程 */
.enter-active {
  transition: transform 1s ease-in-out;
}
js
// 创建 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");
    });
  });
});

当元素被卸载时,不要将其立即卸载,而是等待过渡效果结束后再卸载它。为了实现这个目标,我们需要把用于卸载 DOM 元素的代码封装到一个函数中,该函数会等待过渡结束后被调用

css
/* 离场动画 */
/* 初始状态 */
.leave-from {
  transform: translateX(0);
}
/* 结束状态 */
.leave-to {
  transform: translateX(200px);
}
/* 过渡过程 */
.leave-active {
  transition: transform 2s ease-out;
}
js
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();
      });
    });
  });
});

2. 实现 Transition 组件

先设计它在虚拟 DOM 层面的表现形式

html
<template>
  <Transition>
    <div>我是需要过渡的元素</div>
  </Transition>
</template>
js
function render() {
  return {
    type: Transition,
    children: {
      default() {
        return { type: "div", children: "我是需要过渡的元素" };
      },
    },
  };
}
js
const Transition = {
  name: "Transition",
  setup(props, { slots }) {
    return () => {
      // 通过默认插槽获取需要过渡的元素
      const innerVNode = slots.default();
      // 在过渡元素的 VNode 对象上添加 transition 相应的钩子函数
      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;
    };
  },
};

修改 mountElement 函数和 unmount 函数

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

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

上次更新于: