42行代码实现Vue3响应式
lihongxun945 opened this issue · 2 comments
Vue3响应式系统设计理念
先说点题外话,Vue3
正式发布之后,我面试的时候如果发现对方简历写有“精通Vue”之类的话,一般都会问一问Vue3
相对Vue2
有哪些变化,很多人只能答上来一点就是响应式从 defineProperty
变成了 proxy
。其实Vue3
是一次比较大的重构,变化的地方非常多,举几个重要的点:
- mono repo 设计:源码分成了十几个比较独立的模块,减少了代码耦合,部分模块可以独立使用也可以换成其他的实现版本
- 编译时优化:在编译阶段对模板中动态内容和静态内容进行打标,并对动态内容进行分块(block),在运行时仅更新分块之后的动态部分,大幅提升更新效率
- 组合式API:全新的组合式API,提供类似 React Hooks的全新语法
上面的题外话有助于理解接下来的内容。回到本文议题,在Vue3
中响应式模块其实有两个层次的变化:
- 内部实现从
definePropery
变成了proxy
,规避了一些老版本难以解决的bug reactivity
变成了一个功能完善的有明确API定义的独立模块,而不是耦合在Vue源码中,这就意味着我们可以在任何项目中引入reactivity
不需要依赖Vue
框架
既然是一个独立的模块,那么它就会有自己完整的设立思路。响应式系统的设计思路是什么?总结为一句话就是:在目标发生变化时,执行对应的函数。顺着这个思路,我们就可以定义出响应式系统的几个要素:
- 需要定义被监听的目标和执行的函数,我们统一把这两个对象称为
target
和effectFn
- 需要建立
target
和effectFn
之间的关联,当target
变化之后自动执行effectFn
怎么实现上面两个要素呢? Vue3
中是这么实现的:
- 提供
reactive
函数用来声明需要被监听的目标,提供effect
函数用来声明需要执行的函数 - 内部实现了一套依赖收集和触发机制来建立
target
和effectFn
的关联关系,主要是提供track
和trigger
两个函数分别收集和触发依赖。
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
的时候触发依赖。这种依赖收集方式非常巧妙,使得我们代码中不必额外写依赖声明,只要读取了值就会自动收集依赖。
track
和 trigger
函数分别是用来记录依赖和触发依赖的,配合 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
函数记录依赖。
第二个要点是三个数据结构 WeakMap
,Map
和 Set
。
targetMap
为什么不用 Map
或者 Object
呢?主要是两个原因:
WeakMap
可以用任意的JS类型作为key
,这里我们需要用target
对象作为key
WeakMap
对key
的应用是弱引用,不会影响垃圾回收。
那dep
为什么可以用Map
呢? 因为dep
整体会被作为垃圾回收,通过key
持有引用不会影响垃圾回收,而且key
一定是一个字符串。
为什么funcs
用Set
而不用数组呢?因为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 ofthis
provided for the call totarget
if agetter
is encountered. When used withProxy,
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行代码实现相媲美。那么官方的这么多代码额外做了哪些工作呢?总结一下:
- 对原始类型、嵌套类型、数组、Map、Set, Symbol、只读等不同类型数据的处理
2.effect
执行的时候支持lazy
模式,支持自定义调度器 - 官方还提供了
Ref
,Computed
等API - 完善的TS类型定义
- 异常处理,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