likes
comments
collection
share

Vue 3 源码解析(2)reactive 的实现(上)

作者站长头像
站长
· 阅读数 14

本系列的文章 demo 存放于我的 Github 仓库,推荐大家下载和调试,进而加深理解。

一. 最简单的响应式示例

1.1 Proxy

Vue 3 抛弃了 Object.defineProperty,改用 Proxy 来作为观察响应式对象变更的底层接口。 这是因为对于具有多个属性的对象来说,Proxy 只需调用一次便能监听到全部属性的变动,而 Object.defineProperty 每次调用时只能监听单个属性,需要通过遍历对象属性来达成完整的监听能力,效率很低:

/** Proxy 示例 **/

var obj = {
  name: 'VaJoy',
  country: 'China'
}

const observedObj = new Proxy(obj, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      console.log(`Getting ${key}...`);
      return res
    },
    set(target, key, newVal, receiver) {
      const res = Reflect.set(target, key, newVal, receiver);
      console.log(`Setting ${key}...`);
      return res
    }
});

observedObj.name = 'Tom';
observedObj.country = 'USA';

// Setting name...
// Setting country...
/** Object.defineProperty 示例 **/

var obj = {
  name: 'VaJoy',
  country: 'China'
}

let value = obj.name;
Object.defineProperty(obj, 'name', {
    get() {
      console.log('Getting name...');
      return value
    },
    set(newVal) {
      console.log('Setting name...');
      value = newVal;
    }
});

obj.name = 'Tom';
obj.country = 'USA';

// Setting name...

上方 Object.defineProperty 只监听和打印出了 name 属性的变动。 如果你对 Proxy 还不熟悉,请先阅读相关的介绍文档

1.2 简单的示例

利用 Proxy 的特性,我们可以实现一个最简单的响应式示例:

let viewEffect = () => {};

export const setViewEffect = (fn) => {
    viewEffect = fn;
    fn();
}

export function reactive(target) {
    return new Proxy(target, {
        set(target, key, value, receiver) {
            const res = Reflect.set(target, key, value, receiver);
            viewEffect();
            return res;
        },
    })
}

点击访问 codepen 代码示例

在该示例中,我们通过 Proxy 拦截了目标对象的属性赋值行为,让其在对属性进行赋值时,触发 viewEffect 函数。 viewEffect 则通过对外的 setViewEffect 接口,被重写为视图层和对象属性交互的副作用函数:

  setViewEffect(() => {
    div.innerText = obj.msg || ''
  })

因此当 obj.msg 被赋值为 Bye bye. 时,Proxy 会拦截并触发被用户重新定义了的 viewEffect 函数的执行。

副作用(Side Effects)函数:会与外部可变状态进行交互的函数。

二. Track 和 Trigger

上述的示例存在很多问题,例如当用户每次重新调用 setViewEffect 接口时,viewEffect 函数都会被覆盖,导致之前定义过的副作用函数都失效了。 另外用户所定义的副作用函数,和响应式对象的属性没有任何映射关系,任何属性的改变都会触发副作用函数的执行,这也是很不合理的。

Vue 3 中使用了观察者模式来实现响应式事件的收集和分发,其收集、分发的行为由 track 和 trigger 方法实现:

const targetMap = new WeakMap();

// 追踪、收集依赖(会在 Proxy 的 get 拦截属性中调用该方法)
const track = (target, key) => {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
        depsMap.set(key, (dep = new Set()));
    }
    dep.add(viewEffect);
}

// 触发(会在 Proxy 的 set 拦截属性中调用该方法)
const trigger = (target, key, value) => {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
        // 未被追踪过
        return
    }
    const viewEffects = depsMap.get(key);
    viewEffects.forEach(effectFn => effectFn());
}

track 会在响应式对象的属性被访问时,将该对象作为键存入名为 targetMap 的 WeakMap 容器中,对应值是一个以被访问属性为键,以属性被访问时的副作用函数的去重集合为值的 Map 对象。下图演示了两个响应式对象,在该 WeakMap 中存放的情况(红色的属性表示被访问过):

Vue 3 源码解析(2)reactive 的实现(上)

在响应式对象的属性被修改时,trigger 会从 targetMap 容器中检索到该属性先前被收集到的副作用函数集合,通过遍历该集合来执行每个副作用函数,即可达成响应式的效果。

我们将 track 和 trigger 放入 Proxy 中的 get / set 拦截器内,来完善先前的 reactive 函数:

const proxyMap = new WeakMap();
const targetMap = new WeakMap();

