likes
comments
collection
share

亲自动手实现vue3响应式原理

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

前言

“好记性不如烂笔头”,本文章结合例子来带大家了解下vue3中的响应式原理,感兴趣的不妨继续往下阅读。

响应式原理

vue3的响应式原理,主要是利用 Proxy 来代理目标数据对象。通过代理,我们可以在对目标数据对象操作前做一些事情,例如:在获取对象的某个属性值的时候,把相应的函数存放起来,这个被保存起来的函数,我们不妨称之为“依赖函数”;然后,在更新对象的这个属性值的之后,我们可以触发之前保存起来的“依赖函数”。这样一来,在数据更新时,我们就可以感知到数据的更新并且相应地做一些事情。

为了便于理解如何存放的依赖函数,放上一张图片更清晰直观:

亲自动手实现vue3响应式原理 图中,globalMap保存的是响应式对象,而响应式对象的每个key,又对应一个集合,用于保存依赖函数effect1, effect2, effect3, .....。结合图例,可以更好理解上面说的,例如,在依赖函数 effect1 中获取 obj1 的 key1 属性时,effect1 就会被存放起来,等 ojb1.key1 更新时,effect1 就会被触发。

实践

我们通过一个具体的简单例子来一步一步实现响应式原理。如下图所示,往输入框中输入内容,下方的输入框会相应展示输入内容。

亲自动手实现vue3响应式原理

reactive实现

Proxy使用

按照上述描述,利用 Proxy API代理对象,get 的时候收集依赖函数,set 的时候触发依赖函数,我们写出以下代码:

/** xxx/reactive/index.ts
 * 响应式原理
 */

const PROXY_HANDLER = {
  get(target: object, key: string){
    const res: any = Reflect.get(target, key);
    // TODO: 收集依赖
    return res;
  },
  set(target: object, key: string, value: any){
    const res = Reflect.set(target, key, value);
    // TODO: 触发依赖
    return res;
  }
};

/**
 * 创建响应式数据
 * @param data 目标对象
 * @returns 响应式对象
 */
export function reactive<T extends object>(data: T): T{
  return new Proxy(data, PROXY_HANDLER) as T;
}

其中,TODO 注释,就是我们后续需要补充的操作:收集依赖和触发依赖

依赖收集/触发

如上面的图例所示,我们需要一个名为 globalMap 的数据结构来存放各个响应式数据,这里我们使用 WeakMap,相较于 Map,WeakMap 主要优势是当作为 key 值的对象销毁时,其在 WeakMap 中相应保存的值也会被销毁,性能上更好一些。感兴趣的朋友们可以自行搜索 WeakMap 的相关知识,这里不展开讲。此外,我们实际保存的,并不是依赖函数本身,而是依赖函数作为参数而创建出来的依赖实例。这里,我们用名为 ReactiveEffect 的类来接受依赖函数。基于此,我们需要一个变量,保存当前需要被收集的依赖实例,不妨名为 activeEffect。综上,我们得出以下代码:

/** xxx/effect/index.ts
 * 依赖相关
 */

const globalMap = new WeakMap();          // 存放响应式对象
let activeEffect: ReactiveEffect | null;  // 指向当前需要收集的依赖

// 接受依赖函数的类
export class ReactiveEffect {
  fn: Func;
  scheduler?: Func;
  constructor(fn: Func, scheduler?: Func){
    this.fn = fn;
    this.scheduler = scheduler;
  }

  run(){
    activeEffect = this;
    const res: any = this.fn();
    activeEffect = null;
    return res;
  }
}

接下来,我们实现具体的收集依赖以及触发依赖的函数,结合 Proxy,我们知道 get 和 set 函数都含有 target,key 这两个参数,而 target 指的是数据对象本身, key 指的是键名,正好有我们需要的数据。所以,我们的思路是:get函数触发的时候,把数据对象target存放到globalMap中,把依赖实例存放到key对应的集合中;set函数触发的时候也是一样,从globalMap中获取target对应的依赖映射,再从映射中获取key对应的依赖集合,把集合中的依赖都执行一遍即可:

// xxx/effect/index.ts
/**
 * 收集依赖
 * @param target 目标数据对象
 * @param key 键名
 */
export function track(target: object, key: string): void{
  if(!activeEffect) return;
  // 1. 获取 target 对应的依赖映射
  let effectMap = globalMap.get(target);
  if(!effectMap) globalMap.set(target, ( effectMap = new Map() ));
  // 2. 获取 key 对应的依赖集合
  let effectSet = effectMap.get(key);
  if(!effectSet) effectMap.set(key, ( effectSet = new Set() ));
  // 3. 收集依赖
  effectSet.add(activeEffect);
}


