Vue

引言

既然 Vue.js 3 的响应式数据是基于 Proxy 实现的,那么我们就有必要了解 Proxy 以及与之相关联的 Reflect。什么是 Proxy 呢?简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。

创建对象代理 Proxy

内置的对象 Reflect

当我们讨论编程语言中的”基本语义”时,我们指的是对数据进行读取和修改的最基本操作。在JavaScript中,这些操作通常包括读取属性值和设置属性值。例如,在给定一个对象obj的情况下,以下操作被认为是基本语义的操作:

  1. 读取属性值:obj.foo (读取属性foo的值)
  2. 设置属性值:obj.foo = newValue (设置属性foo的值)

在上述代码中,Proxy对象允许我们拦截(或者说重定义)这些基本语义的操作。Proxy的构造函数接受两个参数:被代理的对象和一个包含拦截器(也称为夹子或陷阱)的对象。在拦截器对象中,我们可以定义get方法来拦截读取属性操作,定义set方法来拦截设置属性操作。这样,我们就可以在这些操作发生时执行自定义的逻辑。

关于Reflect对象,它是JavaScript的一个全局对象,提供了与Proxy拦截器方法一一对应的方法。这些Reflect方法提供了默认的操作行为。例如,Reflect.get(target, key)方法提供了访问对象属性的默认行为,与直接使用target[key]是等价的。同时,Reflect方法还可以接受第三个参数,用来指定函数调用时的this值。

理解这些基本语义操作以及如何使用ProxyReflect来拦截和处理这些操作,是理解JavaScript中响应式数据(Reactive Data)实现的关键。在响应式数据中,我们可以利用ProxyReflect来追踪对象属性的读取和修改,从而实现数据的响应式更新。

Proxy 的基本用法

当我们谈论基本语义时,我们指的是 JavaScript 中的一些基本操作,比如读取对象属性值和设置对象属性值。考虑下面的对象 obj

const obj = { foo: 1 };

在这里,obj.foo 是一个读取属性的基本语义操作,obj.foo = newValue 是一个设置属性的基本语义操作。

现在,我们可以使用 Proxy 来拦截这些基本语义的操作。

