vue 原理
从 Object.defineProperty 到 Proxy
一切的一切还得从 Object.defineProperty 开始讲起,那是一个不一样的 API … (
)bgm 响起,自行体会
Object.defineProperty
Object.defineProperty(obj, prop, descriptor) 方法会直接在一个对象上定义一个 新属性,或修改一个 对象 的 现有属性,并返回此对象,其参数具体为:
obj:要定义属性的对象prop:要定义或修改的 属性名称 或Symboldescriptor:要定义或修改的 属性描述符
从以上的描述就可以看出一些限制,比如:
目标是 对象属性,不是 整个对象
一次只能
定义或修改一个属性
- 当然有对应的一次处理多个属性的方法
Object.defineProperties(),但在vue中并不适用,因为vue不能提前知道用户传入的对象都有什么属性,因此还是得经过类似Object.keys() + for循环的方式获取所有的key -> value,而这其实是没有必要使用Object.defineProperties()
- 当然有对应的一次处理多个属性的方法
在 Vue2 中的缺陷
Object.defineProperty() 实际是通过 定义 或 修改 对象属性 的描述符来实现 数据劫持,其对应的缺点也是没法被忽略的:
只能拦截对象属性的
get和set操作,比如无法拦截delete、in、方法调用等操作动态添加新属性(响应式丢失)
- 保证后续使用的属性要在初始化声明
data时进行定义 - 使用
this.$set()设置新属性
- 保证后续使用的属性要在初始化声明
通过
1
delete
删除属性(响应式丢失)
- 使用
this.$delete()删除属性
- 使用
使用数组索引
替换/新增
元素(响应式丢失)
- 使用
this.$set()设置新元素
- 使用
使用数组
1
push、pop、shift、unshift、splice、sort、reverse
等
原生方法
改变原数组时(响应式丢失)
- 使用 重写/增强 后的
push、pop、shift、unshift、splice、sort、reverse方法
- 使用 重写/增强 后的
一次只能对一个属性实现 数据劫持,需要遍历对所有属性进行劫持
数据结构复杂时(属性值为 引用类型数据),需要通过 递归 进行处理
【扩展】Object.defineProperty 和 Array ?
它们有啥关系,其实没有啥关系,只是大家习惯性的会回答 Object.defineProperty 不能拦截 Array 的操作,这句话说得对但也不对。
使用 Object.defineProperty 拦截 Array
Object.defineProperty 可用于实现对象属性的 get 和 set 拦截,而数组其实也是对象,那自然是可以实现对应的拦截操作,如下:
Vue2 为什么不使用 Object.defineProperty 拦截 Array?
尤大在曾在 GitHub 的 Issue 中做过如下回复:
说实话性能问题到底指的是什么呢? 下面是总结了一些目前看到过的回答:
- 数组 和 普通对象 在使用场景下有区别,在项目中使用数组的目的大多是为了 遍历,即比较少会使用
array[index] = xxx的形式,更多的是使用数组的Api的方式 - 数组长度是多变的,不可能像普通对象一样先在
data选项中提前声明好所有元素,比如通过array[index] = xxx方式赋值时,一旦index的值超过了现有的最大索引值,那么当前的添加的新元素也不会具有响应式 - 数组存储的元素比较多,不可能为每个数组元素都设置
getter/setter - 无法拦截数组原生方法如
push、pop、shift、unshift等的调用,最终仍需 重写/增强 原生方法
Proxy & Reflect
由于在 Vue2 中使用 Object.defineProperty 带来的缺陷,导致在 Vue2 中不得不提供了一些额外的方法(如:Vue.set、Vue.delete())解决问题,而在 Vue3 中使用了 Proxy 的方式来实现 数据劫持,而上述的问题在 Proxy 中都可以得到解决。
Proxy
Proxy 主要用于创建一个 对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),本质上是通过拦截对象 内部方法 的执行实现代理,而对象本身根据规范定义的不同又会区分为 常规对象 和 异质对象(这不是重点,可自行了解)。
new Proxy(target, handler)是针对整个对象进行的代理,不是某个属性代理对象属性拥有
读取、修改、删除、新增、是否存在属性
等操作相应的捕捉器,
更多可见
get()属性 读取 操作的捕捉器set()属性 设置 操作的捕捉器deleteProperty()是delete操作符的捕捉器ownKeys()是Object.getOwnPropertyNames方法和Object.getOwnPropertySymbols方法的捕捉器has()是in操作符的捕捉器
Reflect
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法,这些方法与 Proxy handlers 提供的的方法是一一对应的,且 Reflect 不是一个函数对象,即不能进行实例化,其所有属性和方法都是静态的。
Reflect.get(target, propertyKey[, receiver])获取对象身上某个属性的值,类似于target[name]Reflect.set(target, propertyKey, value[, receiver])将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回trueReflect.deleteProperty(target, propertyKey)作为函数的delete操作符,相当于执行delete target[name]Reflect.ownKeys(target)返回一个包含所有自身属性(不包含继承属性)的数组。(类似于Object.keys(), 但不会受enumerable影响)Reflect.has(target, propertyKey)判断一个对象是否存在某个属性,和in运算符 的功能完全相同
Proxy 为什么需要 Reflect 呢?
在 Proxy 的 get(target, key, receiver)、set(target, key, newVal, receiver) 的捕获器中都能接到前面所列举的参数:
target指的是 原始数据对象key指的是当前操作的 属性名newVal指的是当前操作接收到的 最新值receiver指向的是当前操作 正确的上下文
怎么理解 Proxy handler 中 receiver 指向的是当前操作正确上的下文呢?
正常情况下,**
receiver** 指向的是 当前的代理对象特殊情况下,**
receiver** 指向的是 引发当前操作的对象- 通过
Object.setPrototypeOf()方法将代理对象proxy设置为普通对象obj的原型 - 通过
obj.name访问其不存在的name属性,由于原型链的存在,最终会访问到proxy.name上,即触发get捕获器
- 通过
在 Reflect 的方法中通常只需要传递 target、key、newVal 等,但为了能够处理上述提到的特殊情况,一般也需要传递 receiver 参数,因为 Reflect 方法中传递的 receiver 参数代表执行原始操作时的 this 指向,比如:Reflect.get(target, key , receiver)、Reflect.set(target, key, newVal, receiver)。
总结:**Reflect** 是为了在执行对应的拦截操作的方法时能 传递正确的 this 上下文。
Vue3 如何使用 Proxy 实现数据劫持?
Vue3 中提供了 reactive() 和 ref() 两个方法用来将 目标数据 变成 响应式数据,而通过 Proxy 来实现 数据劫持(或代理) 的具体实现就在其中,下面一起来看看吧!
reactive 函数
从源码来看,其核心其实就是 createReactiveObject(...) 函数,那么继续往下查看对应的内容
源码位置:packages\reactivity\src\reactive.ts
1 | js |
createReactiveObject() 函数
源码的体现也是非常简单,无非就是做一些前置判断处理:
若目标数据是 原始值类型,直接向返回 原数据
若目标数据的
__v_raw属性为true,且是【非响应式数据】或 不是通过调用readonly()方法,则直接返回 原数据若目标数据已存在相应的
proxy代理对象,则直接返回 对应的代理对象若目标数据不存在对应的
白名单数据类型
中,则直接返回原数据,支持响应式的数据类型如下:
- 可扩展的对象,即是否可以在它上面添加新的属性
- __v_skip 属性不存在或值为 false 的对象
- 数据类型为
Object、Array、Map、Set、WeakMap、WeakSet的对象 - 其他数据都统一被认为是 无效的响应式数据对象
通过
Proxy创建代理对象,根据目标数据类型选择不同的Proxy handlers
看来具体的实现又在不同数据类型的 捕获器 中,即下面源码的 collectionHandlers 和 **baseHandlers **,而它们则对应的是在上述 reactive() 函数中为 createReactiveObject() 函数传递的 mutableCollectionHandlers 和 mutableHandlers 参数。
源码位置:packages\reactivity\src\reactive.ts
1 |
|
捕获器 Handlers
对象类型的捕获器 — mutableHandlers
这里的对象类型指的是 数组 和 普通对象
源码位置:packages\reactivity\src\baseHandlers.ts
1 | js |
以上这些捕获器其实就是我们在上述 Proxy 部分列举出来的捕获器,显然可以拦截对普通对象的如下操作:
- 读取,如
obj.name - 设置,如
obj.name = 'zs' - 删除属性,如
delete obj.name - 判断是否存在对应属性,如
name in obj - 获取对象自身的属性值,如
obj.getOwnPropertyNames()和obj.getOwnPropertySymbols()
get 捕获器
具体信息在下面的注释中,这里只列举核心内容:
若当前数据对象是
数组
,则
重写/增强
数组对应的方法
- 数组元素的 查找方法:
includes、indexOf、lastIndexOf - 修改原数组 的方法:
push、pop、unshift、shift、splice
- 数组元素的 查找方法:
若当前数据对象是
普通对象
,且非
只读
的则通过
1
track(target, TrackOpTypes.GET, key)
进行
依赖收集
- 若当前数据对象是 浅层响应 的,则直接返回其对应属性值
- 若当前数据对象是 ref 类型的,则会进行 自动脱 ref
若当前数据对象的属性值是
对象类型
- 若当前属性值属于 只读的,则通过
readonly(res)向外返回其结果 - 否则会将当前属性值以
reactive(res)向外返回 proxy 代理对象
- 若当前属性值属于 只读的,则通过
否则直接向外返回对应的 属性值
1 | js |
set 捕获器
除去额外的边界处理,其实核心还是 更新属性值,并通过 trigger(...) 触发依赖更新
1 | js |
deleteProperty & has & ownKeys 捕获器
这三个捕获器内容非常简洁,其中 has 和 ownKeys 本质也属于 读取操作,因此需要通过 track() 进行依赖收集,而 deleteProperty 相当于修改操作,因此需要 trigger() 触发更新
1 | js |
数组类型捕获器 —— arrayInstrumentations
数组类型 和 对象类型 的大部分操作是可以共用的,比如 obj.name 和 arr[index] 等,但数组类型的操作还是会比对象类型更丰富一些,而这些就需要特殊处理。
源码位置:
packages\reactivity\src\baseHandlers.ts
处理数组索引 index 和 length
数组的 index 和 length 是会相互影响的,比如存在数组 const arr = [1] :
arr[1] = 2的操作会隐式修改length的属性值arr.length = 0的操作会导致原索引位的值发生变更
为了能够合理触发和 length 相关副作用函数的执行,在 set() 捕获器中会判断当前操作的类型:
- 当
Number(key) < target.length证明是修改操作,对应TriggerOpTypes.SET类型,即当前操作不会改变length的值,不需要 触发和length相关副作用函数的执行 - 当
Number(key) >= target.length证明是新增操作,TriggerOpTypes.ADD类型,即当前操作会改变length的值,需要 触发和length相关副作用函数的执行
1 | js |
处理数组的查找方法
数组的查找方法包括 includes、indexOf、lastIndexOf,这些方法通常情况下是能够按预期进行工作,但还是需要对某些特殊情况进行处理:
当查找的目标数据是响应式数据本身时,得到的就不是预期结果
1
2
3
4js
const obj = {}
const proxy = reactive([obj])
console.log(proxy.includs(proxy[0])) // false- 【产生原因】首先这里涉及到了两次读取操作,第一次 是
proxy[0]此时会触发get捕获器并为obj生成对应代理对象并返回,第二次 是proxy.includs()的调用,它会遍历数组的每个元素,即会触发get捕获器,并又生成一个新的代理对象并返回,而这两次生成的代理对象不是同一个,因此返回false - 【解决方案】源码中会在
get中设置一个名为proxyMap的WeakMap集合用于存储每个响应式对象,在触发get时优先返回proxyMap存在的响应式对象,这样不管触发多少次get都能返回相同的响应式数据
- 【产生原因】首先这里涉及到了两次读取操作,第一次 是
当在响应式对象中查找原始数据时,得到的就不是预期结果
1
2
3
4js
const obj = {}
const proxy = reactive([obj])
console.log(proxy.includs(obj)) // false【产生原因】
proxy.includes()会触发get捕获器并为obj生成对应代理对象并返回,而includes方法的参数传递的是 原始数据,相当于此时是 响应式对象 和 原始数据对象 进行比较,因此对应的结果一定是为false【
解决方案
】核心就是将它们的数据类型统一,即统一都使用
原始值数据对比
或
响应式数据对比
,由于
1
includes()
的方法本身并不支持对传入参数或内部响应式数据的处理,因此需要自定义以上对应的数组查找方法
- 在 重写/增强 的
includes、indexOf、lastIndexOf等方法中,会将当前方法内部访问到的响应式数据转换为原始数据,然后调用数组对应的原始方法进行查找,若查找结果为true则直接返回结果 - 若以上操作没有查找到,则通过将当前方法传入的参数转换为原始数据,在调用数组的原始方法,此时直接将对应的结果向外进行返回
- 在 重写/增强 的
源码位置:packages\reactivity\src\baseHandlers.ts
1 | js |
处理数组影响 length 的方法
隐式修改数组长度的原型方法包括 push、pop、shift、unshift、splice 等,在调用这些方法的同时会间接的读取数组的 length 属性,又因为这些方法具有修改数组长度的能力,即相当于 length 的设置操作,若不进行特殊处理,会导致与 length 属性相关的副作用函数被重复执行,即 栈溢出,比如:
1 | js |
在源码中还是通过 重写/增强 上述对应数组方法的形式实现自定义的逻辑处理:
- 在调用真正的数组原型方法前,会通过设置
pauseTracking()方法来禁止track依赖收集 - 在调用数组原生方法后,在通过
resetTracking()方法恢复track进行依赖收集 - 实际上以上的两个方法就是通过控制
shouldTrack变量为true或false,使得在track函数执行时是否要执行原来的依赖收集逻辑
源码位置:packages\reactivity\src\baseHandlers.ts
1 | js |
集合类型的捕获器 — mutableCollectionHandlers
集合类型 包括 Map、WeakMap、Set、WeakSet 等,而对 集合类型 的 代理模式 和 对象类型 需要有所不同,因为 集合类型 和 对象类型 的操作方法是不同的,比如:
Map类型 的原型 属性 和 方法 如下,详情可见:
- size
- clear()
- delete(key)
- has(key)
- get(key)
- set(key)
- keys()
- values()
- entries()
- forEach(cb)
Set类型 的原型 属性 和 方法 如下,详情可见:
- size
- add(value)
- clear()
- delete(value)
- has(value)
- keys()
- values()
- entries()
- forEach(cb)
源码位置:
packages\reactivity\src\collectionHandlers.ts
解决 代理对象 无法访问 集合类型 对应的 属性 和 方法
代理集合类型的第一个问题,就是代理对象没法获取到集合类型的属性和方法,比如:
从报错信息可以看出 size 属性是一个访问器属性,所以它被作为方法调用了,而主要错误原因就是在这个访问器中的 this 指向的是 代理对象,在源码中就是通过为这些特定的 属性 和 方法 定义对应的 key 的 mutableInstrumentations 对象,并且在其对应的 属性 和 方法 中将 this指向为 原对象.
1 | js |
处理集合类型的响应式
集合建立响应式核心还是 track 和 trigger,转而思考的问题就变成,什么时候需要 track、什么时候需要 trigger:
track时机:get()、get size()、has()、forEach()trigger时机:add()、set()、delete()、clear()
这里涉及一些优化的内容,比如:
- 在
add()中通过has()判断当前添加的元素是否已经存在于Set集合中时,若已存在就不需要进行trigger()操作,因为Set集合本身的一个特性就是 去重 - 在
delete()中通过has()判断当前删除的元素或属性是否存在,若不存在就不需要进行trigger()操作,因为此时的删除操作是 无效的
1 | js |
避免污染原始数据
通过重写集合类型的方法并手动指定其中的 this 指向为 原始对象 的方式,解决 代理对象 无法访问 集合类型 对应的 属性 和 方法 的问题,但这样的实现方式也带来了另一个问题:**原始数据被污染** 。
简单来说,我们只希望 代理对象(响应式对象) 才具备 依赖收集(track) 和 依赖更新(trigger) 的能力,而通过 原始数据 进行的操作不应该具有响应式的能力。
如果只是单纯的把所有操作直接作用到 原始对象 上就不能保证这个结果,比如:
1 | js |
在源码中的解决方案也是很简单,直接通过 value = toRaw(value) 获取当前设置值对应的 原始数据,这样旧可以避免 响应式数据对原始数据的污染。
处理 forEach 回调参数
首先 Map.prototype.forEach(callbackFn [, thisArg]) 其中 callbackFn 回调函数会接收三个参数:
- 当前的 值
value - 当前的 键
key - 正在被遍历的
Map对象(原始对象)
遍历操作 等价于 读取操作,在处理 普通对象 的 get() 捕获器中有一个处理,如果当前访问的属性值是 对象类型 那么就会向外返回其对应的 代理对象,目的是实现 惰性响应 和 深层响应,这个处理也同样适用于 集合类型。
因此,在源码中通过 callback.call(thisArg, wrap(value), wrap(key), observed) 的方式将 Map 类型的 键 和 值 进行响应式处理,以及进行 track 操作,因为 Map 类型关注的就是 键 和 值。
1 | js |
处理迭代器
集合类型的迭代器方法:
entries()keys()values()
Map 和 Set 都实现了 可迭代协议(即 Symbol.iterator 方法,而 迭代器协议 是指 一个对象实现了 next 方法),因此它们还可以通过 for...of 的方式进行遍历。
根据对 forEach 的处理,不难知道涉及遍历的方法,终究还是得将其对应的遍历的 键、值 进行响应式包裹的处理,以及进行 track 操作,而原本的的迭代器方法没办法实现,因此需要内部自定义迭代器协议。
1 | js |
这一部分的源码涉及的内容比较多,以上只是简单的总结一下,更详细的内容可查看对应的源码内容。
ref 函数 — 原始值的响应式
原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined、null 等类型的值,我们知道用 Object.defineProperty 肯定是不支持,因为它拦截的就是对象属性的操作,都说 Proxy 比 Object.defineProperty 强,那么它能不能直接支持呢?
直接支持是肯定不能的,别忘了 Proxy 代理的目标也还是对象类型呀,它的强是在自己的所属领域,跨领域也是遭不住的。
因此在 Vue3 的 ref 函数中对原始值的处理方式是通过为 原始值类型 提供一个通过 new RefImpl(rawValue, shallow) 实例化得到的 包裹对象,说白了还是将原始值类型变成对象类型,但 ref 函数的参数并 不限制数据类型:
原始值类型,
ref函数中会为原始值类型数据创建RefImpl实例对象(必须通过.value的方式访问数据),并且实现自定义的get、set用于分别进行 依赖收集 和 依赖更新,注意的是这里并不会通过Proxy为原始值类型创建代理对象,准确的说在RefImpl内部自定义实现的get、set就实现了对原始值类型的拦截操作,**因为原始值类型不需要向对象类型设置那么多的捕获器**对象类型
,
1
ref
函数中除了为
对象类型
数据创建
1
RefImpl
实例对象之外,还会通过
1
reactive
函数将其转换为响应式数据,其实主要还是为了支持类似如下的操作
1
2
3js
const refProxy = ref({name: 'zs'})
refProxy.value.name = 'ls'依赖容器 dep,在
ref类型中依赖存储的位置就是每个ref实例对象上的dep属性,它本质就是一个Set实例,触发get时往dep中添加副作用函数(依赖),触发set时从dep中依次取出副作用函数执行
源码位置:packages\reactivity\src\ref.ts
1 | js |
Vue3 如何进行依赖收集?
在 Vue2 中依赖的收集方式是通过 Dep 和 Watcher 的 观察者模式 来实现的,是不是还能想起初次了解 Dep 和 Watcher 之间的这种 剪不断理还乱 的关系时的心情 ……
关于 设计模式 部分感兴趣可查看 常见 JavaScript 设计模式 — 原来这么简单 一文,里面主要围绕着
Vue中对应的设计模式来进行介绍,相信会有一定的帮助
依赖收集 其实说的就是 track 函数需要处理的内容:
声明
1
targetMap
作为一个容器,用于保存和当前响应式对象相关的依赖内容,本身是一个
1
WeakMap
类型
- 选择
WeakMap类型作为容器,是因为WeakMap对 键(对象类型)的引用是 弱类型 的,一旦外部没有对该 键(对象类型)保持引用时,WeakMap就会自动将其删除,即 能够保证该对象能够正常被垃圾回收 - 而
Map类型对 键 的引用则是 强引用 ,即便外部没有对该对象保持引用,但至少还存在Map本身对该对象的引用关系,因此会导致该对象不能及时的被垃圾回收
- 选择
将对应的 响应式数据对象 作为
targetMap的 键,存储和当前响应式数据对象相关的依赖关系depsMap(属于Map实例),即depsMap存储的就是和当前响应式对象的每一个key对应的具体依赖将
deps(属于Set实例)作为depsMap每个key对应的依赖集合,因为每个响应式数据可能在多个副作用函数中被使用,并且Set类型用于自动去重的能力
可视化结构如下:
源码位置:packages\reactivity\src\effect.ts
1 | js |
最后
以上就是针对 Vue3 中对不同数据类型的处理的内容,无论是 Vue2 还是 Vue3 响应式的核心都是 数据劫持/代理、依赖收集、依赖更新,只不过由于实现数据劫持方式的差异从而导致具体实现的差异,在 Vue3 中值得注意的是:
普通对象类型 可以直接配合
Proxy提供的捕获器实现响应式数组类型 也可以直接复用大部分和 普通对象类型 的捕获器,但其对应的查找方法和隐式修改
length的方法仍然需要被 重写/增强为了支持 集合类型 的响应式,也对其对应的方法进行了 重写/增强
原始值数据类型
主要通过
1
ref
函数来进行响应式处理,不过内容不会对
原始值类型
使用
1
reactive(或 Proxy)
函数来处理,而是在内部自定义
1
get value(){}
和
1
set value(){}
的方式实现响应式,毕竟原始值类型的操作无非就是
读取
或
设置
,核心还是将
原始值类型
转变为了
普通对象类型
ref函数可实现原始值类型转换为 响应式数据,但ref接收的值类型并没只限定为原始值类型,若接收到的是引用类型,还是会将其通过reactive函数的方式转换为响应式数据
