Vue3源码解读之首次渲染DOM树

262 阅读7分钟

版本:v3.3.4

在首次渲染过程中,完成了根组件实例的挂载后,Vue3 会将template的内容编译后存放在根组件实例的 render 属性上(具体实现可参阅vue3 源码解读之初始化流程中的 finishComponentSetup)。然后在开始渲染根组件时执行当前根组件实例的render函数获取子元素的VNode,将子元素的VNode传入patch函数中,递归渲染子元素(具体实现可参阅vue3 源码解读之初始化流程中的 setupRenderEffect),将VNode转换成真实DOM,渲染到界面上。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app">
      <h1>{{title}}</h1>
    </div>
    <script src="../../dist/vue.global.js"></script>
    <script>
      // 1.创建实例
      // vue3: createApp()
      const { createApp } = Vue;
      // 传入根组件配置
      const app = createApp({
        data() {
          return {
            title: "hello,vue3!",
          };
        },
      }).mount("#app");
    </script>
  </body>
</html>

在上面的HTML代码中,根组件实例的template如下:

image.png

执行根组件实例的render函数后获取的子元素VNode则如下图:

image.png

将该VNode传入patch函数中,开始渲染子元素。

// 进入 Diff 过程,将子树渲染到container中
patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);

patch

patch 源码

// core/packages/runtime-core/src/renderer.ts

const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
  // ...

  const { type, ref, shapeFlag } = n2;
  switch (type) {
    // ...
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      }
    // ...
  }

  // ...
};

在上面的subTree中,typeh1,即元素类型为 ELEMENT,因此会进入processElement函数,执行 patch 过程。

流程图

总的流程图如下👇:

下面我们来分析下每个步骤做的事情,逐一击破💥:

processElement

processElement 源码

// core/packages/runtime-core/src/renderer.ts

const processElement = (
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  isSVG = isSVG || (n2.type as string) === 'svg'
  if (n1 == null) {
    // 首次渲染执行 mountElement 挂载 ELEMENT
    mountElement(
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    // 更新过程
  }
}

由于是首次渲染,因此执行 mountElement 函数渲染 ELEMENT 类型的元素。

mountElement

processElement 源码

// core/packages/runtime-core/src/renderer.ts

const mountElement = (
  vnode: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  let el: RendererElement
  let vnodeHook: VNodeHook | undefined | null
  const { type, props, shapeFlag, transition, patchFlag, dirs } = vnode
  if (
    !__DEV__ &&
    vnode.el &&
    hostCloneNode !== undefined &&
    patchFlag === PatchFlags.HOISTED
  ) {
    // 判断当前 VNode 是否是静态节点,并且是否已经被挂载到 DOM 上。如果是,则说明当前 VNode 可以被复用,只需要对已有的 DOM 元素进行克隆即可。
    // 在复用节点时,需要注意只有静态节点才能被复用,因此需要判断当前 VNode 是否是静态节点。此外,只有在生产环境下才能进行节点克隆,因为克隆的节点无法进行热更新。
    el = vnode.el = hostCloneNode(vnode.el)
  } else {
    // 创建节点
    el = vnode.el = hostCreateElement(
      vnode.type as string,
      isSVG,
      props && props.is,
      props
    )

    // 元素节点可以包含文本子节点和元素子节点。在挂载元素节点时,需要先挂载子节点,因为某些属性可能依赖于子节点的内容,例如 <select> 元素的 value 属性
    // 判断当前节点是否包含文本子节点。如果是,则直接设置节点的文本内容。
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 设置节点的文本内容
      hostSetElementText(el, vnode.children as string)
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 当前节点下还有子节点,则向下递归挂载子节点
      mountChildren(
        vnode.children as VNodeArrayChildren,
        el,
        null,
        parentComponent,
        parentSuspense,
        isSVG && type !== 'foreignObject',
        slotScopeIds,
        optimized
      )
    }

    if (dirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'created')
    }
    // props
    // 如果元素有属性,则初始化这些属性
    if (props) {
      for (const key in props) {
        if (key !== 'value' && !isReservedProp(key)) {
          hostPatchProp(
            el,
            key,
            null,
            props[key],
            isSVG,
            vnode.children as VNode[],
            parentComponent,
            parentSuspense,
            unmountChildren
          )
        }
      }
      /**
       * 首先判断当前节点的 props 对象中是否包含 value 属性。如果包含,则需要对 value 属性进行特殊处理。
        在处理 value 属性时,需要注意以下两点:
            1、value 属性的设置顺序可能会影响表单元素的最终值。例如,在设置 min 和 max 属性后再设置 value 属性,可能会导致 value 属性的值被覆盖。因此,在设置 value 属性时,需要保证它是在其他相关属性之后设置的。
            2、value 属性的更新可能需要被强制执行。例如,在某些情况下,表单元素的值可能会被外部代码修改,此时需要强制更新 value 属性的值。因此,在设置 value 属性时,需要使用 hostPatchProp 函数,并将第三个参数设置为 null,以确保 value 属性的更新能够被强制执行。
       */
      if ('value' in props) {
        hostPatchProp(el, 'value', null, props.value)
      }
      if ((vnodeHook = props.onVnodeBeforeMount)) {
        invokeVNodeHook(vnodeHook, parentComponent, vnode)
      }
    }
    // scopeId
    setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent)
  }
  // ...

  // 将当前节点追加到父元素里
  hostInsert(el, container, anchor)

  // ...
}

