Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

42行代码实现Vue3响应式 #52

Open
lihongxun945 opened this issue May 13, 2022 · 2 comments
Open

42行代码实现Vue3响应式 #52

lihongxun945 opened this issue May 13, 2022 · 2 comments

Comments

@lihongxun945
Copy link
Owner

lihongxun945 commented May 13, 2022

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

@legend80s
Copy link

这个实现不太对

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 的

@legend80s
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants