跳转到内容

数组相关问题

其实现在直接是通过Proxy做的代理,和vue2不同的是Proxy本身就能对数组做出相应的处理,所以大部分情况下,我们不需要做过多的处理

虽然 Vue 3 已经能用 Proxy 直接拦截数组了,但数组有些方法光靠普通 get/set 还不够,所以源码里还是做了额外处理。

主要是两个原因

  1. 某些查找方法要兼容“代理对象”和“原始对象”
const raw = {}
const arr = reactive([raw])
arr.includes(raw)

数组里实际存的有时候可能是原对象,有时候拿去比较的是代理对象,或者反过来。 如果完全交给原生 includes / indexOf,可能会因为引用不一样导致判断不准。

所以 Vue 3 会对这些方法做一层包装:

  • includes
  • indexOf
  • lastIndexOf

先按原值查一次,不行再按 toRaw 后的值查一次。

  1. 某些变异方法会顺带读写 length,容易造成多余追踪甚至死循环

比如:

  • push
  • pop
  • shift
  • unshift
  • splice

这些方法内部会改数组长度。 如果 effect 里正好操作数组,而执行这些方法时又把 length 错误收集成依赖,就可能导致:

  • 重复触发
  • 不必要更新
  • 甚至递归死循环

所以 Vue 3 对这些方法也会做特殊处理,比如在执行某些数组变异时临时暂停依赖收集。

const arr = [1, 2, 3]
const state = reactive(arr)
state[0]
state[1] = 10
for (let i = 0; i < state.length; i++) {
state[i]
}
for (const key in state) {
}

20260331140620

从上图可以看到,读取和设置都是正常的,但是这里对于 length 一直在重复的收集依赖,可以把循环写成下面这样,当然只限于 length 属性不会被修改的前提

for (let i = 0, l = state.length; i < l; i++) {
state[i]
}

20260331140545

这样就只对 length 收集一次依赖了


数组也有一些自己身上的读取方法

const arr = [1, 2, 3]
const state = reactive(arr)
state.indexOf(2)

20260331141237

从上图可以发现一个问题,就是每次都要看每一个下标是否存在,存在再去读取这个数组对应下标的值,有点多此一举,这是比较隐式的,下面这个代码就比较明显了

const obj = { a: 1 }
const arr = [1, obj, 3]
const state = reactive(arr)
const idx = state.indexOf(obj)
console.log(idx)

20260331141858

可以看到从代理对象中去找 obj 找不到。因为我们在处理 reactive 的时候做了判断,如果是对象就递归处理。所以肯定不想等了,除非用下面的方式去判断

const idx = state.indexOf(reactive(obj))
console.log(idx)

20260331141928

可以看到这样就找到了。

那么解决问题的方案也很简单,只需要把 state 转化成原始对象就可以了,之前在 ReactiveFlags 里面有个 RAW 一直没用到

只需要在 reactive 里面判断下

export function reactive<T extends object>(target: T): T
export function reactive(target: object) {
// 判断传入的是否是对象
if (!isObject(target)) {
console.log('传入的必须是一个对象')
return target
}
// 判断是否已经被代理过
if (targetMap.has(target)) {
return targetMap.get(target)
}
// 只要读到了__v_isReactive,就返回target
// 因为Proxy对象直接拦截了这个属性
// 同样 读到target[ReactiveFlags.RAW]直接返回对象
if (target[ReactiveFlags.RAW] && target[ReactiveFlags.IS_REACTIVE]) {
return target
}
const proxy = new Proxy(target, mutableHandlers)
// 存储代理对象
targetMap.set(target, proxy)
return proxy
}

这里 target[ReactiveFlags.RAW] 会触发 targetget 操作,在 get 操作里面再返回原始对象就行了

function get(target: object, key: string | symbol, receiver: object): any {
// 如果进入到get方法,说明肯定是一个proxy代理对象
// 如果访问的是__v_isReactive,返回true
if (key === ReactiveFlags.IS_REACTIVE) {
return true
} else if (key === ReactiveFlags.RAW && receiver === targetMap.get(target)) {
// 访问的是 __v_raw 属性,并且是代理对象本身在访问
return target
}
// todo: 收集依赖
track(target, TrackOpTypes.GET, key)
// 返回对象的相应属性值
const result = Reflect.get(target, key, receiver)
// 判断是不是对象,是对象就递归代理
if (isObject(result)) {
return reactive(result)
}
return result
}

