likes
comments
collection
share

Vue3源码——从patch函数到组件更新

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

前言

前面我们探讨了组件的挂载逻辑,挂载逻辑主要就是通过执行render函数生成vnode,然后将vnode通过patch函数处理,最终生成真实DOM,并挂载到父容器,页面也就呈现出内容。这一节我们来看一下Vue3的组件更新逻辑是怎样进行的。

组件更新

组件更新的大体流程和组件挂载比较相似,具体为:

  • 首先,获取编译阶段得到的render函数
  • 然后,执行render函数,得到新的vnode树
  • 最后,通过patch函数比较新旧vnode树,并根据节点的类型去执行不同的操作(这里挂载阶段还不存在旧树,旧树为null)。在这个阶段则会生成真实DOM结构,并将DOM挂载到父容器,从而呈现在页面上。

我们把重点放在更新和挂载差异相对较大的patch函数上。

patch函数

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
      // 如果新旧vnode节点相同,则无须patch
      if (n1 === n2) {
        return;
      }
      // 如果新旧vnode节点,type类型不同,则直接卸载旧节点
      // 这里isSameVNodeType会判断规则为n1.type === n2.type && n1.key === n2.key
      if (n1 && !isSameVNodeType(n1, n2)) {
        anchor = getNextHostNode(n1);
        unmount(n1, parentComponent, parentSuspense, true);
        n1 = null;
      }
      ...
      const { type, ref: ref2, shapeFlag } = n2;
      // 根据新节点的类型,采用不同的函数进行处理
      switch (type) {
        // 处理文本
        case Text:
          processText(n1, n2, container, anchor);
          break;
        // 处理注释
        case Comment:
          processCommentNode(n1, n2, container, anchor);
          break;
        // 处理静态节点
        case Static:
          if (n1 == null) {
            mountStaticNode(n2, container, anchor, isSVG);
          } else if (true) {
            patchStaticNode(n1, n2, container, isSVG);
          }
          break;
        // 处理Fragment
        case Fragment:
          // Fragment
          ...
          break;
        default:
          if (shapeFlag & 1 /* ELEMENT */) {
            // element类型
            processElement(
              n1,
              n2,
              container,
              anchor,
              parentComponent,
              parentSuspense,
              isSVG,
              slotScopeIds,
              optimized
            );
          } else if (shapeFlag & 6 /* COMPONENT */) {
            // 组件
            ...
          } else if (shapeFlag & 64 /* TELEPORT */) {
            // teleport 
            ...
          } else if (shapeFlag & 128 /* SUSPENSE */) {
            // suspense
            ...
          } else if (true) {
            warn2("Invalid VNode type:", type, `(${typeof type})`);
          }
      }
      ...
    };

patch函数上一节也有提到,这里再回顾一下:

  • 首先,判断新旧节点是否为相同节点,如果不是相同节点,则直接将旧节点卸载
  • 然后,再根据新节点的类型type,采用不同的操作。

我们仍旧以element的节点类型为例,看一下processElement函数

processElement函数

const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
      isSVG = isSVG || n2.type === "svg";
      if (n1 == null) {
        // 挂载
        ...
      } else {
       // 更新
        patchElement(
          n1,
          n2,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      }
    };

可以看到processElement函数在更新阶段接到的参数n1,n2分别为新旧vnode节点,n1不为空,所以更新阶段的核心逻辑在于patchElement函数

patchElement函数

