Vue3源码——组件挂载

前言

前面我们一起探讨了 Vue3 的 响应式原理编译过程 ,render函数我们已经拿到了,那具体到底要怎么用呢?这一节,我们就开启一个新篇章------组件的挂载

组件挂载/更新函数------setupRenderEffect

组件挂载和更新的核心函数都是setupRenderEffect ,这里我们就从setupRenderEffect函数作为切入点:

js 复制代码
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
      const componentUpdateFn = () => {
        if (!instance.isMounted) {
          // 组件尚未挂载,执行挂载操作
          ...
        } else {
          // 组件已经挂载,执行更新操作
          ...
      };
      // 初始化响应式副作用函数
      const effect = instance.effect = new ReactiveEffect(
        componentUpdateFn,
        () => queueJob(update),
        instance.scope
      );
      const update = instance.update = () => effect.run();
      update.id = instance.uid;
      ...
      // 执行副作用函数
      update();
    };

我们可以看到在 setupRenderEffect 函数中:

  • 首先,定义了一个 组件挂载/更新函数 componentUpdateFn ,该函数会根据组件实例的是否已经挂载来进行不同的操作。
  • 然后,将 挂载/更新函数 componentUpdateFn 包装为一个 effect副作用函数
  • 最后,执行副作用函数 ,完成挂载或更新操作。

可以看出,组件更新的核心就在于 componentUpdateFn 函数,接下来我们深入来看一下这个函数内部都执行了哪些操作。

componentUpdateFn

我们首先来看实例尚未挂载 的情况下,componentUpdateFn函数是如何处理挂载的:

js 复制代码
const componentUpdateFn = () => {
  // 组件尚未挂载,执行挂载操作
  if (!instance.isMounted) {
    let vnodeHook;
    const { el, props } = initialVNode;
    const { bm, m, parent } = instance;
    ...
    
    // 将实例上的allowRecurse属性(允许递归)设置为false
    toggleRecurse(instance, false);
    
    // 如果存在onBeforeMount生命周期函数
    if (bm) {
      // 执行onBeforeMount中的函数
      invokeArrayFns(bm);
    }
    ...
    // 将实例上的allowRecurse属性(允许递归)设置为true
    toggleRecurse(instance, true);
    if (el && hydrateNode) {
      ...
    } else {
     // 生成子树的vnode
      const subTree = (instance.subTree = renderComponentRoot(instance));
      // 挂载子树vnode到容器中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);
      initialVNode.el = subTree.el;
    }
    ...
  
    // 将实例上的isMounted属性设置为true
    instance.isMounted = true;
    initialVNode = container = anchor = null;
  } else {
    // 组件已经挂载过,执行更新操作
    ...
};

componentUpdateFn在处理组件挂载时主要做的事情就是:

  • 首先,判断组件是否存在beforeMount生命周期函数,如果存在,则执行内部定义的函数。
  • 然后,根据实例instance生成子树vnode
  • 之后,通过patch函数 ,将子树vnode挂载到容器 。(因为目前是挂载阶段,所以patch函数 第一个参数默认设定为了null
  • 最后,将对应的属性值isMounted进行相关配置,将变量指针置空。

接下来,我们进入renderComponentRoot函数 ,看一看生成子树vnode的整个过程是怎样的。

生成vnode的函数------renderComponentRoot

ts 复制代码
function renderComponentRoot( instance: ComponentInternalInstance ): VNode {
  ...
  let result
  ...
      const proxyToUse = withProxy || proxy
      // 取出render函数,并执行
      result = normalizeVNode(
        render!.call(
          proxyToUse,
          proxyToUse!,
          renderCache,
          props,
          setupState,
          data,
          ctx
        )
      )
  ...
  return result
}

上面我们抽离出该函数的核心 ,可以看到,renderComponentRoot函数 的关键逻辑就是执行了render函数

前面章节中,我们已经介绍了render函数生成的过程,我们还用之前的例子:

模板template:

html 复制代码
<div>
    <span> {{x}} </span>
    <div>123</div>
</div>

经过编译后,生成的render函数是这个样子的:

js 复制代码
function render(_ctx, _cache) {
  with (_ctx) {
    const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", null, [
      _createElementVNode("span", null, _toDisplayString(x), 1 /* TEXT */),
      _hoisted_1
    ]))
  }
}

