又回到了经典的一句话:“知其然,而后使其然”。相信大家对 Vue 提供 v-if
和 v-show
指令的使用以及对应场景应该都滚瓜烂熟了。但是,我想仍然会有很多同学对于 v-if
和 v-show
指令实现的原理存在知识空白。
所以,今天就让我们来一起了解一番 v-if
和 v-show
指令实现的原理~
在之前 《从编译过程,理解静态节点提升》 一文中,我给大家介绍了 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
的这个过程(派发更新)究竟发生了什么?
注:如果不了解 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
的定义(伪代码):
// 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
的组件树 subTree
为 nextTree
patch
新旧组件树 prevTree
和 nextTree
,如果存在 dynamicChildren
,即 Block Tree
,则会命中靶向更新的逻辑,显然我们此时满足条件注:组件树则指的是该组件对应的 VNode Tree 。
总体来看,v-if
指令的实现较为简单,基于数据驱动的理念,当 v-if
指令对应的 value
为 false
的时候会预先创建一个注释节点在该位置,然后在 value
发生变化时,命中派发更新的逻辑,对新旧组件树进行 patch
,从而完成使用 v-if
指令元素的动态显示隐藏。
注:下面,我们来看一下 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
在源码中则对应着 vShow
,它被定义在 packages/runtime-dom/src/directives/vShow
。它的职责是对 v-show
指令进行特殊处理,主要表现在 beforeMount
、mounted
、updated
、beforeUnMount
这四个生命周期中:
// 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-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()
顾名思义和指令相关,即在 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()
)一个 dir
给 VNode.dir
。
VNode.dir
的作用体现在 vShow
在生命周期改变元素的 CSS display
属性,而这些生命周期会作为派发更新的结束回调被调用。
注:接下来,我们一起来看看其中的调用细节~
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
生命周期
postRenderEffect
到这里,我们已经围绕 v-Show
介绍完了 vShow
、withDirectives
、postRenderEffect
等概念。但是,万事具备只欠东风,还缺少一个调用 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
队列,分别是 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。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.