const handler = {
  get(target, key) {
    console.log(`读取属性 ${key}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`设置属性 ${key}${value}`);
    target[key] = value;
  }
};

const proxyObj = new Proxy(obj, handler);

proxyObj.foo; // 输出:读取属性 foo
proxyObj.foo = 2; // 输出:设置属性 foo 为 2

在上面的代码中,我们创建了一个 handler 对象,其中定义了 getset 方法,用来拦截属性的读取和设置。然后,我们使用 Proxy 构造函数创建了一个代理对象 proxyObj,它会拦截 obj 对象上的读取和设置操作。当我们访问 proxyObj.foo 时,会触发 get 方法,输出相应的信息。当我们设置 proxyObj.foo 的值时,会触发 set 方法,同样输出相应的信息。

这样,Proxy 允许我们在基本语义操作发生时执行自定义的逻辑,而不需要直接操作原始对象。在实际应用中,这种能力可以用来实现响应式数据、数据验证、日志记录等功能。

当我们使用 Proxy 拦截对象属性的读取操作时,我们需要特别注意访问器属性(accessor properties)的情况,因为访问器属性使用 getter 函数来定义,而这个函数内部的 this 关键字会根据调用方式而变化。

Reflect 在响应式中的用法

在拦截器函数中,我们希望建立副作用函数与响应式数据之间的关联,确保当属性被访问时,能够正确地进行依赖收集,以便在属性发生变化时触发副作用函数的重新执行。然而,如果我们直接使用 target[key] 来获取属性值,那么访问器属性内部的 this 关键字将指向原始对象,而不是代理对象,这会导致无法正确建立响应关系。

解决这个问题的方法是使用 Reflect.get(target, key, receiver) 来代替 target[key]。这样,Reflect.get 的第三个参数 receiver 就能正确地指向代理对象,而不是原始对象。这样一来,在访问器属性的 getter 函数内部,this 关键字就会指向代理对象,从而建立了正确的响应关系。

以下是使用 Reflect.get 的修正代码:

const handler = {
  get(target, key, receiver) {
    track(target, key); // 响应式数据依赖收集
    return Reflect.get(target, key, receiver); // 使用 Reflect.get 获取属性值
  },
  // 其他拦截器方法...
};

const proxyObj = new Proxy(obj, handler);

effect(() => {
  console.log(proxyObj.bar); // 在副作用函数内部访问 bar 属性
});

proxyObj.foo++; // 触发副作用函数的重新执行

我们可以再看一个简单一点的示例:

当使用代理对象时,receiver参数主要用于确保在代理的拦截函数内部,this指向代理对象,从而建立响应式联系。下面我将对比使用receiver参数和不使用的情况,以便更清楚地理解它的作用。

1. 使用receiver参数的情况:

const data = {
  foo: 1
};

const proxy = new Proxy(data, {
  get(target, key, receiver) {
    // 使用 Reflect.get 保证 this 指向代理对象
    const result = Reflect.get(target, key, receiver);
    // 在实际应用中,你可能还需要进行其他处理,例如触发更新操作等
    console.log(`Accessed ${key} property with value ${result}`);
    return result;
  }
});

console.log(proxy.foo); // 输出: Accessed foo property with value 1

在这个例子中,我们使用了receiver参数传递给Reflect.get,确保在get拦截函数内部,this指向代理对象proxy。当你访问proxy.foo时,get拦截函数被触发,并且this指向proxy对象。

2. 不使用receiver参数的情况:

const data = {
  foo: 1
};

const proxy = new Proxy(data, {
  get(target, key) {
    // 不使用 receiver 参数,this 指向原始对象 data

    
    const result = target[key];
    // 在实际应用中,你可能还需要进行其他处理,例如触发更新操作等
    console.log(`Accessed ${key} property with value ${result}`);
    return result;
  }
});

console.log(proxy.foo); // 输出: Accessed foo property with value 1

在这个例子中,我们没有使用receiver参数。由于没有传递receiver参数,thisget拦截函数内部指向的是原始对象data。虽然代理对象proxy被使用,但get拦截函数内部的this并不指向proxy,而是指向原始对象data。因此,这种情况下,响应式联系不会得到建立。

虽然说两个函数的输出是一致的,但显然没有使用receiver参数时响应式联系不会得到建立。也就是说在effect函数里面,对象不会得到正确的响应。

引言

当我们讨论竞态问题时,通常指的是在多进程或多线程编程中出现的一种并发问题。然而,在前端开发中,我们可能较少直接面对多线程编程,但我们经常会遇到与竞态问题相似的情境。一个常见的例子是在异步编程中,特别是在处理异步事件、回调函数或者Promise时。

例如,考虑以下的异步代码:

let data;

function fetchData() {
  setTimeout(() => {
    data = 'Fetched data';
  }, 1000);
}

fetchData();
console.log(data); // 输出 undefined

在这个例子中,fetchData 函数是异步的,它在1秒后将数据赋给 data 变量。但是,由于 JavaScript 是单线程的,fetchData 函数会在主线程的事件队列中等待1秒,而在这1秒内,console.log(data) 语句会立即执行,此时 data 的值为 undefined,因为 fetchData 函数还未完成执行。

在异步编程中,由于代码的非阻塞性质,会出现类似的竞态条件问题。在处理异步操作时,我们需要小心确保数据的一致性和正确性,避免在异步操作完成前就去访问或修改相关数据。

竞态问题与响应式

那么竞态问题跟我们响应式有什么联系呢?

举个例子:

let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})

在这段代码中,我们使用 watch 观测 obj 对象的变化,每次 obj对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成功之后,将结果赋值给 finalData 变量。观察上面的代码,乍一看似乎没什么问题。但仔细思考会发现这段代码会发生竞态问题。假设我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果

对比

但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是“最新”的,而请求 A 则应该被视为“过期”的,所以我们希望变量finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回的结果。

实际上,我们可以对这个问题做进一步总结。请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产生的副作用。由于请求 B 后发生,所以请求 B 的结果应该被视为“最新”的,而请求 A 已经“过期”了,其产生的结果应被视为无效。通过这种方式,就可以避免竞态问题导致的错误结果。归根结底,我们需要的是一个让副作用过期的手段。为了让问题更加清晰,我们先拿 Vue.js 中的 watch 函数来复现场景,看看 Vue.js是如何帮助开发者解决这个问题的,然后尝试实现这个功能。

watch(obj, async (newValue, oldValue, onInvalidate) => {
  // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
  let expired = false;
  
  // 调用 onInvalidate() 函数注册一个过期回调
  onInvalidate(() => {
    // 当过期时,将 expired 设置为 true
    expired = true;
  });

  // 发送网络请求
  const res = await fetch('/path/to/request');

  // 只有当该副作用函数的执行没有过期时,才会执行后续操作。
  if (!expired) {
    finalData = res;
    // 后续操作...
  }
});

如上面的代码所示,在发送请求之前,我们定义了 expired 标志变量,用来标识当前副作用函数的执行是否过期;接着调用onInvalidate 函数注册了一个过期回调,当该副作用函数的执行过期时将 expired 标志变量设置为 true;最后只有当没有过期时才采用请求结果,这样就可以有效地避免上述问题了。

对比

引言

之前介绍过了 effect 函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options,例如指定 scheduler 调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track 函数,以及用来触发副作用函数重新执行的 trigger 函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力——计算属性。

计算属性与lazy属性

在Vue.js中,effect函数是用来创建响应式副作用的函数。默认情况下,传递给effect的副作用函数会立即执行。例如,下面的代码中,effect函数会立即执行传递给它的副作用函数:

effect(() => {
  console.log(obj.foo);
});

然而,在某些情况下,我们希望副作用函数在需要的时候才执行,而不是立即执行。一个典型的例子是计算属性。为了实现这种延迟执行的效果,我们可以在options中添加一个lazy属性,并将其设置为true。当lazytrue时,副作用函数不会在初始化时立即执行,而是在需要的时候才执行。修正后的代码如下所示:

effect(
  // 这个函数不会立即执行
  () => {
    console.log(obj.foo);
  },
  // options
  {
    lazy: true
  }
);

具体实现中,我们将副作用函数effectFn作为effect函数的返回值返回。这意味着,当我们调用effect函数时,我们会得到对应的副作用函数,并且可以在需要的时候手动执行它。这种机制赋予了我们更多的控制权,允许我们决定何时触发副作用函数的执行,而不是立即执行它。

这种设计模式特别适用于特定场景,例如计算属性。在计算属性中,我们可能希望在特定时刻触发副作用函数的执行,而不是在初始化时立即执行。通过将副作用函数作为effect函数的返回值,我们能够灵活地控制副作用函数的执行时机,以满足不同场景的需求。

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // 设置副作用函数的 options 和 deps
  effectFn.options = options;
  effectFn.deps = [];

  // 只有非 lazy 时执行副作用函数
  if (!options.lazy) {
    effectFn();
  }

  // 将副作用函数作为返回值返回
  return effectFn;
}

在这个代码中,effect函数的第二个参数是一个options对象,其中lazy属性被设置为true。这意味着传递给effect的副作用函数会在需要的时候才执行,例如在计算属性被访问时。这种延迟执行的特性使得effect函数非常适合用于实现计算属性等场景。

在上述代码中,我们通过options参数的lazy属性控制副作用函数的立即执行。如果options.lazytrue,副作用函数将被延迟执行,直到手动触发为止。

现在我们通过计算属性实现了lazy懒加载,那么数据缓存该怎么实现呢。

function computed(getter) {
  // value 用来缓存上一次计算的值
  let value;
  // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true
  });

  const obj = {
    get value() {
      // 只有“脏”时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn();
        // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
        dirty = false;
      }
      return value;
    }
  };

  return obj;
}

解决了懒计算的问题,只有在真正需要计算value的时候,才会执行effectFn。同时,它还引入了一个dirty标志,用于标识当前的计算是否需要重新进行。如果dirtytrue,则重新计算value的值,并将dirty标志设置为false,以便下一次访问时可以直接使用缓存的值。

watch 的实现原理

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:

watch(obj, () => {
  console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++

假设 obj 是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。实际上,watch 的实现本质上就是利用了 effect 以及options.scheduler 选项,如以下代码所示:

effect(() => {
  console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})

在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而watch 的实现就是利用了这个特点。

下面是最简单的 watch 函数的实现:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler: scheduler(),
      // 当数据变化时,调用回调函数 cb
      fn: () => {
        cb();
      },
    }
  );
}

于是一段完整的代码:

// 响应式数据对象
const data = { foo: 1 };

// 创建代理对象,用于监听数据变化
const obj = new Proxy(data, {
  set(target, key, value) {
    target[key] = value;
    // 数据变化时触发回调函数
    watch(obj, () => {
      console.log('数据变化了');
    });
    return true;
  },
});

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(() => source.foo, {
    scheduler: scheduler(),
    fn: () => {
      cb();
    },
  });
}

// 模拟 effect 函数
function effect(fn, options) {
  // 在这里执行 effect 相关逻辑
  fn(); // 这里假设执行 fn 会触发响应式数据的读取操作
}

// 模拟 scheduler 函数
function scheduler() {
  // 在这里可以添加调度逻辑
  // 这里返回一个函数作为 scheduler
  return function () {
    // 这里可以添加具体的调度逻辑
    // ...
  };
}

// 数据变化
obj.foo++;

// 输出: 数据变化了

我们首先定义了一个原始的数据对象data,其中有一个属性foo,初始值为1。接着,我们使用Proxy创建了一个代理对象obj,该代理对象会拦截对data的操作。

当你调用obj.foo++时,会触发Proxy的set拦截器。在set拦截器中,我们首先将属性值设置到目标对象上,然后调用watch函数,并传入obj和一个回调函数。在watch函数中,我们使用了一个假设的effect函数(实际开发中可能是框架提供的响应式函数),这个函数用于监听数据的变化。在watch函数中,我们传入了source.foo的读取操作,以及一个包含scheduler和fn属性的配置对象。scheduler可以用于定义调度逻辑(在示例中为空函数),fn则是一个当数据变化时会被调用的回调函数。

当obj.foo++执行时,set拦截器触发,watch函数被调用。

引言

effect 副作用函数是可以发生嵌套的,至于为什么要设计成这样呢

嵌套的 effect

effect(function effectFn1() {
  effect(function effectFn2() { /* ... */ })
    /* ... */
})

在上面这段代码中,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。那么,什么场景下会出现嵌套的 effect 呢?拿 Vue.js 来说,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的.

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:

// Bar 组件
const Bar = {
  render()  {/* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
  render() {
    return <Bar /> }// jsx 语法
}

此时就发生了 effect 嵌套,它相当于:

effect(() => {
  Foo.render()
// 嵌套
  effect(() => {
    Bar.render()}
)}
)

effect函数可以嵌套使用,也就是说,一个effect函数内部可以包含另一个effect函数。当外部effect函数依赖于内部effect函数创建的响应式数据时,内部effect函数会被自动追踪,确保外部effect函数在内部effect函数发生变化时得以执行。

这种嵌套的effect函数用于创建依赖关系链,确保当某个响应式数据变化时,所有依赖于它的effect函数都能够被触发执行,从而保持应用的响应性。

而”effect 栈”,在Vue 3的内部实现中,Vue使用了一个effect栈来追踪当前正在执行的effect函数,这个栈的作用类似于函数调用栈,用于管理effect函数的执行顺序和依赖关系。

现在有一个不使用栈结构的嵌套的effect函数的例子,但是他并不能实现嵌套的功能。假设我们有两个响应式数据count1count2,其中count2的值依赖于count1的值。我们可以使用嵌套的effect函数来实现这种依赖关系。

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

// 代理对象
const obj = new Proxy(data, {
  get(target, key) {
    console.log(`读取属性: ${key}`);
    return target[key];
  }
});

// 全局变量
let temp1, temp2;

// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
  console.log('effectFn1 执行');

  effect(function effectFn2() {
    console.log('effectFn2 执行');
    // 在 effectFn2 中读取 obj.bar 属性
    temp2 = obj.bar;
  });

  // 在 effectFn1 中读取 obj.foo 属性
  temp1 = obj.foo;
});

effectFn1是外部的effect函数,它依赖于obj.foo的值,并且在内部包含了一个innerEffect,内部的effect函数依赖于obj.bar的值。当我们修改obj.foo时,我们希望外部的effect函数被触发执行,并且输出obj.foo的值,然后触发内部的依赖函数。当我们修改obj.bar时,内部的effect函数被触发执行,并且输出obj.bar的值。

我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;

function effect(fn) {
  // 定义副作用函数
  const effectFn = () => {
    // 调用 cleanup 函数,具体实现需要根据需求补充
    cleanup(effectFn);
    // 将副作用函数赋值给 activeEffect
    activeEffect = effectFn;
    // 执行副作用函数
    fn();
    // 将当前副作用函数的依赖集合存储在 effectFn.deps 中(需要根据实际逻辑补充)
    effectFn.deps = []; // 这里需要根据实际逻辑设置依赖集合
  };
  
  // 执行副作用函数
  effectFn();
}

但其实只使用一个变量储存而不使用栈结构,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,也就是说我在读取obj.foo的时候,activeEffect还只是innerEffect的值,并且只触发了innerEffect的效果。

为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;

// effect 栈
const effectStack = [];

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 调用 cleanup 函数,具体实现需要根据需求补充
    activeEffect = effectFn;

    // 将当前副作用函数压入栈中
    effectStack.push(effectFn);

    fn();

    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // 初始化副作用函数的依赖集合
  effectFn.deps = [];

  // 执行副作用函数
  effectFn();
}

引言

响应式这一个概念应该不难理解,就是js在对某一个对象或者某一个值进行操作时,我们希望通过实现一个响应式系统达到触发某些事件,也就是对操作的相应。

响应式数据与副作用函数

副作用函数指的是会产生副作用的函数,如下面的代码所示:

function effect() {
  document.body.innerText = 'hello vue3'
}

当 effect 函数执行时,它会设置 body 的文本内容,但除了effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。

其实副作用函数并不少见,我们前面在讨论webpack的 tree shaking 话题里面涉及到的是否进行数据流处理对 tree shaking 的效果是不可忽略的。这边不再赘述。

副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:

// 全局变量
let val = 1
function effect() {
  val = 2 // 修改全局变量,产生副作用
}

理解了什么是副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:

const obj = { text: 'hello world' }
function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text
}

副作用函数 effect 会设置 body 元素的 innerText 属性,其值为 obj.text,当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行。

那这样我们的思路就变成了:通过一些手段,在读取 obj.text 值的时候可以将effect函数储存进一个bucket里面,而在设置obj.text 值的时候可以在bucket里面把effect拿出来执行。

响应式数据的基本实现

如何才能让 obj 变成响应式数据呢?通过观察我们能发现:

  • 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
  • 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。

如何拦截一个对象属性的读取和设置操作。

在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

function createReactiveObject(target, proxyMap, baseHandlers) {
  // 核心就是 proxy
  // 目的是可以侦听到用户 get 或者 set 的动作
  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }

  const proxy = new Proxy(target, baseHandlers);

  // 把创建好的 proxy 给存起来,
  proxyMap.set(target, proxy);
  return proxy;
}

这样我们就基于 proxy 创建了一个用于存储副作用函数,并且我们使用了一个proxyMap,这是一个可以把各类型的proxy储存起来的容器。我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。

设计一个完善的响应系统

从上面的示例不难看出,一个响应系统的工作流程如下:

  • 当读取操作发生时,将副作用函数收集到“桶”中;

  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

下面通过一个简单的响应式系统实现来讲解原理:

我们知道proxy对象是可以传入一个具有getter和setter的对象进行get或set操作时处理的函数,因此我们可以创建一个baseHandlers,进行getter和setter的管理。

//baseHandlers
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {

    //Reflect.get方法允许你从一个对象中取属性值。就如同属性访问器语法,但却是通过函数调用来实现
    const res = Reflect.get(target, key, receiver);

    if (!isReadonly) {
      // 在触发 get 的时候进行依赖收集
      track(target, "get", key);
    }

    return res;
  };
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);

    // 在触发 set 的时候进行触发依赖
    trigger(target, "set", key);

    return result;
  };
}

我们还需要在副作用函数与被操作的目标字段之间建立明确的联系。
例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个Set 类型的数据作为“桶”了。

我们通过WeakMap来实现刚才上面我们说的储存effect的bucket。不了解WeakMap类的特性的可以看下WeakMap 对象是一组键/值对的集合

WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。

WeakMap

//Map存放不同类型的代理
export const reactiveMap = new WeakMap();
export const readonlyMap = new WeakMap();
export const shallowReadonlyMap = new WeakMap();

定义好了上面的几种bucket,我们开始实现响应式系统最核心的部分,也就是proxy的实现:

function createReactiveObject(target, proxyMap, baseHandlers) {
  // 核心就是 proxy
  // 目的是可以侦听到用户 get 或者 set 的动作

  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }
  
  const proxy = new Proxy(target, baseHandlers);

  // 把创建好的 proxy 给存起来,
  proxyMap.set(target, proxy);
  return proxy;
}

对于前面的trick和trigger:

export function track(target, type, key) {
  if (!isTracking()) {
    return;
  }
  console.log(`触发 track -> target: ${target} type:${type} key:${key}`);
  // 1. 先基于 target 找到对应的 dep
  // 如果是第一次的话,那么就需要初始化
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    // 初始化 depsMap 的逻辑
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);

  if (!dep) {
    dep = createDep();
    depsMap.set(key, dep);
  }

  trackEffects(dep);
}

export function trigger(target, type, key) {
  // 1. 先收集所有的 dep 放到 deps 里面,
  // 后面会统一处理
  let deps: Array<any> = [];

  const depsMap = targetMap.get(target);

  if (!depsMap) return;
  // 暂时只实现了 GET 类型
  // get 类型只需要取出来就可以
  const dep = depsMap.get(key);

  // 最后收集到 deps 内
  deps.push(dep);

  const effects: Array<any> = [];
  deps.forEach((dep) => {
    // 这里解构 dep 得到的是 dep 内部存储的 effect
    effects.push(...dep);
  });
  // 这里的目的是只有一个 dep ,这个dep 里面包含所有的 effect
  // 这里的目前应该是为了 triggerEffects 这个函数的复用
  triggerEffects(createDep(effects));
}