会Vue的同学都使用过v-if指令,本着会用就得懂的心态去Vue3源码找v-if的出处,但奇怪的是找不到,仅找到如V-modelv-onv-show等指令的代码文件。

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_响应式

既然找不到,那就换一种思路,连猜带蒙,先写一个Demo找找头绪。

从Demo说起

<div v-if="visible">
    <span>看见我了没?</span>
</div>

以上几行代码包含在App.vue模板中,查看template编译后的渲染源码,首先映入眼帘的是一个三元运算符$setup.visible ? 逻辑1 : 逻辑2

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return $setup.visible ? 
        (
            _openBlock(),
            _createElementBlock("div", _hoisted_1, [_createElementVNode("span", null, "看见我了没?", -1)]))
        ) : _createCommentVNode("v-if", true);
}

假如visible为false,则渲染函数执行_createCommentVNode("v-if", true),在Dom节点中创建一个注释节点。虽然注释节点在界面上无任何呈现,但它会在虚拟Tree中占一席位,其目的是在visible值发生变化时,能够快速更新为可见的div节点。

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_子节点_02

查看App组件生成的节点tree,其包含了subTree属性,并且subTree的类型为Symbol(v-cmt),可理解为注释节点,并且值children为字符串v-if

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_子节点_03

以上假设的是visible为false,现将其更新为true, Vue如何触发render函数重新执行?以及openBlockcreateElementBlock函数的作用是什么?接下来将围绕这两个问题讲解底层渲染流程。

openBlock和createElementBlock

再重温下三元符运算左侧的代码:

(
    _openBlock(),
    _createElementBlock("div", _hoisted_1, [_createElementVNode("span", null, "看见我了没?", -1)]))
)

_createElementBlock的第三个参数为子节点集合,这里我们不详说_createElementVNode如何把span创建为虚拟节点,仅看下生成的结果:

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_响应式_04

patchFlag: -1表示该节点为静态节点,不需要patch更新。一般当patchFlag大于0时才需要动态更新。

既然有openBlock函数,那应该也有对应的closeBlock函数:

export function openBlock(): void {
  blockStack.push((currentBlock = []))
}

export function closeBlock(): void {
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
}

为什么会有blockStackv-ifv-for是导致节点动态变化的主要指令,Vue为了优化这类场景的patch更新,会将动态变化的节点统一放到一个block集合(也即是currentBlock)管理,这样的好处是,当执行patch时能快速找出哪些虚拟节点需要动态更新,例如100个节点中仅有3个节点动态更新,而这个3个节点就存在currentBlock中。

当调用openBlock会为代码片段<div><span>看见我了没?</span></div>对应的节点创建一个新的block(currentBlock = [])。那什么时候会用到currentBlock?我们接着分析。

createElementBlock函数代码如下,传递的参数为createElementBlock('div', { key: 0 }, [span节点]),源代码先调用createBaseNodediv创建虚拟节点,再调用setupBlock。

export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number,
): VNode {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */,
    ),
  )
}

先查看createBaseVNode函数生成的结果,其节点类型为div,patchFlag为0表示改节点也不需要动态更新。

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_Vue_05

createBaseVNode函数重点关注的代码片段如下,当vnode的patchFlag大于0时,即vnode为动态节点,需要将其存放到currentBlock列表中。

if (
    currentBlock && vnode.patchFlag > 0 // 伪代码
  ) {
    currentBlock.push(vnode)
  }

createBaseVNode执行完后,返回的vnode节点作为参数传给setupBlocksetupBlock函数源码如下:

function setupBlock(vnode: VNode) {
  vnode.dynamicChildren = currentBlock || (EMPTY_ARR as any)
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}

openBlock函数中会创建一个新的block,并赋值给currentBlock。 由于demo中div子节点仅有一个span元素节点(静态节点),因此currentBlock依然为空数组,所以vnode.dynamicChildren也为空数组,如下图所示:

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_Vue_06

closeBlock函数会从blockStack堆栈中移出最近的block,并将次新的block赋值给currentBlock,假如此时的currentBlock不为空,即为父级block,因此需要将当前vnode附加到父级block中,这样父级就能快速的找到子级的动态节点。

到此,当visible为true时,对应的虚拟节点树就生成了。

什么时候dynamicChildren不为空?

将demo中template修改为:

<div v-if="visible">
    <span>看见我了没?</span>
    <span>{{ text1 }}</span>
    <span>{{ text2 }}</span>
  </div>

再看编译后的渲染代码:

(_openBlock(), _createElementBlock("div", { key: 0 }, [
    _createElementVNode(
      "span",
      null,
      "\u770B\u89C1\u6211\u4E86\u6CA1\uFF1F",
      -1
      /* HOISTED */
    ),
    _createElementVNode(
      "span",
      null,
      _toDisplayString($setup.text1),
      1
      /* TEXT */
    ),
    _createElementVNode(
      "span",
      null,
      _toDisplayString($setup.text2),
      1
      /* TEXT */
    )
  ]))

首先通过openBlock创建一个存放动态块(currentBlock)的空数组,而creteElementVNode(即createBaseVNode)会判断每个节点的patchFlag是否大0,满足条件则添加到currentBlock数组中,对应代码中的第2、3节点的patchFlag都为1,因此生成的虚拟节点如下所示。下次当text1text2变化时,patch仅需要从dynamicChildren中查找即可。

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_响应式_07

Vue如何触发render函数重新执行