let viewEffect = () => { };

export const setViewEffect = (fn) => {
    viewEffect = fn;
    fn();
}

export function reactive(target) {
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
        // 已被代理过,直接返回缓存的代理对象
        // 避免重复被代理
        return existingProxy
    }

    const proxy = new Proxy(
        target,
        baseHandlers
    );

    proxyMap.set(target, proxy);
    return proxy
}

const baseHandlers = {
    get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        track(target, key);
        return res;
    },
    set(target, key, value, receiver) {
        const res = Reflect.set(target, key, value, receiver);
        trigger(target, key);
        return res;
    }
}

// 追踪
const track = (target, key) => {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
        depsMap.set(key, (dep = new Set()));
        console.log(`Key "${key}" is traced...`);
    }
    dep.add(viewEffect);
}

// 触发
const trigger = (target, key) => {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
        // 未被追踪过
        return
    }
    const viewEffects = depsMap.get(key);
    console.log(`Trggering effects Functions of key "${key}"...`);
    viewEffects.forEach(effectFn => effectFn());
}

点击访问 codepen 代码示例

注意此处我们还定义了一个名为 proxyMap 的 WeakMap,作为响应式对象的缓存容器。这是因为同一个对象不需要被重复代理。

WeakMap 的键名所引用的对象都是弱引用(即垃圾回收机制不将该引用考虑在内),一旦该对象在 WeakMap 外部的引用被清除了,WeakMap 里对应的该对象的键值对会同步被移除(无需手动从 WeakMap 中删除)。这是 Vue 采用 WeakMap 作为对象缓存容器类型的原因。

三、功能完善

3.1 嵌套属性处理

Proxy 只能拦截最外层属性的变更,若代理对象存在类型也是 Object 的属性值时,该属性对象的子属性将无法被同步代理到。

为了让存在多层属性的对象,其嵌套属性均可被正确地代理到,我们只需要在 get 拦截器中新增一个判断 —— 若被访问的属性对应值的类型为 Object,将该属性值传给 reactive 方法进行递归调用:

// 略...

const baseHandlers = {
    get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        track(target, key);

        // 新增 - 类型为对象的嵌套属性处理
        if (isObject(res)) {
            return reactive(res);
        }

        return res;
    },
    set(target, key, value, receiver) {
        // 略...
    }
}

// 略...

点击访问 codepen 代码示例

3.2 完善拦截器

Proxy 所支持的拦截操作共有十来种,目前我们只对其中的 set 和 get 做了拦截。为了实现完整的响应式能力,还需增加对 hasdeletePropertyownKeys 的拦截处理。

3.2.1 has 和 deleteProperty 拦截器

Proxy 的 has 拦截器可以拦截 key in target 的操作,我们需要对该操作做依赖收集(调用 track);而 deleteProperty 拦截器可以拦截移除对象属性的 delete 操作,我们需要在该阶段触发先前收集到的依赖函数(调用 trigger):

    /** 新增 has 拦截处理 **/
    has(target, key) {
        const res = Reflect.has(target, key);
        // 依赖收集
        track(target, key);
        return res;
    },
    /** 新增 deleteProperty 拦截处理 **/
    deleteProperty(target, key) {
        const hadKey = hasOwn(target, key);
        const res = Reflect.deleteProperty(target, key);
        if (res && hadKey) {
            trigger(target, key);  // 调用依赖(收集到的副作用函数)
        }
        return res
    }

点击访问 codepen 代码示例

3.2.2 ownKeys 拦截器

Proxy 的 ownKeys 方法会拦截对象自身属性的读取操作(如 for...in),因此和 get 方法类似,我们需要在该阶段对依赖进行收集(调用 track)。

但这里存在一个问题 —— ownKeys 方法只接收目标对象参数,不同于其它拦截器还有对象键名作为第二个参数。这是因为 ownKeys 所拦截的操作,都是遍历对象属性的方法(例如 Object.keys()),即直接访问了全部可枚举的对象键名,而非单一的键名。 然而在对依赖进行收集时,却需要有一个凭证(例如对象键名)来将副作用函数存入 depsMap 容器才行。

此问题的解决方法也很简单 —— 可以通过 Symbol 方法生成一个唯一值,来作为 ownKeys 拦截阶段收集依赖、存入 depsMap 的凭证:

const ITERATE_KEY = Symbol();

const baseHandlers = {
  // ...
  ownKeys(target) {
      track(target, ITERATE_KEY);  // 统一使用该 Symbol 符号作为凭证
      return Reflect.ownKeys(target);
  }
}

前面提到了,ownKeys 所拦截的操作,会悄悄访问对象全部的可枚举属性,但它并不会触发 get 拦截器的执行。这意味着每个属性单拎出来,在 depsMap 中是没有存储任何该属性对应的依赖的。 这会导致用户修改对象属性时,ownKeys 阶段所收集的依赖无法被触发。

对此 Vue 的解决方案是,在 trigger 方法中,判断当前的操作是否添加属性删除属性的操作,若是则提取在 ownKeys 阶段所收集到的依赖函数,压入当前阶段的副作用函数栈中去一并触发。

我们进一步完善 trigger 函数:

const trigger = (target, key, type) => {  // 新增 type 参数
    const depsMap = targetMap.get(target);

    // deps 用于存放从 depsMap 取出的依赖函数
    let deps = [];  

    // 只有 SET / ADD / DELETE 操作才存在 key
    if (key !== void 0) {
        deps.push(depsMap.get(key));
    }

    // 取出 ownKeys 拦截阶段收集的依赖
    switch (type) {
        case 'add':
        case 'delete':
            if (!isArray(target)) {
                deps.push(depsMap.get(ITERATE_KEY));
            }
            break;
    }
    
    let viewEffects = [];
    for (const dep of deps) {
        if (dep) {
            viewEffects.push(...dep)
        }
    }
    viewEffects = new Set(viewEffects); // 去重

    viewEffects.forEach(effectFn => {
        effectFn && effectFn()
    });
}

调用 trigger 的地方需要传入新增的 type 参数:

const isIntegerKey = (key) =>
    isString(key) &&
    key !== 'NaN' &&
    key[0] !== '-' &&
    '' + parseInt(key, 10) === key;
    
const baseHandlers = {
    // ...
    set(target, key, value, receiver) {
        // 判断当前操作属于“新增”还是“修改”
        const hadKey = isArray(target) && isIntegerKey(key)
            ? Number(key) < target.length
            : hasOwn(target, key);

        const res = Reflect.set(target, key, value, receiver);
        trigger(target, key, hadKey ? 'set' : 'add');  // 新增操作类型参数
        return res;
    },
    deleteProperty(target, key) {
        const hadKey = hasOwn(target, key);
        const res = Reflect.deleteProperty(target, key);
        if (res && hadKey) {
            trigger(target, key, 'delete');  // 传入 'delete'
        }
        return res
    }
}

点击访问 codepen 代码示例

3.3 完善数组能力

3.3.1 trigger 部分的完善

上述的应用示例存在一个问题 —— 如果我们将被代理的对象更换为数组,再为数组增/删元素时,无法触发 ownKeys 阶段收集到的依赖函数:

  import { setViewEffect, reactive } from './reactive.js';
  const div = document.querySelector('div');

  // 更改为数组
  const arr = reactive(['a', 'b', 'c']);

  setViewEffect(() => {
    let html = '';
    for(let s in arr) {  // 触发 ownKeys 拦截器
      html += arr[s];
    }
    div.innerText = html;
  });

  
  setTimeout(() => {
    // 无法触发 ownKeys 阶段收集到的依赖 :(
    arr[3] = 'd'
  }, 2000);

点击访问 codepen 代码示例

解决上方的问题挺简单,在 trigger 方法中新增处理,对于添加类型的操作,如果传入的属性为数组索引,则取出 ownKeys 阶段收集的依赖。 具体调整如下:

const trigger = (target, key, type) => { 
    // ...
    switch (type) {
        case 'add':
            if (!isArray(target)) {
                deps.push(depsMap.get(ITERATE_KEY));
            } else if (isIntegerKey(key)) {  // 若数组被修改的属性为索引字符串
                deps.push(depsMap.get(ITERATE_KEY))  // 也取出 ownKeys 阶段收集的依赖
            }
            break;
        // ...
    }
    // ...
}

这么修改后,之前的问题示例就能按预期正常运作了(div 会在两秒后显示 abcd)。你可以点击这里进行在线调试。

对于数组而言,使用 length 来替代 ITERATE_KEY,是个更好的选择。 这是因为 ownKeys 拦截阶段所收集的依赖,除了需要辐射到数组的每一个元素,也需要辐射到 length(即 length 的变更也需要触发 ownKeys 的副作用函数)。 如果直接使用 length,来作为把依赖存入 depsMap 的凭证,会更省事:

const baseHandlers = {
    ownKeys(target) {
        // 数组改用 length 为凭证
        const key = isArray(target) ? 'length' : ITERATE_KEY;
        track(target, key);
        return Reflect.ownKeys(target);
    },
    // ...
}

const trigger = (target, key, type) => { 
    // ...
    switch (type) {
        case 'add':
            if (!isArray(target)) {
                deps.push(depsMap.get(ITERATE_KEY));
            } else if (isIntegerKey(key)) {
                deps.push(depsMap.get('length'))  // 更换凭证为 length
            }
            break;
        // ...
    }
    // ...
}

另外我们还需考虑数组的 length 属性被外部直接修改了的场景,例如 arr.length = 2

这种情况下,一方面需要获取以 length 为凭证的依赖,一方面由于大于等于 length 的索引值所对应的数组元素被移除了,也需要获取这些被移除元素的依赖:

const trigger = (target, key, type, newValue) => {  // 新增 newValue 参数
    // ...
    if (key === 'length' && isArray(target)) {
        // 注意 Map 的 forEach 参数为 (MapValue, MapKey)
        depsMap.forEach((dep, key) => {
            // newValue 在这里为用户赋予 length 的新值
            if (key === 'length' || key >= newValue) {
                // 当含有以 length 为凭证的依赖,
                // 或以被移除元素的索引值为凭证的依赖时,取出该依赖
                deps.push(dep)
            }
        })
    }
    // ...
}

完整的示例请点击访问 codepen

3.3.2 修复递归追踪问题

目前我们基本完善了 reactive 对数组的能力,但还存在一个容易被忽略的 bug —— 如果在副作用函数中调用了 push 等方法,会导致调用栈溢出。

这是因为 push 方法在修改数组前,会先隐式地访问 length 属性,导致 length 被追踪,接着将新元素压入数组时,又会触发新增元素索引的 set 拦截, 进而触发 length 收集到的副作用函数,副作用函数中又调用了 push,导致程序进入了死循环。

  import { setViewEffect, reactive } from './reactive.js';
  let arr = reactive(['a', 'b', 'c']);

  setViewEffect(() => {
    // Uncaught RangeError: Maximum call stack size exceeded
    arr.push('d');  
  });

Vue 3 源码解析(2)reactive 的实现(上)

会触发同样问题的还有 popshiftunshift 和 splice 方法,事实上这些方法都是更改数组的方法,它们的目的并不是为了获取属性。故当外部调用了这些方法时,是不应该做任何依赖收集的。 我们需要在 get 拦截器中,对这些方法做单独的处理,让它们绕过 track

💡 popshiftunshift 和 splice 统称为数组的栈方法,它们在执行时都会隐式地去访问和改变数组的 length 属性。以 push 为例,你可以点击这里查看其执行流程。

我们尝试让这些方法在被 get 拦截时直接返回结果,不再调用 track 方法:

const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice'];

const baseHandlers = {
    get(target, key, receiver) {
        const targetIsArray = isArray(target);
        if (targetIsArray && arrayMethods.includes(key)) {
            // 尝试直接返回,不再调用 track 方法
            return Reflect.get(target, key, receiver);
        }

        const res = Reflect.get(target, key, receiver);
        track(target, key);

        // ...
    },
    // ...
}

但你会发现,此举仅仅是限制了对方法名的追踪,当这些方法执行时,依旧会触发对 length 属性的访问和追踪。

正确的处理是重写这些方法,让它们在执行的过程中暂停 track 的能力:

function createArrayInstrumentations(target) {
    const instrumentations = {};
    // 重写原生方法,让它们在执行时,暂停追踪
    ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
        instrumentations[key] = function (...args) {
            pauseTracking();  // 暂停 track
            const res = target[key].apply(this, args);
            resetTracking();  // 恢复 track
            return res
        }
    })
    return instrumentations  // { push(...args){...}, pop(...args){...}, ... }
}

const baseHandlers = {
    get(target, key, receiver) {
        const targetIsArray = isArray(target);
        const arrayInstrumentations = createArrayInstrumentations(target);
        if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
            return Reflect.get(arrayInstrumentations, key, receiver)
        }

        const res = Reflect.get(target, key, receiver);
        track(target, key);

        // ...
    },
    // ...
}

其中的 pauseTracking 和 resetTracking 将分别用于暂停/恢复 track 的能力,它们在 Vue 中的实现较简单:

// 标志变量,决定 track 方法是否应当追踪
export let shouldTrack = true;
const trackStack = [];

export function pauseTracking() {
    trackStack.push(shouldTrack)
    shouldTrack = false
}

export function resetTracking() {
    const last = trackStack.pop()
    shouldTrack = last === undefined ? true : last
}

const track = (target, key) => {
    // 在 track 中判断是否应当追踪
    if (!shouldTrack) return;
    // ...
}

该代码段定义了一个统一判断是否应当执行追踪的标志变量 shouldTracktrack 方法在执行时会先判断该变量,如果为 true 才执行追踪。 另外这里的 trackStack 用于存取 shouldTrack 的状态,方便在未来更复杂的场景下调度 track 的执行。

重写后就能在副作用函数中正常调用 push 等方法了:

  let arr = reactive(['a', 'b', 'c']);

  setViewEffect(() => {
    // 不再陷入死循环
    arr.push('d');  
    div.innerText = arr.reduce((p, c) => (p + c))
  });

但会发现通过 push 插入的新元素,如果对其进行修改,副作用函数不会执行:

  let arr = reactive(['a', 'b', 'c']);
  setViewEffect(() => {
    div.innerText = arr.reduce((p, c) => (p + c))
  });

  arr.push('d');
  arr[3] = null;  // 不会执行副作用函数

这是因为 push 方法在执行时暂停了依赖收集,在 set 拦截阶段执行副作用函数时会绕过 track 的执行,导致新元素的索引无法被追踪。

实质上我们只希望数组的栈方法在执行时,屏蔽掉对其自身的追踪即可,那么在副作用函数执行前我们应该恢复 track 的能力:

const trigger = (target, key, type, newValue) => {
    // ...
    viewEffects.forEach(effectFn => {
        shouldTrack = true;  // 副作用执行前要恢复 track 的能力
        effectFn && effectFn()
    });
}

3.3.3 toRaw

细心的读者可能会发现,在 get 拦截器中,每次拦截数组都需要传入 target 给 createArrayInstrumentations 方法来生成一个 arrayInstrumentations 对象。这种处理方法性能较低,这么写的原因只是为了支持动态传入 target

/** get 拦截器 **/

const baseHandlers = {
    get(target, key, receiver) {
        const targetIsArray = isArray(target);
        // 动态传入 target 参数
        const arrayInstrumentations = createArrayInstrumentations(target);
        if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
            return Reflect.get(arrayInstrumentations, key, receiver)
        }

        // ...
    },
    // ...
}


/** createArrayInstrumentations **/

function createArrayInstrumentations(target) {
    const instrumentations = {};
    ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
        instrumentations[key] = function (...args) {
            pauseTracking();
            // target 不能换成 this,否则会陷入死循环
            const res = target[key].apply(this, args);
            resetTracking();
            return res
        }
    })
    return instrumentations
}

在 createArrayInstrumentations 方法中,this 是 target 被 Proxy 代理后的响应式对象,如果我们将 target 换成 this,那么 this[key] 不再是原生的数组原型方法,而是被重写后的方法,其执行时会陷入自我递归调用的死循环。 这意味着在 createArrayInstrumentations 中获取未被代理的原生对象,是必要的事情。

但我们依旧可以选择一种取巧的方式,来实现不传参也能获取原生的 target 的功能 —— 通过 get 拦截器中的属性规则匹配,从响应式对象自身获取。 改动如下:

/** get 拦截器 **/

get(target, key, receiver) {
    // 新增属性匹配规则来获取原生引用
    if (key === "__v_raw" && proxyMap.get(target)) {
        return target;
    }

    const targetIsArray = isArray(target);
    // ...
},

新增规则后,只需调用响应式对象的 __v_raw 属性,即可获取其原生引用。 我们再封装一个 toRaw 方法,方便统一调用:

function toRaw(observed) {
    const raw = observed && observed["__v_raw"];
    return raw ? toRaw(raw) : observed;
}

如此一来,我们便可轻松地将 createArrayInstrumentations 方法的调用移出 get 拦截器,避免了在 get 拦截阶段的重复创建:

/** get 拦截器 **/

const baseHandlers = {
    get(target, key, receiver) {
        if (key === "__v_raw" && proxyMap.get(target)) {
            return target;
        }

        const targetIsArray = isArray(target);
        // 此处不再需要调用 createArrayInstrumentations 方法
        if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
            return Reflect.get(arrayInstrumentations, key, receiver)
        }

        // ...
    },
    // ...
}


/** createArrayInstrumentations **/

