Vue3 中 v-if 和 v-show 指令实现的原理 | 源码解读 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
fewuliu
V2EX    Vue.js

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

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

    前言

    又回到了经典的一句话:“知其然,而后使其然”。相信大家对 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     Solana     912 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 26ms UTC 19:56 PVG 03:56 LAX 12:56 JFK 15:56
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86