8000 59. Vue 性能优化 · Issue #60 · funfish/blog · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
59. Vue 性能优化 #60
Open
Open
@funfish

Description

@funfish

一次插槽带来的性能问题

在一次正常的业务代码里面使用了 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_tooltippatchFlag = 1024 /* DYNAMIC_SLOTS */,而这导致的变化是在 patch 阶段,判断组件是否更新会调用 shouldUpdateComponent,而 patchFlag = 1024 会返回 truepatchFlag = 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 返回 falseItemComponent 则不需要更新,从而避免了数据过大导致的性能问题。

还有一种方式是,采用 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 方式,都是通过依赖来缓存是否需要更新。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0