Vue3是如何将Object转成响应式数据

1,152 阅读14分钟

一、开篇

本篇文章主要从Object的获取层面来分析如何代理Object。在分析Object转成响应式数据之前,建议大家尝试阅读这几篇文章:

// 存储副作用的桶
const bucket = new WeakMap();
// 原始数据
const data = { foo: "foo", bar: "bar" };

// 用一个全局变量存储被注册的副作用函数
let activeEffect;

// effect 栈
const effectStack = [];

// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key, receiver) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return Reflect.get(target, key, receiver);
  },
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 设置属性值
    Reflect.set(target, key, newVal, receiver);
    
    // 把副作用从桶中取出并执行
    trigger(target, key);
    return true;
  },
});

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return;
  // 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
  let depsMap = bucket.get(target);
  // 如果 depsMap 不存在,创建一个新的map,并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
  // 里面存储着所有与当前 key 相关的副作用函数:effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 effectFn.deps 数组中
  activeEffect.deps.push(deps);
}

// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key) {
  // 从桶中取出 depsMap,它也是一个map 类型,key-->effects
  const depsMap = bucket.get(target);
  if (!depsMap) return true;
  // 再根据 key 从depsMap 中取得 副作用函数 effects
  const effects = depsMap.get(key);

  const effects2Run = new Set();
  effects &&
    effects.forEach((effectFn) => {
      // 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
      if (effectFn !== activeEffect) {
        effects2Run.add(effectFn);
      }
    });

  effects2Run.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则直接执行副作用函数
      effectFn();
    }
  });
}

// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 删除与 effectFn 相关的关系
    cleanup(effectFn);
    // 调用effect函数的时候,将 effectFn 赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
    effectStack.push(effectFn);
    // 执行副作用函数,得到返回值
    const res = fn();
    // 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 将返回值 return 出去
    return res;
  };
  // 将 options 挂在到 effectFn 上
  effectFn.options = options;
  // effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非lazy的时候才会执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach((deps) => {
    // 将 effectFn 从依赖集合中删除
    deps.delete(effectFn);
  });
  // 清空 effectFn.deps
  effectFn.deps.length = 0;
}

// 遍历 source 内所有的属性,从而触发 track 函数
function traverse(source, seen = new Set()) {
  // 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
  if (typeof source !== "object" || source === null || seen.has(source)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
  seen.add(source);

  // 暂时不考虑数组等的情况
  // 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
  for (let key in source) {
    // 如果是对象,递归调用
    traverse(source[key], seen);
  }
  return source;
}

function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    // 如果是function,直接作为 getter
    getter = source;
  } else {
    // 如果是对象,监听source内所以的属性
    getter = () => traverse(source);
  }
  // 存储用户注册过的过期回调
  let cleanup;
  // 定义 过期回调
  function onInvalidate(fn) {
    // 将过期回调存储起来
    cleanup = fn;
  }

  const job = () => {
    newVal = effectFn();
    // 需要注意的是,第一次执行 watch cb的时候,不执行 过期回调,再次触发watch cb就会执行 上一个过期回调
    if (cleanup) {
      // 在调用cb之前,执行过期回调
      cleanup();
    }
    cb(oldVal, newVal, onInvalidate);
    oldVal = newVal;
  };

  // 定义旧值和新值
  let oldVal, newVal;
  // 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        // flush 为 post,放到微任务队列中执行
        Promise.resolve().then(job);
      } else {
        job();
      }
    },
  });

  if (options.immediate) {
    job();
  } else {
    // 手动调用,将执行的结果存储到 oldVal 中
    oldVal = effectFn();
  }
}

二、在Object读取操作中添加track

1. Object 有哪些读取属性的操作

  • 访问属性:obj.key
  • 判断对象或原型上是否存在给定的key: key in obj
  • 使用 for...in 循环遍历对象:for(let key in obj){}

2. trackobj.key

obj.key 直接通过 get 捕捉器就能 track

// 原始数据
const data = { foo: "foo", bar: "bar" };
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key, receiver) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return Reflect.get(target, key, receiver);
  },
});

3. trackkey in obj

看到 in 这个操作符,会一脸懵,不知道 in 到底是触发了哪个内置方法或者内置槽,那么我们就去看看 ESMAScript 是如何介绍 in 的执行逻辑的 image.png

  1. 让lref 的值为 RelationalExpression执行结果
  2. 让lval的值为 ? GetValue(lref)
  3. 让 rref值为ShiftExpression执行结果
  4. 让rval值为 ? GetValue(rref).
  5. 返回 HasProperty(rval, ? ToPropertyKey(lval)).

