处理数组
约 1646 字大约 5 分钟
2026-03-09
先让代理对象可以访问到原始对象
数组里的
includes、indexOf、lastIndexOf在查找对象时,本质上还是在做相等比较。如果数组里保存的是原始对象,而查找时传入的是代理对象,那么比较结果就会失败所以在真正处理数组查找之前,需要先让代理对象能够拿到自己对应的原始对象,这样后面数组方法在第一次查找失败后,才可以继续用原始值再比较一次
关键点
通过
ReactiveFlags[RAW]给代理对象提供一个 "取回原始对象" 的入口,为后面的数组查找兜底做准备代码
reactive.tsexport const enum ReactiveFlags { IS_REACTIVE = '__v_isReactive', RAW = '__v_raw', } export const targetMap = new WeakMap<Target, any>() export function toRaw<T>(observed: T): T { const raw = (observed as Target)[ReactiveFlags.RAW] || observed return raw === observed ? raw : toRaw(raw) }baseHandlers.tsfunction get(target: object, key: string | symbol, receiver: object): any { if (key === ReactiveFlags.IS_REACTIVE) { return true } else if (key === ReactiveFlags.RAW && receiver === targetMap.get(target)) { return target } track(target, TrackOpTypes.GET, key) const result = Reflect.get(target, key, receiver) if (isObject(result)) { return reactive(result) } return result }注
其中
receiver === targetMap.get(target)这层判断,是为了限制只有当前原始对象对应的那个代理对象自己访问RAW时,才返回原始对象,避免原型链对象或外层套壳代理也把raw取出来重写数组查找方法
仅仅能通过
RAW拿到原始对象还不够,因为数组上的includes、indexOf、lastIndexOf在默认情况下还是会直接按照当前传入的参数去比较所以还需要额外维护一个
arrayInstrumentationsRecord,把这几个查找方法统一改写掉。这样当调用代理数组上的查找方法时,就可以先在原始数组上查找一次;如果没找到,再把参数转成原始值继续查一次关键点
arrayInstrumentationsRecord本质上是对数组部分方法做拦截改写。当代理数组调用includes、indexOf、lastIndexOf时,get会优先返回这里包装过的方法,而不是直接返回数组原本的方法代码
baseHandlers.tsconst arrayInstrumentationsRecord: Record<string, Function> = {} ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach((key) => { const methods = Array.prototype[key] as any arrayInstrumentationsRecord[key] = function (this: unknown[], ...args: unknown[]) { // 先拿到代理数组对应的原始数组 const raw = toRaw(this) // 查找过程中会访问数组的每一项,这里把访问过的索引收集起来 for (let i = 0; i < raw.length; i++) { track(raw, TrackOpTypes.GET, i) } // 先直接用当前参数在原始数组中查找 const res = methods.apply(raw, args) if (res === -1 || res === false) { // 如果传入的是代理对象,第一次可能匹配失败 // 这里再把参数转成原始值查找一次 return methods.apply(raw, args.map(toRaw)) } return res } })get.tsfunction get(target: object, key: string | symbol, receiver: object): any { const targetArray = isArray(target) // 数组上的这几个查找方法需要走改写逻辑 if (targetArray && key in arrayInstrumentationsRecord) { return Reflect.get(arrayInstrumentationsRecord, key, target) } track(target, TrackOpTypes.GET, key) return Reflect.get(target, key, receiver) }处理下标修改带来的
length联动数组和普通对象不一样的地方在于,很多操作虽然表面上只改了某一个下标,但实际上还会连带影响
length比如给
arr[3]赋值时,看起来改的是下标3,但如果这个下标原本不存在,数组长度也会跟着变大;而直接修改arr.length时,又可能把后面的元素整体截断关键点
当数组写入是,至少会有两类影响:
- 当前这次修改影响了哪个 key
- 这次修改是否同时也修改了数组的 length
因此不能只判断当前
key有没有变化,还要额外比较修改前后的length。这样无论是 "隐式改了长度",还是 "显式改了长度",都可以补上对应的触发逻辑代码
function set( target: Record<string | symbol, unknown>, key: string | symbol, value: unknown, receiver: object, ): boolean { const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD const oldValue = target[key] // 先记录修改之前的数组长度 const oldLength = isArray(target) ? target.length : 0 const result = Reflect.set(target, key, value, receiver) if (!result) { return result } // 再拿到修改之后的数组长度 // Reflect.set 一旦成功,target 上的数据就已经是“修改后的状态”了。 const newLength = isArray(target) ? target.length : 0 // 如果 hasChanged 是 false 那么证明两个值不一样,那就是 set // 如果 hasChanged 是 true 说明两个值变了也就是 add if (hasChanged(value, oldValue) || type === TriggerOpTypes.ADD) { trigger(target, type, key) if (isArray(target) && oldLength !== newLength) { // 通过下标新增元素时,虽然改的是某个索引,但 length 也会跟着变化 if (key !== 'length') { trigger(target, TriggerOpTypes.SET, 'length') } else { // 直接修改 length 缩短数组时,需要把被截掉的索引逐个触发删除 // 如果 newLength 小于 oldLength 则进入循环,也就意味着删除数组后续元素 for (let i = newLength; i < oldLength; i++) { trigger(target, TriggerOpTypes.DELETE, i) } } } } return result }注
这里顺手把原来 "先判断
hadKey,再分别写ADD/SET/hasChanged" 的分支结构做了重构:- 先抽出
type - 再统一判断
hasChanged(value, oldValue) || type === TriggerOpTypes.ADD - 最后在同一个入口里补数组长度相关的额外触发逻辑
这样
hasChanged就不只是在服务普通对象的SET,而是和type一起,变成整段set流程的统一判断条件避免数组变异方法误收集
length仅通过额外判断数组项的变化还不够,因为数组上的
push、pop、shift、unshift、splice这类方法,在执行过程中也会间接读取和修改length注意
如果这些方法在副作用函数内部执行,那么它们内部为了完成操作而读取
length的动作,也会被track收集进去。这样一来,后面方法自己再去修改length,就可能把当前副作用函数重新触发,甚至出现递归执行关键点
如何避免方法内部对
length的读取被错误收集。这里的做法是:在这些数组变异方法执行期间,临时暂停依赖收集,等方法执行完再恢复代码
baseHandlers.ts;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach((key) => { const method = Array.prototype[key] as any arrayInstrumentationsRecord[key] = function (this: unknown, ...args: unknown[]) { // 先暂停依赖收集,避免方法内部读取 length 时被 track pauseTracking() const res = method.apply(this, args) // 方法执行完之后,再恢复正常的依赖收集 enableTracking() return res } })effect.tslet 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(`依赖收集: ${type} ${key} 属性被读取了`) }
