Vue 3 中 v-if 和 v-show 指令实现的原理(源码分析)

  • Post author:
  • Post category:vue




前言

又回到了经典的一句话:“

知其然,而后使其然

”。相信大家对 Vue 提供

v-if



v-show

指令的使用以及对应场景应该都

滚瓜烂熟

了。但是,我想仍然会有很多同学对于

v-if



v-show

指令实现的原理存在知识空白。

所以,今天就让我们来一起了解一番

v-if



v-show

指令实现的原理~



v-if

在之前

【Vue3 源码解读】从编译过程,理解静态节点提升

一文中,我给大家介绍了 Vue 3 的编译过程,即一个模版会经历

baseParse



transform



generate

这三个过程,最后由

generate

生成可以执行的代码(

render

函数)。

这里,我们就不从编译过程开始讲解

v-if

指令的

render

函数生成过程了,有兴趣了解这个过程的同学,可以看我之前的文章

从编译过程,理解静态节点提升

我们可以直接在

Vue3 Template Explore

输入一个使用

v-if

指令的栗子:

<div v-if="visible"></div>

然后,由它编译生成的

render

函数会是这样:

render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.visible)
    ? (_openBlock(), _createBlock("div", { key: 0 }))
    : _createCommentVNode("v-if", true)
}

可以看到,一个简单的使用

v-if

指令的模版编译生成的

render

函数最终会返回一个

三目运算表达式

。首先,让我们先来认识一下其中几个变量和函数的意义:


  • _ctx

    当前组件实例的上下文,即

    this

  • _openBlock()



    _createBlock()

    用于构造

    Block Tree



    Block VNode

    ,它们主要用于靶向更新过程

  • _createCommentVNode()

    创建注释节点的函数,通常用于占位

显然,如果当

visible



false

的时候,会在当前模版中创建一个

注释节点

(也可称为占位节点),反之则创建一个真实节点(即它自己)。例如当

visible



false

时渲染到页面上会是这样:

在 Vue 中很多地方都运用了注释节点来作为

占位节点

,其目的是在不展示该元素的时候,标识其

在页面中的位置

,以便在

patch

的时候将该元素放回该位置。

那么,这个时候我想大家就会抛出一个疑问:当

visible

动态切换

true



false

的这个过程(派发更新)究竟发生了什么?



派发更新时 patch,更新节点

如果不了解 Vue 3 派发更新和依赖收集过程的同学,可以看我之前的文章

4k+ 字分析 Vue 3.0 响应式原理(依赖收集和派发更新)

在 Vue 3 中总共有四种指令:

v-on



v-model



v-show



v-if

。但是,实际上在源码中,只针对前面三者

进行了特殊处理

,这可以在

packages/runtime-dom/src/directives

目录下的文件看出:

// packages/runtime-dom/src/directives
|-- driectives
    |-- vModel.ts       ## v-model 指令相关
    |-- vOn.ts          ## v-on 指令相关
    |-- vShow.ts        ## v-show 指令相关

而针对

v-if

指令是直接走派发更新过程时

patch

的逻辑。由于

v-if

指令订阅了

visible

变量,所以当

visible

变化的时候,则会触发

派发更新

,即

Proxy

对象的

set

逻辑,最后会命中

componentEffect

的逻辑。

当然,我们也可以称这个过程为组件的更新过程

这里,我们来看一下

componentEffect

的定义(伪代码):

function componentEffect() {
    if (!instance.isMounted) {
    	....
    } else {
      	...
        const nextTree = renderComponentRoot(instance)
        const prevTree = instance.subTree
        instance.subTree = nextTree
        patch(
          prevTree,
          nextTree,
          hostParentNode(prevTree.el!)!,
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )
        ...
      }
  }
}

可以看到,当

组件还没挂载时

,即第一次触发派发更新会命中

!instance.isMounted

的逻辑。而对于我们这个栗子,则会命中

else

的逻辑,即组件更新,主要会做三件事:

  • 获取当前组件对应的组件树

    nextTree

    和之前的组件树

    prevTree
  • 更新当前组件实例

    instance

    的组件树

    subTree



    nextTree

  • patch

    新旧组件树

    prevTree



    nextTree

    ,如果存在

    dynamicChildren

    ,即

    Block Tree

    ,则会命中靶向更新的逻辑,显然我们此时满足条件

注:组件树则指的是该组件对应的 VNode Tree。



小结

总体来看,

v-if

指令的实现较为简单,基于

数据驱动

的理念,当

v-if

指令对应的

value



false

的时候会

预先创建一个注释节

点在该位置,然后在

value

发生变化时,命中派发更新的逻辑,对新旧组件树进行

patch

,从而完成使用

v-if

指令元素的动态显示隐藏。

下面,我们来看一下

v-show

