一次插槽带来的性能问题
在一次正常的业务代码里面使用了 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 方式,都是通过依赖来缓存是否需要更新。
一次插槽带来的性能问题
在一次正常的业务代码里面使用了 element-plus 的
el-tooltip组件,结果用 performance 调试的时候发现,操作时候该部分的性能异常的差,能阻塞 1ms。考虑到用户是连续操作背景下,无疑会带来明显的卡顿。代码简化如下就是上面这段看起来没有问题的代码,通过遍历生成了 10 个
el-tooltip组件,是正常不过的用法了。可是这里却又一个奇怪的问题,在count发生更新时候,下面的el-tooltip这里会也会自动更新,不仅Date.now()值会随着count更新而更新,而且通过 performance 调试可以发现el-tooltip组件本身也重新渲染了,按照响应式更新的方式,el-tooltip是不应该更新的,比如上面的el-text组件就很正常的不会更新,难道是el-tooltip组件里面有什么魔法操作?从
el-tooltip开始排查,里面实现大致通过多层包装最后通过OnlyChild来包装生成返回可以看到该
OnlyChild组件内部使用了cloneVNode方法,将传入的slots.default中的第一个节点进行克隆,element-plus组件里面的属性特别的多,直接cloneVNode会有不少性能损耗的,尤其对于用户侧的项目,能少用element-plus这种面向中后台为主的组件库还是比较好。排查看这里确实不应该是触发更新的源头。于是查看编译后的 sfc 代码
上面代码是生成的渲染函数的主要部分,可以看到都是通过
_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 表示的是动态插槽,也就是插槽内容有变化,需要重新渲染。只是为什么是动态的?如果这个时候再模版代码里面最后面添加
可以发现生成的 sfc 是不带有
DYNAMIC_SLOTS标识的,同时和el-text一样,不会触发内部插槽更新。对比一下发现明显的不同点在于前者通过v-for的形式生成,进而导致最后编译patchFlag不同。**这样可以基本确定问题了,在
v-for循环中,如果传入了动态内容作为插槽一部分,则该节点的patchFlag = 1024组件会随着父组件一起更新。**这就导致了如果父组件一直更新,子组件不管如何都会更新的情况,那要如何避免呢?其实对于上述案例,**用v-once就能直接解决,避免只是为了快速生成一组el-tooltip。**对于el-tooltip其更新的机制会重新执行cloneVNode在叠加繁杂的参数,导致性能下降。这里的
patchFlag = 1024的逻辑可以在深入一下,在transform阶段会对v-for元素进行专门处理其中
hasScopeRef会对于当前元素里面是否包含表达式的方式,而我们组件代码里面用了<div> {{ item }} {{ Date.now() }} </div>里面包含表达式内容,因此最后patchFlag = 1024。v-memo 使用
前面提到的
v-for的插槽如果含表达式则会导致patchFlag = 1024,从而导致性能问题,但是v-for还有非常容易忽略的问题,虽然正常都是添加:key来添加唯一标识,让 vue 能正确更新 vnode,但是如果组件状态发生更新变化,整个组件都会被重新渲染,可以看下面的例子:当点击
button的时候,整个组件都会重新渲染,包括v-for里面的部分,普通情况下 vnode 节点的更新,并不会消耗太多性能,然而当v-for存在大量节点的时候,并且每个节点更新都包含不少逻辑,就会导致性能问题,而这一点往往很容易被忽略。最好的优化方式是采用子组件,比如下面:
由于
props未发生变化,shouldUpdateComponent返回false,ItemComponent则不需要更新,从而避免了数据过大导致的性能问题。还有一种方式是,采用
v-memo的方式,官方介绍到:上面例子中,点击按钮,只会触发遍历中的第一个节点更新,当传入的依赖数组没有变化时,甚至虚拟 DOM 的 vnode 创建也将被跳过,达到完美优化的方式。
在生成的 sfc 里面,可以看到
v-memo的主要作用是会有一个_isMemoSame的判断,如果是true的话,都不会进入 patch 直接返回,这对大量数据的渲染是非常有帮助的,减少了不必要的是否更新判断。v-memo的特别适合在数据量特别大,但是节点简单无需做组件封装,或者因耦合过深组件无法封装的业务里面。其使用也非常类似 react 里面的memo方式,都是通过依赖来缓存是否需要更新。