lihongxun945/myblog

42行代码实现Vue3响应式

lihongxun945 opened this issue · 2 comments

Vue3响应式系统设计理念

先说点题外话,Vue3 正式发布之后,我面试的时候如果发现对方简历写有“精通Vue”之类的话,一般都会问一问Vue3相对Vue2有哪些变化,很多人只能答上来一点就是响应式从 defineProperty变成了 proxy。其实Vue3是一次比较大的重构,变化的地方非常多,举几个重要的点:

  • mono repo 设计:源码分成了十几个比较独立的模块,减少了代码耦合,部分模块可以独立使用也可以换成其他的实现版本
  • 编译时优化:在编译阶段对模板中动态内容和静态内容进行打标,并对动态内容进行分块(block),在运行时仅更新分块之后的动态部分,大幅提升更新效率
  • 组合式API:全新的组合式API,提供类似 React Hooks的全新语法

上面的题外话有助于理解接下来的内容。回到本文议题,在Vue3中响应式模块其实有两个层次的变化:

  1. 内部实现从definePropery变成了 proxy,规避了一些老版本难以解决的bug
  2. reactivity变成了一个功能完善的有明确API定义的独立模块,而不是耦合在Vue源码中,这就意味着我们可以在任何项目中引入 reactivity不需要依赖Vue框架

既然是一个独立的模块,那么它就会有自己完整的设立思路。响应式系统的设计思路是什么?总结为一句话就是:在目标发生变化时,执行对应的函数。顺着这个思路,我们就可以定义出响应式系统的几个要素:

  1. 需要定义被监听的目标和执行的函数,我们统一把这两个对象称为targeteffectFn
  2. 需要建立targeteffectFn之间的关联,当 target变化之后自动执行 effectFn

怎么实现上面两个要素呢? Vue3中是这么实现的:

  1. 提供 reactive函数用来声明需要被监听的目标,提供 effect函数用来声明需要执行的函数
  2. 内部实现了一套依赖收集和触发机制来建立 targeteffectFn的关联关系,主要是提供 tracktrigger两个函数分别收集和触发依赖。
    modules

42行代码实现

reactive函数将一个 target变成响应式的,原理是通过 proxy进行了代理,这里为了说明原理,我们只实现对 Object类型的代理,代码如下:

const reactive = function (target) {
  return new Proxy(target, {
    get(target, key, reciever) {
      track(target, key);
      return Reflect.get(target, key, reciever);
    },
    set(target, key, value, reciever) {
      Reflect.set(target, key, value, reciever);
      trigger(target, key);
    }
  });
}

用法如下所示:

const p = reactive({name: 'luxun'}); // p 就变成响应式的了;

reactive的基本原理是get的时候收集依赖,在 set 的时候触发依赖。这种依赖收集方式非常巧妙,使得我们代码中不必额外写依赖声明,只要读取了值就会自动收集依赖。

tracktrigger函数分别是用来记录依赖和触发依赖的,配合 effect记录当前函数,实现如下:

let activeEffect = undefined;
const targetMap = new WeakMap();

const effect = function (fn) {
  activeEffect = fn;
  fn();
}

const track = function (target, key) {
  const dep = targetMap.get(target) || new Map();
  const funcs = dep.get(key) || new Set();
  funcs.add(activeEffect);
  dep.set(key, funcs);
  targetMap.set(target, dep);
}

const trigger = function (target, key) {
  const dep = targetMap.get(target);
  if (dep && dep.get(key)) {
    dep.get(key).forEach(fn => fn());
  }
}

用法如下:

const p = reactive({name: 'luxun'}); 
effect(() => console.log(p.name));
p.name = 'zhangsan'; // 触发effect执行

上面 22行实现代码,有三个要点(面试考点)需要注意。

第一个要点是 effect实现。
effect作用就是记录并执行传入的 fn,这里为什么可以用一个全局变量来记录呢? 因为JS的代码执行是单线程的(不考虑worker),effect函数不可能并行执行,因此这样记录没有问题。而且这不是为了实现简单随便写的,官方实现也是一个全局变量。effect中执行 fn()时,会触发对p的读取操作,此时就会调用track函数记录依赖。

