【源码&库】 Vue3 的组件是如何更新的?

1,075 阅读8分钟

在之前的章节中已经讲过了Vue3的组件是怎么挂载的,组件挂载依赖的是内部实现的render函数,然后通过patch方法进行挂载;

而组件更新的过程,其实就是render函数的重新执行,然后通过patch方法进行更新,他们的过程是一样的,只是在patch方法中,会对新旧VNode进行对比,然后进行更新;

组件挂载

在之前我们是通过源码来感受Vue的组件挂载的,这次我将结合代码示例来演示不一样的组件挂载过程:

<!DOCTYPE html>
<html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

</body>
<script src="../packages/vue/dist/vue.global.js"></script>
<script>
    const {h, render} = Vue

    const app = document.createElement("div");
    document.body.appendChild(app);

    const component = h('div', {id: 'foo'}, 'Hello!');
    render(component, app);
</script>
</html>

这里使用了Vue3的全局构建版本,然后通过h函数创建了一个VNode,然后通过render函数进行挂载,这样页面一样是可以正常显示的;

image.png

如果我们再创建一个VNode,然后再次调用render函数,那么页面就会更新:

const component2 = h('div', {id: 'foo', className: 'foo'}, 'world!');
render(component2, app);

image.png

是不是非常有意思?接下里我们通过断点的方式来看看这个过程,我们可以在第二次调用render函数的时候打上断点,然后看看render函数的执行过程;

组件更新

通过断点,代码执行不出意外会走到patch方法中,然后我们就可以跟到patch方法中,看看patch方法是如何进行组件更新的;

image.png

以我们这个简洁的代码来看,代码最后会走到processElement方法中,之前的章节进入到processElement方法之后只讲了mountElement,今天就来看看patchElement

image.png

patchElement

patchElement方法的源码如下:

const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
  const el = n2.el = n1.el;
  let { patchFlag, dynamicChildren, dirs } = n2;
  patchFlag |= n1.patchFlag & 16;
  const oldProps = n1.props || EMPTY_OBJ;
  const newProps = n2.props || EMPTY_OBJ;
  let vnodeHook;
  parentComponent && toggleRecurse(parentComponent, false);
  if (vnodeHook = newProps.onVnodeBeforeUpdate) {
    invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
  }
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, "beforeUpdate");
  }
  parentComponent && toggleRecurse(parentComponent, true);
  if (isHmrUpdating) {
    patchFlag = 0;
    optimized = false;
    dynamicChildren = null;
  }
  const areChildrenSVG = isSVG && n2.type !== "foreignObject";
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds
    );
    {
      traverseStaticChildren(n1, n2);
    }
  } else if (!optimized) {
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds,
      false
    );
  }
  if (patchFlag > 0) {
    if (patchFlag & 16) {
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      );
    } else {
      if (patchFlag & 2) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, "class", null, newProps.class, isSVG);
        }
      }
      if (patchFlag & 4) {
        hostPatchProp(el, "style", oldProps.style, newProps.style, isSVG);
      }
      if (patchFlag & 8) {
        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) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children);
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    );
  }
  if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
      dirs && invokeDirectiveHook(n2, n1, parentComponent, "updated");
    }, parentSuspense);
  }
};

代码有很多,我来给大家整理一下:

/**
 * @param n1 旧的 VNode
 * @param n2 新的 VNode
 */
const patchElement = (n1, n2) => {
    // 获取旧的 VNode 的真实 DOM
    const el = n2.el = n1.el;
    
    // 获取新 VNode 的 patchFlag
    let { patchFlag } = n2;
    
    // 这里的 patchFlag 是通过位运算来进行计算的,这里的意思是将新旧 VNode 的 patchFlag 进行合并
    patchFlag |= n1.patchFlag & 16;
    
    // 获取新旧的 VNode 的 props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};

    // 先更新子节点
    patchChildren(
        n1,
        n2,
        el,
    );

    // 更新 props
    patchProps(
        el,
        n2,
        oldProps,
        newProps
    );
};