测试一下,访问 代理对象的 __v_raw,看下是不是返回的原始值

const obj = { a: 1 }
const arr = [1, obj, 3]
const state = reactive(arr)
console.log(state['__v_raw'])

20260331144401

可以写个方法包装一下,让其返回原始对象,也就是 toRaw

20260331144735

export function toRaw<T>(observed: T): T {
return (observed && toRaw((observed as Target)[ReactiveFlags.RAW])) || observed
}

当代理对象调用这些方法的时候,可以重写一下这些方法,然后在这些方法里面去处理。

vue-source/packages/reactivity/src/baseHandlers.ts
const arrayInstrumentations: Record<string, Function> = {}
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
// 获取原生方法的引用
const method = Array.prototype[key] as any
arrayInstrumentations[key] = function (this: unknown[], ...args: unknown[]) {
// 将 this 转化为 非响应式(代理)对象 --> 这里的 this 就是调用这些方法的数组
const arr = toRaw(this)
// 遍历当前数组的每个索引,通过track函数对数组索引进行依赖收集
for (let i = 0, l = this.length; i < l; i++) {
track(arr, TrackOpTypes.GET, i + '')
}
// 直接在原始对象中查找,使用原始数组和参数
const res = method.apply(arr, args)
if (res === -1 || res === false) {
// 如果在原始数组中没有找到,注意,还需要进行处理,因为参数也有可能是响应式的
return method.apply(arr, args.map(toRaw))
} else {
return res
}
}
})

然后当调用这些方法的时候,就会触发到 get 对应的 key。所以继续在 get 里面处理。

vue-source/packages/reactivity/src/baseHandlers.ts
function get(target: object, key: string | symbol, receiver: object): any {
// 如果进入到get方法,说明肯定是一个proxy代理对象
// 如果访问的是__v_isReactive,返回true
if (key === ReactiveFlags.IS_REACTIVE) {
return true
} else if (key === ReactiveFlags.RAW && receiver === targetMap.get(target)) {
// 访问的是 __v_raw 属性,并且是代理对象本身在访问
return target
}
// 判断是不是数组,如果是数组,并且 key 是 arrayInstrumentations 对应的方法
const targetIsArray = isArray(target)
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// todo: 收集依赖
track(target, TrackOpTypes.GET, key)
// 返回对象的相应属性值
const result = Reflect.get(target, key, receiver)
// 判断是不是对象,是对象就递归代理
if (isObject(result)) {
return reactive(result)
}
return result
}
const obj = { a: 1 }
const arr = [1, obj, 3]
const state = reactive(arr)
console.log(state.indexOf(obj))
console.log(state.indexOf(reactive(obj)))

20260331163400

可以看到这个时候就能正常找到了,而且会发现之前每次都还需要 has 判断,现在也没有触发 has 了,也没有去收集 indexOf 这些依赖了。

const arr = [1, 2, , 4, 5]
const state = reactive(arr)
state[0] = 99 // set
state[2] = 100 // add
state[10] = 1000 // add

20260401142110

当数组在写入值的时候,某个位置上有值,当然是set,某个位置上没有值,是add,这些都没有问题。但是仔细思考state[10] = 1000**应该是少了一种操作,因为我们这样做之后,数组的长度是发生了变化的,数组的长度变化,肯定会对相应的调用产生影响,因此这里少了一步对数组长度变化的处理

数组 length 被隐式修改的处理办法