第二个要点是三个数据结构 WeakMapMapSet
targetMap为什么不用 Map或者 Object呢?主要是两个原因:

  1. WeakMap可以用任意的JS类型作为 key,这里我们需要用 target对象作为 key
  2. WeakMapkey 的应用是弱引用,不会影响垃圾回收。
    dep为什么可以用 Map呢? 因为 dep整体会被作为垃圾回收,通过 key持有引用不会影响垃圾回收,而且 key一定是一个字符串。
    为什么funcsSet而不用数组呢?因为 Set 是自动去重的。

第三个要点 是targetMap的结构,依赖信息是如何记录的。结构是这样的: targetMap[target][key] = new Set(fn1, fn2, fn3);

为什么要用 Reflect

还有一个非常需要注意的点,是对 Reflect 的使用。大家考虑下这两行代码有什么区别?

Reflect.get(target, key, reciever); // 通过Reflect取值
target[key]; // 通过key取值

假设我们把 Reflect.get换成 target[key]会有什么问题吗? 要回答这个问题,先看看MDN上的定义:

The static Reflect.get() method works like getting a property from an object (target[propertyKey]) as a function.
按照说明,似乎是没有区别的,不过关键是第三个参数 Reciever
receiver Optional:
The value of this provided for the call to target if a getter is encountered. When used with Proxy, it can be an object that inherits from target.
这个参数可以指定取值时的 this? 大家肯定会奇怪取值的时候哪来的 this 呢? 我们把前面的例子改一下就知道了:

const raw = {
  firstName: 'Lu',
  lastName: 'xun',
  get name() {
    return this.firstName + this.lastName;
  }
}
const person = reactive(raw);

当取值 name的时候,这不就有 this了吗?此时如果我们通过 target[key]取值,相当于通过 raw.name进行了取值,那么其中的 this就指向了raw 而不是 person。这样就会有问题了,因为只有 person.firstName才会进入 getter收集依赖, raw.firstName 并不会触发依赖收集。

结论就是: Reflect 是为了解决 this 指向问题,如果用 target[key]会导致 this指向原始值而无法收集到依赖。

和官方实现有什么区别?

前文的42行玩具实现其实已经揭示了核心逻辑,当显然不能和官方2000行代码实现相媲美。那么官方的这么多代码额外做了哪些工作呢?总结一下:

  1. 对原始类型、嵌套类型、数组、Map、Set, Symbol、只读等不同类型数据的处理
    2. effect执行的时候支持 lazy模式,支持自定义调度器
  2. 官方还提供了 Ref, Computed等API
  3. 完善的TS类型定义
  4. 异常处理,DEV模式

上文中的完整的42行代码参见这里:https://github.com/lihongxun945/42lines-vue3-reactivity/blob/master/reactivity.js

这个实现不太对

const p = reactive({ firstName: 'foo', lastName: 'bar' })

effect(function none () {
  console.log('函数 none 触发\n')
})

effect(function firstName () {
  console.log('函数 firstName 触发', p.firstName, '\n')
})

effect(function lastName () {
  console.log('函数 lastName 触发', p.lastName, '\n')
})

p.firstName = 'first#1'

输出

函数 none 触发

函数 firstName 触发 foo

函数 lastName 触发 bar

函数 firstName 触发 first#1

函数 lastName 触发 bar

最后的 函数 lastName 触发 bar 不应该输出。因为只是修改了 firstName p.firstName = 'first#1'

我试试 vue 的

vue 是正确的

import { reactive, effect } from 'vue';

// ----------------------------------------------------------------
const p = reactive({ firstName: 'foo', lastName: 'bar' })

effect(function none () {
  console.log('函数 none 触发\n')
})

effect(function firstName () {
  console.log('函数 firstName 触发', p.firstName, '\n')
})

effect(function lastName () {
  console.log('函数 lastName 触发', p.lastName, '\n')
})

p.firstName = 'first#1'

输出

函数 none 触发

函数 firstName 触发 foo

函数 lastName 触发 bar

函数 firstName 触发 first#1