因为我们上面的实例代码就只会执行这么多内容,所以简化之后的代码应该是都可以看明白的,非常简单;

patchChildren

不再提供源码,因为源码太长了,对于阅读体验不是很好,如果想看源码的可以自行阅读,后面只会提供简化之后的代码;

patchChildren方法简化之后如下:

/**
 * @param n1 旧的 VNode
 * @param n2 新的 VNode
 * @param container 真实 DOM
 */
const patchChildren = (n1, n2, container) => {
  // 获取新旧 VNode 的 children,这里是 Hellow! 和 world! 文本内容
  const c1 = n1 && n1.children;
  const c2 = n2.children;
  const { shapeFlag } = n2;
  
  // shapeFlag 指代的是 VNode 的类型,这里是文本类型,目前还没完全摸清楚
  if (shapeFlag & 8) {
    // 两个文本节点不相等,那么就直接更新文本内容
    if (c2 !== c1) {
      hostSetElementText(container, c2);
    }
  }
};

// 更新文本内容很简单,就是直接使用 textContent 属性进行更新
const hostSetElementText = (el, text) => {
  el.textContent = text;
};

这样就非常简单的完成了文本节点的更新,接下来看看属性的更新;

patchProps

patchProps方法简化之后如下:

/**
 * @param el 真实 DOM
 * @param vnode 新的 VNode
 * @param oldProps 旧的 props
 * @param newProps 新的 props
 */
const patchProps = (el, vnode, oldProps, newProps) => {
  // 新旧 props 不相等,那么就进行更新
  if (oldProps !== newProps) {
      
    // 旧的 props 不为空,那么就遍历旧的 props
    if (oldProps !== EMPTY_OBJ) {
        
      for (const key in oldProps) {
          
        // 判断是否是保留的属性,如果不是保留的属性,并且新的 props 中不存在这个属性,那么就移除这个属性 
        if (!isReservedProp(key) && !(key in newProps)) {
          hostPatchProp(
            el,
            key,
            oldProps[key],
            null,
          );
        }
      }
    }
    
    // 遍历新的 props
    for (const key in newProps) {
      // 如果是保留属性就跳过
      if (isReservedProp(key)) continue;
      
      // 获取新旧 props 中的值
      const next = newProps[key];
      const prev = oldProps[key];
      
      // 如果新旧 props 中的值不相等,并且不是 value 属性,那么就更新这个属性
      if (next !== prev && key !== "value") {
        hostPatchProp(
          el,
          key,
          prev,
          next
        );
      }
    }
  }
};

const hostPatchProp = (el, key, prevValue, nextValue) => {
    // 如果是 class 属性,那么就更新 class
    if (key === "class") {
        patchClass(el, nextValue);
    } else if (key === "style") {
        patchStyle(el, prevValue, nextValue);
    } else if (isOn(key)) {
        if (!isModelListener(key)) {
            patchEvent(el, key, prevValue, nextValue);
        }
    } else if (shouldSetAsProp(el, key, nextValue)) {
        patchDOMProp(
            el,
            key,
            nextValue
        );
    }
};

