用 Proxy 简单实现 Vue 3 的 Reactive

Posted 三钻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用 Proxy 简单实现 Vue 3 的 Reactive相关的知识,希望对你有一定的参考价值。

这里要给同学们分享的是 Proxy 与双向绑定,我们对大部分的 javascript 的这种基础库其实已经在其他文章中做过一些讲解了,或者是在我们编程的时候有所接触了。唯有这个 Proxy 我们之前是非常的回避的,因为在业务中也不太推荐大量的使用 Proxy。

Proxy 的设计其实是一种,强大且危险的一种设计。因为应用了 Proxy 的一些代码,它的 “预期性” 会变差,所以 proxy 这个特性是专门为底层库而设计的。

Proxy 基本用法

这里我们就一起学习一下 proxy 的基本用法,在后面我们会一起实现一下 Vue 3.0reactive 的模型。当然这里实现的 reactive 并不是一个生产可用的代码,只是写一个概念版或者是玩具版的一个 reactive。主要还是用它去认识和学习一下 proxy 有哪些强大的用途。

这里我们边写代码边了解 Proxy 的一个整体特性。首先我们先创建一个 object,然后我们给这个 object 一些属性。

let object = 
  a: 1,
  b: 2

现在如果我们去访问这个 objecta 属性和 b 属性,这个中间其实是有一个获取过程,但是在 JavaScript 的底层是一个写死的方法,也就是说我们无法去干预或者监听这个获取对象属性的过程的代码。

那么这个 object 它就是一个不可 observe (观察) 的对象。所以就是一个单纯的数据存储。这也是 JavaScript 最底层的机制,我们是没有办法去改变的。

那么如果我们想有一个对象,我们既想它拥有普通对象一样的特性,又想让它能够被监听,那么我们可以怎么做呢?这个时候我们就可以通过一个 proxy 来给 object 做一层包裹。

那么接下来我们就用 proxy 来实现一个这样的对象。

  • 首先我们需要创建一个 Proxy()
  • 并且第一个参数需要把我们的 object 传进去
  • 然后第二个参数是一个 config 的配置对象
  • 这个 config 对象里面就包含了所有的我们针对 proxy 对象的钩子
  • 这里我们就做一个最简单的钩子 set —— 当我们去设置对象的一个属性的时候就会触发我们的 set 函数
  • 这个 set 函数会接收我们当前对象属性名属性值等三个参数
let object = 
  a: 1,
  b: 2


let po = new Proxy(object, 
  set(obj, prop, val) 
     console.log(obj, prop, val)
  
) 

这个时候我们把这个代码在浏览器运行一下,这里我们运行一个 po.a = 6

这里同学们可以看到,如果 po 是一个普通对象的话这里应该什么代码都不会去执行的,除非 a 它本身就是一个 setter。但是在我们编写的这个 proxy 对象上,不管我们去设置哪一个属性,都会运行我们的 set 函数,并且获得不一样的值。

我们来尝试设置一个 po 对象中没有的属性看看。

首先 proxy 跟 getter 和 setter 最主要的一个区别就是,proxy 对象上即使我们设置一个没有的属性,它也会默认触发这个 set 的方法。

我们的 proxy 里面不只提供了 getset 这些属性的钩子,其实里面还可以拦截并且改变原生的操作或者是对对象进行操作的内置函数的行为。

如果我们上 MDN 的网站上是可以看到所有 proxy 所支持的钩子。这里列出的有 applyconstructdefinePropertydeleteProperty 等等这一系列的内置或者原生的操作进行拦截并且改变它们的行为。所以说 proxy 对象是一个非常强大的对象

回到我们的例子中,我们 proxy 实际上就是代理了 object 这个对象。如果我们去调用原始的 object上的值,并不会触发 proxy 上的 hook (钩子) 里面的函数。

只有使用我们的 po(也就是我们定义的一个 object 对象的 proxy 代理对象) 才会最后去执行到 proxy 对象的拦截行为,而 object 还是原来的 object。