const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
      const el = n2.el = n1.el;
      let { patchFlag, dynamicChildren, dirs } = n2;
      ...
      // hmr触发更新时
      if (isHmrUpdating) {
        patchFlag = 0;
        optimized = false;
        dynamicChildren = null;
      }
      // 动态节点数组
      if (dynamicChildren) {
        patchBlockChildren(
          n1.dynamicChildren,
          dynamicChildren,
          el,
          parentComponent,
          parentSuspense,
          areChildrenSVG,
          slotScopeIds
        );
      } else if (!optimized) {
        // 全量更新(在热更新hmr时才会触发)
        patchChildren(
          n1,
          n2,
          el,
          null,
          parentComponent,
          parentSuspense,
          areChildrenSVG,
          slotScopeIds,
          false
        );
      }
      
      // 根据patchFlag来判断需要更新的类型
      if (patchFlag > 0) {
        if (patchFlag & 16 /* FULL_PROPS */) {
          // 全量更新props
          patchProps(
            el,
            n2,
            oldProps,
            newProps,
            parentComponent,
            parentSuspense,
            isSVG
          );
        } else {
          // 更新class
          if (patchFlag & 2 /* CLASS */) {
            if (oldProps.class !== newProps.class) {
              hostPatchProp(el, "class", null, newProps.class, isSVG);
            }
          }
          // 更新style
          if (patchFlag & 4 /* STYLE */) {
            hostPatchProp(el, "style", oldProps.style, newProps.style, isSVG);
          }
          // 更新props
          if (patchFlag & 8 /* PROPS */) {
            const propsToUpdate = n2.dynamicProps;
            for (let i = 0; i < propsToUpdate.length; i++) {
              const key = propsToUpdate[i];
              const prev = oldProps[key];
              const next = newProps[key];
              if (next !== prev || key === "value") {
                hostPatchProp(
                  el,
                  key,
                  prev,
                  next,
                  isSVG,
                  n1.children,
                  parentComponent,
                  parentSuspense,
                  unmountChildren
                );
              }
            }
          }
        }
        // 更新文本节点
        if (patchFlag & 1 /* TEXT */) {
          if (n1.children !== n2.children) {
            hostSetElementText(el, n2.children);
          }
        }
      } else if (!optimized && dynamicChildren == null) {
       // 热更新触发更新props
        patchProps(
          el,
          n2,
          oldProps,
          newProps,
          parentComponent,
          parentSuspense,
          isSVG
        );
      }
      ...
    };

抽离出函数的核心逻辑,我们可以看到patchElement函数会根据是否为热更新hmr来决定调用哪个函数:

  • 如果不是热更新,那么就走patchBlockChildren函数,仅更新动态节点,而不用去管静态节点,这样也能节省一些性能。
  • 如果是热更新,那么就走patchChildren函数,进行全量更新
  • 之后再根据patchFlag的值来定位具体的更新位置,最后做出相应的处理。

我们以下面模板为例:

<div>
    <span> {{x}} </span>
    <div>123</div>
</div>

这个模板经过处理后得到vnode是这个样子的:

Vue3源码——从patch函数到组件更新

我们可以看到,包含变量 xspan节点被处理进入了dynamicChildren数组中,而不会变动的静态节点<div>123</div>不在dynamicChildren数组中。

这也很容易理解,只要我们不是从代码层面来修改静态节点的话,那无论我们做什么操作,<div>123</div>这个节点始终是不会改变的,都是可以直接复用的。而我们通过页面操作,一般来说会改变的只有变量 x 的值,所以Vue在做patch处理的时候,只需要关注这一部分动态节点即可。

动态节点的处理——patchBlockChildren函数

const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG, slotScopeIds) => {
  // 遍历新旧动态节点,并对每一个动态节点递归执行patch操作
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i];
    const newVNode = newChildren[i];
    const container = (
      oldVNode.el && 
      (oldVNode.type === Fragment || !isSameVNodeType(oldVNode, newVNode) || oldVNode.shapeFlag & (6 /* COMPONENT */ | 64 /* TELEPORT */)) ? hostParentNode(oldVNode.el) : (fallbackContainer)
    );
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      true
    );
  }
};

patchBlockChildren函数的逻辑非常明确,就是直接遍历新旧vnode中的动态节点数组dynamicChildren,然后通过递归调用patch函数的方式去比对每一个动态节点。

我们仍以这个模板为例,只不过这次我们加入一个定时器来触发更新操作:

<template>
   <div>
    <span>{{ x }}</span>
    <div>123</div>
  </div>
</template>

<script setup>
import { ref } from "vue";
let x = ref(999)

setTimeout(() => {
  x.value++
}, 1000);
</script>

