前言
   
    又回到了经典的一句话:“
    
     知其然,而后使其然
    
    ”。相信大家对 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
    
    事件
   
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
    
   
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
。
 
