Vue3源码09: 组件的渲染和更新流程

2022-09-27 14:27:14 浏览数 (1)

  • Vue3源码01 : 代码管理策略-monorepo
  • Vue3源码02: 项目构建流程和源码调试方法
  • Vue3源码03: Vue3响应式核心原理
  • Vue3源码04: Vue3响应式系统源码实现1/2
  • Vue3源码05 : Vue3响应式系统源码实现(2/2)
  • Vue3源码06: reactive、ref相关api源码实现
  • Vue3源码07: 故事要从createApp讲起
  • Vue3源码08: 虚拟Node到真实Node的路其实很长

前面我们分析patch函数的时候,我们知道了内部通过不同类型的判断来调用不同的函数来比较新旧虚拟Node之间的差异并抹平这种差异,当时也介绍了patch函数调用的部分函数实现细节。本文会带着大家分析processElementprocessComponent这两个函数的大部分源码实现,并在文末以一张流程图来概括patch函数的核心工作流程,至于diff函数的具体实现,作为一个难点,将会在下一篇文章中深入讲解。

processElement

我们先看看processElement的代码实现:

代码语言:javascript复制
// 代码片段1
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(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      patchElement(
        n1,
        n2,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    }
  }

我们目前暂时只关注processElement的前4个参数。

  1. n1: 旧虚拟Node;
  2. n2: 新虚拟Node;
  3. container: 虚拟Node转化为真实Node后将要挂载到的DOM元素;
  4. anchor:虚拟Node转化为真实Node后将要挂载到的DOM元素上的具体哪个位置。

对参数有了了解后,我们来看条件判断:

代码语言:javascript复制
// 代码片段2
if (n1 == null) {
      mountElement(
        // 此处省略若干代码...
      )
    } else {
      patchElement(
        // 此处省略若干代码...
      )
    }

代码片段2表达的含义很简单,如果旧虚拟Node为null,说明旧节点不存在,也就没必要进行所谓的比较,直接调用函数mountElement将新虚拟Node也就是参数n2所对应的值,挂载到容器节点中即可。如果旧虚拟Node不为null,说明旧节点是存在的,就需要我们比较二者的差异,并以具备优良性能的代码实现来抹平这种差异,而patchElement函数正具备这样的能力。下面我们开始进入到mountElementpatchElement两个函数内部进行分析。

mountElement

我们先看看mountElement的源码实现:

代码语言:javascript复制
// 代码片段3
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
    ) {
      el = vnode.el = hostCloneNode(vnode.el)
    } else {
      el = vnode.el = hostCreateElement(
        vnode.type as string,
        isSVG,
        props && props.is,
        props
      )
      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
        )
      }
      // 此处省略了若干代码...
    }
    // 此处省略了若干代码...
    hostInsert(el, container, anchor)
    // 此处省略了若干代码...
  }

mountElement内部的实现逻辑是比较丰富了,但为了突出主线,我将回掉指令声明周期函数以及处理动画和异步等逻辑相关代码,还有为创建好的DOM元素添加属性的逻辑都省略掉了,这些省略的内容会在特定主题的文章中进行阐述。从代码片段3中内容,我们可以认为mountElement主要完成了下面几项工作:

  1. 根据传入的虚拟Node创建相应的真实Node元素el
  2. 如果el的子元素为文本,则将文本设置到该元素中,如果子元素为数组,则调用mountChildren函数对每一个子元素进行挂载,而挂载到容器就是我们这里创建的el,当然如果子元素的子元素仍然是数组,则会不断递归直到没有子元素;
  3. el挂载到container元素中。

对于代码片段3的第一个if条件判断可能让人疑惑:

代码语言:javascript复制
// 代码片段4
if (
      !__DEV__ &&
      vnode.el &&
      hostCloneNode !== undefined &&
      patchFlag === PatchFlags.HOISTED
    ) {
      el = vnode.el = hostCloneNode(vnode.el)
    }

我们前面说过,所谓挂载就是将创建好的DOM元素添加到目标DOM节点中。那为什么这里还能在某些条件下进行复用呢,实际上Vue3在编译过程中做了静态变量提升,进行了一定程度的优化,才有了这里的判断条件,相关内容会在编译相关的文章中介绍,此处先简单了解即可。

patchElement

相较于mountElementpatchElement的逻辑会复杂不少,为什么呢,因为mountElement是不存在寻找差异的,只需要根据虚拟Node创建元素并挂载到目标节点上即可。而patchElement需要找出新虚拟Node和旧虚拟Node之前的不同,而且还要在性能比较优良的情况下对当前的DOM元素进行增加、删出、修改等操作。

鉴于patchElement内容较多,即使是关键的内容也不少。为了方便描述,我分几个方面来介绍,介绍相关内容的时候把局部代码以单独的代码块呈现。主要涉及到下面几个方面:

  1. patchElement的核心逻辑
  2. patchBlockChildrenpatchChildren各自的职责
  3. patchFlag

patchElement的核心功能

前面我们提到过patchElement的核心功能是寻找差异并抹平差异。但是寻找和抹平什么差异呢?顾名思义patchElement操作的对象是DOM元素,而一个DOM元素其实含有两大块内容,第一块是DOM元素自身的各种属性状态;第二块是DOM元素的子元素。那patchElement其实的核心功能就是利用patchChildrenpatchProps分别寻找和抹平子元素的差异及当前DOM元素的属性的差异。由于Vue3内部作来优化,所以不一定总是调用patchChildrenpatchProps,也可能是patchBlockChildren或其他函数完成相关工作。

当然除了核心功能,还有分支功能,分支功能包括调用指令和虚拟Node对应的和更新相关的生命周期函数以及一些异步流程的处理,介绍完核心流程,后续会有专门的文章介绍相关内容。

patchBlockChildrenpatchChildren

我们这里只需要知道patchBlockChildren和优化相关,相关内容会在后续文章中合适的时机进行介绍,而patchChildren本文也不会讲,因为该函数可以说是整个patchElement函数的灵魂所在,逻辑比较复杂。众所周知的diff算法也会从patchChildren函数开始讲起,请大家期待下一篇文章关于diff算法的解析。

patchFlag

代码语言:javascript复制
// 代码片段5
 if (patchFlag > 0) {
      if (patchFlag & PatchFlags.FULL_PROPS) {
        patchProps(
          el,
          n2,
          oldProps,
          newProps,
          parentComponent,
          parentSuspense,
          isSVG
        )
      } else {
        if (patchFlag & PatchFlags.CLASS) {
          if (oldProps.class !== newProps.class) {
            hostPatchProp(el, 'class', null, newProps.class, isSVG)
          }
        }
        if (patchFlag & PatchFlags.STYLE) {
          hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
        }
        if (patchFlag & PatchFlags.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 as VNode[],
                parentComponent,
                parentSuspense,
                unmountChildren
              )
            }
          }
        }
      }
      if (patchFlag & PatchFlags.TEXT) {
        if (n1.children !== n2.children) {
          hostSetElementText(el, n2.children as string)
        }
      }
    } else if (!optimized && dynamicChildren == null) {
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      )
    }

代码片段5的内容逻辑还是很清晰的,但是有一点需要提出来,就是形如patchFlag & PatchFlags.TEXT的代码,这雨上一篇文章介绍的ShapeFlags原理是一致的。我们来看看PatchFlags的代码实现:

代码语言:javascript复制
// 代码片段6
export const enum PatchFlags {
  TEXT = 1,
  CLASS = 1 << 1,
  STYLE = 1 << 2,
  PROPS = 1 << 3,
  FULL_PROPS = 1 << 4,
  HYDRATE_EVENTS = 1 << 5,
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  DEV_ROOT_FRAGMENT = 1 << 11,
  HOISTED = -1,
  BAIL = -2
}

从代码片段6中不难发现,PatchFlags代表了要操作哪种类型的属性,同时从代码片段5结合上一篇文章中关于位运算的介绍,也不难发现变量patchFlag可以同时表达多种状态,比如既可以操作class属性又可以操作style属性等等。