最终,我们通过执行上面的render函数 ,得到节点的虚拟DOM 也就是vnode

然后,我们将这样的一个数据结构(vnode )传入normalizeVNode函数 中做进一步的处理,我们看一下normalizeVNode函数又做了什么操作:

js 复制代码
function normalizeVNode(child) {
    // 如果节点vnode为空,则创建为注释节点
    if (child == null || typeof child === "boolean") {
      return createVNode(Comment);
    } else if (isArray(child)) {
      // 如果节点为数组,则在外层包裹一层根节点fragment
      return createVNode(
        Fragment,
        null,
        child.slice()
      );
    } else if (typeof child === "object") {
      // 如果是对象形式
      return cloneIfMounted(child);
    } else {
      // 其他情况,比如创建文本类型的节点
      return createVNode(Text, null, String(child));
    }
}

function cloneIfMounted(child) {
    // 如果节点已经挂载,则直接返回对应的vnode,否则克隆一份返回
    return child.el === null && child.patchFlag !== -1 /* HOISTED */ || child.memo ? child : cloneVNode(child);
  }

在这个函数中会对传入的参数进行分情况讨论:

  • 如果参数vnode ,则创建为注释节点
  • 如果参数vnode数组 ,则在外层包裹一层根节点Fragment,再执行创建vnode的函数。
  • 如果参数vnode对象 形式,则直接返回或克隆该节点vnode
  • 其他情况,主要是像文本类型节点的处理。

我们传入的 child参数 是一个对象 形式,所以会最终执行的是cloneIfMounted函数 ,而这个函数中,会去判断 vnode节点 是否已经被挂载过,如果已经执行过挂载操作,那么其 vnodeel属性 上就会被赋值,该函数就直接将原vnode 节点返回,否则,执行拷贝操作再返回。

这里,因为的组件在该阶段还未挂载,所以normalizeVNode函数 最终的返回结果也是直接将上面render函数生成的vnode 直接返回,而我们最终renderComponentRoot函数 的返回值同样也是执行render函数得到的vnode

至此,我们大概理清楚了生成子树vnode的函数renderComponentRoot 的逻辑,它的主要工作就是通过执行模板编译后生成的render函数,再进行相应的处理,得到最终的vnode

挂载/更新函数patch

接下来我们开启下一个环节,也是vue中极其重要的一个函数------patch函数

patch直译过来就是"补丁 "的意思,可以理解为在vue中,组件的挂载和更新都是通过打"补丁 "的方式来进行的。当然,打"补丁"前要先比对一下,看看两个节点到底是哪些信息不一样了,然后再进行定点的更新。

在进入patch函数之前先说明一下patch函数的几个关键参数:

  • n1: 旧vnode节点
  • n2: 新vnode节点
  • container: 挂载的容器
  • anchor: 挂载的参考元素
js 复制代码
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函数整体的处理逻辑就是:

  • 比对新旧节点,如果新旧节点相同,则无须处理。
  • 如果新旧节点的类型不同 ,则直接将旧节点卸载 (我们这一节主要研究挂载阶段,所以旧节点为null,可以先不关注这一点)。
  • 根据新节点的类型,再分情况进行处理。

这里,我们就用处理element类型来举例,看一下processElement函数,其他情况同理。

processElement函数

js 复制代码
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
      isSVG = isSVG || n2.type === "svg";
      if (n1 == null) {
       // 如果存在旧节点
        mountElement(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else {
        // 旧节点不存在
        patchElement(
          n1,
          n2,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      }
    };

processElement函数 ,会根据旧节点是否存在进行分情况讨论,这里我们主要看挂载阶段的函数------mountElement

js 复制代码
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
      let el;
      let vnodeHook;
      const { type, props, shapeFlag, transition, dirs } = vnode;
      // 创建真实DOM结构,并将其保存在在vnode的el属性上
      el = vnode.el = hostCreateElement(
        vnode.type,
        isSVG,
        props && props.is,
        props
      );
      // 处理文本节点
      if (shapeFlag & 8 /* TEXT_CHILDREN */) {
        hostSetElementText(el, vnode.children);
      } else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        // 如果节点类型是数组,则递归的对子节点进行处理
        mountChildren(
          vnode.children,
          el,
          null,
          parentComponent,
          parentSuspense,
          isSVG && type !== "foreignObject",
          slotScopeIds,
          optimized
        );
      }
      // 处理vnode上的指令相关内容,并执行指令的生命周期钩子函数
      if (dirs) {
        invokeDirectiveHook(vnode, null, parentComponent, "created");
      }
      setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);
      // 处理props相关内容
      if (props) {
        for (const key in props) {
          if (key !== "value" && !isReservedProp(key)) {
            hostPatchProp(
              el,
              key,
              null,
              props[key],
              isSVG,
              vnode.children,
              parentComponent,
              parentSuspense,
              unmountChildren
            );
          }
        }
        if ("value" in props) {
          hostPatchProp(el, "value", null, props.value);
        }
        if (vnodeHook = props.onVnodeBeforeMount) {
          invokeVNodeHook(vnodeHook, parentComponent, vnode);
        }
      }
      ...
      // 处理vnode上的指令相关内容,并执行指令的生命周期钩子函数
      if (dirs) {
        invokeDirectiveHook(vnode, null, parentComponent, "beforeMount");
      }
      ...
      // 将dom挂载到container
      hostInsert(el, container, anchor);
      ...
    };

mountElement函数 中,我们终于创建出了期待已久的真实DOM结构。该函数的逻辑为:

  • 根据vnode创建出真实DOM 结构,并保存在el属性上。
  • 根据子节点类型来进行不同的操作:
    • 文本类型,直接生成文本节点
    • 数组类型,则递归的处理子节点
  • 对vnode的指令 以及props内容进行处理。
  • 最后将生成的DOM挂载到容器container上,也就最终呈现在页面上了。

这里可能有的朋友就是想看一看document.createElement这种API到底在哪里,那就提一下hostCreateElement函数

hostCreateElement函数 在源码中是通过解构赋值并重命名得来的,它原来的名字叫createElement,改回本名瞬间就直观了很多~

js 复制代码
createElement: (tag, isSVG, is, props) => {
  const el = isSVG ? doc.createElementNS(svgNS, tag) : doc.createElement(tag, is ? { is } : void 0);
  if (tag === "select" && props && props.multiple != null) {
    ;
    el.setAttribute("multiple", props.multiple);
  }
  return el;
}

// hostInsert指向的函数就是insert
insert: (child, parent, anchor) => {
  parent.insertBefore(child, anchor || null);
},

两个工具函数的逻辑也很好理解,就不再多说了吧。总之,终于是看到了document.createElement就是舒服了^_^

最后

这一节,我们深入研究了Vue3中,组件的挂载逻辑,整个的流程虽然过程繁琐,但要做的事比较清晰,总结下来就是:

  • 判断当前vnode是否已经进行过挂载操作,来决定是进行挂载流程还是更新流程。
  • 进入挂载流程。
  • 执行模板编译阶段生成的render函数 ,得到虚拟dom(vnode)
  • 通过patch函数 ,对vnode 进行分类处理,同时在这个阶段创建出真实的DOM结构
  • 将创建的DOM挂载到容器container中,完成最终呈现。
相关推荐
掘金安东尼几秒前
官方:什么是 Vite+?
前端·javascript·vue.js
柒崽2 分钟前
ios移动端浏览器,vh高度和页面实际高度不匹配的解决方案
前端
渣哥18 分钟前
你以为 Bean 只是 new 出来?Spring BeanFactory 背后的秘密让人惊讶
javascript·后端·面试
烛阴27 分钟前
为什么游戏开发者都爱 Lua?零基础快速上手指南
前端·lua
大猫会长36 分钟前
tailwindcss出现could not determine executable to run
前端·tailwindcss
Moonbit41 分钟前
MoonBit Pearls Vol.10:prettyprinter:使用函数组合解决结构化数据打印问题
前端·后端·程序员
533_42 分钟前
[css] border 渐变
前端·css
云中雾丽1 小时前
flutter的dart语言和JavaScript的消息循环机制的异同
前端
地方地方1 小时前
Vue依赖注入:provide/inject 问题解析与最佳实践
前端·javascript·面试
云中雾丽1 小时前
dart的继承和消息循环机制
前端