mountElement 中:

  1. 在复用节点时,需要注意只有静态节点才能被复用,因此需要判断当前 VNode 是否是静态节点。此外,只有在生产环境下才能进行节点克隆,因为克隆的节点无法进行热更新。
  2. 首先执行hostCreateElement创建该VNode的原生element元素。
  3. 然后需要注意:元素节点可以包含文本子节点和元素子节点。在挂载元素节点时,需要先挂载子节点,因为某些属性可能依赖于子节点的内容,例如 <select> 元素的 value 属性
  4. 接着创建当前VNode的子节点,如果当前VNode的子节点是文本节点,则调用hostSetElementText设置当前节点的文本内容;如果当前节点下还有多个子节点,则调用mountChildren,进入patch流程,向下递归挂载子节点。
  5. 如果当前VNode上有props,则调用hostPatchProp初始化当前元素的props属性。
  6. 当前元素的属性都已经初始化完并且其子节点都已经挂载完,则将当前元素追加到父元素container

hostCreateElement

createElement 源码

// core/packages/runtime-dom/src/nodeOps.ts

// hostCreateElement 其实执行的是 createElement
createElement: (tag, isSVG, is, props): Element => {
    const el = isSVG
      ? doc.createElementNS(svgNS, tag)
      : doc.createElement(tag, is ? { is } : undefined)

    if (tag === 'select' && props && props.multiple != null) {
      ;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
    }

    return el
  },

执行 hostCreateElement 创建 HTML 元素,其实执行的是 nodeOpscreateElement,在 createElement 中,调用了 document 的方法来创建 HTML 元素。

hostSetElementText

setElementText

// core/packages/runtime-dom/src/nodeOps.ts
// hostSetElementText 其实执行的是 setElementText
setElementText: (el, text) => {
  el.textContent = text
},

逻辑比较简单,执行 hostSetElementText 设置节点的文本内容,其实执行的是 nodeOpssetElementText,通过节点的 textContent 属性来设置节点的文本内容。

mountChildren

mountChildren源码

// core/packages/runtime-core/src/renderer.ts

const mountChildren: MountChildrenFn = (
  children,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized,
  start = 0
) => {
  for (let i = start; i < children.length; i++) {
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i] as VNode)
      : normalizeVNode(children[i]))
    patch(
      null,
      child,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
}

mountChildren 中,遍历子节点,进入 patch 流程,向下递归挂载子节点

hostPatchProp

hostPatchProp源码

// core/packages/runtime-dom/src/patchProp.ts

// hostPatchProp 实际上执行的 patchProp
export const patchProp: DOMRendererOptions['patchProp'] = (
  el,
  key,
  prevValue,
  nextValue,
  isSVG = false,
  prevChildren,
  parentComponent,
  parentSuspense,
  unmountChildren
) => {
  if (key === 'class') {
    patchClass(el, nextValue, isSVG)
  } else if (key === 'style') {
    patchStyle(el, prevValue, nextValue)
  } else if (isOn(key)) {
    // ignore v-model listeners
    if (!isModelListener(key)) {
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  } else if (
    key[0] === '.'
      ? ((key = key.slice(1)), true)
      : key[0] === '^'
      ? ((key = key.slice(1)), false)
      : shouldSetAsProp(el, key, nextValue, isSVG)
  ) {
    patchDOMProp(
      el,
      key,
      nextValue,
      prevChildren,
      parentComponent,
      parentSuspense,
      unmountChildren
    )
  } else {
    // 判断当前属性的键是否为 true-value 或 false-value。如果是,则说明当前属性是用于设置复选框元素的选中值的。
    if (key === 'true-value') {
      ;(el as any)._trueValue = nextValue
    } else if (key === 'false-value') {
      ;(el as any)._falseValue = nextValue
    }
    patchAttr(el, key, nextValue, isSVG, parentComponent)
  }
}

在初始化元素的 props 时,根据属性名,调用 DOM 元素的原生方法,初始化其属性。

但是在处理复选框元素的选中值时,需要注意以下几点:

  • 复选框元素的选中值可能是非字符串类型,例如布尔值、数字等。为了确保选中值能够正确地被存储,需要将选中值存储在 DOM 属性中,而不是存储在 VNodeprops 对象中。在上面代码中,使用了 _trueValue_falseValue 属性来存储复选框元素的选中值。
  • 复选框元素的选中值可能会被序列化为字符串。为了避免这种情况,需要将选中值存储在 DOM 属性中,并在更新时从 DOM 属性中读取选中值。

hostInsert

hostInsert源码

// core/packages/runtime-dom/src/nodeOps.ts
// hostInsert 实际上执行的是 insert
insert: (child, parent, anchor) => {
  parent.insertBefore(child, anchor || null)
},

执行 hostInsert,实际执行的是 nodeOpsinsert,通过节点的 insertBefore 方法,将子节点的内容插入到父节点中。

总结

在首次渲染过程中,完成根组件实例的挂载后,获取 template 的虚拟 DOM,将其传入 patch 函数中,递归渲染子元素。在子元素的渲染过程中,会首先创建节点,然后创建当前节点的子元素。如果当前节点上有 props,则初始化当前节点的 props 属性。最后将当前元素插入到到父元素 container 中。