Vue3.0 —— Ref 是怎么实现的?
《工欲善其事,必先利其器》
(图片取自网络,侵删。)
一、Introduction
在阅读这篇文章之前,如果你还没有用上 Vue3.0,或是已经用上 Vue3.0,我相信多多少少都听过或者用过他的响应式,都应该知道它由原先的 object.defineProperty
代理对象,变成了 Proxy
代理对象。
如果你连 Proxy
也不知道,没关系。我会由浅入深一点点的剖析 Vue3.0 的响应式原理。让我们开始吧。
二、What is Proxy & Reflect ?
# SubTitle1 - Region
无法监听对象、数组新增和删除的属性,并不是 Vue2.0 的通病,而是 object.defineProperty
这个 API 的弊端。要知道,由于当时 Vue2.0 的研发到问世的阶段,Proxy
这个 API 还仍未真正稳定且完善。因此,尤大考虑到受众群体的广泛性和 Vue2.0 的宣传性,纵使 object.defineProperty
有很多弊病,也依旧采用了这个API作为响应式原理的核心。
但不能监听对象数组的增删,对于一个完整框架来说,这显然是不能够令大众接受的。因此后面就有了我们熟知的:递归收集依赖和$set
、$delete
这两个API 的补充和完善。
# SubTitle2 - Data hijacking
我们是从什么时候开始拥有了《数据劫持》这个概念的呢?答案就是 Vue2.0 真正被大众接受的时候。
响应式 —— 意味着数据的监听和双向绑定。即一个响应式的数据,从发生变化的那一刻开始,UI 视图就会立马做出相应的改变。在此之前,尽管社区有过像 Mustache 这种优秀的模板语法的存在,我们开发人员始终应用着 UI -> Data(视图到数据)的惯性思维。程序需要用户的操作行为来定义接下来的数据处理,一切都是主动的。这有点类似于 Flux
架构。
我们从未想过,这个数据如果是被动的、自动的去更新UI,而不是每次都要我去操作DOM这种行为就好了。直到 Vue 的出现,我们才会惊叹,Wow,数据原来可以这样操作!这个框架居然能劫持我定义好的数据使其自动化更新!
实际上这个这种行为的名词一般被称之为《代理》,只是 object.defineProperty
在 Vue2.0 里面的表现让我们感觉这好像就是把数据劫持了起来一样。等人们真正了解到这个API的限制之后才发现,感觉它离真正的 “代理” 应该还差点意思,倒不如说是劫持更符合它自身的气质。这也是科学与技术在生活上具像化的一种表现,也因此为后面 Proxy
代理的出现,在主观性上埋下了一个伏笔。
# SubTitle3 - What About Proxy
有了 Vue2.0 响应式的影响和铺垫,外加 MDN 的官方说明,我们可以很大方的在大脑中映射一个新概念:
Vue3.0 响应式的核心采用了新的API叫 Proxy,Proxy 在 MDN 中被称之为代理,数据是被它代理的。
《代理》其实在我们的生活中非常常见,诸如销售代理、网络代理。不管是什么样的代理,它的目的始终只有一个:将代理的所有资源和数据,本源的回溯到主体当中。
最常见的例子就是网络层面的代理,正向代理和反向代理我相信各位前端科学家们背八股文的时候应该是非常熟悉的了吧?如果有不熟悉的小伙伴也可以翻翻我以前写的关于Nginx的文章,里面剖析了代理和负载均衡的原理以及实现。
现在,我们有一条清晰的思路:Vue3.0 的数据处理由 Proxy
来解决的,Proxy
可以拦截用户对数据的操作(如属性查找、赋值、枚举、函数调用等),而它的本质其实是代理溯源。以前 object.defineProperty
能做到的,它能做到;其他不能做到的,它也能做到。
object.defineProperty
只能拦截对象的某个属性,而Proxy
能拦截整个对象。
const proxy = new Proxy({}, {
get: function(target, prop, receiver) {
return 'vk';
}
})
// 对proxy进行get获取拦截,使其返回固定值
console.log(proxy.a) // 'vk'
console.log(proxy.b) // 'vk'
console.log(proxy.c) // 'vk'
console.log(proxy.d) // 'vk'
object.defineProperty
只能监听对象的初始属性,Proxy
更是是能监听新增和删除的属性。
新增:
let proxy = { a: 1 }
proxy = new Proxy(proxy, {
set: function(target, prop, value, receiver) {
target[prop] = value;
console.log('property set: ' + prop + ' = ' + value);
return true;
}
})
// 对proxy进行set拦截,使其触发特定流程
console.log('b' in proxy); // false
proxy.b = 10; // "property set: a = 10"
console.log('b' in proxy); // true
console.log(proxy.b); // 10
删除:
let proxy = new Proxy({}, {
deleteProperty: function(target, prop) {
console.log('called: ' + prop);
return true;
}
});
// 对proxy进行delete拦截,使其触发特定流程
delete proxy.a; // "called: a"
object.defineProperty
需要初始递归监听全部属性,Proxy
则是能惰性递归监听。
let proxy = {
a: 1,
b: {
c: 2,
d: {
e: 3
}
},
f: {
z: 4
}
}
const handler = {
get:function(target, prop, receiver){
let val = target[prop];
console.log('拦截get:', prop);
if(val !== null && typeof val==='object') {
return new Proxy(val, handler); // 递归代理内层属性
}
return val;
},
set:function(target, prop, value, receiver){
console.log('拦截set:', prop, value);
return target[prop] = value;
}
}
proxy = new Proxy(proxy, handler)
proxy.b.d.e = 10 // 拦截get b, 拦截get d, 拦截set e
此处只递归了b里面的数据,同级f属性里面并未递归,这就是proxy的惰性递归特性。
# SubTitle4 - Reflect
Reflect
和 Proxy
实际上是相辅相成的,Proxy
那些内置的 API,拥有 Reflect
的加持更是尽显优雅。尽管不用 Reflect
我们也能通过参数(target,prop 等)去操作对象,但通过函数调用的形式会改变我们日常开发中那些命令式的写法,使得操作对象的流程变得更加合理以及规范。
Reflect
对象的方法与 Proxy
对象的方法一一对应,也就是说,不管怎么修改 Proxy
的默认行为,我们总能在 Reflect
上获取到与其对应默认行为。就拿上面的递归监听来说,用 Reflect
改写对象的操作方法得到的结果是一致的:
const handler = {
get:function(target, prop, receiver){
let val = Reflect.get(target, prop, receiver); // 替换API
console.log('拦截get:', prop);
if(val !== null && typeof val==='object') {
return new Proxy(val, handler);
}
return val;
},
set:function(target, prop, value, receiver){
console.log('拦截set:', prop, value);
return Reflect.set(target, prop, value, receiver); // 替换API
}
}
应用 Reflect
来处理代理对象的好处有如下几点:
-
- 规范语言内部的方法和所属对象,不全都堆放在Object对象或Function等对象的原型上;
-
- 将代理对象的操作都变成函数行为,结合它的返回结果让其变得更加合理;
-
- 两者默认方法相同,让
Proxy
的代理看起来更加统一和规范。
- 两者默认方法相同,让
联系本文所学的 Ref
对象,个人认为尤大可能一定程度上也是借鉴了 Reflect
的语义来命名的。
三、What is Ref ?
# SubTitle1 - Responsive
响应式对象,本质上是 Javascript Proxy
,或 getter
/ setter
的应用实现,它的行为跟一般的对象相似。不同之处在于 Vue 能够跟踪对响应式对象属性的访问和更改操作。
而在 Javascript 中,默认的编程思维并不是这样的,例如我们如果想要用 Javascript 实现类似响应式的逻辑,你通常会这么处理:
let a = 1;
let b = 2;
let c = a + b;
console.log(c); // 3
a = 2;
console.log(c); // 3
如上所示,我们更改 a
这个变量之后,变量 c
并不会自动跟着发生改变。那么我们要如何在 Javascript 上做到这一点呢?首先,为了能重新运行计算代码来更新 c
,我们需要将运算的逻辑封装成一个函数以便重复执行:
function update() {
c = a + b;
}
然后,我们预先定义几个专业的术语,方便我们接下来对程序的理解:
- 这个
update()
函数会产生一个副作用,我们简称就简称为作用(effect),因为它会更改程序里的状态。 a
和b
被视为这个作用的依赖(dependency),因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者(subscriber)。
做完这些之后,我们需要定义一个魔法函数,能够在 a
和 b
这两个依赖变化时调用 update()
函数(产生作用)。
whenDepsChange(update)
那么这个 whenDepsChange()
函数负责什么任务呢?
- 当一个变量被读取时进行追踪。例如我们执行了表达式
a + b
的计算,则a
和b
都会被追踪到。 - 如果一个变量在当前运行的副作用中被读取了,就将该副作用设置为此变量的一个订阅者。例如
a
和b
在执行update()
(作用)时被访问到了,则update()
需要在第一次调用之后成为a
和b
的订阅者。 - 探测/监听,一个变量的变化。例如当我们给
a
重新赋值之后,应该通知其所有订阅了的副作用重新执行。
细心的朋友已经看出来了,这其实就是一个发布订阅模式。在我以前分析 Vue2.0 源码的文章中就介绍过这个设计模式。只是如果我们想在 Javascript 中实现一个响应式,就不得不实现这么多逻辑的话,其成本和效率的利弊,说实话我不置可否,多的是仁者见仁智者见智罢了。
这一切,都源自于原生 Javascript 中,是没有存在任何机制可以追踪到上述例子中局部变量的读写的。但是,我们可以追踪对象属性的读写。Vue 正是结合了发布订阅模式和 Javascript 原生监听对象属性的方法,实现了其独有的响应式系统。
# SubTitle2 - Ref
Vue3.0 版本使用 Proxy
API 代理数据,结合 发布订阅模式
,实现响应式系统。其中 ref
方法就是允许我们创建一个可以使用任何值类型的响应式。一个简单的 ref
实现可能如下:
function ref(value) {
const refObject = new Proxy(value, {
get(target, prop, receiver) {
track(refObject, prop); // 收集依赖
return Reflect.get(target, prop, receiver);
},
set(target, prop, newVal, receiver) {
trigger(refObject, prop); // 触发依赖
Reflect.set(target, prop, newVal, receiver);
}
})
return refObject;
}
经过上面长时间的论述,我相信你很容易就能够理解这段代码。套用前一个例子中原生 Javascript 实现方法的术语就是:对在产生副作用的过程中访问到的对象属性进行 track()
(监听并收集)订阅者,当对象属性更新时,trigger()
(通知)订阅者发生副作用。
在 track()
内部,我们会检查当前是否有正在运行的副作用。如果有,我们会查找到一个存储了所有追踪了该属性的订阅者的 Set
,然后将当前这个副作用作为新订阅者添加到该 Set
中:
// 假设这是当前运行的副作用
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key); // 寻找或创建副作用集合
effects.add(activeEffect); // 添加当前副作用
}
}
而寻找或创建副作用合集 getSubscribersForProerty()
的工作,就是在第一次追踪时没有找到对应属性订阅的副作用集合的话,它就会新建一个副作用,然后被存储在一个全局的 WeakMap<target, Map<key, Set<effect>>>
对象中。
可能对于这个数据结构部分人会觉得难以读懂,没关系,我们慢慢解析。WeakMap<target, Map>
存储 target
单个目标对象的副作用。然而一个目标对象的响应式属性不可能只有一个,所有还会有针对目标对象 key
(响应式属性)的集合,即 Map<key, Set>
。最后,单个目标对象(target
)的单个响应式属性(key
)也不可能只会被一个组件引用,因此也会产生多个订阅者,所以它的数据结构就是 Set<effect>
。
整体的数据结构分析就是:单个对象的副作用集合 WeakMap<target, Map> =>
到单个属性的副作用集合 Map<key, Set> =>
再到副作用集合 Set<effect>
,结合起来就是 WeakMap<target, Map<key, Set<effect>>>
。
接着,在 trigger()
之中,我们会再查找到该属性的所有订阅者,继而执行它们触发副作用:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key); // 寻找副作用集合
effects.forEach((effect) => effect()); // 遍历触发副作用
}
现在,借鉴简化版的 ref
,我们现在可以逆向推导出上文 whenDepsChange()
函数的代码:
whenDepsChange(update) {
const effect () => {
activeEffect = effect;
update();
activeEffect = null;
}
effect();
}
它将原本的 update
函数包装在了一个副作用函数中。在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。这使得在更新期间的 track()
调用都能定位到这个当前活跃的副作用。
至此,我们已经分析完 ref
整体的代码实现思路,并结合这点,运用原生 Javascript 创建了一个能自动跟踪其依赖的副作用,它会在任意依赖被改动时重新运行。
# SubTitle3 - Ref Source Code
在此学习
ref
源码之前,我们假设你拥有一定的 Typescript 基础。
我们前面利用 Proxy
简单实现了没有任何边界情况的 ref
,但源码其实不是这么一回事,因为我们忽略了另外一种基础情况,如果传入的值不是一个对象,而是一个基本数据类型(number,string等)该怎么处理?那么 Proxy
这个API岂不是不适用了?
是啊,真正的 ref
源码其实还是用 getter
和 setter
来实现的,不过也的确没有再延续使用 object.defineProperty
,而是改用类形式实现的:
// typescript函数重载
export function ref<T extends object>(
value: T
): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {
return createRef(value, false) // 注意这里shallow参数传入false
}
/**
* @rawValue unknown 初始数据
* @shallow boolean 是否浅响应
*/
function createRef(rawValue: unknown, shallow: boolean) {
if (isRef(rawValue)) { // 判断是否属于响应式
return rawValue
}
return new RefImpl(rawValue, shallow) // 通过类返回一个响应式对象
}
class RefImpl<T> {
private _value: T // 私有变量,用于存储响应式变量
private _rawValue: T // 私有变量,用于存储初始变量
public dep?: Dep = undefined // 添加该对象依赖集合,初始为 undefined
public readonly __v_isRef = true // 定义只读响应式标识
// 类构造函数
constructor(value: T, public readonly __v_isShallow: boolean) {
this._rawValue = __v_isShallow ? value : toRaw(value) // 变量赋值,返回原始对象
this._value = __v_isShallow ? value : toReactive(value) // 构造响应式并赋值
}
// 拦截读操作
get value() {
trackRefValue(this) // 触发收集副作用机制
return this._value // 返回响应式
}
// 拦截写操作
set value(newVal) {
// 判断新值是否浅响应或仅是只读
const useDirectValue =
this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
// 如果是则返回该值否则返回获取的原始值
newVal = useDirectValue ? newVal : toRaw(newVal)
// 如果新值和原始值不等则赋值
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
// 再把新值包装响应式赋值给响应式变量
this._value = useDirectValue ? newVal : toReactive(newVal)
// 最后触发副作用
triggerRefValue(this, newVal)
}
}
}
如上源码所示,调用 ref
函数时会 new 一个 RefImpl
实例对象,这个对象监听了属性的 get
和 set
,实现了在 get
中收集依赖,在 set
中触发依赖。其实跟以前也差不多思路,只不过数据都交由给 toReactive
这个函数去处理:
export const toReactive = <T extends unknown>(value: T): T =>
// 判断如果是对象就构建深层次的响应式监听,否则返回原始数据
isObject(value) ? reactive(value) : value
以上就是 ref
源码,针对基础类型数据,ref
会将数据加工包装读拦截和写拦截实现响应式,最后返回原始数据。想要让数据使用 Proxy
对参数深层监听的话,我们需要传入一个对象。例如这样:
ref(1); // 通过监听对象(类)的value属性实现响应式
ref({a: 2}); // 调用reactive方法对对象进行深度监听,.value时获取的则是这个响应式对象,访问value时是ref实现的响应式,访问.value.a 时则是 reactive实现的响应式。
有时候针对数组的操作,为了不破坏其响应式,我们可以采用这种做法,或采用数组API方式操作。但一般基本数据类型似乎没什么必要这么做,那样只会导致浪费不必要的资源。
# SubTitle4 - Ref Usage
正常使用,Typescript
会为你推导数据类型并提示警告,但它原始的状态是这样的,通过修改类型可以定义一些复杂场景:
import { ref } from 'vue'
import type { Ref } from 'vue'
const year: Ref<string | number> = ref('2020')
year.value = 2020 // 成功!
或者,在调用 ref()
时传入一个泛型参数,来覆盖默认的推导行为:
// 得到的类型:Ref<string | number>
const year = ref<string | number>('2020')
year.value = 2020 // 成功!
如果你指定了一个泛型参数但没有给出初始值,那么最后得到的就将是一个包含 undefined
的联合类型:
// 推导得到的类型:Ref<number | undefined>
const n = ref<number>()
四、Summary
本文写了2天,是的。2天。但我们仅仅全面的分析了 ref()
的源码,其一些关联上的响应源码还没有分析:例如依赖收集机制、解包等等。这些我们留着下一篇文章分享。
最后,这万字长文希望对你有帮助,希望你的未来一片光明。
五、参考文章
- Vue3.0官网 —— 响应式基础
- 掘金社区 —— Vue3的reactive和ref原理区别详解