App.vue作为Root组件,运行时源代码文件会转换为虚拟节点,再回顾下其生成的节点信息:

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_响应式_08

从上图节点的type属性能看出当前节点正是App组件,包含上文源码看到的render函数(_sfc_render),并且isMounted为false,表示组件还未挂载。

查看源代码调用堆栈,着重分析流程mountComponent->setupRenderEffect -> componentUpdateFn,并且关注节点中的render函数最终是在哪个环节执行

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_Vue_09

流程说明:

  • mountComponent: 组件挂载;
  • setupRenderEffect:生成ReactiveEffect, 用于收集render过程的响应式依赖项;
  • componentUpdateFn:执行组件的渲染流程;

mountComponent

通过createApp(App).mount("#app"),先创建app实体,然后执行app的mount方法。mount方法代码如下所示,先通过createVNode函数为应用创建一个root虚拟节点,然后执行render开始挂载子节点。

const app:APP = {
  ...
  mount( rootContainer: HostElement): any {
    if (!isMounted) {
      const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
      vnode.appContext = context
      render(vnode, rootContainer)

      return getComponentPublicInstance(vnode.component!)
    }
  },
}

render函数内部经过一系列流转,将会调用到专为App Component执行挂载的mountComponent函数,并且其container为#app

const mountComponent: MountComponentFn = (
    initialVNode,
    container,
    anchor,
    parentComponent
  ) => {
    const instance: ComponentInternalInstance = (initialVNode.component = createComponentInstance(
        initialVNode,
        parentComponent
      ))

      setupRenderEffect(instance, initialVNode, container)
  }

使用Vue开发组件时, 我们可以通过getCurrentInstance()函数获取当前component的实体,而这个实体正是在mountComponent函数内创建的instance

函数最后调用setupRenderEffect函数加载并挂载子节点, initialVNode为在app.mount函数创建root虚拟节点, container#app

setupRenderEffect

看到Effect关键词,就知道和收集依赖有关系,setupRenderEffect函数的作用是创建ReactiveEffect响应式副作用,并执行子节点的渲染、挂载。什么是ReactiveEffect?在之前文章《Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁》有详细介绍,如下图所示,其作用是监听相关的响应式对象,当这些对象值更新时触发scheduler执行patch。

为什么Vue的v-if指令会导致DOM中大量的<!-- v-if -->注释?_子节点_10

setupRenderEffect函数代码如下,每一个组件setup过程都包含一个作用域scope,而新建的effect都会被push到scope.effects中。scope提供的onoff函数的作用分别为激活、取消当前scope,例如先执行on激活scope,而new ReactiveEffect(componentUpdateFn)过程会将实例化的effect自动添加到scope上。

const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container
  ) => {
    const componentUpdateFn = () => {
    }

    instance.scope.on()
    const effect = (instance.effect = new ReactiveEffect(componentUpdateFn))
    instance.scope.off()

    const update = (instance.update = effect.run.bind(effect))

    update()
  }

当执行update函数时,实际会调用到构造函数传入的componentUpdateFn。在componentUpdateFn函数内,只要是任意响应式对象,如Demo中的类型为Refvisible变量,其deps都会附加上当前effect,这样只要值更新了就会通知effect,重新执行componentUpdateFn,也就是执行patch过程。

componentUpdateFn

还记得在查看app节点时,包含有属性subTree,也就是组件内包含的子节点,而这个节点正是在componentUpdateFn内调用上文看到的_sfc_render渲染函数生成的。其源码如下:

const componentUpdateFn = () => {
  if (!instance.isMounted) {
    const subTree = (instance.subTree = renderComponentRoot(instance))

    patch(
      null,
      subTree,
      container,
      anchor,
      instance,
      parentSuspense,
      namespace,
    )
    initialVNode.el = subTree.el
  }
  instance.isMounted = true   
}

当instance第一次挂载时,isMounted为false,因此会进入if代码块逻辑,首先会调用renderComponentRoot生成subTree,然后调用patch函数为子节点执行挂载流程。patch内部会根据每个节点的type类型确定调用processElementprocessComponent处理函数,这些函数又会调用到如mountElementmountComponent挂载函数,从而进入到下一个递归循环。

renderComponentRoot函数内部会调用组件实例的render函数(源代码文件中的__sfc__render函数),生成虚拟节点,而这个过程中,会读取如visible响应式对象的值,那么上文提到的effect就会自动添加到visible的deps集合中,这样就随带完成了依赖项收集。

export function renderComponentRoot(
  instance: ComponentInternalInstance,
): VNode {
  const {
    type: Component,
    vnode,
    render
    props,
    data,
    ctx,
  } = instance
  
  const result = normalizeVNode(
    render!.call(
      thisProxy,
      proxyToUse!,
      renderCache,
      __DEV__ ? shallowReadonly(props) : props,
      setupState,
      data,
      ctx,
    ),
  )
  
  return result
}

通过以上的流程分析,也就回答了问题 "Vue如何触发render函数重新执行"

总结

虽然在Vue源码中没看到单独定义v-if的代码文件,但并不代表它不重要,正好相反,v-if已经和render函数合为一体,通过三元运算符来表达。

下次再看到DOM中的注释元素<!-- v-if -->,也就不会感到奇怪,因为每一个注释元素都会和虚拟节点一一对应,方便patch过程快速更新。

通过本篇内容,我们能够了解到appmount内部执行的大致流程,包括虚拟节点的创建以及当响应式对象更新时如何触发组件的patch过程。