Skip to content

实现响应式系统

笔记记录重要内容。

核心要素

要实现一个响应式系统,最为核心的有两个部分:

  1. 监听数据的读写
  2. 关联数据和函数

只要把这两个部分完成了,那么整个响应式系统也就基本成型了。

监听数据读写

  • 数据:在 JS 中,能够拦截读写的方式,要么 Object.defineProperty,要么就是 Proxy,这两个方法针对的目标是对象,因此我们这里考虑对对象类型进行监听
  • 读写:虽然说是监听读写,但是细分下来要监听的行为如下:
    • 获取属性:读取
    • 设置属性:写入
    • 新增属性:写入
    • 删除属性:写入
    • 是否存在某个属性:读取
    • 遍历属性:读取

拦截后对应的处理

不同的行为,拦截下来后要做的事情是不一样的。整体来讲分为两大类:

  • 收集器:针对读取的行为,会触发收集器去收集依赖,所谓收集依赖,其实就是建立数据和函数之间的依赖关系
  • 触发器:针对写入行为,触发器会工作,触发器所做的事情就是触发数据所关联的所有函数,让这些函数重新执行

下面是不同行为对应的事情:

  • 获取属性:收集器
  • 设置属性:触发器
  • 新增属性:触发器
  • 删除属性:触发器
  • 是否存在某个属性:收集器
  • 遍历属性:收集器

总结起来也很简单,只要涉及到属性的访问,那就是收集器,只要涉及到属性的设置(新增、删除都算设置),那就是触发器

完善 reactive 方法

reactive 需要加上一些边界的判断条件:

  • 如果传入的值本身就是一个代理对象,返回这个本身的代理对象(采用 weakMap 的映射缓存代理对象和原对象)
  • 如果传入的不是对象,直接返回这个数据

数组中查找对象

因为在进行代理的时候,是进行了递归代理的,也就是说对象里面成员包含对象的话,也会被代理,这就会导致数组中成员有对象的话,是找不到的。原因很简答,比较的是原始对象和代理对象,自然就找不到。

问题:

js
import { reactive } from "./reactive/index.js";

const obj = { name: "Yan" };
const arr = reactive([1, obj, 3]);

console.log(arr.indexOf(obj)); //-1 出现问题了,因为obj(原)和数组中的obj(代理对象)不是一个对象。
console.log(arr.lastIndexOf(obj)); //-1
console.log(arr.includes(obj)); //false

解决方案:先正常找,找不到就在原始对象中重新找一遍

  • 重写数组的读取方法:indexOf,includes,lastIndexOf 方法。[采用 symbol 来做一个标识,标识这个方法需要重写]

此处的代码实现有点难,需要好好的理解,看代码

getterHandler.js

js
//+++
const arrayInstrumentations = {};
["indexOf", "lastIndexOf", "includes"].forEach((key, index) => {
  arrayInstrumentations[key] = function (...args) {
    //先从数组中正常找
    const res = Array.prototype[key].apply(this, args);
    //如果找不到,就从原对象中去找
    if (res === -1 || res === false) {
      //注意:这里访问了代理对象的一个属性,那么就会被该代理对象的拦截函数拦截。即会走到下main的getter中
      return Array.prototype[key].apply(this[SYMBOL_RAW], args);
    }
    return res;
  };
});

//getter拦截函数
export default function (target, key) {
  //+++如果读取的是特殊的属性,我们就需要将原对象返回,这是供重写数组方法用的
  if (key === SYMBOL_RAW) return target;

  //依赖收集
  track(target, options.GET, key);
  //递归深层次代理对象
  const result = Reflect.get(target, key);
  if (isObject(result)) {
    return reactive(result);
  }
  //+++ 判断是否是数组,是的话就返回重写后的方法
  if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
    return arrayInstrumentations[key];
  }
  return result;
}

数组改动长度

关于数组长度的改变,也会有一些问题,如果是隐式的改变长度,不会触发 length 的派发更新。

js
const proxyArr = reactive([1, 2, 3]);
//只进行了5下标的set拦截。实际上这里隐式的改变length,也期望length进行派发更新
proxyArr[5] = 100;

//显示的更新length会触发两次
proxyArr.length = 10;

//如果显示改变length操作,新length<原length,是不会触发删除操作的派发更新的
proxyArr.length = 1;

另外即便是显式的设置 length,这里会涉及到新增和删除,新增情况下的拦截是正常的,但是在删除的情况下,不会触发 DELETE 拦截,因此也需要手动处理。

解决方案:

  • 在 set 拦截器中,缓存原 length,当隐士改变 length 的时候,手动调用派发更新 trigger
  • 在显示改变 length 同时小于原 length 长度,手动派发更新 delete 的 trigger
js
import trigger from "../../effect/trigger.js";
import { hasChanged, options } from "../../utils/index.js";

export default function (target, key, value) {
  //判断操作是新增还是修改
  const type = target.hasOwnProperty(key) ? options.SET : options.ADD;
  //缓存旧值,没修改的话不派发更新
  const oldVal = target[key];
  //+++缓存数组原长度
  const oldLen = Array.isArray(target) ? target.length : undefined;

  //+++先进行设置
  const result = Reflect.set(target, key, value);

  // 设置值不一样,派发更新
  if (hasChanged(oldVal, value)) {
    trigger(target, type, key);
    //+++数组情况:隐士和显示length处理
    if (Array.isArray(target) && oldLen !== target.length) {
      if (key !== "length") {
        // 隐式设置length
        trigger(target, options.SET, "length");
      } else {
        // 显示设置length要考虑length长度小于旧长度的情况,执行删除的派发更新
        for (let i = target.length; i < oldLen; i++) {
          trigger(target, options.DELETE, i.toString());
        }
      }
    }
  }

  return result;
}

自定义是否要收集依赖

当调用 push、pop、shift 等方法的时候,因为涉及到了 length 属性的变化,会触发依赖收集,这是我们不期望的。

最好的方式,就是由我们来控制是否要依赖收集。

解决方案

track.js中依赖追踪收集函数,加一个变量来控制是否搜集依赖。

重写数组的"push", "pop", "shift", "unshift", "splice"方法,关闭和打开依赖

track,js

js
import { options } from "../utils/index.js";
// 控制依赖收集的开关
let shouldTrack = true;
export function openTrack() {
  shouldTrack = true;
}
export function stopTrack() {
  shouldTrack = false;
}
// 依赖收集
export default function track(target, type, key) {
  // 先判断收集开关是否打开
  if (!shouldTrack) return;

  if (type === options.ITERATE) {
    // console.log("收集器:原对象", target);
    console.log(`收集器:代理函数的${type}操作`);
    return;
  }

  // console.log("收集器:原对象", target);
  console.log(`收集器:代理函数的${key}的${type}操作`);
}

getterHandler,js

js
// 重写数组:手动控制push等方法收集依赖
["push", "pop", "shift", "unshift", "splice"].forEach((key) => {
  arrayInstrumentations[key] = function (...args) {
    stopTrack();
    const res = Array.prototype[key].apply(this, args);
    openTrack();
    return res;
  };
});

//getter拦截函数
//xxxx.......

MIT License