指令的实现~



v-show

同样地,对于

v-show

指令,我们在 Vue 3 在线模版编译平台输入这样一个栗子:

<div v-show="visible"></div>

那么,由它编译生成的

render

函数:

render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createBlock("div", null, null, 512 /* NEED_PATCH */)), 
  [
    [_vShow, _ctx.visible]
  ])
}

此时,这个栗子在

visible



false

时,渲染到页面上的 HTML:

从上面的

render

函数可以看出,不同于

v-if

的三目运算符表达式,

v-show



render

函数返回的是

_withDirectives()

函数的执行。

前面,我们已经简单介绍了

_openBlock()



_createBlock()

函数。那么,除开这两者,接下来我们逐点分析一下这个

render

函数,首当其冲的是

vShow



vShow 在生命周期中改变 display 属性


_vShow

在源码中则对应着

vShow

,它被定义在

packages/runtime-dom/src/directives/vShow

。它的职责是对

v-show

指令进行

特殊处理

,主要表现在

beforeMount



mounted



updated



beforeUnMount

这四个生命周期中:

// packages/runtime-dom/src/directives/vShow
export const vShow: ObjectDirective<VShowElement> = {
  beforeMount(el, { value }, { transition }) {
    el._vod = el.style.display === 'none' ? '' : el.style.display
    if (transition && value) {
      // 处理 tansition 逻辑
      ...
    } else {
      setDisplay(el, value)
    }
  },
  mounted(el, { value }, { transition }) {
    if (transition && value) {
      // 处理 tansition 逻辑
      ...
    }
  },
  updated(el, { value, oldValue }, { transition }) {
    if (!value === !oldValue) return
    if (transition) {
      // 处理 tansition 逻辑
      ...
    } else {
      setDisplay(el, value)
    }
  },
  beforeUnmount(el, { value }) {
    setDisplay(el, value)
  }
}

对于

v-show

指令会处理两个逻辑:普通

v-show



transition

时的

v-show

情况。通常情况下我们只是使用

v-show

指令,

命中的就是前者

这里我们只对普通

v-show

情况展开分析。

普通

v-show

情况,都是调用的

setDisplay()

函数,以及会传入两个变量:


  • el

    当前使用

    v-show

    指令的

    真实元素

  • v-show

    指令对应的

    value

    的值

接着,我们来看一下

setDisplay()

函数的定义:

function setDisplay(el: VShowElement, value: unknown): void {
  el.style.display = value ? el._vod : 'none'
}


setDisplay()

函数正如它本身

命名的语意

一样,是通过改变该元素的 CSS 属性

display

的值来动态的控制

v-show

绑定的元素的

显示

或隐藏。

并且,我想大家可能注意到了,当

value



true

的时候,

display

是等于的

el.vod

,而

el.vod

则等于这个真实元素的 CSS

display

属性(默认情况下为空)。所以,当

v-show

对应的

value



true

的时候,

元素显示与否是取决于它本身

的 CSS

display

属性。

其实,到这里

v-show

指令的本质在源码中的体现已经出来了。但是,仍然会留有一些疑问,例如

withDirectives

做了什么?

vShow

在生命周期中对

v-show

指令的处理又是如何运用的?



withDirectives 在 VNode 上增加 dir 属性


withDirectives()

顾名思义和指令相关,即在 Vue 3 中和指令相关的元素,最后生成的

render

函数都会调用

withDirectives()

处理指令相关的逻辑,



vShow

的逻辑作为

dir

属性添加



VNode

上。


withDirectives()

函数的定义:

// packages/runtime-core/directives
export function withDirectives<T extends VNode>(
  vnode: T,
  directives: DirectiveArguments
): T {
  const internalInstance = currentRenderingInstance
  if (internalInstance === null) {
    __DEV__ && warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }
  const instance = internalInstance.proxy
  const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])
  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
    if (isFunction(dir)) {
      ...
    }
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }
  return vnode
}

首先,

withDirectives()

会获取当前渲染实例处理

边缘条件

,即如果在

render

函数外面使用

withDirectives()

则会抛出异常:

“withDirectives can only be used inside render functions.”

然后,在

vnode

上绑定

dirs

属性,并且遍历传入的

directives

数组,而对于我们这个栗子

directives

就是:

[
  [_vShow, _ctx.visible]
]

显然此时只会

迭代一次

(数组长度为 1)。并且从

render

传入的 参数可以知道,从

directives

上解构出的

dir

指的是

_vShow

,即我们上面介绍的

vShow

。由于

vShow

是一个对象,所以会重新构造(

bindings.push()

)一个

dir



VNode.dir


VNode.dir

的作用体现在

vShow

在生命周期改变元素的 CSS

display

属性,而这些

生命周期会作为派发更新的结束回调被调用