可以看到第6步,调用了 HasProperty 这个抽象方法,那我们再看看它吧 image.png 这下就一目了然了,是调用的 [[HasProperty]] 内置方法,在 Proxy为什么需要Reflect,从原理层面理解它们 表格中已经介绍了 [[HasProperty]] 触发的是 Proxyhas 捕捉器。咱们就可以这么做了

代码

image.png

效果

Kapture 2022-05-24 at 22.10.49.gif

4. track: for(let key in obj){}

咱们就和 in 一样来分析一下吧 image.png 核心分析第6步,如果iterationKind 是一个枚举

a. 如果 exprValueundefined 或者 null,那么返回 Completion Record { [[Type]]: break, [[Value]]: empty, [[Target]]: empty }.
b. 让obj 的值为! ToObject(exprValue)
c. 让 iterator 值是 EnumerateObjectProperties(obj).
d. 让 nextMethod 值是 ! GetV(iterator, "next").
e. 返回 Iterator Record { [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }.

上面 第e步可以看出 循环获取的值 是通过iterator迭代器获取的,那么咱么看看 iterator 是怎么从EnumerateObjectProperties得到的

image.png EnumerateObjectProperties 是一个 generator 函数,接受的参数 obj 就是被我们for...in循环的对象,里面有个亮眼的 代码 Reflect.ownKeys(obj)

原来如此 for...in 遍历的对象会触发 ownKeys,那么我们就可以从 ownKeys 捕捉器来下手追踪了。

a. 拦截 ownKeys,收集 effectFn

经过上面的分析,我们只需要在 ownKeys 捕捉器上添加追踪即可,但是经过查询 ownKeys 只有一个参数 target image.png保姆式地带你从0到1实现Vue3 Effect分析过,建立响应关系,需要三个成员,即:targetkeyeffectFn image.png 目前 ownKeys 只有两个 targeteffectFn,因此我们需要重建一个唯一的id作为targeteffectFn之间的桥梁,即: ITERATE_KEY

代码

image.png

咱么这里只做了追踪收集,却没有做trigger触发,那咱么继续往下看

b.触发 ownKeys 收集 effectFn

思考一下 有哪些会触发 for...in 循环呢?额。。。,自问自答吧,其实是setdelete 会触发这个操作。

其中 set 只能是新增属性,delete 只能是删除已有属性,并且删除成功了才会触发。分析了之后,咱么就继续干吧。

1) set 新增属性
代码

image.png

image.png

效果

Kapture 2022-05-24 at 23.31.39.gif

2) delete 删除属性

delete依赖的是 [DELETE] 内部方法,在保姆式地带你从0到1实现Vue3 Effect表中,[DELETE] 对应的捕捉器是 deleteProperty

代码

image.png

image.png

effect(() => {
  console.log("EffectFn 执行了");
  for (const key in obj) {
  }
});

image.png Kapture 2022-05-24 at 23.42.52.gif

三、将响应做一层reactive封装

// 原始数据
const data = { foo: "foo", bar: "bar" };

// 将原始数据转成响应数据
const reactive = (data) => {
  return new Proxy(data, {
    // 拦截读取操作
    get(target, key, receiver) {
      // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
      track(target, key);
      // 返回属性值
      return Reflect.get(target, key, receiver);
    },
    // 拦截 in 操作符的捕捉器
    has(target, key) {
      track(target, key);
      return Reflect.has(target, key);
    },
    ownKeys(target) {
      // 对于没有key的时候,建立`target`,`effectFn`之前的桥梁
      track(target, ITERATE_KEY);
      return Reflect.ownKeys(target);
    },
    deleteProperty(target, key) {
      // 检测备操作的属性是否是对象自己的属性
      const hasKey = Object.prototype.hasOwnProperty.call(target, key);
      // 使用 Reflect.defineProperty 删除属性
      const res = Reflect.deleteProperty(target, key);
      // 如果是自己的属性,且删除成功,触发去作用执行
      if (hasKey && res) {
        trigger(target, key, TriggerType.DELETE);
      }
      return res;
    },
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      // 缓存旧值
      const oldVal = target[key];

      // 当属性存在时候,标识修改属性值,否则就是新增属性值
      const type = Object.prototype.hasOwnProperty.call(target, key)
        ? TriggerType.SET
        : TriggerType.ADD;

      // 设置属性值
      const res = Reflect.set(target, key, newVal, receiver);

      // 比较新旧值是否相等 并且都不是 NAN的时候才会触发副作用函数
      if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
        // 把副作用从桶中取出并执行
        trigger(target, key, type);
      }
      return res;
    },
  });
};

// 将data转成响应数据
const obj = reactive(data);

四、每次set都是需要触发 副作用函数 执行吗?

答案肯定不是的

比如:设置相同的值,不应该触发 副作用函数

1. 设置值相同,不触发副作用函数

只需要在set拦截器那边判断值是否相同。有个列外 NAN与自身相比是不相同的

代码

image.png

效果

Kapture 2022-05-25 at 00.06.16.gif

2. 继承关系,不触发副作用函数

这个标题,感觉会给你产生迷糊,那我们用一个demo来演示一下问题 代码

const obj = {};
const proto = { bar: 1 };
const parent = reactive(proto);
const child = reactive(obj);
Object.setPrototypeOf(child, parent);
effect(() => {
  console.log("parent.bar", parent.bar);
});

effect(() => {
  console.log("child.bar", child.bar);
});
setTimeout(() => {
  child.bar = 2;
}, 1000);

效果 Kapture 2022-05-25 at 00.21.39.gif

有木有发现 我只设置了 child.bar 会触发 父级的 副作用函数

a. 分析

上面的主要是因为 [[SET]] 导致的,下面我们分析一下 这是 Proxy [[SET]]内置函数 image.png 咱么核心分析一下 6、7步,如果我们没设置set,会执行 target的[[SET]],注意了,将 Receiver(代表child) 传递进去。如果设置了,会走我们下面的代码,其实,和target的[[SET]] 一样的执行,也是将 Receiver(代表child) 传递进去

// 拦截设置操作
set(target, key, newVal, receiver) {
  const res = Reflect.set(target, key, newVal, receiver);
  return res;
},

咱们再看看 [[SET]]内置方法,调用 OrdinarySet(OPVReceiver) image.png 再继续看看 OrdinarySet,调用了OrdinarySetWithOwnDescriptor(OPVReceiverownDesc). image.png 再继续看看 OrdinarySetWithOwnDescriptor,终于看到了谜底了 image.png child没有 bar元素,会从 parent那里设置,注意了 parent [[SET]] 的入参 Receiver 是从 child 那里获取的到,因此,parent 内 set捕捉器是 receiverchild

逻辑再捋一下:修改 child.bar值 实际是 给 parent.bar 赋值,但是我们只想要child.bar对应的副作用函数执行。

细心的小伙伴会看到我上面着重说的 receiver,修改 child.barchild 内的 setreceiverchildparent 内的 setreceiver 也是 child,那么这就好做了,咱们看看代码吧

b.代码

image.png

c.效果

Kapture 2022-05-25 at 01.06.08.gif

五、总结

  1. obj.key 通过 get 收集副作用函数
  2. key in obj 通过 has 收集副作用函数
  3. for(let key in obj){} 通过 ownKeys 收集副作用函数
    • ownKeys 不提供 key,需要 ITERATE_KEY 作为 targeteffectFn之间的桥梁
    • set 设置新属性才触发副作用函数
    • delete 只能是删除已有属性,并且删除成功了才会触发
  4. 设置值相同,不触发副作用函数
    • 需要特殊处理 NAN 自己与自己不相等
  5. 通过代理子级修改代理父级属性,只触发子级对应的副作用函数

六、完整代码

// 存储副作用的桶
const bucket = new WeakMap();

// 用一个全局变量存储被注册的副作用函数
let activeEffect;

// effect 栈
const effectStack = [];

// 用于没有key的时候,建立`target`,`effectFn`之前的桥梁
const ITERATE_KEY = Symbol();

// 触发 trigger 类型
const TriggerType = {
  SET: "set",
  ADD: "add",
  DELETE: "delete",
};

// 从代理中获取被代理对象
const RAW = Symbol();

// 将原始数据转成响应数据
const reactive = (data) => {
  return new Proxy(data, {
    // 拦截读取操作
    get(target, key, receiver) {
      // 获取原始对象
      if (key === RAW) {
        return target;
      }
      // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
      track(target, key);
      // 返回属性值
      return Reflect.get(target, key, receiver);
    },
    // 拦截 in 操作符的捕捉器
    has(target, key) {
      track(target, key);
      return Reflect.has(target, key);
    },
    ownKeys(target) {
      // 对于没有key的时候,建立`target`,`effectFn`之前的桥梁
      track(target, ITERATE_KEY);
      return Reflect.ownKeys(target);
    },
    deleteProperty(target, key) {
      // 检测备操作的属性是否是对象自己的属性
      const hasKey = Object.prototype.hasOwnProperty.call(target, key);
      // 使用 Reflect.defineProperty 删除属性
      const res = Reflect.deleteProperty(target, key);
      // 如果是自己的属性,且删除成功,触发去作用执行
      if (hasKey && res) {
        trigger(target, key, TriggerType.DELETE);
      }
      return res;
    },
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      // 缓存旧值
      const oldVal = target[key];

      // 当属性存在时候,标识修改属性值,否则就是新增属性值
      const type = Object.prototype.hasOwnProperty.call(target, key)
        ? TriggerType.SET
        : TriggerType.ADD;

      // 设置属性值
      const res = Reflect.set(target, key, newVal, receiver);
      // receiver 是代理对象的时候才触发副作用函数
      if (target === receiver[RAW]) {
        // 比较新旧值是否相等 并且都不是 NAN的时候才会触发副作用函数
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          // 把副作用从桶中取出并执行
          trigger(target, key, type);
        }
      }
      return res;
    },
  });
};

// 在get 拦截函数内调用 track 函数 追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return;
  // 根据 target 从 桶中取出 depsMap,它也是一个map 类型,key-->effects
  let depsMap = bucket.get(target);
  // 如果 depsMap 不存在,创建一个新的map,并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从depsMap 中取得 deps,它是一个 Set 集合
  // 里面存储着所有与当前 key 相关的副作用函数:effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到桶里
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 effectFn.deps 数组中
  activeEffect.deps.push(deps);
}

// 在 set 拦截函数内调用 trigger 函数 触发变化
function trigger(target, key, type) {
  // 从桶中取出 depsMap,它也是一个map 类型,key-->effects
  const depsMap = bucket.get(target);
  if (!depsMap) return true;
  // 再根据 key 从depsMap 中取得 副作用函数 effects
  const effects = depsMap.get(key);

  const effects2Run = new Set();
  effects &&
    effects.forEach((effectFn) => {
      // 如果trigger触发执行的副作用函数与当前的副作用函数相同,则不触发执行
      if (effectFn !== activeEffect) {
        effects2Run.add(effectFn);
      }
    });

  // 执行新增/删除属性,才会触发 桥梁 key ITERATE_KEY 对应的 effectFn
  if (type === TriggerType.ADD || type === TriggerType.DELETE) {
    const interEffects = depsMap.get(ITERATE_KEY);
    interEffects &&
      interEffects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effects2Run.add(effectFn);
        }
      });
  }

  effects2Run.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则直接执行副作用函数
      effectFn();
    }
  });
}

// 用于注册副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    // 删除与 effectFn 相关的关系
    cleanup(effectFn);
    // 调用effect函数的时候,将 effectFn 赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数添加到 effectStack 栈中
    effectStack.push(effectFn);
    // 执行副作用函数,得到返回值
    const res = fn();
    // 在调用副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 将返回值 return 出去
    return res;
  };
  // 将 options 挂在到 effectFn 上
  effectFn.options = options;
  // effectFn.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非lazy的时候才会执行
  if (!options.lazy) {
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach((deps) => {
    // 将 effectFn 从依赖集合中删除
    deps.delete(effectFn);
  });
  // 清空 effectFn.deps
  effectFn.deps.length = 0;
}

// 遍历 source 内所有的属性,从而触发 track 函数
function traverse(source, seen = new Set()) {
  // 如果要读取的数据是原始数据,或者已经被读取过,那么什么都不做
  if (typeof source !== "object" || source === null || seen.has(source)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起死循环
  seen.add(source);

  // 暂时不考虑数组等的情况
  // 假设 source 是一个对象,使用 for in 循环遍历读取每一个值,并递归调用 traverse 进行处理
  for (let key in source) {
    // 如果是对象,递归调用
    traverse(source[key], seen);
  }
  return source;
}

function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    // 如果是function,直接作为 getter
    getter = source;
  } else {
    // 如果是对象,监听source内所以的属性
    getter = () => traverse(source);
  }
  // 存储用户注册过的过期回调
  let cleanup;
  // 定义 过期回调
  function onInvalidate(fn) {
    // 将过期回调存储起来
    cleanup = fn;
  }

  const job = () => {
    newVal = effectFn();
    // 需要注意的是,第一次执行 watch cb的时候,不执行 过期回调,再次触发watch cb就会执行 上一个过期回调
    if (cleanup) {
      // 在调用cb之前,执行过期回调
      cleanup();
    }
    cb(oldVal, newVal, onInvalidate);
    oldVal = newVal;
  };

  // 定义旧值和新值
  let oldVal, newVal;
  // 使用 effect 注册将副作用函数时候,开启 lazy 选项,并把返回值存储到 effectFn ,便于后面手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        // flush 为 post,放到微任务队列中执行
        Promise.resolve().then(job);
      } else {
        job();
      }
    },
  });

  if (options.immediate) {
    job();
  } else {
    // 手动调用,将执行的结果存储到 oldVal 中
    oldVal = effectFn();
  }
}

const obj = {};
const proto = { bar: 1 };
const parent = reactive(proto);
const child = reactive(obj);
Object.setPrototypeOf(child, parent);
effect(() => {
  console.log("parent.bar", parent.bar);
});

effect(() => {
  console.log("child.bar", child.bar);
});
setTimeout(() => {
  child.bar = 2;
}, 1000);

七、参考

  1. ESMAScript@2023
  2. 《Vue.js设计与实现》