所以我们可以把 po 理解成一个特殊的对象,而 po 上面所有的行为都是可以被重新去指定的。这个也就是为什么我们一开始的时候会说,object 中使用了 proxy 之后对象行为的可预测性就会降低。因为我们看到的一个代码,比如 po.a = 6 在执行的时候也许背后就做了一系列很复杂的操作,这些我们是不会知道的。所以 proxy 的这个特性是一个非常危险的特性。

接下来我们来看看 Proxy 的一些应用。

模仿 Reactive 实现原理

这里我们尝试给对象做一个简单的包装。 Vue 3.0 其中一个改动就是把 Vue 原来的能力拆了一个包,产生了一个叫reactive 的这一个单独的包。

Reactive 是一个 Vue 3.0 中非常好的东西,这里我们就尝试去模仿一下它在 Vue 中的实现原理。如果有看过 Vue 3.0 源码的同学应该都会知道,Vue 3.0 中的 reactive 是使用 proxy 来实现的。

那么我们就来一起实现一个玩具版的 reactive 的小练习,从而我们更能了解 proxy 的实际应用场景。

首先我们要知道,一般对 proxy 的使用,都是会对对象做某种监听或者是改变他行为的事情。所以说对 proxy 的封装是不会像我们这样,直接用 new Proxy 这样的方式。我们都会把它包进一个函数里面,跟我们的 Promise 比较类似。

封装 reative 函数

所以这里我们先来实现一个包裹起来的 reactive 函数:

  • reactive 函数会接收一个 object 作为参数
  • 然后我们的 proxy 对象就是这个函数的返回值
  • 之前的 Proxyconfig 中我们写了 set, 这里我们加上一个 get 方法
  • 然后我们就可以把 po 改为使用 reactive(object) 来监听它所有的属性相关的操作了
let object = 
  a: 1,
  b: 2,
;

let po = reactive(object);

function reactive(object) 
  return new Proxy(object, 
    set(obj, prop, val) 
      console.log(obj, prop, val);
    ,
    get(obj, prop) 
      console.log(obj, prop);
    ,
  );

就是这样我们就把 new Proxy 给包装起来了,我们可以看到如果我们想去包装多个 object 的话就可以继续去复用这个 reactive 的代码。

然后我们来看看在浏览器中运行的效果:

这里我们执行以下 po.a = 666

这里我们就可以得到 set 函数中的 console.log 打印出来的内容了。但是这里面其实还有一个问题,如果我们在 console 中打印 object ,就会发现我们的 object 原来它并没有变化。就是我们执行的 po.a = 666 并没有在 object 中生效。

所以这里我们需要在 set 函数中把这个执行改变的代码加上,让它实际的去操作这个 object 改变的行为。然后我们同时可以把 get 的功能也实现了。

let object = 
  a: 1,
  b: 2,
;

let po = reactive(object);

function reactive(object) 
  return new Proxy(object, 
    set(obj, prop, val) 
      obj[prop] = val;
      console.log(obj, prop, val);
      return obj[prop];
    ,
    get(obj, prop) 
      console.log(obj, prop);
      return obj[prop];
    ,
  );

这时候我们执行 po.x = 666,我们就会发现原始被代理的对象 object 上面已经添加了新的属性 x,同样我们也是可以去改原来的变量的,如果我们执行 po.b = 777 那么 object 中的属性也会跟着发生变化。

这里我们就实现了一个 poobject 的一个完全的代理,当然如果我们想真正做一个完整的代理我们是需要把 proxy 中所有的 hook 都要考虑清楚。因为有的时候我们去访问一个对象或者改变一个对象的时候,其实并不是说通过这种表面的 getset 的属性的方式去访问的。

我们还是可以通过一些内置的方法,比如说 defineProperty ,需要对我们的对象发生作用,这个时候我们就需要把所有的 hook 都补全了。

但是我们可以忽略一些 hook 不去处理,比如说 applyconstruct,因为它们管的是用 new 去调用这个对象和对象后面加圆括号产生的结果。

学习到这里我们已经获得了一个基本的,能够代理 object 行为并且可以去监听 object ,并且包含了所有设置属性或者改变属性的行为的一个 proxy 对象。

接下来我们一起来尝试给他再加入真正的 reactive 特性,让事件可以变得可监听。

实现事件监听

