V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
fewuliu
V2EX  ›  Vue.js

Vue3 中 v-if 和 v-show 指令实现的原理 | 源码解读

  •  
  •   fewuliu · 2021-01-26 22:09:41 +08:00 · 2393 次点击
    这是一个创建于 1404 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    又回到了经典的一句话:“知其然,而后使其然”。相信大家对 Vue 提供 v-ifv-show 指令的使用以及对应场景应该都滚瓜烂熟了。但是,我想仍然会有很多同学对于 v-ifv-show 指令实现的原理存在知识空白。

    所以,今天就让我们来一起了解一番 v-ifv-show 指令实现的原理~

    v-if

    在之前 《从编译过程,理解静态节点提升》 一文中,我给大家介绍了 Vue 3 的编译过程,即一个模版会经历 baseParsetransformgenerate 这三个过程,最后由 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 TreeBlock VNode,它们主要用于靶向更新过程
    • _createCommentVNode() 创建注释节点的函数,通常用于占位

    显然,如果当 visiblefalse 的时候,会在当前模版中创建一个注释节点(也可称为占位节点),反之则创建一个真实节点(即它自己)。例如当 visiblefalse 时渲染到页面上会是这样:

    注:在 Vue 中很多地方都运用了注释节点来作为占位节点,其目的是在不展示该元素的时候,标识其在页面中的位置,以便在 patch 的时候将该元素放回该位置。

    那么,这个时候我想大家就会抛出一个疑问:当 visible 动态切换 truefalse 的这个过程(派发更新)究竟发生了什么?

    派发更新时 patch,更新节点

    注:如果不了解 Vue 3 派发更新和依赖收集过程的同学,可以看我之前的文章4k+ 字分析 Vue 3.0 响应式原理(依赖收集和派发更新)

    在 Vue 3 中总共有四种指令:v-onv-modelv-showv-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 的定义(伪代码):

    // packages/runtime-core/src/renderer.ts
    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 的组件树 subTreenextTree
    • patch 新旧组件树 prevTreenextTree,如果存在 dynamicChildren,即 Block Tree,则会命中靶向更新的逻辑,显然我们此时满足条件

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

    小结

    总体来看,v-if 指令的实现较为简单,基于数据驱动的理念,当 v-if 指令对应的 valuefalse 的时候会预先创建一个注释节点在该位置,然后在 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]
      ])
    }
    

    此时,这个栗子在 visiblefalse 时,渲染到页面上的 HTML:

    从上面的 render 函数可以看出,不同于 v-if 的三目运算符表达式,v-showrender 函数返回的是 _withDirectives() 函数的执行。

    前面,我们已经简单介绍了 _openBlock()_createBlock() 函数。那么,除开这两者,接下来我们逐点分析一下这个 render 函数,首当其冲的是 _vShow

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

    _vShow 在源码中则对应着 vShow,它被定义在 packages/runtime-dom/src/directives/vShow。它的职责是对 v-show 指令进行特殊处理,主要表现在 beforeMountmountedupdatedbeforeUnMount 这四个生命周期中:

    // packages/runtime-dom/src/directives/vShow.ts
    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-showtransition 时的 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 绑定的元素的显示或隐藏。

    并且,我想大家可能注意到了,当 valuetrue 的时候,display 是等于的 el.vod,而 el.vod 则等于这个真实元素的 CSS display 属性(默认情况下为空)。所以,当 v-show 对应的 valuetrue 的时候,元素显示与否是取决于它本身的 CSS display 属性。

    注:其实,到这里 v-show 指令的本质在源码中的体现已经出来了。但是,仍然会留有一些疑问,例如 withDirectives 做了什么?vShow 在生命周期中对 v-show 指令的处理又是如何运用的?

    withDirectives 在 VNode 上增加 dir 属性

    withDirectives() 顾名思义和指令相关,即在 Vue 3 中和指令相关的元素,最后生成的 render 函数都会调用 withDirectives() 处理指令相关的逻辑,vShow 的逻辑作为 dir 属性添加VNode 上。

    withDirectives() 函数的定义:

    // packages/runtime-core/src/directives.ts
    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())一个 dirVNode.dir

    VNode.dir 的作用体现在 vShow 在生命周期改变元素的 CSS display 属性,而这些生命周期会作为派发更新的结束回调被调用

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

    派发更新时 patch,注册 postRenderEffect 事件

    相信大家应该都知道 Vue 3 提出了 patchFlag 的概念,其用来针对不同的场景来执行对应的 patch 逻辑。那么,对于上面这个栗子,我们会命中 patchElement 的逻辑。

    而对于 v-show 之类的指令来说,由于 Vnode.dir 上绑定了处理元素 CSS display 属性的相关逻辑( vShow 定义好的生命周期处理)。所以,此时 patchElement() 中会为注册一个 postRenderEffect 事件。

    // packages/runtime-core/src/renderer.ts
    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 介绍完了 vShowwithDirectivespostRenderEffect 等概念。但是,万事具备只欠东风,还缺少一个调用 postRenderEffect 事件的时机,即处理 pendingPostFlushCbs 队列的时机。

    在 Vue 3 中 effect 相当于 Vue 2.x 的 watch。虽然变了个命名,但是仍然保持着一样的调用方式,都是调用的 run() 函数,然后由 flushJobs() 执行 effect 队列。而调用 postRenderEffect 事件的时机则是在执行队列的结束

    flushJobs() 函数的定义:

    // packages/runtime-core/src/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 队列,分别是 preRenderEffectrenderEffectpostRenderEffect,它们各自对应 flushPreFlushCbs()queueflushPostFlushCbs

    那么,显然 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-ifv-show 实现的原理,你可以用一两句话概括,也可以用一大堆话概括。如果牵扯到面试场景下,我更欣赏后者,因为这说明你研究的够深以及理解能力够强。并且,当你了解一个指令的处理过程后,对于其他指令 v-onv-model 的处理,相信也可以很容易地得出结论。最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

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

    7 条回复    2021-03-26 21:58:07 +08:00
    imgbed
        1
    imgbed  
       2021-01-26 22:19:42 +08:00
    用了这么多年,从来没关心过原理。。知道原理有什么好处?
    LeeReamond
        2
    LeeReamond  
       2021-01-26 23:00:44 +08:00 via Android
    @imgbed 可以水文章,发公众号
    fewuliu
        3
    fewuliu  
    OP
       2021-01-26 23:27:33 +08:00
    好处的话,因人而异吧,知道原理也不是必然行为,纯看兴趣哈😂
    rodrick
        4
    rodrick  
       2021-01-27 08:23:01 +08:00
    我觉得可以发公众号或者那种文章社区,v2 感觉对这不太感冒
    linxl
        5
    linxl  
       2021-01-27 09:15:39 +08:00
    @imgbed 这就是面试里的“造火箭”,虽然我也没关心过。
    Reapper
        6
    Reapper  
       2021-01-27 10:10:56 +08:00
    原理就是算法
    nttisthebest
        7
    nttisthebest  
       2021-03-26 21:58:07 +08:00
    面试用。。面试造火箭,工作拧螺丝。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1863 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 16:23 · PVG 00:23 · LAX 08:23 · JFK 11:23
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.