Description
一次插槽带来的性能问题
在一次正常的业务代码里面使用了 element-plus 的 el-tooltip
组件,结果用 performance 调试的时候发现,操作时候该部分的性能异常的差,能阻塞 1ms。考虑到用户是连续操作背景下,无疑会带来明显的卡顿。代码简化如下
<button @click="count++">update</button>
count: {{ count }}
<el-text>
<div> {{ Date.now() }} </div>
</el-text>
<div v-for="item in 10" :key="item">
<el-tooltip>
<div> {{ item }} {{ Date.now() }} </div>
</el-tooltip>
</div>
就是上面这段看起来没有问题的代码,通过遍历生成了 10 个 el-tooltip
组件,是正常不过的用法了。可是这里却又一个奇怪的问题,在 count
发生更新时候,下面的 el-tooltip
这里会也会自动更新,不仅 Date.now()
值会随着 count
更新而更新,而且通过 performance 调试可以发现 el-tooltip
组件本身也重新渲染了,按照响应式更新的方式,el-tooltip
是不应该更新的,比如上面的 el-text
组件就很正常的不会更新,难道是 el-tooltip
组件里面有什么魔法操作?
从 el-tooltip
开始排查,里面实现大致通过多层包装最后通过 OnlyChild
来包装生成返回
export const OnlyChild = defineComponent({
name: 'onlyChild',
setup(_, { slots, attrs }) {
return () => {
const defaultSlot = slots.default?.(attrs)
const forwardRefDirective = useForwardRefDirective(
NOOP
)
if (!defaultSlot) return null
const firstLegitNode = defaultSlot?.[0]
const newNode = cloneVNode(firstLegitNode!, attrs);
const result = withDirectives(newNode, [
[forwardRefDirective],
])
return result
}
},
})
可以看到该 OnlyChild
组件内部使用了 cloneVNode
方法,将传入的 slots.default
中的第一个节点进行克隆,element-plus
组件里面的属性特别的多,直接 cloneVNode
会有不少性能损耗的,尤其对于用户侧的项目,能少用 element-plus
这种面向中后台为主的组件库还是比较好。排查看这里确实不应该是触发更新的源头。
于是查看编译后的 sfc 代码
// _sfc_render 函数主要内容
_createVNode(_component_el_text, null, {
default: _withCtx(() => [
_createElementVNode(
"div",
null,
_toDisplayString(Date.now()),
1 /* TEXT */
),
]),
_: 1 /* STABLE */,
}),
(_openBlock(),
_createElementBlock(
_Fragment,
null,
_renderList(10, (item) => {
return _createElementVNode(
"div",
{
key: item,
},
[
_createVNode(
_component_el_tooltip,
null,
{
default: _withCtx(() => [
_createElementVNode(
"div",
null,
_toDisplayString(item) +
" " +
_toDisplayString(Date.now()),
1 /* TEXT */
),
]),
_: 2 /* DYNAMIC */,
},
1024 /* DYNAMIC_SLOTS */
),
]
);
}),
64 /* STABLE_FRAGMENT */
)),
上面代码是生成的渲染函数的主要部分,可以看到都是通过 _createVNode
的方式来创建新的节点,不过可以看到主要的不通电, _component_el_text
里面 patchFlag = 0
,而 _component_el_tooltip
是 patchFlag = 1024 /* DYNAMIC_SLOTS */
,而这导致的变化是在 patch 阶段,判断组件是否更新会调用 shouldUpdateComponent
,而 patchFlag = 1024
会返回 true
,patchFlag = 0
则为 false
。从而导致了 el-tooltip
的会更新,而 el-text
不会更新。从 patchFlag = 1024
可以看到,这个 flag 表示的是动态插槽,也就是插槽内容有变化,需要重新渲染。只是为什么是动态的?
如果这个时候再模版代码里面最后面添加
<el-tooltip>
<div> {{ count2 }} {{ Date.now() }} </div>
</el-tooltip>
<!-- 生成的sfc -->
_createVNode(_component_el_tooltip, null, {
default: _withCtx(() => [
_createElementVNode(
"div",
null,
_toDisplayString($setup.count2) + " " + _toDisplayString(Date.now()),
1 /* TEXT */
),
]),
_: 1 /* STABLE */,
});
可以发现生成的 sfc 是不带有 DYNAMIC_SLOTS
标识的,同时和 el-text
一样,不会触发内部插槽更新。对比一下发现明显的不同点在于前者通过 v-for
的形式生成,进而导致最后编译 patchFlag
不同。
**这样可以基本确定问题了,在 v-for
循环中,如果传入了动态内容作为插槽一部分,则该节点的 patchFlag = 1024
组件会随着父组件一起更新。**这就导致了如果父组件一直更新,子组件不管如何都会更新的情况,那要如何避免呢?其实对于上述案例,**用 v-once
就能直接解决,避免只是为了快速生成一组 el-tooltip
。**对于 el-tooltip
其更新的机制会重新执行 cloneVNode
在叠加繁杂的参数,导致性能下降。
这里的 patchFlag = 1024
的逻辑可以在深入一下,在 transform
阶段会对 v-for
元素进行专门处理
// postTransformElement 处理
const shouldBuildAsSlots = isComponent && vnodeTag !== TELEPORT && vnodeTag !== KEEP_ALIVE;
if (shouldBuildAsSlots) {
const { slots, hasDynamicSlots } = buildSlots(node, context);
vnodeChildren = slots;
if (hasDynamicSlots) {
patchFlag |= 1024;
}
}
//buildSlots
let hasDynamicSlots = context.scopes.vSlot > 0 || context.scopes.vFor > 0;
hasDynamicSlots = hasScopeRef(node, context.identifiers);
其中 hasScopeRef
会对于当前元素里面是否包含表达式的方式,而我们组件代码里面用了 <div> {{ item }} {{ Date.now() }} </div>
里面包含表达式内容,因此最后 patchFlag = 1024
。
v-memo 使用
前面提到的 v-for
的插槽如果含表达式则会导致 patchFlag = 1024
,从而导致性能问题,但是 v-for
还有非常容易忽略的问题,虽然正常都是添加 :key
来添加唯一标识,让 vue 能正确更新 vnode,但是如果组件状态发生更新变化,整个组件都会被重新渲染,可以看下面的例子:
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0)
</script>
<template>
<button @click="count += 1">更新{{ count }}</button>
<div v-for="item in 100" :key="item">
{{ item }} {{ Date.now() }}
</div>
</template>
当点击 button
的时候,整个组件都会重新渲染,包括 v-for
里面的部分,普通情况下 vnode 节点的更新,并不会消耗太多性能,然而当 v-for
存在大量节点的时候,并且每个节点更新都包含不少逻辑,就会导致性能问题,而这一点往往很容易被忽略。
最好的优化方式是采用子组件,比如下面:
<script setup lang="ts">
import { ref } from 'vue';
import ItemComponent from './ItemComponent.vue'
const count = ref(0)
</script>
<template>
<button @click="count += 1">更新{{ count }}</button>
<ItemComponent v-for="item in 100" :key="item" />
</template>
由于 props
未发生变化,shouldUpdateComponent
返回 false
,ItemComponent
则不需要更新,从而避免了数据过大导致的性能问题。
还有一种方式是,采用 v-memo
的方式,官方介绍到:
v-memo 仅用于性能至上场景中的微小优化,应该很少需要。最常见的情况可能是有助于渲染海量 v-for 列表 (长度超过 1000 的情况)
<script setup lang="ts">
import { ref } from 'vue';
const list = ref([...new Array(100)].map(() => ({ count: 0 })))
const test = ref(0)
</script>
<template>
<button @click="list[0].count += 1">更新{{ test }}</button>
<div v-for="(item, index) in list" :key="index" v-memo="[item.count]">
{{ item.count }}{{ +Date.now() }}
</div>
</template>
上面例子中,点击按钮,只会触发遍历中的第一个节点更新,当传入的依赖数组没有变化时,甚至虚拟 DOM 的 vnode 创建也将被跳过,达到完美优化的方式。
在生成的 sfc 里面,可以看到 v-memo
的主要作用是会有一个 _isMemoSame
的判断,如果是 true
的话,都不会进入 patch 直接返回,这对大量数据的渲染是非常有帮助的,减少了不必要的是否更新判断。
v-memo
的特别适合在数据量特别大,但是节点简单无需做组件封装,或者因耦合过深组件无法封装的业务里面。其使用也非常类似 react 里面的 memo
方式,都是通过依赖来缓存是否需要更新。