会Vue的同学都使用过v-if
指令,本着会用就得懂
的心态去Vue3源码找v-if
的出处,但奇怪的是找不到,仅找到如V-model
、v-on
、v-show
等指令的代码文件。
既然找不到,那就换一种思路,连猜带蒙,先写一个Demo找找头绪。
从Demo说起
<div v-if="visible">
<span>看见我了没?</span>
</div>
以上几行代码包含在App.vue
模板中,查看template编译后的渲染源码,首先映入眼帘的是一个三元运算符$setup.visible ? 逻辑1 : 逻辑2
。
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return $setup.visible ?
(
_openBlock(),
_createElementBlock("div", _hoisted_1, [_createElementVNode("span", null, "看见我了没?", -1)]))
) : _createCommentVNode("v-if", true);
}
假如visible
为false,则渲染函数执行_createCommentVNode("v-if", true)
,在Dom节点中创建一个注释节点。虽然注释节点在界面上无任何呈现,但它会在虚拟Tree
中占一席位,其目的是在visible
值发生变化时,能够快速更新为可见的div
节点。
查看App组件生成的节点tree,其包含了subTree属性,并且subTree的类型为Symbol(v-cmt)
,可理解为注释节点,并且值children为字符串v-if
。
以上假设的是visible为false
,现将其更新为true
, Vue如何触发render
函数重新执行?以及openBlock
和createElementBlock
函数的作用是什么?接下来将围绕这两个问题讲解底层渲染流程。
openBlock和createElementBlock
再重温下三元符运算左侧的代码:
(
_openBlock(),
_createElementBlock("div", _hoisted_1, [_createElementVNode("span", null, "看见我了没?", -1)]))
)
_createElementBlock
的第三个参数为子节点集合,这里我们不详说_createElementVNode
如何把span创建为虚拟节点,仅看下生成的结果:
patchFlag: -1
表示该节点为静态节点,不需要patch
更新。一般当patchFlag
大于0时才需要动态更新。
既然有openBlock
函数,那应该也有对应的closeBlock
函数:
export function openBlock(): void {
blockStack.push((currentBlock = []))
}
export function closeBlock(): void {
blockStack.pop()
currentBlock = blockStack[blockStack.length - 1] || null
}
为什么会有blockStack
? 像v-if
或v-for
是导致节点动态变化的主要指令,Vue
为了优化这类场景的patch
更新,会将动态变化的节点统一放到一个block
集合(也即是currentBlock)管理,这样的好处是,当执行patch
时能快速找出哪些虚拟节点需要动态更新,例如100个节点中仅有3个节点动态更新,而这个3个节点就存在currentBlock
中。
当调用openBlock
会为代码片段<div><span>看见我了没?</span></div>
对应的节点创建一个新的block
(currentBlock = [])。那什么时候会用到currentBlock
?我们接着分析。
createElementBlock
函数代码如下,传递的参数为createElementBlock('div', { key: 0 }, [span节点])
,源代码先调用createBaseNode
为div
创建虚拟节点,再调用setupBlock。
export function createElementBlock(
type: string | typeof Fragment,
props?: Record<string, any> | null,
children?: any,
patchFlag?: number,
dynamicProps?: string[],
shapeFlag?: number,
): VNode {
return setupBlock(
createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
true /* isBlock */,
),
)
}
先查看createBaseVNode
函数生成的结果,其节点类型为div
,patchFlag为0表示改节点也不需要动态更新。
createBaseVNode
函数重点关注的代码片段如下,当vnode的patchFlag大于0时,即vnode为动态节点,需要将其存放到currentBlock
列表中。
if (
currentBlock && vnode.patchFlag > 0 // 伪代码
) {
currentBlock.push(vnode)
}
createBaseVNode
执行完后,返回的vnode节点作为参数传给setupBlock
。setupBlock
函数源码如下:
function setupBlock(vnode: VNode) {
vnode.dynamicChildren = currentBlock || (EMPTY_ARR as any)
// close block
closeBlock()
// a block is always going to be patched, so track it as a child of its
// parent block
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
在openBlock
函数中会创建一个新的block
,并赋值给currentBlock
。 由于demo中div
子节点仅有一个span
元素节点(静态节点),因此currentBlock
依然为空数组,所以vnode.dynamicChildren也为空数组,如下图所示:
closeBlock
函数会从blockStack堆栈中移出最近的block
,并将次新的block
赋值给currentBlock
,假如此时的currentBlock
不为空,即为父级block
,因此需要将当前vnode
附加到父级block
中,这样父级就能快速的找到子级的动态节点。
到此,当visible为true时,对应的虚拟节点树就生成了。
什么时候dynamicChildren
不为空?
将demo中template修改为:
<div v-if="visible">
<span>看见我了没?</span>
<span>{{ text1 }}</span>
<span>{{ text2 }}</span>
</div>
再看编译后的渲染代码:
(_openBlock(), _createElementBlock("div", { key: 0 }, [
_createElementVNode(
"span",
null,
"\u770B\u89C1\u6211\u4E86\u6CA1\uFF1F",
-1
/* HOISTED */
),
_createElementVNode(
"span",
null,
_toDisplayString($setup.text1),
1
/* TEXT */
),
_createElementVNode(
"span",
null,
_toDisplayString($setup.text2),
1
/* TEXT */
)
]))
首先通过openBlock
创建一个存放动态块(currentBlock)的空数组,而creteElementVNode
(即createBaseVNode)会判断每个节点的patchFlag
是否大0,满足条件则添加到currentBlock
数组中,对应代码中的第2、3节点的patchFlag
都为1,因此生成的虚拟节点如下所示。下次当text1
或text2
变化时,patch仅需要从dynamicChildren
中查找即可。
Vue如何触发render
函数重新执行
App.vue
作为Root组件,运行时源代码文件会转换为虚拟节点,再回顾下其生成的节点信息:
从上图节点的type属性能看出当前节点正是App组件,包含上文源码看到的render
函数(_sfc_render),并且isMounted为false,表示组件还未挂载。
查看源代码调用堆栈,着重分析流程mountComponent
->setupRenderEffect
-> componentUpdateFn
,并且关注节点中的render
函数最终是在哪个环节执行。
流程说明:
- mountComponent: 组件挂载;
- setupRenderEffect:生成ReactiveEffect, 用于收集render过程的响应式依赖项;
- componentUpdateFn:执行组件的渲染流程;
mountComponent
通过createApp(App).mount("#app")
,先创建app实体,然后执行app的mount方法。mount方法代码如下所示,先通过createVNode
函数为应用创建一个root虚拟节点,然后执行render开始挂载子节点。
const app:APP = {
...
mount( rootContainer: HostElement): any {
if (!isMounted) {
const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
vnode.appContext = context
render(vnode, rootContainer)
return getComponentPublicInstance(vnode.component!)
}
},
}
render
函数内部经过一系列流转,将会调用到专为App
Component执行挂载的mountComponent
函数,并且其container为#app
。
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent
) => {
const instance: ComponentInternalInstance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent
))
setupRenderEffect(instance, initialVNode, container)
}
使用Vue开发组件时, 我们可以通过getCurrentInstance()
函数获取当前component的实体,而这个实体正是在mountComponent
函数内创建的instance
。
函数最后调用setupRenderEffect
函数加载并挂载子节点, initialVNode
为在app.mount
函数创建root虚拟节点, container
为#app
。
setupRenderEffect
看到Effect
关键词,就知道和收集依赖有关系,setupRenderEffect
函数的作用是创建ReactiveEffect
响应式副作用,并执行子节点的渲染、挂载。什么是ReactiveEffect
?在之前文章《Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁》有详细介绍,如下图所示,其作用是监听相关
的响应式对象,当这些对象值更新时触发scheduler执行patch。
setupRenderEffect
函数代码如下,每一个组件setup过程都包含一个作用域scope
,而新建的effect
都会被push到scope.effects
中。scope提供的on
、off
函数的作用分别为激活、取消当前scope,例如先执行on
激活scope,而new ReactiveEffect(componentUpdateFn)
过程会将实例化的effect自动添加到scope上。
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container
) => {
const componentUpdateFn = () => {
}
instance.scope.on()
const effect = (instance.effect = new ReactiveEffect(componentUpdateFn))
instance.scope.off()
const update = (instance.update = effect.run.bind(effect))
update()
}
当执行update
函数时,实际会调用到构造函数传入的componentUpdateFn
。在componentUpdateFn
函数内,只要是任意响应式对象,如Demo中的类型为Ref
的visible
变量,其deps
都会附加上当前effect
,这样只要值更新了就会通知effect
,重新执行componentUpdateFn
,也就是执行patch
过程。
componentUpdateFn
还记得在查看app
节点时,包含有属性subTree,也就是组件内包含的子节点,而这个节点正是在componentUpdateFn
内调用上文看到的_sfc_render
渲染函数生成的。其源码如下:
const componentUpdateFn = () => {
if (!instance.isMounted) {
const subTree = (instance.subTree = renderComponentRoot(instance))
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
namespace,
)
initialVNode.el = subTree.el
}
instance.isMounted = true
}
当instance第一次挂载时,isMounted
为false,因此会进入if代码块逻辑,首先会调用renderComponentRoot
生成subTree,然后调用patch
函数为子节点执行挂载流程。patch
内部会根据每个节点的type
类型确定调用processElement
、processComponent
处理函数,这些函数又会调用到如mountElement
、mountComponent
挂载函数,从而进入到下一个递归循环。
renderComponentRoot
函数内部会调用组件实例的render
函数(源代码文件中的__sfc__render函数),生成虚拟节点,而这个过程中,会读取如visible
响应式对象的值,那么上文提到的effect
就会自动添加到visible
的deps集合中,这样就随带完成了依赖项收集。
export function renderComponentRoot(
instance: ComponentInternalInstance,
): VNode {
const {
type: Component,
vnode,
render
props,
data,
ctx,
} = instance
const result = normalizeVNode(
render!.call(
thisProxy,
proxyToUse!,
renderCache,
__DEV__ ? shallowReadonly(props) : props,
setupState,
data,
ctx,
),
)
return result
}
通过以上的流程分析,也就回答了问题 "Vue如何触发render
函数重新执行"。
总结
虽然在Vue
源码中没看到单独定义v-if
的代码文件,但并不代表它不重要,正好相反,v-if
已经和render
函数合为一体,通过三元运算符来表达。
下次再看到DOM中的注释元素<!-- v-if -->
,也就不会感到奇怪,因为每一个注释元素都会和虚拟节点一一对应,方便patch
过程快速更新。
通过本篇内容,我们能够了解到app
的mount
内部执行的大致流程,包括虚拟节点的创建以及当响应式对象更新时如何触发组件的patch
过程。