我们来顺一下整个的组件更新流程加深一下对源码的认识:

  • 编译到挂载

    1. template经过模板编译生成了render函数
    2. 通过执行render函数得到了新节点的虚拟DOM,也就是vnode
    3. 进入挂载阶段,执行patch函数因为此时没有旧节点,所以patch函数的第一个参数n1传null),完成组件的挂载,最终将DOM结构呈现在页面上。
    4. 定时器执行后,触发组件更新
  • 更新阶段,我们直接从patch函数开始:

    1. 进入patch函数,通过isSameVNodeType函数比对新旧节点vnode是否为相同节点,如果不是相同节点,则直接将旧节点卸载
    2. 根据节点类型,判断当前节点为element类型,进入processElement函数
    3. processElement函数判断是否存在旧节点,存在旧节点进入更新函数patchElement
    4. patchElement函数中,首先判断此次更新是否为热更新HMR触发,我们这里当然不是HMR,继续往下判断是否存在动态节点数组dynamicChildrenVue3源码——从patch函数到组件更新
    5. 存在动态节点数组dynamicChildren,则调用patchBlockChildren函数对动态节点数组进行处理。
    6. 遍历动态节点数组,对内部的每个动态节点递归调用patch函数。这里我们就是对span节点进行递归操作,我们看一下此时传入patch函数的新旧节点是什么样子的: Vue3源码——从patch函数到组件更新Vue3源码——从patch函数到组件更新
    7. 可以看到新旧节点的shapFlag值为9,所以进入patch函数后,会再次使用processElement函数对它们进行处理。
    8. 同样,因为是更新操作,所以进入processElement函数后,会调用patchElement执行更新操作。
    9. patchElement函数中,首先会判断此次更新并不是来自于热更新HMR,然后判断是否存在动态节点数组dynamicChildren,我们从上面的新旧vnode可以看出,新节点的dynamicChildren属性为null,所以直接跳过对子节点的处理。
    10. 继续执行patchElement函数后面的逻辑,根据patchFlag的值,我们判断出新旧节点的更新发生在文本节点处。又因为n1.children999n2.children1000,二者不等,所以执行hostSetElementText函数hostSetElementText(el, n2.children);
    11. setElementText函数即为hostSetElementText函数setElementText: (el, text) => {el.textContent = text;},可以看到,在这个函数中,通过直接操作DOM的方式,将新的 x 的值呈现在了DOM 结构上。

至此,我们就将整个patchBlockChildren函数的更新逻辑走完了,是不是就也还好了,没有想象中那么的遥不可及~

接下来,我们再看一下,当我们通过修改源码由HMR触发的组件更新如何操作的。

HMR触发全量更新——patchChildren函数

关于HMR热更新,还不清楚的朋友可以先查一下,大概知道它是做什么的就行,不影响我们这里继续探究源码。

可以就先将HMR理解为,我们在本地将代码通过脚手架跑起来之后,然后在编辑器上修改了代码文件并保存,页面会在不刷新的情况下实时的将我们的修改内容体现在页面上,这个过程就是热更新HMR

我们直接看patchChildren函数

const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized = false) => {
  const c1 = n1 && n1.children;
  const prevShapeFlag = n1 ? n1.shapeFlag : 0;
  const c2 = n2.children;
  const { patchFlag, shapeFlag } = n2;
  // ...
  // 如果新节点是文本节点
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 如果旧节点的子节点为数组,则直接卸载旧节点
    if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
      unmountChildren(c1, parentComponent, parentSuspense);
    }
    if (c2 !== c1) {
      // 将新的文本节点加入到DOM
      hostSetElementText(container, c2);
    }
  } else {
    // 如果新节点不是文本节点
    // 旧节点为数组
    if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
      // 新节点也为数组节点
      if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        // 进行数组间的diff,也是常考的diff算法操作
        patchKeyedChildren(
          c1,
          c2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else {
        // 新节点不是数组,直接将旧节点卸载
        unmountChildren(c1, parentComponent, parentSuspense, true);
      }
    } else {
      // 旧节点为文本
      if (prevShapeFlag & 8 /* TEXT_CHILDREN */) {
        // 清空对应的DOM
        hostSetElementText(container, "");
      }
      // 新节点为数组,旧节点不是数组
      if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        // 执行挂载新节点的操作
        mountChildren(
          c2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      }
    }
  }
};

patchChildren函数在做全量更新的时候,它的主要思路就是,通过判断新旧节点的类型,决定执行不同的操作。具体的细节在上面代码注释中已经体现,这里就不再赘述。

关于新旧节点都是数组的时内部是如何处理的,这一节暂时还没有体现。因为这个地方也是Vue的diff算法比较奇特的一节,准备下一节详细的去分析一下diff算法。

最后

至此,我们就将组件更新的逻辑完全顺下来了,整个的过程的关键就是不断的判断新旧节点的类型,然后执行不同的操作,其中里面还涉及到了递归的思想,所以作为前端工作之余玩一玩算法还是挺好的,也推荐有兴趣的朋友可以尝试玩一下~