我们有了 reactive 这样一个函数之后,我们可以考虑一下如何去监听。当然我们可以给 po 上面去加 addEventListener 类似的操作,但是在 Vue 当中他们用了一个特别有意思的 API。

就是我们可以直接通过 effect 传一个函数进入来监听 po 上面的一个属性,以此来代替这个事件监听的机制。那么下面我们来尝试实现一个 “粗糙版”。

  • 因为这个 effect 是接收一个回调函数的,所以我们这里需要再写一个 effect 函数
  • 然后我们的 effect 函数需要接收一个 callback 参数
  • 我们需要一个全局的 callbacks 数组变量来储存我们所有的 callback 函数
  • effect 函数中我们把传入进来的 callback 函数给 push 到我们的 callback 数组中储存起来
  • 这样的话,我们在 set 的时候就直接遍历 callbacks并执行里面所有的回调函数即可
// 回调函数储存组数
let callbacks = [];

let object = 
  a: 1,
  b: 2,
;

let po = reactive(object);

/**
   * effect 函数
   * @param Function callback 回调函数
   * @return void
   */
function effect(callback) 
  callbacks.push(callback);


// 加入一个监听事件
effect(() => 
  console.log('effected a : ', po.a);
);

/**
   * reactive 相应函数
   * @param Object object
   * @return Object
   */
function reactive(object) 
  return new Proxy(object, 
    // 对象赋值
    set(obj, prop, val) 
      obj[prop] = val;

      // 调用所有监听回调函数
      for (let callback of callbacks) 
        callback();
      

      return obj[prop];
    ,
    // 对象取值
    get(obj, prop) 
      console.log(obj, prop);
      return obj[prop];
    ,
  );

这个就是一个非常粗糙的实现了 reactive 中属性的监听事件。接下来我们来看看实际效果如何:

这里可以看到我们加入的 effect() 回调函数确实被执行了。如果我们只考虑实现的正确性,而不考虑性能的话我们就已经完成了 reactive 的操作。但是这个里面显然它有一个严重的性能问题的,比如说我们有 100 个对象,并且给 100 个对象设置了 100 个 effect,那么每次执行一遍就要调一万遍。因为每次它都把我们全局变量 callbacks 中记录的回调函数都执行一遍。

显然我们实现的这个 reactive 只是一个中间步骤,它并不是一个最终结果。那么我们接下来就去尝试解决这个问题,看看能不能做到仅传一个函数就能让它只有在对应的变量变化的时候,触发这个函数的调用。

建立 reactive 与 effect 连接

上一部分我们建立了对象属性的监听,这里我们给 reactive 对象属性和 effect 函数之间建立独立的连接。之前我们的 effect 函数与我们 reactive对象属性是没有一对一的关系的。这样 100 个对象就会绑定 100 effect,所以这里就会有一个性能隐患。

也就是说如果我们监听了 po.a 的话,当我们执行 po.a = 2 的时候,我们的 effect 回调函数就会被执行。但是如果我们执行的是 po.b = 3时,就不应该执行我们的 effect 函数,因为 po.b 并没有被监听。

如果我们想实现这样的效果,我们就需要一个对象属性effect 之间的依赖关系,它们之间有一个一对一的关联关系,互相响应。

让我们先来尝试一下建立一个 userReactivities 来储存我们的监听对象属性。

  • 首先我们需要准备一个 usedReactivities 的全局变量,来储存我们需要监听的对象和对象的属性
  • 接着我们尝试在 effect 里面去调用一次这个代理对象的属性,比如 po.a,这样就触发了这个属性的监听,因为我们调用了 po.a,也就是一个获取变量值的动作,所以这里就会调用到我们 reactive 中的 get。这里我们把对象和对象属性都注册进入 usedReactivies 这个变量里面
  • 然后我们改造一下我们的 effect 函数,在这里我们首先需要清除一次我们的 usedReactivities,保证每次注册的时候都是全新的,这样才会清除掉之前监听的对象属性。
// 回调函数储存组数
let callbacks = [];
// 使用过的函数属性
let usedReactivities = [];

let object = 
  a: 1,
  b: 2,
;

let po = reactive(object);

/**
   * effect 函数
   * @param Function callback 回调函数
   * @return void
   */
