我是前端下饭菜,两娃的爸创业中。公众号“绘个球”(各种号全网同名)实时分享创业动态,提供军事、地理、地产短视频工具。
会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过程。
我是
前端下饭菜,原创不易,未经本人同意,请勿转载。各位看官动动手,帮忙关注、点赞、收藏、评轮!