/**
 * 触发依赖
 * @param target 目标数据对象
 * @param key 键名
 */
export function trigger(target: object, key: string): void{
  // 1. 获取 target 对应的依赖映射
  const effectMap = globalMap.get(target);
  if(!effectMap) return;
  // 2. 获取 key 对应的依赖集合
  const effectSet = effectMap.get(key);
  if(!effectSet) return;
  // 3. 触发依赖
  effectSet.forEach((effect: ReactiveEffect) => effect.scheduler ? effect.scheduler() : effect.run());
}

至此,收集依赖和触发依赖的逻辑就完成了,但是还差一样东西,那就是获取依赖函数的桥梁。因为上面的依赖收集和触发,都依赖 activeEffect,而该变量保存的是 ReactiveEffect 的实例对象,所以我们需要一个函数,把依赖函数提供给 ReactiveEffect ,从而创建出依赖实例对象,不妨把这个作为桥梁的函数命名为 effect:

// xxx/effect/index.ts
/**
 * 生成依赖实例
 * @param fn 依赖函数
 * @param options 配置项对象
 */
export function effect(fn: Func, options?: EffectOptions){
 const _effect: ReactiveEffect = new ReactiveEffect(fn, options?.scheduler);
 _effect.run();
 return _effect.run.bind(_effect);
}

现在,依赖相关的功能已经实现,Proxy 中的相关代码里的 TODO 事项,就可以补充完全:

亲自动手实现vue3响应式原理

响应式原理应用

写了那么多代码,接下来我们来实际应用下,看下具体效果如何。

首先,我们有这样一个页面结构:

亲自动手实现vue3响应式原理

id 为 reactiveInput 的输入框,用于接收我们的输入。id 为 effectInput 的输入框,用于展示我们的输入。

接下来,就是具体的验证逻辑了。先导入我们写好的 reactive 函数和 effect 函数,并且获取两个目标input输入框。

亲自动手实现vue3响应式原理 接着,创建响应式数据,并且监听 id 为 reactiveInput 的输入框的 change 事件,事件内更新响应式数据:

亲自动手实现vue3响应式原理 根据 console.log 语句,验证下响应式数据对应值是否发生改变:

亲自动手实现vue3响应式原理 可以看到,响应式数据发生相应改变,目前来说,符合预期。

接下来,就是最重要的部分了。声明一个名为 effectVal 的函数,内部逻辑就是把响应式数据赋值给 id 为 relativeInput 的输入框。然后把这个函数作为参数提供给 effect 函数执行:

亲自动手实现vue3响应式原理

接下来,就是验证响应式原理的时候了,我们改变上面输入框的内容,看看下方的输入框是否相应发生改变即可。

亲自动手实现vue3响应式原理

ref 实现

上面的 reactive 的实现看懂了的话,其实 ref 的原理就很容易理解了,因为本质上是一样的,取值时收集依赖,更新值的时候触发依赖。在 vue3 中,我们使用 ref 类型的响应式数据,都是 xxx.value 来使用的。这说明,ref函数返回的,已经不是原始类型数据了,而是一个包含 value 属性的结构,这里我们用类来实现,名为 RefImpl,然后手动设置 get value(){} 和 set value(){},这样就能与reactive实现保持一致了:

亲自动手实现vue3响应式原理 然后导出 ref 函数,返回的自然是 RefImpl 实例对象:

亲自动手实现vue3响应式原理 当然了,如果提供给 ref 函数的参数不是普通类型数据,那么我们就调用 reative 来创建响应式数据,其它的则利用 RefImpl 类来创建响应式数据.

接下来,把 RefImpl 中的TODO事项补充完整,即收集依赖和触发依赖。此时我们可以直接从 effect 文件内导入, track 函数和 trigger 函数,像这样使用: 亲自动手实现vue3响应式原理 但是这样的话,不够优雅。因为实际上,一个 RefImpl ,内部实际只有一个 value 属性,用不着把依赖函数存放到 globalMap 中去,存放在自身内部即可。我们在内部创建一个 Set 集合,用于存放自身的依赖函数:

亲自动手实现vue3响应式原理 虽然依赖函数可以存放在自身内部,但是我们还是需要借助 effect 函数以及 activeEffect 变量来收集依赖,所以,此时 track 函数和 trigger 函数就得把实际存放依赖以及执行依赖的逻辑抽离出来:

亲自动手实现vue3响应式原理