Section titled “数组 length 被隐式修改的处理办法”
function set(target: Record<string | symbol, unknown>, key: string | symbol, value: unknown, receiver: object): boolean {
// 判断是新增还是修改
const type = hasOwn(target, key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
const oldValue = target[key]
const targetIsArray = isArray(target)
// 旧值的长度
const oldLen = targetIsArray ? target.length : 0
// 设置对象的相应属性值
const result = Reflect.set(target, key, value, receiver)
if (!result) {
return result
}
// 这里代表设置成功
const newLen = targetIsArray ? target.length : 0
if (hasChanged(value, oldValue) || type === TriggerOpTypes.ADD) {
trigger(target, type, key)
if (targetIsArray && oldLen !== newLen) {
// 数组长度变化了,但是不是直接改的 length 属性
if (key !== 'length') {
trigger(target, TriggerOpTypes.SET, 'length')
}
}
}
return result
}

20260401154943

也就是当数组长度新旧不相等的时候,需要手动触发 length 属性的更新

当然上面是隐式设置数组length可能会出现的问题,那如果直接设置数组长度会有什么问题吗?

length变大和变小都触发了set,这好像没什么问题。但是仔细思考一下,当length变小的时候,仅仅就只是触发了length长度的改变吗?

const arr = [1, 2, 3, 4, 5, 6]
const state = reactive(arr)
state.length = 3

数组的长度变小了,其实相当于是把后面几个都给删掉了

function set(target: Record<string | symbol, unknown>, key: string | symbol, value: unknown, receiver: object): boolean {
// 判断是新增还是修改
const type = hasOwn(target, key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
const oldValue = target[key]
const targetIsArray = isArray(target)
// 旧值的长度
const oldLen = targetIsArray ? target.length : 0
// 设置对象的相应属性值
const result = Reflect.set(target, key, value, receiver)
if (!result) {
return result
}
// 这里代表设置成功
const newLen = targetIsArray ? target.length : 0
if (hasChanged(value, oldValue) || type === TriggerOpTypes.ADD) {
trigger(target, type, key)
if (targetIsArray && oldLen !== newLen) {
// 数组长度变化了,但是不是直接改的 length 属性
if (key !== 'length') {
trigger(target, TriggerOpTypes.SET, 'length')
} else {
// 操作的是 key,并且 key 的长度小于旧的长度,则需要删除(长度变长不需要处理)
for (let i = newLen; i < oldLen; i++) {
trigger(target, TriggerOpTypes.DELETE, i + '')
}
}
}
}
return result
}

20260401155522

const arr = [1, 2, 3, 4, 5, 6]
const state = reactive(arr)
state.push(7)

20260401160228

我们调用的是push方法,当然就应该对push去进行依赖收集,push了之后当然伴随着新的值的增加和数组长度的增加,这些都没有问题,但是length这个属性该不该收集呢?

从代码逻辑上来说,push方法肯定会查找到之前的length,然后对数组做长度+1的操作,这是没有疑问的。

但是对于我们依赖收集来说,还需要在push方法中去收集length属性吗?其实是不需要的。

而且这很容易引起死循环的问题,当然这个问题也并不是一开始就被发现的,vue3在其版本3.0.0-rc.12中修复了这个问题issue

那怎么让push方法不去收集length属性呢?方式有很多,最简单的其实就直接在代理对象中暂停依赖收集

vue-source/packages/reactivity/src/effect.ts
import { TrackOpTypes, TriggerOpTypes } from './operations'
// 用来表示对对象的“迭代依赖”的标识
export const ITERATE_KEY = Symbol('')
// 是否进行依赖收集的开关
let shouldTrack = true
export function pauseTracking() {
shouldTrack = false
}
export function enableTracking() {
shouldTrack = true
}
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (!shouldTrack) {
return
}
console.log(`%c依赖收集: target ${JSON.stringify(target)}${type}${String(key)}`, 'color: #f40')
}
export function trigger(target: object, type: TriggerOpTypes, key: unknown) {
console.log(`%c派发更新: target ${JSON.stringify(target)}${type}${String(key)}`, 'color: #0f0')
}
vue-source/packages/reactivity/src/baseHandlers.ts
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
const method = Array.prototype[key] as any
arrayInstrumentations[key] = function (this: unknown[], ...args: unknown[]) {
pauseTracking()
const res = method.apply(this, args)
enableTracking()
return res
}
})

20260401160722

这样加个开关,当调用这几个方法的时候就不会去收集依赖了