function effect(callback) 
  // callbacks.push(callback);
  usedReactivities = [];
  callback();
  console.log(usedReactivities);


// 加入一个监听事件
effect(() => 
  console.log('effected a : ', po.a);
);

/**
   * reactive 相应函数
   * @param Object object
   * @return Object
   */
function reactive(object) 
  return new Proxy(object, 
    // 对象赋值
    set(obj, prop, val) 
      obj[prop] = val;

      // 调用所有监听回调函数
      for (let callback of callbacks) 
        callback();
      

      return obj[prop];
    ,
    // 对象取值
    get(obj, prop) 
      usedReactivities.push([obj, prop]);
      return obj[prop];
    ,
  );

这里我们可以看到,在 effect 被调用的时候,我们的对象和对象的属性都被正确的注入到 usedReactivities 之中。这里我们只是做了一个简单的对象和对象属性的存储,并不能让我们建立对象属性与 effect 函数的依赖关系。我们需要另外把所有 callbacks 储存起来,从而让他们与我们的对象属性建立依赖关系。

  • 接下来我们可以使用 callbacks 这个全局变量来存储我们的依赖关系,所以这里我们就需要把它改造成一个 new Map() 来存,因为我们需要把 object 对象作为一个 key,这样我们才可以用它来找到对应的 reactivities (对象属性的对应 callback 函数)。
  • 然后我们就可以去改造我们的 effect 函数,在我们调用了 callback() 之后,我们的 usedReactivites 中就会拥有我们需要监听的对象和对象属性了。接着我们就需要注入我们的对象属性与 effect 依赖关系到 callbacks 里面。
  • 我们的 对象属性effect 的依赖数据是以对象和对象属性为 key,key [0] 是我们的对象key [1] 是我们的对象属性,我们的 value 就是我们的 callback 回调函数
  • 有了这个依赖关系,我们就需要在 reactive 触发 set 的时候根据当前对象和对象属性找到对应的 callback 函数来执行,如果找不到就是这个对象属性没有被监听,不需要执行回调函数。
// 回调函数储存组数
let callbacks = new Map();
// 使用过的函数属性
let usedReactivities = [];

let object = 
  a: 1,
  b: 2,
;

let po = reactive(object);

/**
   * effect 函数
   * @param Function callback 回调函数
   * @return void
   */
function effect(callback) 
  // callbacks.push(callback);
  usedReactivities = [];
  callback();

  for (let reactivity of usedReactivities) 
    if (!callbacks.has(reactivity[0])) 
      callbacks.set(reactivity[0], new Map());
    

    if (!callbacks.has(reactivity[1])) 
      callbacks.get(reactivity[0]).set(reactivity[1], []);
    

    callbacks.get(reactivity[0]).get(reactivity[1]).push(callback);
  


// 加入一个监听事件
effect(() => 
  console.log('effected a : ', po.a);
);

/**
   * reactive 相应函数
   * @param Object object
   * @return Object
   */
function reactive(object) 
  return new Proxy(object, 
    // 对象赋值
    set(obj, prop, val) 
      obj[prop] = val;

      if (callbacks.get(obj))
        if (callbacks.get(obj).get(prop))
          // 调用所有监听回调函数
          for (let callback of callbacks.get(obj).get(prop)) 
            callback();
          

      return obj[prop];
    ,
    // 对象取值
    get(obj, prop) 
      usedReactivities.push([obj, prop]);
      return obj[prop];
    ,
  );

最后我们在浏览器中运行,我们就会发现执行 po.a=3 触发了我们 effect 回调函数,但是 po.b=6 并没有触发。这个就是我们想要的效果了,但是我们的代码还是写的比较粗糙的,也没有考虑到解除的效果。不过我们这段代码已经演示了 reactivity 的实现原理。

优化 reactive

到了这里我们的 effectreactive 已经可以跑起来了,但是其实里面还是有一些小问题的。

比如说现在我们的 object 中的 a 也是一个对象:

let object = 
  a: b: 3,
  b: 2

然后我们在 effect 里面,调用了 po.a.b 这样的连级对象调用,那么这个对象它是一个监听不到 a 里面的 b 属性的。