function patchDOMProp(el, key, value) {
    // 这里处理了很多特殊的属性,比如 innerHTML、textContent、value 等等
    // 最开头这里有 innerHTML、textContent 的处理,这里被我删除
    
    // 表单元素和自定义元素的 value 属性的一些特殊处理
    const tag = el.tagName;
    if (key === "value" && tag !== "PROGRESS" && // custom elements may use _value internally
        !tag.includes("-")) {
        el._value = value;
        const oldValue = tag === "OPTION" ? el.getAttribute("value") : el.value;
        const newValue = value == null ? "" : value;
        if (oldValue !== newValue) {
            el.value = newValue;
        }
        if (value == null) {
            el.removeAttribute(key);
        }
        return;
    }
    
    // 其他常规属性的属性值处理
    let needRemove = false;
    if (value === "" || value == null) {
        const type = typeof el[key];
        if (type === "boolean") {
            value = includeBooleanAttr(value);
        } else if (value == null && type === "string") {
            value = "";
            needRemove = true;
        } else if (type === "number") {
            value = 0;
            needRemove = true;
        }
    }
    
    try {
        // 直接通过真实 dom 元素的属性来设置属性值
        el[key] = value;
    } catch (e) {
        if (!needRemove) {
            warn(
                `Failed setting prop "${key}" on <${tag.toLowerCase()}>: value ${value} is invalid.`,
                e
            );
        }
    }
    
    // 如果需要移除属性,那么就移除属性
    needRemove && el.removeAttribute(key);
}

对于属性的更新,其实就是通过真实 DOM 元素的属性来进行更新,这里的代码比较多,还是需要有耐心的看一下;

首先是对旧的属性进行处理,如果旧的属性在新的属性中不存在就移除这个属性,移出很简单,就是调用hostPatchProp方法,对nextValue传入null即可;

for (const key in oldProps) {
    // 这里使用 in 操作符来判断是否存在这个属性
    if (!(key in newProps)) {
      hostPatchProp(
        el,
        key,
        oldProps[key],
        null,
      );
    }
  }

然后是对新的属性进行处理,就是拿出新旧的属性进行对比,然后如果不相等就更新这个属性,更新的过程也是调用hostPatchProp方法,这里的prevValue传入的是旧的属性值,nextValue传入的是新的属性值;

// 遍历新的 props
for (const key in newProps) {
  // 如果是保留属性就跳过
  if (isReservedProp(key)) continue;
  
  // 获取新旧 props 中的值
  const next = newProps[key];
  const prev = oldProps[key];
  
  // 如果新旧 props 中的值不相等,并且不是 value 属性,那么就更新这个属性
  if (next !== prev && key !== "value") {
    hostPatchProp(
      el,
      key,
      prev,
      next
    );
  }
}

hostPatchProp内部的实现就是区分这个属性应该怎么处理,因为dom属性有很多设置了之后会有特殊效果,同时属性名在js dom中呈现的形式和在html中呈现的形式也不一样,所以这里需要做一些特殊处理;

const hostPatchProp = (el, key, prevValue, nextValue) => {
    if (key === "class") {
        // 如果是 class 属性,那么就更新 class
        patchClass(el, nextValue);
    } else if (key === "style") {
        
        // 如果是 style 属性,那么就更新 style
        patchStyle(el, prevValue, nextValue);
    } else if (shouldSetAsProp(el, key, nextValue)) {
        
        // 如果是其他常规属性,那么就更新其他常规属性
        patchDOMProp(
            el,
            key,
            nextValue
        );
    }
};

这里我只列出了patchDOMProp方法的实现,因为patchClass就是设置class属性,不过操作的是className属性;

patchStyle方法内部实现较多,不易吸收,所以我就不列出来了,有兴趣的可以自行阅读;

patchDOMProp方法的实现也并没有详细介绍每一行的作用,因为都是一些边界情况的处理,需要很多的知识储备,所以了解一下就好;

简单实现

有了上面的一些内容做铺垫,那么现在我们也来简单的实现一下这个更新的过程:

总结

这次只是初窥组件更新的皮毛,只是替换了一点文本内容,而真实的一个组件内部是有很多节点的,更新过程更加复杂;

常听网上说的diff算法、最长递增子序列算法、双端比较算法等等,为啥在我这里没见到?

因为并不是所有的节点都需要用算法来搞定,像这种简单的更新,直接替换就可以了,所以这里并没有用到算法;

当我们了解了这一段过程之后,再去深入了解diff算法,那么就会更加容易理解了,而这一块内容将在下一章中进行讲解;

历史章节