亲自动手实现vue3响应式原理 然后,在 ref 文件内,我们直接使用上面抽离出来的 trackEffect 和 triggerEffect 函数即可:

亲自动手实现vue3响应式原理 这样,使用 ref 函数创建普通类型的响应式数据就完成了,我们来验证一下:

首先,页面内新增一个 id 为 refInput 的输入框:

亲自动手实现vue3响应式原理 接着,获取该元素,并导入 ref 函数且创建响应式数据test:

亲自动手实现vue3响应式原理 然后,在 reactiveInput 输入框的监听事件中,更新test.value,而且在 effectVal 函数内相应把test.value 的值绑定到 refInput:

亲自动手实现vue3响应式原理 这样,如果ref的实现没错的话,当我们往输入框输入内容时, refInput 输入框的内容也会相应发生变化:

亲自动手实现vue3响应式原理 可以看到,结果跟预期完全一致。ref的实现就完成了。

响应式原理概述

结合上面的例子以及写的代码,我们简单过一遍流程:

依赖收集流程

首先,我们使用 effect 函数,给 effect 函数提供了 effectVal 函数。effect函数执行时,会创建一个 ReactiveEffect 实例,之后会执行实例的 run 函数:

亲自动手实现vue3响应式原理 实例的 run 函数执行时,activeEffect 会把当前实例保存下来,接着执行提供的依赖函数,此处是 effectVal 函数:

亲自动手实现vue3响应式原理

依赖函数 effectVal 执行时,内部访问了响应式数据的某个属性(此处是 data.val):

亲自动手实现vue3响应式原理 访问响应式数据的属性,会触发 Proxy 中的 get 函数,从而依赖实例被收集起来:

亲自动手实现vue3响应式原理

收集的依赖实例,就是刚刚 activeEffect 保存的包含了 effectVal 函数的实例对象。至此,收集依赖流程走完了。

依赖触发流程

当我们通过输入框输入内容,改变响应式数据的某个属性值时(此处时,data.val)时:

亲自动手实现vue3响应式原理 该操作会触发 Proxy 的 set 函数,从而触发依赖:

亲自动手实现vue3响应式原理

触发依赖时,从globalMap中获取响应式对象对应的映射,在从映射中拿到键名对应的依赖集合,最后遍历集合,把每个依赖都触发一遍:

亲自动手实现vue3响应式原理 (图中的,scheduler忽略即可,后续讲到 computed 的实现时才用到,这里只关注 run 函数。)很明显,触发依赖执行实例的 run 函数时,会触发依赖函数,从而就达到了视图更新的目的。至此,依赖触发的流程也走完了。

补充

computed 实现

利用 ReactiveEffect 类,我们可以实现computed的功能。vue3中的 computed 数据,与 ref 数据类似,取值时也是 xxx.value 的形式,所以我们也相应地,创建一个名为 ComputedImpl 的类来进行实现:

亲自动手实现vue3响应式原理 最后,导出名为 computed 的函数,供外部使用:

亲自动手实现vue3响应式原理 Computed有个缓存的特点,即所依赖的响应式数据的值更新时,再次获取计算属性数据的值的时候才会重新计算一遍所依赖的值,而不是每次获取计算属性数据的值的时候都会计算一遍。说得有点拗口,看图中的 get value(){}函数内部实现即可明白:_dirty变量的值为true的时候,才会调用 _effect.run 函数获取最新值,否则获取的是上一次的返回值,这就是所谓的“缓存”。

而 _dirty 什么时候会重新为 true 呢?很明显,在 ComputedImpl 的构造函数内,我们提供的第二个参数,就会把 _dirty 的值重置设置为 true。这个函数,vue中就叫 scheduler。就是利用这个 scheduler 函数巧妙实现了计算属性的缓存功能。那么,为什么会这样呢?还记得,上面执行依赖函数的 triggerEffect 具体是如何执行的吗?请看:

亲自动手实现vue3响应式原理 当我们提供 scheduler 函数给 ReactiveEffect 类的时候,那么,后续当响应式数据的值更新时,触发的函数就不再是依赖实例的 run 函数了,而是这个 scheduler 函数。在此前提下,当响应式数据更新时,于是就把 _dirty 重新置为 true,那么下一次获取计算属性的值的时候,就会重新获取值了。

接下来,我们来使用以下计算属性。首先,页面内新增以下元素是:

亲自动手实现vue3响应式原理 验证逻辑是这样的:id 为 leftVal 的输入框,以及 id 为 rightVal 的输入框,绑定的都是响应式数据,id 为 computedDesc 的元素,显示的是两个输入框相加后的结果,像这样:

亲自动手实现vue3响应式原理 接着,我们来获取这三个新增的元素,并创建对应的响应式数据:

亲自动手实现vue3响应式原理

亲自动手实现vue3响应式原理 分别监听两个输入框的 input 事件,更新响应式数据的同时,也把计算属性的值更新到 descElement 元素。这样,两个响应式数据之一发生变化,descElement 元素的文本都是最新的结果:

亲自动手实现vue3响应式原理 从动图来看,计算属性是正常运作的。但是,我们如何知道,它是否真的像刚刚说的,具有“缓存”的特点呢?我们来验证以下:

首先,在 ComputedImpl 的 get value(){} 函数内打印一句测试用的语句:

亲自动手实现vue3响应式原理 接着,把计算属性数据绑定得到 window 上,并且把 input 事件内的关于计算属性的代码注释掉:

亲自动手实现vue3响应式原理 然后按 F12 打开调试工具,开始验证:

亲自动手实现vue3响应式原理 可以看到,每当响应式数据更新后,再次获取计算属性的值,才会打印测试语句,而且获取一次后,在所依赖的响应式数据未更新的情况下,无论再获取计算属性的值多少次,测试语句都没再打印,说明结果完全符合预期,验证了计算属性具有“缓存”的特点。

watch 实现

vue中,我们要监听响应式数据的改变,通常会使用 watch 来实现,vue3中就有名为 watch 的函数,它用法有几种,这里我们看其中的一种,以官方文档里的这个用法为例:

亲自动手实现vue3响应式原理 该用法里,watch 函数的第一个参数是一个函数,且该函数返回的必须是响应式数据的某个属性值,即我们需要监听的属性值,第二个参数则是当监听值更新时需要执行的回调函数。watch 函数还可以传第三个参数,即配置项:

亲自动手实现vue3响应式原理 这里我们后续实现 watch 功能时,会用到 immediate 这个选项。

watch 函数的实现,我们可以像上面实现 computed 功能一样,借助 ReactiveEffect 类进行实现。大概思路就是:watch 函数的第一个函数参数,不妨称为 getter,作用类似于 effect 函数的参数,用于触发依赖收集;而第二个参数,就作为 scheduler 进行触发。不考虑 immediate 立即执行的情况,我们写出以下代码:

亲自动手实现vue3响应式原理 这里我们把 callback 函数放到一个名为 job 的函数里执行,是因为需要给 callback 回调函数传递新值与旧值。新值自然是从依赖实例的 run 函数里获取,旧值就是每次 callback 回调函数执行后的新值。这个 job 函数,作为 ReactiveEffect 类的第二个参数创建实例对象,最后手动调用一次 run 函数,触发依赖收集即可。这样,依赖实例就会被收集起来,当被监听的响应式数据更新时,就会执行 job 函数,重新获取新值并执行回调函数。这里有一个小点需要留意下,就是记得把 run 函数的返回值赋值给 oldVal,否则当响应式数据除去初始化后的首次更新时,旧值就是不对的。

接下来,我们验证一下,引入 watch 函数,并监听一个响应式数据:

亲自动手实现vue3响应式原理 这里我们监听的时 data响应式对象的 val 属性,回调函数内,打印一句测试语句,按照预期,每次data.val更新时,回调函数都会被执行一遍,打印测试语句:

亲自动手实现vue3响应式原理 可以看到,除了首次初始化的时候不触发,之后的每次响应式数据更新,回调函数都被执行了。

接下来,实现下立即监听的功能,即 immediate 为 true 的情况。其实就是加个判断条件,当 immediate 为 true 时,立即执行一遍job函数,否则就不执行 job 函数,只需触发依赖收集即可:

亲自动手实现vue3响应式原理 watch 函数添加 immediate 选项,开启立即监听:

亲自动手实现vue3响应式原理 最后验证一下效果:

亲自动手实现vue3响应式原理 可以看到,每次刷新后,都会显示打印的测试数据,而此时并没有往输入框输入内容,证明立即监听是正确的。

watch 功能的原理大致就是如此了。

总结

简单总结下,vue3的响应式原理,主要利用 Proxy API的代理能力,代理数据对象的get和set操作,在 get 函数触发的时候,即取值的时候,收集依赖;在 set 函数触发的时候,即更新值的时候,触发收集到的依赖,从而实现 数据更新 => 视图更新 的目的。

当然,get 和 set 只是响应式原理的其中一部分而已,毕竟还有属性的增、删等操作,感兴趣的朋友可可以自行查阅源码或相关文章看看其他情况的逻辑具体是怎样实现的。

代码地址:github.com/JuneRain954…