案例介绍
在开发列表页时,对列表中数据的增删改查是常见的操作。我们来假设一种比较极端的情境:一个页面展示了 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 监测到数据发生变化重新渲染之后,我就让变量 isUpdate 为 true,从而在该条数据的开头显示“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,这是为了防止x和y为0和-0的特殊情况。因为0 === -0是为true的,但是数据由0变成-0是应该被判断为发生了变化,hasChanged应该返回true:
else分支中,也就是x !== y的情况,它不是直接返回一个true而是返回x === x || y === y,是防止x和y为NaN的情况,此时数据应该被判断为没发生变化,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-memo 和 v-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 就不会发生变化,继而不会被重新渲染,而是使用最后一次缓存的结果:
