Vue3源码,响应式原理-数组

看完这篇文章,你将能回答以下:

  • Vue3是如何拦截通过索引或者length属性 修改/访问数组?
  • Vue3如何拦截数组的for in遍历?
  • Vue3如何拦截数组for ...... of遍历?
  • Vue3如何拦截数组的查找方法,includes indexOf lastIndexOf?
  • Vue3如何拦截数组的push/pop/shift/unshift方法?

1. 拦截数组索引

拦截数组索引是指:当通过索引访问数组的值时,能够收集对应的依赖(副作用函数);当通过索引修改值时,能够触发对应的副作用的函数。

我的这篇文章里面的实现,已经能够满足数组的索引的访问和修改了。其实就是通过Proxy的get和set拦截函数

测试效果:

js 复制代码
let arr = reactive([11])
effect(() => {
    console.log(arr[0], 'arr[0]');
})

在控制台打印和修改值,成功触发对应的副作用函数

1.1 边缘情况1:Proxy无法检测length的变化

但是要处理边缘情况,如果索引超过了数组的长度,拦截就会失效,如下某个副作用函数访问了数组的length,我们希望当通过arr[10] = 1这样的赋值操作后能够通过下面的副作用函数执行一次:

js 复制代码
effect(() => {
    console.log(arr.length, 'arr[0]');
})

没有打印长度:

不能触发的原因是,Proxy无法自动捕捉到数组的length的变化,length的变化是隐式的,是浏览器的内部行为,所以需要我们手动触发

createReactiveset函数里判断如果是数组,并且修改的索引大于数组的length,则是ADD类型:

diff 复制代码
set(target, key, newVal, receiver) {
    // 只读
    if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`);
        return true
    }
    // 旧值
    let oldVal = target[key]
+    let type = Array.isArray(target) ? 
+        (Number(key) < target.length ? 'SET' : 'ADD')
+        : 
+        (Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD')
    const res = Reflect.set(target, key, newVal, receiver)
    // 普通对象和receiver指向的raw相等
    if (target === receiver.raw) {
        if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
            trigger(target, key, type)
        }
    }
},

在trigger函数里面,判断是数组且是add类型操作,则把length对应的副作用函数拿出来重新执行:

diff 复制代码
function trigger (target, key, type) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    // 取得与key相关联的副作用函数
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    // 将与key相关联的副作用函数添加到effectsToRun
    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    // 操作类型是ADD的时候,把length对应的副作用函数取出来,加入到effectsToRun中拿出来执行
+    if (Array.isArray(target) && type === 'ADD') {
+        const lengthOfEffects = depsMap.get('length')
+        lengthOfEffects && lengthOfEffects.forEach(effectFn => {
+            if (effectFn !== activeEffect) {
+                effectsToRun.add(effectFn)
+            }
+        })
+    }
    if (type === 'ADD' || type === 'DELETE') {
        // 取得与ItERATE_KEY关联的副作用函数
        const iterateEffects = depsMap.get(ITERATE_KEY)
        // 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
        iterateEffects && iterateEffects.forEach(effectFn => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn)
            }
        })
    }
    ......省略下面的代码
}

此时再调试,能够成功:

1.2 边缘情况2,修改数组的length属性

如果某个副作用函数访问了arr[2],此时把数组的length改为0,那么应该通知这个副作用函数,因为索引为2的那个数已经被删掉了。

在set拦截函数:

diff 复制代码
set(target, key, newVal, receiver) {
    // 只读
    if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`);
        return true
    }
    // 旧值
    let oldVal = target[key]
    let type = Array.isArray(target) ? 
        (Number(key) < target.length ? 'SET' : 'ADD')
        : 
        (Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD')
    // target[key] = newVal
    const res = Reflect.set(target, key, newVal, receiver)
    // 普通对象和receiver指向的raw相等
    if (target === receiver.raw) {
        if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
             // 传递新的newVal参数过去
+            trigger(target, key, type, newVal)
        }
    }
},

在trigger函数:

diff 复制代码
function trigger (target, key, type, newVal) {
    let depsMap = bucket.get(target)
    if (!depsMap) return 
    // 取得与key相关联的副作用函数
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    ......省略其他副作用函数
    // 如果操作目标是数组,并且修改了数组的key属性
+    if (Array.isArray(target) && key === 'length') {
+        depsMap.forEach((effects, effectKey) => {
+            if (effectKey >= newVal) {
+                effects.forEach(effectFn => {
+                    if (effectFn !== activeEffect) {
+                        effectsToRun.add(effectFn)
+                    }
+                })
+            }
+        })
+    }
    effectsToRun && effectsToRun.forEach(effectFn => {
        if (effectFn.options && effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

如上,在set拦截函数中传递newVal,在trigger函数中,判断是数组并且是修改length属性,把索引大于length对应的副作用函数要拿出来执行

js 复制代码
let arr = reactive([11, 12, 13])
effect(() => {
    console.log(arr[2], 'arr[2]');
})

调试结果如下:

对应源码

修改arr[3]时,索引3超过数组长度:

diff 复制代码
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
  constructor(isShallow = false) {
    super(false, isShallow)
  }

  set(
    target: Record<string | symbol, unknown>,
    key: string | symbol,
    value: unknown,
    receiver: object,
  ): boolean {
    let oldValue = target[key]
    ......省略一部分代码
    
    // 如果是数组,并且key是整数,则判断key是否小于数组的长度
+    const hadKey =
+      isArray(target) && isIntegerKey(key)
+        ? Number(key) < target.length
+        : hasOwn(target, key)
    const result = Reflect.set(
      target,
      key,
      value,
      isRef(target) ? target : receiver,
    )
    // don't trigger if target is something up in the prototype chain of original
    if (target === toRaw(receiver)) {
       // hadKey是false,则执行add操作,是新增的属性/值
+      if (!hadKey) {
+        trigger(target, TriggerOpTypes.ADD, key, value)
+      } else if (hasChanged(value, oldValue)) {
        // 有这个key,则执行set操作
+        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
+      }
    }
    return result
  }
  ......省略其他拦截函数
}

修改数组索引,索引超过原本长度 以及 修改length触发副作用函数都在下面

diff 复制代码
// /packages/reactivity/src/dep.ts
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>,
): void {
  

  if (type === TriggerOpTypes.CLEAR) {
    // collection being cleared
    // trigger all effects for target
    depsMap.forEach(run)
  } else {
    const targetIsArray = isArray(target)
    const isArrayIndex = targetIsArray && isIntegerKey(key)

    if (targetIsArray && key === 'length') {
      const newLength = Number(newValue)
      depsMap.forEach((dep, key) => {
        if (
          key === 'length' ||
          key === ARRAY_ITERATE_KEY ||
+          // 直接修改arr.length = 0,length属性修改触发副作用函数
+          (!isSymbol(key) && key >= newLength)
        ) {
+          run(dep)
        }
      })
    } else {
      // schedule runs for SET | ADD | DELETE
      if (key !== void 0 || depsMap.has(void 0)) {
        run(depsMap.get(key))
      }

      // schedule ARRAY_ITERATE for any numeric key change (length is handled above)
      if (isArrayIndex) {
        run(depsMap.get(ARRAY_ITERATE_KEY))
      }

      // also run for iteration key on ADD | DELETE | Map.SET
+      switch (type) {
        case TriggerOpTypes.ADD:
          if (!targetIsArray) {
            run(depsMap.get(ITERATE_KEY))
            if (isMap(target)) {
              run(depsMap.get(MAP_KEY_ITERATE_KEY))
            }
+          } else if (isArrayIndex) {
+            // 修改arr[3] = 1,索引3超过数组长度
+            // new index added to array -> length changes
+            run(depsMap.get('length'))
          }
          break
        case TriggerOpTypes.DELETE:
          if (!targetIsArray) {
            run(depsMap.get(ITERATE_KEY))
            if (isMap(target)) {
              run(depsMap.get(MAP_KEY_ITERATE_KEY))
            }
          }
          break
        case TriggerOpTypes.SET:
          if (isMap(target)) {
            run(depsMap.get(ITERATE_KEY))
          }
          break
      }
    }
  }

  endBatch()
}

对比Vue2

Vue2中,因为Object.defineProperty方法的限制,无法拦截到数组的length和索引的变化,以下两个操作都无法拦截到,只能通过Vue.set来实现。

js 复制代码
this.arr[0] = 2
this.arr.length = 0

2. 拦截数组for in操作

定义:拦截数组的for in操作是指,当某个副作用函数通过for in访问数组时,其他对数组的操作能够触发这个副作用函数重新执行。

js 复制代码
let arr = reactive([11, 12, 13])
effect(() => {
    for (const key in arr) {
        console.log(arr[key], 'arr[key]');
    }
})

如下操作会影响for in遍历,只要是修改了数组的长度的操作,都会影响for ...... in的遍历

js 复制代码
arr[100] = '100'
arr.length = 0

具体实现: for in访问数组,会触发Proxy的拦截函数ownKeys,判断是数组,则传length属性给track函数,这样length属性一旦受到影响,就会触发对应的副作用函数

js 复制代码
ownKeys (target) {
    track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
    return Reflect.ownKeys(target)
}

对应源码

diff 复制代码
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
  constructor(isShallow = false) {
    super(false, isShallow)
  }
+省略其他拦截函数
+  ownKeys(target: Record<string | symbol, unknown>): (string | symbol)[] {
    track(
      target,
      TrackOpTypes.ITERATE,
+      isArray(target) ? 'length' : ITERATE_KEY,
    )
    return Reflect.ownKeys(target)
  }
}

3. 拦截数组的for of方法

定义:当某个副作用函数通过for of访问数组时,其他地方对数组的修改能够触发for of重新执行。

js 复制代码
let arr = reactive([11, 12, 13])
effect(() => {
    for (const value of arr) {
        console.log(value, 'value');
    }
})

如下图,我们修改数组的索引为0的值,能够触发for of重新执行,通过new Proxy已经能够实现拦截了:

这是因为数组能够被for of访问是得益于@@iterator也就是内部方法Symbol.iterator,其内部实现就是访问了数组的length属性,Proxy本身能够实现对length的读取

边缘情况

要解决一个情况是,for of在遍历数组时,会把对应symbol类型的Symbol.iterator这个key也收集到依赖中,进而会让数字和symbol进行比较,产生报错。

如下报错:

报错原因:

对应的报错代码:

实际上我们只需要数组的lengthkey与对应的副作用函数之间建立联系就可以。修改方式如下:只有在key的类型不是symbol时才会去进行依赖追踪:

diff 复制代码
 代码解读
复制代码
get(target, key, receiver) {
    if (key === 'raw') {
        return target
    }
+    if (!isReadonly && typeof key !== 'symbol') {
+        track(target, key)
+    }
    const res = Reflect.get(target, key, receiver)
    if (isShallow) {
        return res
    }
    if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(res)
    }
    return res
},

调试正常

对应源码

js 复制代码
// /packages/reactivity/src/baseHandlers.ts
get(target: Target, key: string | symbol, receiver: object): any {
    
    ......省略一部分代码
    const res = Reflect.get(
      target,
      key,
      // if this is a proxy wrapping a ref, return methods using the raw ref
      // as receiver so that we don't have to call `toRaw` on the ref in all
      // its class methods
      isRef(target) ? target : receiver,
    )
    // 如果是symbol的key,则执行这里
+    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }
    // 非只读才会执行这里
+    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    if (isShallow) {
      return res
    }

    if (isRef(res)) {
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray && isIntegerKey(key) ? res : res.value
    }

    if (isObject(res)) {
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

4. 拦截数组的查找方法indexOf lastIndexOf includes

实现机制:Proxy能够直接拦截侦听到indexOf、lastIndexOf、includes等方法。但是需要做一些特殊处理,比如拿原始对象去代理对象里面找,就会出现找不到的情况。

如下原始数值正常实现拦截

js 复制代码
const arr = reactive([1,2,3])
effect(() => {
    console.log(arr.includes(1), 'arr.includes(1)'); // true
})

修改数组的索引值时再次触发了副作用函数

边缘情况1:

但如下情况,从arr里面找arr[0],返回会是false,如果期待他返回是true还应该做额外的处理

js 复制代码
const obj = {}
let arr = reactive([obj])
console.log(arr.includes(arr[0]), 'arr.includes(arr[0])'); // false

返回是false的原因是,arr[0]在访问时,产生了一个新的代理对象,includes访问数组内部时会再次针对obj产生一个代理对象,如下代码会执行两次

diff 复制代码
get(target, key, receiver) {
    if (key === 'raw') {
        return target
    }
    if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
    }
    const res = Reflect.get(target, key, receiver)
    if (isShallow) {
        return res
    }
    if (typeof res === 'object' && res !== null) {
+        debugger
        return isReadonly ? readonly(res) : reactive(res)
    }
    return res
},

这两个代理对象是不同的,解决方式是用一个map数据结构记录已经转化的数据

js 复制代码
const reactiveMap = new Map()
function reactive (obj) {
    // 通过obj去查找是否之前根据他创建过一个proxy对象,如果创建了直接返回
    const existProxy = reactiveMap.get(obj)
    if (existProxy) {
        return existProxy
    }
    // 如果没有创建过则创建一次,存储到map中
    const proxy = createReactive(obj)
    reactiveMap.set(obj, proxy)
    return proxy
}

边缘情况2:

如果直接去代理对象上找原始对象的信息,就会有找不到的情况,此时的修改方式应该是先从代理对象身上找,找不到再去原始对象身上找

找不到的情况:

diff 复制代码
const obj = {}
let arr = reactive([obj])
console.log(arr.includes(arr[0]), 'arr.includes(arr[0])'); // false
+console.log(arr.includes(obj), 'arr.includes(obj)'); // false

重写数组的方法:

js 复制代码
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
    includes: function (...args) {
      
    }
}

在get拦截函数里:

diff 复制代码
get(target, key, receiver) {
    if (key === 'raw') {
        return target
    }
    // arr.includes拦截数组的includes属性
+    if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
+        // 返回定义在arrayInstrumentations上面的key值
+        return Reflect.get(arrayInstrumentations, key, receiver)
    }
    if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
    }
    const res = Reflect.get(target, key, receiver)
    if (isShallow) {
        return res
    }
    if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(res)
    }
    return res
},

重写部分的内容:

diff 复制代码
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
+    includes: function (...args) {
+        // this是代理对象,先在代理对象中查找,将结果存到res中
+        let res = originMethod.apply(this, args)

+        if (res === false) {
+            // 如果没找到,通过this.raw拿到原始数组,去原始对象上查找
+            res = originMethod.apply(this.raw, args)
+        }

+        return res
    }
}

实现效果:

进一步完善其他方法:

js 复制代码
const arrayInstrumentations = {
};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function (...args) {
        let res = originMethod.apply(this, args)
        if (res === false || res === -1) {
            res = originMethod.apply(this.raw, args)
        }
        return res
    }
})

对应源码

diff 复制代码
// /packages/reactivity/src/arrayInstrumentations.ts
export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
  ......省略其他方法
  includes(...args: unknown[]) {
+    return searchProxy(this, 'includes', args)
  },

  indexOf(...args: unknown[]) {
+    return searchProxy(this, 'indexOf', args)
  },
  
  lastIndexOf(...args: unknown[]) {
+    return searchProxy(this, 'lastIndexOf', args)
  },
}

// instrument identity-sensitive methods to account for reactive proxies
function searchProxy(
  self: unknown[],
  method: keyof Array<any>,
  args: unknown[],
) {
  const arr = toRaw(self) as any
  track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
  // we run the method using the original args first (which may be reactive)
+  const res = arr[method](...args)

+  // if that didn't work, run it again using raw values.
  if ((res === -1 || res === false) && isProxy(args[0])) {
+    args[0] = toRaw(args[0])
    return arr[method](...args)
  }

  return res
}

5. 拦截splice/push/pop/unshift/shift

实现机制:这几个方法能够被Proxy直接拦截,因为他们内部会访问并修改数组的索引和length,只是额外需要处理一些边缘情况,仍然需要修改部分功能。

边缘情况

如下写法会出现栈溢出的情况:

js 复制代码
effect(() => {
    arr.push(1)
})
effect(() => {
    arr.push(2)
})

第一个arr.push(1) 会修改 length 属性,触发依赖更新。arr.push(2)再次调用 push,会影响arr.push(1),形成递归调用链,最终导致栈溢出。

解决方式:

重写数组的这几个方法。声明了变量shouldTrack,在方法执行前,不允许追踪length属性,改为false,方法执行完毕后,改为true。

js 复制代码
let shouldTrack = true
;['push', 'pop', 'unshift', 'shift'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function (...args) {
        shouldTrack = false
        let res = originMethod.apply(this, args)
        shouldTrack = true
        return res
    }
})

track函数里面进行判断:

diff 复制代码
function track (target, key) {
+    if (!activeEffect || !shouldTrack) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    activeEffect.deps.push(deps)
}

对应源码

diff 复制代码
// /packages/reactivity/src/arrayInstrumentations.ts

// instrument length-altering mutation methods to avoid length being tracked
// which leads to infinite loops in some cases (#2137)
function noTracking(
  self: unknown[],
  method: keyof Array<any>,
  args: unknown[] = [],
) {
  // 这个就是我们上面写的暂停追踪
+  pauseTracking()
  startBatch()
  // 执行函数
  const res = (toRaw(self) as any)[method].apply(self, args)
  endBatch()
  // 恢复追踪
+  resetTracking()
  return res
}

pauseTracking函数

diff 复制代码
// /packages/reactivity/src/effect.ts
export function pauseTracking(): void {
  trackStack.push(shouldTrack)
+  shouldTrack = false
}

如下是track函数追踪

diff 复制代码
// /packages/reactivity/src/dep.ts
export class Dep {
  ......
  constructor(public computed?: ComputedRefImpl | undefined) {
    if (__DEV__) {
      this.subsHead = undefined
    }
  }

+  track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
    if (!activeSub || !shouldTrack || activeSub === this.computed) {
      return
    }
  }
  ......
}
相关推荐
月明长歌1 分钟前
Vue + Axios + Mock.js 全链路实操:从封装到数据模拟的深度解析
前端·javascript·vue.js·elementui·es6
CodeCraft Studio11 分钟前
Excel处理控件Spire.XLS系列教程:C# 合并、或取消合并 Excel 单元格
前端·c#·excel
头顶秃成一缕光22 分钟前
若依——基于AI+若依框架的实战项目(实战篇(下))
java·前端·vue.js·elementui·aigc
冴羽yayujs26 分钟前
SvelteKit 最新中文文档教程(17)—— 仅服务端模块和快照
前端·javascript·vue.js·前端框架·react
木木黄木木38 分钟前
HTML5图片裁剪工具实现详解
前端·html·html5
念九_ysl40 分钟前
基数排序算法解析与TypeScript实现
前端·算法·typescript·排序算法
海石40 分钟前
vue2升级vue3踩坑——【依赖注入】可能成功了,但【依赖注入】成功了不太可能
前端·vue.js·响应式设计
uhakadotcom1 小时前
Vite 与传统 Bundler(如 Webpack)在 Node.js 应用的性能对比
前端·javascript·面试
uhakadotcom1 小时前
Socket.IO 简明教程:实时通信的基础知识
前端·javascript·面试