processComponent

我们先看看processComponent函数的源码实现:

代码语言:javascript复制
// 代码片段7
const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    n2.slotScopeIds = slotScopeIds
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
      updateComponent(n1, n2, optimized)
    }
  }

这一块逻辑其实和processComponent有高的相似度,不同的是对于组件有keep-alive相关特性,本文暂不做介绍。接下来我们就进行mountComponentupdateComponent两个函数中分析其实现。

mountComponent

我们先进入mountComponent函数中去:

代码语言:javascript复制
// 代码片段8
const mountComponent: MountComponentFn = (
    initialVNode,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    // 此处省略若干代码...
    const instance: ComponentInternalInstance =
      compatMountInstance ||
      (initialVNode.component = createComponentInstance(
        initialVNode,
        parentComponent,
        parentSuspense
      ))
    // 此处省略若干代码...
    setupRenderEffect(
      instance,
      initialVNode,
      container,
      anchor,
      parentSuspense,
      isSVG,
      optimized
    )
    // 此处省略若干代码...
  }

在省略了若干代码后,留下了最关键的代码,体现了mountComponent函数的关键的两项工作:

  1. 通过函数createComponentInstance创建组件实例;
  2. 在函数setupRenderEffect中为组件实例创建渲染子组件的函数并传给ReactiveEffect实例,使该函数能够在响应式数据发生变化的时候重新执行。

接下来我们就进入createComponentInstancesetupRenderEffect两个函数中一探究竟。

createComponentInstance

代码语言:javascript复制
// 代码片段9
export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null
) {
  // 此处省略若干代码...
  const instance: ComponentInternalInstance = {
    // 此处省略若干代码...
  }
  // 此处省略若干代码...

  return instance
}

我们需要知道,所谓的组件实例实际上就是一个对象,对应代码片段9中的对象instance。当然既然是组件实例,意味着这里的参数vnode代表的虚拟Node的类型是组件,接着会将该组件实例作为参数传给setupRenderEffect,现在我们进入该函数进行分析。

setupRenderEffect

先查看相关代码实现:

代码语言:javascript复制
// 代码片段10
const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    const componentUpdateFn = () => {
      // 此处省略若干代码...
    }
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(instance.update),
      instance.scope 
    ))

    const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
    update.id = instance.uid
    // 此处省略若干代码...
    update()
  }

代码片段10中对逻辑进行了大量精简只留下了最关键的逻辑。不难看出,函数setupRenderEffect主要完成了3项工作:

  1. 定义函数componentUpdateFn;
  2. 创建ReactiveEffect实例,将定义的函数componentUpdateFn作为构造函数的参数传入;
  3. effect.run.bind(effect)作为组件实例instanceupdate属性的值;

完成这3步后会带来什么结果呢?结果就是当函数componentUpdateFn中用到的响应式数据发生变化后会被重新执行。我们知道函数mountComponent的功能是将组件所对应的DOM树挂载到目标节点上。那也就不难知道函数componentUpdateFn的核心作用就是将组件实例转化成真实的DOM树并把该DOM树挂载到容器节点上。至于具体怎么实现,请看下文。

componentUpdateFn

代码语言:javascript复制
// 代码片段11
   const componentUpdateFn = () => {
      if (!instance.isMounted) {
        // 此处省略若干代码...
        if (el && hydrateNode) {
          // 此处省略若干代码...
        } else {
          // 此处省略若干代码...
          const subTree = (instance.subTree = renderComponentRoot(instance))
          // 此处省略若干代码...
          patch(
            null,
            subTree,
            container,
            anchor,
            instance,
            parentSuspense,
            isSVG
          )
          // 此处省略若干代码...
          initialVNode.el = subTree.el
        }
        // 此处省略若干代码...
        instance.isMounted = true
        // 此处省略若干代码...
        initialVNode = container = anchor = null as any
      } else {
        // 此处省略若干代码...
        if (next) {
          next.el = vnode.el
          updateComponentPreRender(instance, next, optimized)
        } else {
          next = vnode
        }
        // 此处省略若干代码...
        const nextTree = renderComponentRoot(instance)
        // 此处省略若干代码...
        const prevTree = instance.subTree
        instance.subTree = nextTree
        // 此处省略若干代码...
        patch(
          prevTree,
          nextTree,
          // parent may have changed if it's in a teleport
          hostParentNode(prevTree.el!)!,
          // anchor may have changed if it's in a fragment
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )
        // 此处省略若干代码...
        next.el = nextTree.el
        // 此处省略若干代码...
      }
    }