所以说我们有必要对它再进行一些处理,让它能够支持 po.a.b 这种形式的调用。要满足这样的功能,我们就要对 reactivegetset 有一定的要求。

当我们 get 中的 obj[prop] 是一个对象的时候,我们就需要给它套一个 reactivity。也就是说当我们检测到 prop 是一个 object 的话,我们就给它返回一个 reactive(obj[prop])

那么我们来改造一下 reactive 中的 get 方法:

/**
   * reactive 相应函数
   * @param Object object
   * @return Object
   */
function reactive(object) 
  return new Proxy(object, 
    // 对象赋值
    set(obj, prop, val) 
      obj[prop] = val;

      if (callbacks.get(obj))
        if (callbacks.get(obj).get(prop))
          // 调用所有监听回调函数
          for (let callback of callbacks.get(obj).get(prop)) 
            callback();
          

      return obj[prop];
    ,
    // 对象取值
    get(obj, prop) 
      usedReactivities.push([obj, prop]);

      if (typeof obj[prop] === 'object') return reactive(obj[prop]);

      return obj[prop];
    ,
  );

这样的改造虽然是可以让我们对象中的对象也被代理了,但是我们会一个问题,就是我们的 reactive 是会返回一个新的 proxy 的。那就意味着,po.a.b 拿到的 proxypo.a 不是同一个 proxy

所以这里我们就需要把 proxy 对象放入一个全局的暂存变量里面,方便我们调用的时候在缓存数据里面重新拿出来。我们就声明一个 reativities ,默认值为 new Map()

当每个对象去调用 reactivity 的时候,我们会加一个缓存,因为 proxy 本身它是不存储任何状态的,而所有的状态都会代理到 object 上。某种意义上讲 reactive 其实是一个无状态的函数,所以我们可以对它进行缓存。

我们就在 reactive 的函数开始的位置,加入一个判断,如果我们缓存变量 reactivies 中有这个 object,我们就直接返回。如果没有我们就执行我们的新 proxy 生成并且把它存入 reactivies

好,我们来看看代码是怎么实现的。

// 这个callbacks是一个依赖收集而已
// 它表示的是,某个object的某个prop,被一些函数使用了。
// 我们把这些函数存在一个array里。通过 callbacks.get(object).get(props),
// 我们就能拿到这些函数
let callbacks = new Map();

// reactivities 只是保存了object和它对应的proxy的k-v关系。
let reactivities = new Map();

// useReactivities是当我们初次调用effect(callback)的时候,
// 会先初始化运行一次callback,然后把依赖关系暂存在useReactivities
// 这个数组里面。暂存的格式是像下面这样:
/**
  [
    [对象A,对象A被依赖的某个属性],
    [对象B,对象B被依赖的某个属性],
    [对象C,对象C被依赖的某个属性],
    ...
  ]
  **/
let usedReactivities = [];

let object = 
  a:  b: 3 ,
  b: 2,
;

let po = reactive(object);

/**
   * effect 函数
   * @param Function callback 回调函数
   * @return void
   */
function effect(callback) 
  // callbacks.push(callback);
  usedReactivities = [];
  callback();

  for (let reactivity of usedReactivities) 
    if (!callbacks.has(reactivity[0])) 
      callbacks.set(reactivity[0], new Map());
    

    if (!callbacks.has(reactivity[1])) 
      callbacks.get(reactivity[0]).set(reactivity[1], []);
    
    console.log('123', callbacks);

    callbacks.get(reactivity[0]).get(reactivity[1]).push(callback);
  


// 加入一个监听事件
effect(() => 
  console.log('effected a.b : ', po.a.b);
);

/**
   * reactive 相应函数
   * @param Object object
   * @return Object
   */
function reactive(object) 
  if (reactivities.has(object)) return reactivities.get(object);

  let proxy = new Proxy(object, 
    // 对象赋值
    set(obj, prop, val) 

以上是关于用 Proxy 简单实现 Vue 3 的 Reactive的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Proxy 及 使用Proxy实现vue数据双向绑定

Vue3 双向绑定——Proxy

vue3.0的proxy响应式原理简单实现

你了解 Vue 3.0 响应式数据怎么实现吗?

Vue-cli3 devServe.Proxy 多地址爬坑记

前端框架React、Vue对比