Vue3源码08: 虚拟Node到真实Node的路其实很长

2022-09-27 14:26:36 浏览数 (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讲起

前面我们知道了,从虚拟Node到真实Node是借助一个叫做render的函数来完成。本文会带着大家进入render函数,先从从总体上把握Vue3的渲染核心流程以及部分源码实现细节。至于比较重要的一些细节,比如组件如何渲染如何更新,diff算法具体如何实现,将在后续的文章一一进行分析。

render函数

先直接看render函数的代码实现:

代码语言:javascript复制
// 代码片段1
const render: RootRenderFunction = (vnode, container, isSVG) => {
    if (vnode == null) {
      if (container._vnode) {
        unmount(container._vnode, null, null, true)
      }
    } else {
      patch(container._vnode || null, vnode, container, null, null, null, isSVG)
    }
    flushPostFlushCbs()
    container._vnode = vnode
  }

我们先来看看该函数的参数,第一个参数是虚拟Node对象,第二个参数是一个Element对象,第三个参数暂时先忽略。render函数的内部逻辑也很简单,做了下面几件事情:

  1. 如果传入的虚拟Node对象是空,则判断container对应的元素曾经是否渲染过其他虚拟Node,如果是则从container上卸载该虚拟Node对应的节点,如果不是则什么都不做,将container._vnode置空即可。container._vnode中的值来源于render函数的最后一行代码;
  2. 如果传入的虚拟Node不为空,则需要和container元素上挂载过的_vnode所代表的DOM元素进行比较并修改当前的真实DOM树,这个逻辑都由patch函数来实现,也是本文的重点内容;
  3. 执行flushPostFlushCbs将保存在数组pendingPostFlushCbs中的函数依次执行,至于什么时候给数组pendingPostFlushCbs中添加元素,具体又是如何执行的这些函数,本文暂时不讲,后续的文章中如有必要会用一小节来介绍。

patch才是灵魂

Vue3的渲染流程,虽然是通过调用render函数实现,但patch才是整个渲染流程的灵魂。我们来看看patch函数的具体实现:

代码语言:javascript复制
// 代码片段2
const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
    if (n1 === n2) {
      return
    }

    // patching & not same type, unmount old tree
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }

    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }

    const { type, ref, 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 (__DEV__) {
          patchStaticNode(n1, n2, container, isSVG)
        }
        break
      case Fragment:
        processFragment(
         // 此处省略若干代码...
        )
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(
            // 此处省略若干代码...
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(
            // 此处省略若干代码...
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process(
            // 此处省略若干代码...
          )
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          ;(type as typeof SuspenseImpl).process(
            // 此处省略若干代码...
          )
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }

    // set ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

patch函数内部根据传入的虚拟Node的类型不同,会分别调用不同的函数进行处理。这里面有两个点值得我们关注:

  1. 搞清楚patch函数的使命;
  2. 通过位运算的方式来进行类型判断;

patch函数的使命

可能大家会觉得奇怪,刚才不是已经讲过了patch函数的主要逻辑就是根据虚拟Node的不同类型来调用不同的函数来进行处理吗?还有什么使命?没错,patch函数的逻辑很清晰,但是我想在这里强调,patch存在的根本意义是寻找新虚拟Node和当前真实Node对应的旧虚拟Node的差异,并根据这种差异修改DOM树以抹平这种差异。理解了这个就能很轻松的理解,为什么有这样的语句:

代码语言:javascript复制
// 代码片段3
if (n1 === n2) {
      return
 }

因为新旧虚拟Node没有差异,当然也就没有继续进行的必要了。我们也能轻松的理解下面的代码:

代码语言:javascript复制
// 代码片段4
if (n1 && !isSameVNodeType(n1, n2)) {
  anchor = getNextHostNode(n1)
  unmount(n1, parentComponent, parentSuspense, true)
  n1 = null
}

如果旧虚拟Node存在,而且新虚拟Node和旧虚拟Node的类型不一致,则卸载旧虚拟Node,同时将该旧虚拟Node置为空。会发现这里有个anchor变量,如果该anchor始终为null则会导致我们新插入元素的时候始终是在尾部,与其所替换的元素的位置不一致,所以需要在卸载旧虚拟Node对应的真实Node之前,用anchor记录其下一个元素。

同时我们理解了patch函数的使命,可以尝试想象如果让我们来实现patch函数该怎么做,可能我们很自然的想到,完全可以直接把旧节点删除,插入新节点的内容即可,实现相同的功能可以将几千行代码简化到几行完成,看似低级的实现却也让我们认清了patch函数的本质。在本文的后半部分,会介绍patch函数中调用的很多其他函数,相信有了我们前面的认识可以更好的理解Vue3为什么要这么实现patch函数。

类型判断方式

我们发现代码片段2中有几处形如if (shapeFlag & ShapeFlags.ELEMENT)的代码,为什么要这么判断呢?要回答这个问题,我们先看看shapeFlag是什么,ShapeFlags.ELEMENT是从哪里来的。

shapeFlag是从patch函数的第2个参数也就是新虚拟Node上解构出来的,该值是个数值类型。我们再来看看ShapeFlags的代码:

代码语言:javascript复制
// 代码片段5
export const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,
  STATEFUL_COMPONENT = 1 << 2,
  TEXT_CHILDREN = 1 << 3,
  ARRAY_CHILDREN = 1 << 4,
  SLOTS_CHILDREN = 1 << 5,
  TELEPORT = 1 << 6,
  SUSPENSE = 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9,
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

从代码片段5中可以看见ShapeFlags是一个枚举类型。对位运算不了解的朋友可能已经充满了疑惑,为什么要这么表示?要回答这个问题,还得先了解位运算的左移、与、或运算。

假设我们有8个二进制位00000000,每一个二进制位表示小A是否具备一项能力,1表示具备,0表示不具备,具体能力映射如下。

篮球

足球

游泳

英语

喝酒

美食

跑步

开车

0

0

0

0

0

0

0

0

如果小A会跑步可以这样描述:

篮球

足球

游泳

英语

喝酒

美食

跑步

开车

0

0

0

0

0

0

1

0

如果小A不仅会跑步还会喝酒,可以这样描述:

篮球

足球

游泳

英语

喝酒

美食

跑步

开车

0

0

0

0

1

0

1

0

基于上面的认知,我们可以把不同状态这样来表示:

代码语言:javascript复制
// xiaoAState为0,表示小A,什么技能都不会
let xiaoAState = 0;  // 0 0 0 0 0 0 0 0
const DRIVE_CAR = 1; // 0 0 0 0 0 0 0 1
const RUN = 1 << 1;  // 0 0 0 0 0 0 1 0
const FOOD = 1 << 2; // 0 0 0 0 0 1 0 0
const DRINK = 1 << 3;// 0 0 0 0 1 0 0 0

// 让小A具备喝酒的能力,可以这样进行运算:
xiaoAState |= DRINK; // 0 0 0 0 1 0 0 0
// 让小A具备跑步的能力,可以这样运算:
xiaoAState |= RUN;   // 0 0 0 0 1 0 1 0

或运算可以下面的表格进行理解:

篮球

足球

游泳

英语

喝酒

美食

跑步

开车

运算符号

含义

0

0

0

0

1

0

0

0

DRINK

0

0

0

0

0

0

1

0

RUN

0

0

0

0

1

0

1

0

结果

当我们想判断小A是否具备某项能力的时候可以借助于&运算,例如:

代码语言:javascript复制
if(xiaoAState & DRINK){
  console.log('小A确实会喝酒')
}

if(xiaoAState & FOOD){
  console.log('小A会做饭')
}else{
  console.log('小A不会做饭')
}

为什么可以这样判断呢,我们先来看看,xiaoAState & FOOD的示意:

篮球

足球

游泳

英语

喝酒

美食

跑步

开车

运算符号

含义

0

0

0

0

1

0

1

0

xiaoAState

0

0

0

0

0

1

0

0

&

FOOD

0

0

0

0

0

0

0

0

结果

不难发现xiaoAState和自己不具备的能力进行了&运算之后,结果值是0,反之如果是和自己具备的能力进行&运算,结果值就是1,这也就是为什么能够通过&运算来判断xiaoAState是否具备某个能力的原理。到了这里也就不难发现代码片段5为什么要以1为初始值,然后不断左移1位,一切都是为了方便计算。同时这种方式可以让一个属性值表示多个状态,就像上文示范的xiaoAState不仅可以表示具备喝酒的能力还可以表示具备跑步的能力或者其他很多的能力。不得不说这种方式很巧妙,而且性能也比较高,在实际工作中类似场景完全可以借鉴。

下面我们开始探索patch函数内部调用的其他函:

processText

代码语言:javascript复制
// 代码片段6
const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
    if (n1 == null) {
      hostInsert(
        (n2.el = hostCreateText(n2.children as string)),
        container,
        anchor
      )
    } else {
      const el = (n2.el = n1.el!)
      if (n2.children !== n1.children) {
        hostSetText(el, n2.children as string)
      }
    }
  }

逻辑比较简单,如果旧虚拟Node为null,则直接将文本插入到容器即可,如果不为null,则说明需要进行更新。这里有三个点值得我们关注:

  1. hostInserthostSetText从哪里来的呢?还记得我们在runtime-dom传入的参数const rendererOptions = extend({ patchProp }, nodeOps)吗,没错,具体对DOM节点进行删除、修改、增加都是runtime-dom或者其他平台传入的方法。runtime-core只需要关心将要对节点进行什么类型的操作,但这些操作具体怎么实现由传入的参数决定。这就是runtime-core平台无关的原因。
  2. 代码n2.el = hostCreateText(n2.children as string)可以看出虚拟Node的el属性,保存的是一个DOM对象,哪怕这个DOM对象是个文本也不例外。
  3. const el = (n2.el = n1.el!)这行代码比较巧妙,将旧虚拟Node的el属性值赋值给新虚拟Node的属性el,相当于在旧虚拟Node对应的DOM节点的基础上进行操作,而不是新创建节点,减少了性能消耗。

processCommentNode

代码语言:javascript复制
// 代码片段7
const processCommentNode: ProcessTextOrCommentFn = (
    n1,
    n2,
    container,
    anchor
  ) => {
    if (n1 == null) {
      hostInsert(
        (n2.el = hostCreateComment((n2.children as string) || '')),
        container,
        anchor
      )
    } else {
      // there's no support for dynamic comments
      n2.el = n1.el
    }
  }

这里逻辑比较简单,如果新虚拟Node是注释类型,则判断旧虚拟Node是否存在,如果不存在则直接执行插入操作。如果存在则直接将旧虚拟Node对应的el元素赋值给新虚拟Node的el,不做任何其他处理,因为Vue3中是不支持注释响应式发生变化,也就是说注释创建后不会被更改。

mountStaticNode

代码语言:javascript复制
// 代码片段8
const mountStaticNode = (
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    isSVG: boolean
  ) => {
    // static nodes are only present when used with compiler-dom/runtime-dom
    // which guarantees presence of hostInsertStaticContent.
    ;[n2.el, n2.anchor] = hostInsertStaticContent!(
      n2.children as string,
      container,
      anchor,
      isSVG,
      n2.el,
      n2.anchor
    )
  }

mountStaticNode的功能是将新虚拟Node的静态内容挂载到container上,处理方法也很简单,直接调用runtime-dom传入的函数hostInsertStaticContent。需要注意的两个细节如下:

  1. 在平时编码的过程中,以([开头的表达式,前面应该加一个;以防止在代码被压缩后与上一行的内容拼接成属性访问语句。
  2. 不太清楚解构赋值的语法朋友对[n2.el, n2.anchor] = xxx的表示可能很疑惑,可以查阅MDN文档了解相关含义。

patchStaticNode

代码语言:javascript复制
// 代码片段9
const patchStaticNode = (
    n1: VNode,
    n2: VNode,
    container: RendererElement,
    isSVG: boolean
  ) => {
    // static nodes are only patched during dev for HMR
    if (n2.children !== n1.children) {
      const anchor = hostNextSibling(n1.anchor!)
      // remove existing
      removeStaticNode(n1)
      // insert new
      ;[n2.el, n2.anchor] = hostInsertStaticContent!(
        n2.children as string,
        container,
        anchor,
        isSVG
      )
    } else {
      n2.el = n1.el
      n2.anchor = n1.anchor
    }
  }

函数patchStaticNode只在开发环境下才有可能调用,为什么呢?因为既然是静态节点,就不存在响应式数据的变化也就不存在更新,所以也就不会调用这个函数。但是开发环境热更新的时候可能会变化相应数据,里面逻辑比较简单,如果还是觉得读起来有困难可以先跳过,不做重点掌握。

processFragmentprocessComponent

关于函数processFragmentprocessComponent内部的流程,在后续的文章中进行分析。

setRef

代码语言:javascript复制
// 代码片段10
if (ref != null && parentComponent) {
  setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}

还记得我们在上一篇文章中介绍的关于通过ref获取子组件的内容吗,当时我们介绍了getExposeProxy的核心功能是保护子组件的内容不被父组件随意访问。在patch函数中调用了setRef,而setRef中则调用了getExposeProxy函数。我们看看setRef究竟做了什么:

代码语言:javascript复制
// 代码片段11
export function setRef(
  rawRef: VNodeNormalizedRef,
  oldRawRef: VNodeNormalizedRef | null,
  parentSuspense: SuspenseBoundary | null,
  vnode: VNode,
  isUnmount = false
) {
  // 此处省略许多代码...
  const refValue =
    vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
      ? getExposeProxy(vnode.component!) || vnode.component!.proxy
      : vnode.el
  const value = isUnmount ? null : refValue

  const { i: owner, r: ref } = rawRef
  // 此处省略许多代码...
  const oldRef = oldRawRef && (oldRawRef as VNodeNormalizedRefAtom).r
  const refs = owner.refs === EMPTY_OBJ ? (owner.refs = {}) : owner.refs
  const setupState = owner.setupState

  if (oldRef != null && oldRef !== ref) {
    if (isString(oldRef)) {
      refs[oldRef] = null
      if (hasOwn(setupState, oldRef)) {
        setupState[oldRef] = null
      }
    } else if (isRef(oldRef)) {
      oldRef.value = null
    }
  }

  if (isFunction(ref)) {
    callWithErrorHandling(ref, owner, ErrorCodes.FUNCTION_REF, [value, refs])
  } else {
    const _isString = isString(ref)
    const _isRef = isRef(ref)
    if (_isString || _isRef) {
      const doSet = () => {
        // 此处省略许多代码...
      }
      if (value) {
        ;(doSet as SchedulerJob).id = -1
        queuePostRenderEffect(doSet, parentSuspense)
      } else {
        doSet()
      }
    }
    // 此处省略许多代码...
  }
}

关于函数setRef,我们目前只需要知道主要做了3点工作即可:

  1. 获取ref的代理对象;
  2. 找到旧虚拟Node对应的ref,如果存在且和新虚拟Node对应的ref不一致则置为null
  3. 将新的ref代理对象赋值给新虚拟Node相应的属性。

至于代码片段11呈现出来的关于ref的各种属性以及一些细节,在后续文章中合适的时机我们再继续探讨。

0 人点赞