函数componentUpdateFn包含了200多行代码,代码片段11进行了大量的精简。这个函数可以说是组件渲染和更新的灵魂。从顶层的逻辑判断if (!instance.isMounted) {}else{}就能直观的感受到,其既处理了挂载又处理了更新。

挂载相关逻辑

对于挂载操作,函数componentUpdateFn处理了服务端渲染的逻辑本文不作讨论。正常情况下,对于挂载的操作主要做了两件事:

  1. 调用renderComponentRoot函数,将组件实例instance转化成子虚拟Node树,并赋值给instance.subTree,进而调用patch函数将该子虚拟Node树挂载到目标容器节点上;
  2. 执行initialVNode.el = subTree.el,将子节点对应的el节点赋值给组件虚拟Node的el属性。

这里需要注意的是,组件类型的虚拟Node和subTree之间的关系,假设有下面的代码:

代码语言:javascript复制
// 代码片段12 文件index.vue
<template>
    <App></App>
</template>
代码语言:javascript复制
// 代码片段13 文件App.vue
<template>
    <div>Hello World</div>
</template>

我们可以直观的这样理解,组件类型的虚拟Node代表的是<App></App>,而组件类型的虚拟Node的subTree代表的是<div>Hello World</div>

renderComponentRoot

下面我们进入renderComponentRoot探索如何根据组件实例获得subTree:

代码语言:javascript复制
// 代码片段14
export function renderComponentRoot(
  instance: ComponentInternalInstance
): VNode {
    // 此处省略若干代码...
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      const proxyToUse = withProxy || proxy
      result = normalizeVNode(
        render!.call(
          proxyToUse,
          proxyToUse!,
          renderCache,
          props,
          setupState,
          data,
          ctx
        )
      )
      fallthroughAttrs = attrs
    }
  // 此处省略若干代码...
  return result
}

在省略大量代码后,我们能很轻松的发现,函数renderComponentRoot的核心工作是通过一个代理对象调用了组件的render函数。为什么要代理对象?答案其实在前面的文章中已经回答过了,其中一个重要原因是对ref值的访问不需要再使用.value的形式,另一方面可以保护子组件的内容不被父组件随意访问。至于render函数的作用我们也在前面的文章中解释过,就不在此处赘述了。

更新相关逻辑

有了上文对挂载逻辑的分析,更新逻辑就显得很简单了。可以概括为下面两步工作:

  1. 获取组件新的subTree和当前所具备的subTree;
  2. 调用patch函数来进行更新操作。

updateComponent

我们先来看看updateComponent函数的具体实现:

代码语言:javascript复制
// 代码片段15
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
    const instance = (n2.component = n1.component)!
    if (shouldUpdateComponent(n1, n2, optimized)) {
      if (
        __FEATURE_SUSPENSE__ &&
        instance.asyncDep &&
        !instance.asyncResolved
      ) {
        // 此处省略若干代码...
      } else {
        instance.next = n2
        invalidateJob(instance.update)
        instance.update()
      }
    } else {
      // no update needed. just copy over properties
      n2.component = n1.component
      n2.el = n1.el
      instance.vnode = n2
    }
  }

有了上文的基础,我们可以说函数updateComponent的核心就是执行instance.update()

总结

结合上一篇文章,到目前为止我们可以说已经理解了Vue3渲染机制的核心工作流程。请大家先看这张流程图:

结合这张流程图和上一篇和本文的内容,我们可以比较清晰的理解将虚拟Node转化为真实Node的实现过程。敬请朋友们期待下一篇文章关于diff算法的描述。

0 人点赞