function createArrayInstrumentations() {  // 不再需要传入 target
    const instrumentations = {};
    ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
        instrumentations[key] = function (...args) {
            pauseTracking();
            // 通过 toRaw 获取原生数组
            const res = toRaw(this)[key].apply(this, args);
            resetTracking();
            return res
        }
    })
    return instrumentations
}

// 从 get 拦截器移到外面,只会执行一次
const arrayInstrumentations = createArrayInstrumentations();

3.3.4 检索方法处理

有时候我们会使用数组的 includesindexOflastIndexOf 方法,来检索数组中是否包含某些内容,当传入的参数是一个对象,甚至是被代理过的响应式对象时,会导致非预期的结果:

  import { setViewEffect, reactive } from './reactive.js';
  const div1 = document.querySelector('#div-1');
  const div2 = document.querySelector('#div-2');

  const obj = {a: 1};
  const observedObj = reactive(obj);

  const arr = ['a', obj, 'c'];
  const observedArr = reactive(arr);

  const arr2 = ['a', observedObj, 'c'];
  const observedArr2 = reactive(arr2);
  
  // 下方都返回了 false,预期应该是 true
  div1.innerText = 'arr 中是否包含了 obj 对象:' + observedArr.includes(obj);
  div2.innerText += 'arr2 中是否包含了 obj 响应式对象:' + observedArr2.includes(observedObj);

点击访问 codepen 代码示例

我们可以使用 toRaw 方法,将调用数组检索接口的代理对象,以及传入的响应式参数,都转为其原生引用,从而解决此问题。 改动如下:

function createArrayInstrumentations() {
    const instrumentations = {};
    // 重写数组检索方法
    ['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
        instrumentations[key] = function (...args) {
            const arr = toRaw(this);
            for (let i = 0, l = this.length; i < l; i++) {
                track(arr, "get", i + '');
            }
            const res = arr[key](...args);
            if (res === -1 || res === false) {
                // 匹配失败时,有可能是传入参数也属于响应式对象
                // 将参数转为原生引用,再匹配一次
                return arr[key](...args.map(toRaw));
            } else {
                return res;
            }
        };
    });

    ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
        // ...
    })
    return instrumentations
}

可以点击这里在线调试修改后的代码。

💡 未被代理过的原生对象,和经 Proxy 代理后的响应式对象,二者是不一样的,如果直接使用响应式对象,去调用原生对象的方法,可能会导致问题。这是 reactive 响应式接口的重要知识点。

四、代码优化

4.1 减少冗余的 trigger 调用

如果现在尝试给一个响应式对象属性重复赋值,会发现即使赋予的值都是相同的,每次赋值都会触发副作用函数的执行:

  const obj = reactive({ a: 1 });
  setViewEffect(() => {
    console.log(obj.b);
  });
  // 会打印三个 2
  obj.b = 2; obj.b = 2; obj.b = 2;

如果副作用函数较为复杂,那么这种冗余的多次调用是很耗性能的。 更合理的方式是判断属性值是否真的变动了(对比赋值前后的内容),如果确实被改变了,再调用副作用函数。

对此,我们需要在 set 拦截方法中新增判断:

    set(target, key, value, receiver) {
        let oldValue = target[key];  // 新增
        // 新旧值都有可能属于响应式对象
        // 将它们都转为原生引用,方便做对比
        value = toRaw(value);
        oldValue = toRaw(oldValue);

        const hadKey = isArray(target) && isIntegerKey(key)
            ? Number(key) < target.length
            : hasOwn(target, key);

        const res = Reflect.set(target, key, value, receiver);
        if (!hadKey) {
            trigger(target, key, 'add', value)
        } else if (hasChanged(value, oldValue)) {  // 新增判断
            trigger(target, key, 'set', value)
        }
        return res;
    },

其中工具方法 hasChanged 的实现如下:

export const hasChanged = (value, oldValue) =>
    !Object.is(value, oldValue)

Object.is 是 ES6 语法糖,可用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致,但更符合 Same-value equality 的理念(例如两个 NaN 相比的结果应为 true)。

此时如果再给响应式对象的属性重复赋值,如果赋予的值是相同的,则只会触发一次 trigger,即只会执行一次副作用函数,进而提升了程序性能。

4.2 模块解耦

目前我们已经实现了一个功能基本完备的 reactive 接口,只是各个功能的代码块都写在了一起,这并不利于项目的阅读和维护。

我们可以把模块解耦处理,将其拆分为 reactive.jsbaseHandlers.jseffect.js 三个小模块。

鉴于这是简单的代码梳理工作,故不在本文赘述,读者可以点击这里获取最终的代码。