vue 项目优化之保持对象引用的稳定与 v-memo 的使用

359 阅读3分钟

案例介绍

在开发列表页时,对列表中数据的增删改查是常见的操作。我们来假设一种比较极端的情境:一个页面展示了 1 万条数据,每条数据都有个状态 status 属性是可以更改的。页面代码如下:

<!-- src\App.vue -->
<script setup lang="ts">
  import Item from './components/Item.vue'
  import { nextTick, ref } from 'vue'
  import type { Ref } from 'vue'

  // 创建初始化 10000 条数据的数组
  const list: Ref<any[]> = ref(
    Array.from({ length: 10_000 }, (_, index) => {
      return { id: index, name: `name-${index}`, status: 0 }
    })
  )

  // 更新某条数据的状态
  function updateStatus(id: number) {
    // ...
  }
</script>

<template>
  <template v-for="item in list" :key="item.id">
    <Item :item="item" @update="updateStatus" />
  </template>
</template>

封装了 Item 组件用于展示数据:

<!-- src\components\Item.vue -->
<script lang="ts" setup>
  import { onUpdated, ref } from 'vue'

  interface IItem {
    id: number
    name: string
    status: number
  }
  const { item } = defineProps<{ item: IItem }>()
  const emit = defineEmits(['update'])

  function update(id: number) {
    emit('update', id)
  }

  const isUpdate = ref(false)
  onUpdated(() => {
    isUpdate.value = true
  })
</script>

<template>
  <div>
    <span v-if="isUpdate">isUpdate:</span>
    <span>{{ item.name }} - {{ item.status }}</span>
  </div>
  <button @click="update(item.id)">更新状态</button>
</template>

在 updated 生命周期,即 vue 监测到数据发生变化重新渲染之后,我就让变量 isUpdatetrue,从而在该条数据的开头显示“isUpdate:”,以方便我们查看某条数据是否被重新渲染了。

vue3.2 之前

如果我们只需要更改某一条数据的状态,一般在拿到 id 后,将 id 和更改后的 status 发送给后端,后端会将服务器内的数据进行更新。此时前端的处理,即在 updateStatus 方法里要做的事,在 vue3.2 之前,可以有 2 种思路:

更改引用

重新去请求当前列表的数据,然后赋值给 list.value

function updateStatus(id: number) {
  list.value = Array.from({ length: 10_000 }, (_, index) => {
    const status = index === id ? Math.floor(Math.random() * 10) : 0
    return { id: index, name: `name-${index}`, status }
  })
  console.time()
  nextTick(() => {
    console.timeEnd()
  })
}

这种做法完全更改了 list.value 的引用对象, 即使我们的需求仅仅是改变某一条数据的 status,但是整个列表都会重新渲染。结合 console.time()console.timeEnd()nextTick() 可以查看 DOM 更新的时间:

保持引用的稳定

这种思路是让后端返回发生了更新的这条数据,然后我们在 list.value 中根据 id 去找到它,单独更新这一项,而不改变 list.value 所指向的引用:

function updateStatus(id: number) {
  const index = list.value.findIndex(item => item.id === id)
  const newItem = {
    id: index,
    name: `name-${index}`,
    status: Math.floor(Math.random() * 10)
  }
  // list.value.splice(index, 1, newItem) // vue2 的做法
  list.value[index] = newItem
  console.time()
  nextTick(() => {
    console.timeEnd()
  })
}

现在去更新某条数据的状态,可以看到其它的数据不会被重新渲染,DOM 更新所执行的时间也明显更快:

源码探究

vue 源码中,判断数据是否发生变化,是否需要去重新渲染,依靠的是 hasChanged() 方法,它在 vue2 和 vue3 中的实现如下:

vue2

在使用 Object.defineProperty() 对数据的属性做 setter 拦截的时候,就会先去判断下数据是否真的发生了变化:

hasChanged 定义如下:

// vuejs/vue/src/shared/util.ts
export function hasChanged(x: unknown, y: unknown): boolean {
  if (x === y) {
    return x === 0 && 1 / x !== 1 / (y as number)
  } else {
    return x === x || y === y
  }
}

稍稍解释下代码:

  • x === y 即新旧数据应该是没变化的情况,它没有直接返回 false 而是返回了 x === 0 && 1 / x !== 1 / y,这是为了防止 xy0-0 的特殊情况。因为 0 === -0 是为 true 的,但是数据由 0 变成 -0 是应该被判断为发生了变化,hasChanged 应该返回 true

  • else 分支中,也就是 x !== y 的情况,它不是直接返回一个 true 而是返回 x === x || y === y,是防止 xyNaN 的情况,此时数据应该被判断为没发生变化,hasChanged 应该返回 false,而 NaN === NaN 就是为 false

vue3

在 vue3 中,hasChanged 的实现就相对简单,直接使用了 Object.is() 判断新旧值是否发生了变化:

// vuejs/core/packages/shared/src/general.ts
export const hasChanged = (value: any, oldValue: any): boolean =>
  !Object.is(value, oldValue)

其实 vue2 中的写法就是在一些不支持 Object.is() 的环境下的 Polyfill。

小结

如果传给 hasChanged 的新旧值是对象,那么判断的依据就是它们的引用地址是否相同。所以在项目中,我们应该尽量保持数据的引用的稳定,以避免多余的渲染消耗。

vue3.2 开始:v-memo

从 vue3.2 开始,提供了新的指令 v-memo,用于缓存一个模板的子树,它的值为一个数组,数组的元素个数固定,元素为依赖值。vue 会去判断这些依赖值有没有发生改变,如果没有改变,则整个子树的更新会被跳过。v-memov-for 一起使用时,需要绑定在同一个元素上。结合本文的案例,我们只需要如下添加上 v-memo="[item.status]"

<template>
  <template v-for="item in list" :key="item.id" v-memo="[item.status]">
    <Item :item="item" @update="updateStatus" />
  </template>
</template>

现在,即使在 updateStatus() 中我们采取了更改引用的方式,即将 list.value 重新赋值了,更新某条数据时,因为其它数据的 status 不会改变,所以 item.status 就不会发生变化,继而不会被重新渲染,而是使用最后一次缓存的结果:

感谢.gif 点赞.png