数组相关问题
其实现在直接是通过Proxy做的代理,和vue2不同的是Proxy本身就能对数组做出相应的处理,所以大部分情况下,我们不需要做过多的处理
虽然 Vue 3 已经能用 Proxy 直接拦截数组了,但数组有些方法光靠普通 get/set 还不够,所以源码里还是做了额外处理。
主要是两个原因
- 某些查找方法要兼容“代理对象”和“原始对象”
const raw = {}const arr = reactive([raw])
arr.includes(raw)数组里实际存的有时候可能是原对象,有时候拿去比较的是代理对象,或者反过来。
如果完全交给原生 includes / indexOf,可能会因为引用不一样导致判断不准。
所以 Vue 3 会对这些方法做一层包装:
includesindexOflastIndexOf
先按原值查一次,不行再按 toRaw 后的值查一次。
- 某些变异方法会顺带读写 length,容易造成多余追踪甚至死循环
比如:
pushpopshiftunshiftsplice
这些方法内部会改数组长度。
如果 effect 里正好操作数组,而执行这些方法时又把 length 错误收集成依赖,就可能导致:
- 重复触发
- 不必要更新
- 甚至递归死循环
所以 Vue 3 对这些方法也会做特殊处理,比如在执行某些数组变异时临时暂停依赖收集。
数组读取的相关问题
Section titled “数组读取的相关问题”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) {}
从上图可以看到,读取和设置都是正常的,但是这里对于 length 一直在重复的收集依赖,可以把循环写成下面这样,当然只限于 length 属性不会被修改的前提
for (let i = 0, l = state.length; i < l; i++) { state[i]}
这样就只对 length 收集一次依赖了
数组也有一些自己身上的读取方法
const arr = [1, 2, 3]const state = reactive(arr)
state.indexOf(2)
从上图可以发现一个问题,就是每次都要看每一个下标是否存在,存在再去读取这个数组对应下标的值,有点多此一举,这是比较隐式的,下面这个代码就比较明显了
const obj = { a: 1 }
const arr = [1, obj, 3]
const state = reactive(arr)
const idx = state.indexOf(obj)console.log(idx)
可以看到从代理对象中去找 obj 找不到。因为我们在处理 reactive 的时候做了判断,如果是对象就递归处理。所以肯定不想等了,除非用下面的方式去判断
const idx = state.indexOf(reactive(obj))console.log(idx)
可以看到这样就找到了。
那么解决问题的方案也很简单,只需要把 state 转化成原始对象就可以了,之前在 ReactiveFlags 里面有个 RAW 一直没用到
只需要在 reactive 里面判断下
export function reactive<T extends object>(target: T): Texport 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] 会触发 target 的 get 操作,在 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'])
可以写个方法包装一下,让其返回原始对象,也就是 toRaw

export function toRaw<T>(observed: T): T { return (observed && toRaw((observed as Target)[ReactiveFlags.RAW])) || observed}当代理对象调用这些方法的时候,可以重写一下这些方法,然后在这些方法里面去处理。
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 里面处理。
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)))
可以看到这个时候就能正常找到了,而且会发现之前每次都还需要 has 判断,现在也没有触发 has 了,也没有去收集 indexOf 这些依赖了。
数组长度的相关问题
Section titled “数组长度的相关问题”const arr = [1, 2, , 4, 5]const state = reactive(arr)state[0] = 99 // setstate[2] = 100 // addstate[10] = 1000 // add
当数组在写入值的时候,某个位置上有值,当然是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}
也就是当数组长度新旧不相等的时候,需要手动触发 length 属性的更新
显式修改数组长度
Section titled “显式修改数组长度”当然上面是隐式设置数组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}
push,pop 等方法 length 问题
Section titled “push,pop 等方法 length 问题”const arr = [1, 2, 3, 4, 5, 6]const state = reactive(arr)state.push(7)
我们调用的是push方法,当然就应该对push去进行依赖收集,push了之后当然伴随着新的值的增加和数组长度的增加,这些都没有问题,但是length这个属性该不该收集呢?
从代码逻辑上来说,push方法肯定会查找到之前的length,然后对数组做长度+1的操作,这是没有疑问的。
但是对于我们依赖收集来说,还需要在push方法中去收集length属性吗?其实是不需要的。
而且这很容易引起死循环的问题,当然这个问题也并不是一开始就被发现的,vue3在其版本3.0.0-rc.12中修复了这个问题issue
那怎么让push方法不去收集length属性呢?方式有很多,最简单的其实就直接在代理对象中暂停依赖收集
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')};(['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 }})
这样加个开关,当调用这几个方法的时候就不会去收集依赖了