接下来,我们一起来看看其中的调用细节~



派发更新时 patch,注册

postRenderEffect

事件

相信大家应该都知道 Vue 3 提出了

patchFlag

的概念,其用来针对不同的场景来执行对应的

patch

逻辑。那么,对于上面这个栗子,我们会命中

patchElement

的逻辑。

而对于

v-show

之类的指令来说,由于

Vnode.dir

上绑定了处理元素 CSS

display

属性的相关逻辑(

vShow

定义好的生命周期处理)。所以,此时

patchElement

中会为注册一个

postRenderEffect

事件。

const patchElement = (
    n1: VNode,
    n2: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    ...
    // 此时 dirs 是存在的
    if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
      // 注册 postRenderEffect 事件
      queuePostRenderEffect(() => {
        vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
        dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
      }, parentSuspense)
    }
    ...
  }

这里我们简单分析一下

queuePostRenderEffect



invokeDirectiveHook


  • queuePostRenderEffect



    postRenderEffect

    事件注册是通过

    queuePostRenderEffect

    完成的,因为

    effect

    都是维护在一个队列中(为了保持

    effect

    的有序),这里是

    pendingPostFlushCbs

    ,所以对于

    postRenderEffect

    也是一样的会被

    进队


  • invokeDirectiveHook

    ,由于

    vShow

    封装了对元素 CSS

    display

    属性的处理,所以

    invokeDirective

    的本职是调用指令相关的生命周期处理。并且,需要注意的是此时是

    更新逻辑

    ,所以

    只会调用

    vShow

    中定义好的

    update

    生命周期



flushJobs 的结束(finally)调用

postRenderEffect

到这里,我们已经围绕

v-Show

介绍完了

vShow



withDirectives



postRenderEffect

等概念。但是,万事具备只欠东风,还缺少一个

调用

postRenderEffect

事件的时机

,即处理

pendingPostFlushCbs

队列的时机.

在 Vue 3 中

effect

相当于 Vue 2.x 的

watch

。虽然变了个命名,但是仍然保持着一样的调用方式,都是调用的

run()

函数,然后由

flushJobs()

执行

effect

队列。而调用

postRenderEffect

事件的时机

则是在执行队列的结束


flushJobs

函数的定义:

// packages/runtime-core/scheduler.ts
function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  if (__DEV__) {
    seen = seen || new Map()
  }
  flushPreFlushCbs(seen)
  // 对 effect 进行排序
  queue.sort((a, b) => getId(a!) - getId(b!))
  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      // 执行渲染 effect
      const job = queue[flushIndex]
      if (job) {
        ...
      }
    }
  } finally {
    ...
    // postRenderEffect 事件的执行时机
    flushPostFlushCbs(seen)
    ...
  }
}



flushJobs()

函数中会执行三种

effect

队列,分别是

preRenderEffect



renderEffect



postRenderEffect

,它们各自对应

flushPreFlushCbs()



queue



flushPostFlushCbs

那么,显然

postRenderEffect

事件的

调用时机

是在

flushPostFlushCbs()

。而

flushPostFlushCbs()

内部则会遍历

pendingPostFlushCbs

队列,即执行之前在

patchElement

时注册的

postRenderEffect

事件,

本质上就是执行

updated(el, { value, oldValue }, { transition }) {
  if (!value === !oldValue) return
  if (transition) {
    ...
  } else {
    // 改变元素的 CSS display 属性
    setDisplay(el, value)
  }
},



小结

相比较

v-if

简单干脆地通过

patch

直接更新元素,

v-show

的处理就略显复杂。这里我们重新梳理一下整个过程:

  • 首先,由

    widthDirectives

    来生成最终的

    VNode

    。它会给

    VNode

    上绑定

    dir

    属性,即

    vShow

    定义的在生命周期中对元素 CSS

    display

    属性的处理
  • 其次,在

    patchElement

    的阶段,会注册

    postRenderEffect

    事件,用于调用

    vShow

    定义的

    update

    生命周期处理 CSS

    display

    属性的逻辑
  • 最后,在派发更新的结束,调用

    postRenderEffect

    事件,即执行

    vShow

    定义的

    update

    生命周期,更改元素的 CSS

    display

    属性



结语


v-if



v-show

原理,你可以用一两句话概括,也可以用一大堆话概括。如果牵扯到面试场景下,我更欣赏后者,因为这说明你

研究的够深

以及

理解能力够强

。并且,当你了解一个指令的处理过程后,对于其他指令

v-on



v-model

的处理,相信也可以很容易的得出结论。最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

我是五柳,喜欢创新、捣鼓源码,专注于 Vue3 源码、Vite 源码、前端工程化等技术分享,欢迎关注我的

微信公众号:Code center



版权声明:本文为qq_42049445原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。