为了更好的做解释,我会调整源码中的接口、类型、函数等声明顺序,并会增加一些注释方便阅读
在上一章中,我们介绍了ref
,如果仔细看过,想必对ref
应该已经了如指掌了。如果还没有,或着忘记了....可以先回顾一下上篇文章。
阅读本篇文章需要的前置知识有:
reactive
这个文件代码其实不多,100 来行,很多逻辑其实是在handlers
跟effect
中。我们先看这个文件的引入:
import {
isObject, // 判断是否是对象
toTypeString // 获取数据的类型名称
} from '@vue/shared'
// 此处的handles最终会传递给Proxy(target, handle)的第二个参数
import {
mutableHandlers, // 可变数据代理处理
readonlyHandlers // 只读(不可变)数据代理处理
} from './baseHandlers'
// collections 指 Set, Map, WeakMap, WeakSet
import {
mutableCollectionHandlers, // 可变集合数据代理处理
readonlyCollectionHandlers // 只读集合数据代理处理
} from './collectionHandlers'
// 上篇文章中说了半天的泛型类型
import { UnwrapRef } from './ref'
// 看过单测篇的话,应该知道这个是被effect执行后返回的监听函数的类型
import { ReactiveEffect } from './effect'
所以不用怕,很多只是引了简单的工具方法跟类型,真正跟外部函数有关联的就是几个handlers
。
再来看类型的声明跟变量的声明,先看注释很多的targetMap
。
// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
// which maintains a Set of subscribers, but we simply store them as
// raw Sets to reduce memory overhead.
export type Dep = Set
export type KeyToDepMap = Map
// 翻译自上述英文:利用WeakMap是为了更好的减少内存开销。
export const targetMap = new WeakMap()
traget
的意思就是Proxy(target, handle)
函数的第一个入参,也就是我们想转成响应式数据的原始数据。但这个KeyToDepMap
其实看不明白具体是怎么样的映射。先放着,等到我们真正使用它时,再来看。
继续往下看,是一堆常量的声明。
// raw这个单词在ref篇我们见过,它在这个库的含义是,原始数据
// WeakMaps that store {raw <-> observed} pairs.
const rawToReactive = new WeakMap()
const reactiveToRaw = new WeakMap()
const rawToReadonly = new WeakMap()
const readonlyToRaw = new WeakMap()
// WeakSets for values that are marked readonly or non-reactive during
// observable creation.
const readonlyValues = new WeakSet()
const nonReactiveValues = new WeakSet()
// 集合类型
const collectionTypes = new Set([Set, Map, WeakMap, WeakSet])
// 用于正则判断是否符合可观察数据,object + array + collectionTypes
const observableValueRE = /^[object (?:Object|Array|Map|Set|WeakMap|WeakSet)]$/
如果读过单测篇(reactive 的第 8、9、10 个单测),可能会记得之前说过,内部需要两个WeakMap
来实现原始数据跟响应数据的双向映射。明显的rawToReactive
跟reactiveToRaw
就是这两个WeakMap
。rawToReadonly
跟readonlyToRaw
顾名思义的就是映射原始数据跟只读的响应数据的两个WeakMap
。
readonlyValues
跟nonReactiveValues
根据注释以及之前单测篇的记忆,可能是跟markNonReactive
跟markReadonly
(这个单测篇没讲到)有关。猜测是用来存储用这两个 api 构建的数据,具体也可以后面再看。
collectionTypes
跟observableValueRE
看注释即可。
在真正看reactive
之前,我们把本文件内部的一些工具方案先过一遍,这样看源码时就不会东跳西跳比较乱。这部分比较简单,简单瞄两眼就好了。
// 数据是否可观察
const canObserve = (value: any): boolean => {
return (
// 整个vue3库都没搜到_isVue的逻辑,猜测是vue组件,不影响本库阅读
!value._isVue &&
// 虚拟dom的节点不可观察
!value._isVNode &&
// 属于上述常量中声明的可观察类型
observableValueRE.test(toTypeString(value)) &&
// 该集合中存储的数据不可观察
!nonReactiveValues.has(value)
)
}
// 如果reactiveToRaw或readonlyToRaw中存在该数据了,说明就是响应式数据
export function isReactive(value: any): boolean {
return reactiveToRaw.has(value) || readonlyToRaw.has(value)
}
// 判断是否是只读的响应式数据
export function isReadonly(value: any): boolean {
return readonlyToRaw.has(value)
}
// 将响应式数据转为原始数据,如果不是响应数据,则返回源数据
export function toRaw(observed: T): T {
return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed
}
// 传递数据,将其添加到只读数据集合中
// 注意readonlyValues是个WeakSet,利用set的元素唯一性,可以避免重复添加
export function markReadonly(value: T): T {
readonlyValues.add(value)
return value
}
// 传递数据,将其添加至不可响应数据集合中
export function markNonReactive(value: T): T {
nonReactiveValues.add(value)
return value
}
上述的代码都是佐料,下面看本文件的核心代码,首先看reactive
跟readonly
函数
// 函数类型声明,接受一个对象,返回不会深度嵌套的Ref数据
export function reactive(target: T): UnwrapNestedRefs
// 函数实现
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
// 如果传递的是一个只读响应式数据,则直接返回,这里其实可以直接用isReadonly
if (readonlyToRaw.has(target)) {
return target
}
// target is explicitly marked as readonly by user
// 如果是被用户标记的只读数据,那通过readonly函数去封装
if (readonlyValues.has(target)) {
return readonly(target)
}
// 到这一步的target,可以保证为非只读数据
// 通过该方法,创建响应式对象数据
return createReactiveObject(
target, // 原始数据
rawToReactive, // 原始数据 -> 响应式数据映射
reactiveToRaw, // 响应式数据 -> 原始数据映射
mutableHandlers, // 可变数据的代理劫持方法
mutableCollectionHandlers // 可变集合数据的代理劫持方法
)
}
// 函数声明+实现,接受一个对象,返回一个只读的响应式数据。
export function readonly(
target: T
): Readonly> {
// value is a mutable observable, retrieve its original and return
// a readonly version.
// 如果本身是响应式数据,获取其原始数据,并将target入参赋值为原始数据
if (reactiveToRaw.has(target)) {
target = reactiveToRaw.get(target)
}
// 创建响应式数据
return createReactiveObject(
target,
rawToReadonly,
readonlyToRaw,
readonlyHandlers,
readonlyCollectionHandlers
)
}
两个方法代码其实很简单,主要逻辑都封装到了createReactiveObject
,两个方法的主要作用是:
createReactiveObject
相应地的代理数据与响应式数据的双向映射 map。reactive
会做readonly
的相关校验,反之readonly
方法也是。下面继续看:
function createReactiveObject(
target: any,
toProxy: WeakMap,
toRaw: WeakMap,
baseHandlers: ProxyHandler,
collectionHandlers: ProxyHandler
) {
// 不是一个对象,直接返回原始数据,在开发环境下会打警告
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// 通过原始数据 -> 响应数据的映射,获取响应数据
let observed = toProxy.get(target)
// target already has corresponding Proxy
// 如果原始数据已被观察(劫持)过,则直接返回观察后的响应数据
if (observed !== void 0) {
return observed
}
// target is already a Proxy
// 如果原始数据本身就是个响应数据了,直接返回自身
if (toRaw.has(target)) {
return target
}
// only a whitelist of value types can be observed.
// 如果是不可观察的对象,则直接返回原对象
if (!canObserve(target)) {
return target
}
// 集合数据与(对象/数组) 两种数据的代理处理方式不同。
const handlers = collectionTypes.has(target.constructor)
? collectionHandlers
: baseHandlers
// 声明一个代理对象,也即是响应式数据
observed = new Proxy(target, handlers)
// 设置好原始数据与响应式数据的双向映射
toProxy.set(target, observed)
toRaw.set(observed, target)
// 在这里用到了targetMap,但是它的value值存放什么我们依旧不知道
if (!targetMap.has(target)) {
targetMap.set(target, new Map())
}
return observed
}
我们可以看到一些细节:
reactive
文件其实非常通俗易懂,看完以后,我们心中只有 2 个问题:
baseHandlers
,collectionHandlers
的具体实现以及为什么要区分?targetMap
到底是啥?当然我们知道handlers
肯定是做依赖收集跟响应触发的。那我们就先看着两个文件。
打开此文件,同样先看外部引用:
// 这些我们已经了解了
import { reactive, readonly, toRaw } from './reactive'
import { isRef } from './ref'
// 这些就是些工具方法,hasOwn 意为对象是否拥有某数据
import { isObject, hasOwn, isSymbol } from '@vue/shared'
// 这里定义了操作数据的行为枚举
import { OperationTypes } from './operations'
// LOCKED:global immutability lock
// 一个全局用来判断是否数据是不可变的开关
import { LOCKED } from './lock'
// 收集依赖跟触发监听函数的两个方法
import { track, trigger } from './effect'
只有track
跟trigger
的内部实现我们不知道,其他的要么已经了解了,要么点开看看一眼就明白。
然后是一个代表 JS 内部语言行为的描述符的集合,不明白的可以看相应MDN。具体怎么使用可以后面再看。
const builtInSymbols = new Set(
Object.getOwnPropertyNames(Symbol)
.map(key => Symbol[key])
.filter(key => typeof key === 'symbol')
)
然后会发现下面就百来行代码,我们找到reactive
中引用的mutableHandlers
、readonlyHandlers
。我们先看简单的mutableHandlers
:
export const mutableHandlers: ProxyHandler = {
get: createGetter(false),
set,
deleteProperty,
has,
ownKeys
}
这是一个ProxyHandle
,关于Proxy
如果忘记了,记得再看一遍MDN。
然后终于到了整个响应式系统最关键的地方了,这五个traps
:get
,set
,deleteProperty
,has
,ownKeys
。当然Proxy
能实现的trap
并不仅是这五个。其中defineProperty
跟getOwnPropertyDescriptor
两个trap
不涉及响应式,不需要劫持。还有一个enumerate
已经被废弃。enumerate
原本会劫持for-in
的操作的,那你会想,那这个废弃了,我们的for-in
怎么办?放心,它还是走到ownKeys
这个trap
,进而触发我们的监听函数的。
说远了,回到代码中,我们从负责收集依赖的get
看。这个trap
是通过createGetter
函数生成,那我们来看看它。
createGetter
接受一个入参:isReadonly
。那自然在readonlyHandlers
中就是传true
。
// 入参只有一个是否只读
function createGetter(isReadonly: boolean) {
// 关于proxy的get,请阅读:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
// receiver即是被创建出来的代理对象
return function get(target: any, key: string | symbol, receiver: any) {
// 如果还不了解Reflect,建议先阅读它的文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
// 获取原始数据的相应值
const res = Reflect.get(target, key, receiver)
// 如果是js的内置方法,不做依赖收集
if (isSymbol(key) && builtInSymbols.has(key)) {
return res
}
// 如果是Ref类型数据,其获取value值的过程已经注入了收集依赖的逻辑,直接返回其value值即可。
if (isRef(res)) {
return res.value
}
// 收集依赖
track(target, OperationTypes.GET, key)
// 通过get获取的值不是对象的话,则直接返回即可
// 否则,根据isReadyonly返回响应数据
return isObject(res)
? isReadonly
? // need to lazy access readonly and reactive here to avoid
// circular dependency
readonly(res)
: reactive(res)
: res
}
大致看下来会发现,get
的方法的每个表达式其实都比较简单,不过却好像都有点儿懵。
问题 1: 为什么要通过Reflect
, 而不是直接target[key]
?
确实,target[key]
好像就能实现效果了,为什么要用Reflect
,还要传个receiver
呢?原因在于原始数据的get
并没有大家想的这么简单,比如这种情况:
const targetObj = {
get a() {
return this
}
}
const proxyObj = reactive(targetObj)
这个时候,proxyObj.a
在你想象中应该是proxyObj
还是targetObj
呢?我觉得合理来说应该是proxyObj
。但a
又不是一个方法,没法直接call/apply
。要实现也可以,比较绕,约等于实现了Reflect
的 polyfill。所以感谢 ES6,利用Reflect
,可方便的把现有操作行为原模原样地反射到目标对象上,又保证真实的作用域(通过第三个参数receiver
)。这个receiver
即是生成的代理对象,在上述例子中即是proxyObj
。
问题 2: 为什么内置方法不需要收集依赖?
如果一个监听函数是这样的:
const origin = {
a() {}
}
const observed = reactive(origin)
effect(() => {
console.log(observed.a.toString())
})
很明显,当origin.a
变化时,observed.a.toString()
也是应该会变的,那为什么不用监听了呢?很简单,因为已经走到了observed.a.toString()
已经走了一次get
的 trap,没必要重复收集依赖。故而类似的内置方法,直接 return。
问题 3: 为什么属性值为对象时,需要再用reactive|readonly
执行?
注释中写了:
need to lazy access readonly and reactive here to avoid circular dependency
翻译成普通话是,需要延迟地使用reactive|readonly
来避免循环依赖。这话需要品,细细品,品了一会儿以后终于品懂了。
因为由于Proxy
这玩意儿吧,它的trap
其实只能劫持对象的第一层访问与更新。如果是嵌套对象,其实是劫持不了的。那我们就有了两种方法:
方法一:当通过reactive|readonly
转化原始对象时,一层一层的递归解套,如果是对象,就再用reactive
执行、然后走ProxyHandle
。以后访问这些嵌套属性时,自然也会走到 trap。但这样有个大问题,如果对象是循环引用的呢?那必然是要有个逻辑判断,如果发现属性值是自身则不递归了。那如果是半路循环引用的呢?比如这样:
const a = {
b: {
c: a
}
}
const A = {
B: {
C: a
}
}
想想都头大吧。
方法二:也即是源码中的方法,转化原始对象时,不递归。后续走到get
的 trap 时,如果发现属性值是个对象,再继续转化、劫持。也就是注释中所讲到的lazy
。利用这个办法,自然就可以避免循环引用了。另外还有个显而易见的好处是,可以优化性能。
除了这个三个问题外,还有一个小细节:
if (isRef(res)) {
return res.value
}
如果是Ref
类型的数据,则直接返回 value 值。因为在ref
函数中,已经做了相关的依赖跟踪逻辑。另外,如果看过单测篇跟 ref 篇,我们知道就是此处代码实现了这样的能力:向reacitive
函数传递一个嵌套的Ref
类型数据,可返回一个递归解套了Ref
类型的响应式数据。reactive
函数的返回类型为UnwrapNestedRefs
归功于此。
不过切记:向reactive
传一个纯粹的Ref
类型数据,是不会解套的,它只解套被嵌套着的Ref
数据。示例如下:
reactive(ref(4)) // = ref(4);
reactive({ a: ref(4) }) // = { a: 4 }
那到此为止,除了track
是外部引入的用来收集依赖的方法外(后面再看),get
已经摸透了。
下面看set
。
function set(
target: any,
key: string | symbol,
value: any,
receiver: any
): boolean {
// 如果value是响应式数据,则返回其映射的源数据
value = toRaw(value)
// 获取旧值
const oldValue = target[key]
// 如果旧值是Ref数据,但新值不是,那更新旧的值的value属性值,返回更新成功
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
// 代理对象中,是不是真的有这个key,没有说明操作是新增
const hadKey = hasOwn(target, key)
// 将本次设置行为,反射到原始对象上
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
// 如果是原始数据原型链上的数据操作,不做任何触发监听函数的行为。
if (target === toRaw(receiver)) {
// istanbul 是个单测覆盖率工具
/* istanbul ignore else */
if (__DEV__) {
// 开发环境下,会传给trigger一个扩展数据,包含了新旧值。明显的是便于开发环境下做一些调试。
const extraInfo = { oldValue, newValue: value }
// 如果不存在key时,说明是新增属性,操作类型为ADD
// 存在key,则说明为更新操作,当新值与旧值不相等时,才是真正的更新,进而触发trigger
if (!hadKey) {
trigger(target, OperationTypes.ADD, key, extraInfo)
} else if (value !== oldValue) {
trigger(target, OperationTypes.SET, key, extraInfo)
}
} else {
// 同上述逻辑,只是少了extraInfo
if (!hadKey) {
trigger(target, OperationTypes.ADD, key)
} else if (value !== oldValue) {
trigger(target, OperationTypes.SET, key)
}
}
}
return result
}
set
跟get
一样,每句表达式都很清晰,但我们依旧存在疑问。
问题 1:isRef(oldValue) && !isRef(value)
这段是什么逻辑?
// 如果旧值是 Ref 数据,但新值不是,那更新旧的值的 value 属性值,返回更新成功
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
什么情况下 oldValue 会是个Ref
数据呢?其实看get
部分的时候,我们就知道啦,reactive
有解套嵌套 ref 数据的能力,如:
const a = {
b: ref(1)
}
const observed = reactive(a) // { b: 1 }
此时,observed.b
输出的是 1,当做赋值操作 observed.b = 2
时。oldValue
由于是a.b
,是一个Ref
类型数据,而新的值并不是,进而直接修改a.b
的 value 即可。那为什么直接返回,不需要往下触发 trigger 了呢?是因为在ref
函数中,已经有劫持 set 的逻辑了(不贴代码了)。
问题 2:什么时候会target !== toRaw(receiver)
?
在之前的认知中,receiver
有点儿像是this
一样的存在,指代着被 Proxy 执行后的代理对象。那代理对象用toRaw
转化,也就是转为原始对象,自然跟target
是全等的。这里就涉及了一个偏门的知识点,详细介绍可以看MDN。其中有说到:
Receiver:最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上或以其他方式被间接地调用(因此不一定是 proxy 本身)
这就是代码中的注释背后的意义:
don't trigger if target is something up in the prototype chain of original.
举个实例来说就像这样:
const child = new Proxy(
{},
{
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver)
console.log('child', receiver)
return true
}
}
)
const parent = new Proxy(
{ a: 10 },
{
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver)
console.log('parent', receiver)
return true
}
}
)
Object.setPrototypeOf(child, parent)
child.a = 4
// 打印结果
// parent Proxy {child: true, a: 4}
// Proxy {child: true, a: 4}
在这种情况下,这个父对象parent
的set
竟然也会被触发一次,只不过传递的receiver
都是child
,进而被更改数据的也一直是child
。在这种情况下,parent
其实并没有变更,按道理来说,它确实不应该触发它的监听函数。
问题 3: 数组可能通过方法更新数据,这过程的监听逻辑是怎么样的?
对于一个对象来说,我们可以直接赋值属性值,但对于数组呢?假使const arr = []
,那它既可以arr[0] = 'value'
,也可以arr.push('value')
,但并没有一个trap
是劫持 push 的。但是当你真正去调试时,发现push
还会触发两次set
。
const proxy = new Proxy([], {
set(target, key, value, receiver) {
console.log(key, value, target[key])
return Reflect.set(target, key, value, receiver)
}
})
proxy.push(1)
// 0 1 undefined
// length 1 1
其实push
的内部逻辑就是先给下标赋值,然后设置length
,触发了两次set
。不过还有个现象是,虽然push
带来的length
操作会触发两次set
,但走到 length 逻辑时,获取老的 length 也已经是新的值了,所以由于value === oldValue
,实际只会走到一次trigger
。但是!如果是shift
或unshift
,这样的逻辑又不成立了,而且如果数组长度是 N,shift
|unshift
就会带来 N 次的trigger
。这里其实涉及了Array
的底层实现与规范,我也无法简单的阐述明白,建议可以自己去看ECMA-262中关于Array
的相关标准。
不过这里确实留下一个小坑,shift
|unshift
以及splice
,会带来多次的 effect 触发。在reacivity
系统中,目前还没看到相关的优化。当然,真实在使用 vue@3 的过程中,runtime-core
还是会针对渲染做批量更新的。
那到这,set
本身的逻辑我们也摸透了,除了一个外部引入的trigger
。不过我们知道它是当数据变更时触发监听函数的就好,后面再看。
接下来就比较简单了。
// 劫持属性删除
function deleteProperty(target: any, key: string | symbol): boolean {
const hadKey = hasOwn(target, key)
const oldValue = target[key]
const result = Reflect.deleteProperty(target, key)
if (result && hadKey) {
/* istanbul ignore else */
if (__DEV__) {
trigger(target, OperationTypes.DELETE, key, { oldValue })
} else {
trigger(target, OperationTypes.DELETE, key)
}
}
return result
}
// 劫持 in 操作符
function has(target: any, key: string | symbol): boolean {
const result = Reflect.has(target, key)
track(target, OperationTypes.HAS, key)
return result
}
// 劫持 Object.keys
function ownKeys(target: any): (string | number | symbol)[] {
track(target, OperationTypes.ITERATE)
return Reflect.ownKeys(target)
}
这几个trap
基本没啥难点了,一眼能看明白。
最后看下readonly
的特殊逻辑:
export const readonlyHandlers: ProxyHandler = {
// 创建get的trap
get: createGetter(true),
// set的trap
set(target: any, key: string | symbol, value: any, receiver: any): boolean {
if (LOCKED) {
// 开发环境操作只读数据报警告。
if (__DEV__) {
console.warn(
`Set operation on key "${String(key)}" failed: target is readonly.`,
target
)
}
return true
} else {
// 如果不可变开关已关闭,则允许设置数据变更
return set(target, key, value, receiver)
}
},
// delete的trap,逻辑跟set差不多
deleteProperty(target: any, key: string | symbol): boolean {
if (LOCKED) {
if (__DEV__) {
console.warn(
`Delete operation on key "${String(
key
)}" failed: target is readonly.`,
target
)
}
return true
} else {
return deleteProperty(target, key)
}
},
has,
ownKeys
}
readonly
也很简单啦,createGetter
的逻辑之前已经看过了。不过有些没绕过来的同学可能会想,get
的 trap 又不改变数据,为什么要跟reactive
的做区分,传个isReadonly
呢?那是因为上文中讲到的,通过get
做依赖收集时,对于嵌套的对象数据,是延迟劫持的,所以只能透传了isReadonly
,让后续劫持的子对象知道自身是否应该只读。
has
跟ownKeys
由于不改变数据,也不用递归收集依赖,自然就不用跟可变数据的逻辑做区分了。
看完以后,依赖收集跟触发监听函数的时机,我们就能基本了解了。
关于 baseHandles 我们做个小总结:
Refelct
反射到原始对象上。总结下来,其实还是比较简单的。但是我们还落了对于集合数据的 handlers 没看,这块才是真正的硬骨头。
打开这个文件,发现这个文件比reactive
跟baseHandlers
可长了不少。没想到对于这种平常不怎么用的数据类型的处理,才是最麻烦的。
看源码前,其实会有个疑问,为什么Set
|Map
|WeakMap
|WeakSet
这几个数据需要特殊处理呢?跟其他数据有什么区别吗?我们点开文件,看看这个handlers
,发现竟然是这样:
export const mutableCollectionHandlers: ProxyHandler = {
get: createInstrumentationGetter(mutableInstrumentations)
}
export const readonlyCollectionHandlers: ProxyHandler = {
get: createInstrumentationGetter(readonlyInstrumentations)
}
只有get
,没有set
、has
这些。这就懵了,说好的劫持set
跟get
呢?为什么不劫持set
了?原因是没法这么做,我们可以简单的做个尝试:
const set = new Set([1, 2, 3])
const proxy = new Proxy(set, {
get(target, key, receiver) {
console.log(target, key, receiver)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(target, key, value, receiver)
return Reflect.set(target, key, value, receiver)
}
})
proxy.add(4)
这段代码,一跑就会出一个错误:
Uncaught TypeError: Method Set.prototype.add called on incompatible receiver [object Object]
发现只要劫持set
,或者直接引入了Reflect
,反射行为到target
上,就会报错。为什么会这样呢?这其实也是跟Map|Set
这些的内部实现有关,他们内部存储的数据必须通过this
来访问,被成为所谓的“internal slots”,而通过代理对象去操作时,this
其实是 proxy,并不是 set,于是无法访问其内部数据,而数组呢,由于一些历史原因,又是可以的。详细解释可以见这篇关于 Proxy 的限制的介绍。这篇文章中也提到了解决办法:
let map = new Map()
let proxy = new Proxy(map, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments)
return typeof value == 'function' ? value.bind(target) : value
}
})
proxy.set('test', 1)
大致原理就是,当获取的是一个函数的时候,将this
绑定为原始对象,也即是想要劫持的map|set
。这样就避免了this
的指向问题。
那我们就有点儿明白,为什么collection
数据需要特殊处理,只劫持一个get
了。具体怎么做呢?我们来看代码。
按惯例先看引用与工具方法:
import { toRaw, reactive, readonly } from './reactive'
import { track, trigger } from './effect'
import { OperationTypes } from './operations'
import { LOCKED } from './lock'
import {
isObject,
capitalize, // 首字母转成大写
hasOwn
} from '@vue/shared'
// 将数据转为reactive数据,如果不是对象,则直接返回自身
const toReactive = (value: any) => (isObject(value) ? reactive(value) : value)
const toReadonly = (value: any) => (isObject(value) ? readonly(value) : value)
这些引用,我们应该基本不用看注释都能明白了,除了一个工具方法capitalize
需要点开看看外,基本一眼明白。然后我们需要调整下阅读顺序,先大致看看到底如何通过一个get
的trap
,劫持写操作。
// proxy handlers
export const mutableCollectionHandlers: ProxyHandler = {
// 创建一个插桩getter
get: createInstrumentationGetter(mutableInstrumentations)
}
首先,我们要读懂它的函数名,createInstrumentationGetter
。唔,像我一样英文比较差的同学可能是不太懂Instrumentation
是什么意思的。这里是表达“插桩”的意思。关于“插桩”我不多介绍啦,常见的单测覆盖率往往就是通过插桩实现的。
在本代码中,插桩即指向某个方法被注入一段有其他作用的代码,目的就是为了劫持这些方法,增加相应逻辑,那我们看看此处是如何“插桩”(劫持)的。
// 可变数据插桩对象,以及一系列相应的插桩方法
const mutableInstrumentations: any = {
get(key: any) {
return get(this, key, toReactive)
},
get size() {
return size(this)
},
has,
add,
set,
delete: deleteEntry,
clear,
forEach: createForEach(false)
}
// 迭代器相关的方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
mutableInstrumentations[method] = createIterableMethod(method, false)
readonlyInstrumentations[method] = createIterableMethod(method, true)
})
// 创建getter的函数
function createInstrumentationGetter(instrumentations: any) {
// 返回一个被插桩后的get
return function getInstrumented(
target: any,
key: string | symbol,
receiver: any
) {
// 如果有插桩对象中有此key,且目标对象也有此key,
// 那就用这个插桩对象做反射get的对象,否则用原始对象
target =
hasOwn(instrumentations, key) && key in target ? instrumentations : target
return Reflect.get(target, key, receiver)
}
}
从上文中知道,由于Proxy
跟collection
数据的原生特性,无法劫持set
或者直接反射。所以在这里,创建了一个新的对象,它具有set
跟map
一样的方法名。这些方法名对应的方法就是插桩后,注入了依赖收集跟响应触发的方法。然后通过Reflect
反射到这个插桩对象上,获取的是插桩后的数据,调用的是插桩后的方法。
而对于一些自定义的属性或方法,Reflect
反射的就不是插桩过后的,而是原数据,对于这些情况,也不会做响应式的逻辑,比如单测中的:
it('should not observe custom property mutations', () => {
let dummy
const map: any = reactive(new Map())
effect(() => (dummy = map.customProp))
expect(dummy).toBe(undefined)
map.customProp = 'Hello World'
expect(dummy).toBe(undefined)
})
接下来看这个插装器mutableInstrumentations
,从上往下,我们先看get
。
const mutableInstrumentations: any = {
get(key: any) {
// this 即是调用get的对象,现实情况就是Proxy代理对象
// toReactive是一个将数据转为响应式数据的方法
return get(this, key, toReactive)
}
// ...省略其他
}
function get(target: any, key: any, wrap: (t: any) => any): any {
// 获取原始数据
target = toRaw(target)
// 由于Map可以用对象做key,所以key也有可能是个响应式数据,先转为原始数据
key = toRaw(key)
// 获取原始数据的原型对象
const proto: any = Reflect.getPrototypeOf(target)
// 收集依赖
track(target, OperationTypes.GET, key)
// 使用原型方法,通过原始数据去获得该key的值。
const res = proto.get.call(target, key)
// wrap 即传入的toReceive方法,将获取的value值转为响应式数据
return wrap(res)
}
注意:在get
方法中,第一个入参target
不能跟Proxy
构造函数的第一个入参混淆。Proxy
函数的第一个入参target
指的原始数据。而在get
方法中,这个target
其实是被代理后的数据。也即是Reflect.get(target, key, receiver)
中的receiver
。
然后我们就比较清晰了,本质就是通过原始数据的原型方法+call this
,避免了上述的问题,返回真正的数据。
const mutableInstrumentations: any = {
// ...
get size() {
return size(this)
},
has
// ...
}
function size(target: any) {
// 获取原始数据
target = toRaw(target)
const proto = Reflect.getPrototypeOf(target)
track(target, OperationTypes.ITERATE)
return Reflect.get(proto, 'size', target)
}
function has(this: any, key: any): boolean {
// 获取原始数据
const target = toRaw(this)
key = toRaw(key)
const proto: any = Reflect.getPrototypeOf(target)
track(target, OperationTypes.HAS, key)
return proto.has.call(target, key)
}
size
跟has
,都是“查”的逻辑。只是size
是一个属性,不是方法,所以需要以get size()
的方式去劫持。而has
是个方法,不需要专门绑定 this,两者内部逻辑也简单,跟get
基本一致。不过这里有个关于 TypeScript 的小细节。has
函数第一个入参是this
,这个在 ts 里是假的参数,真正调用这个函数的时候,是不需要传递的,所以依旧是这样使用someMap.has(key)
就好。
那除了这两个查方法,还有迭代器相关的“查”方法。
关于迭代器,如果没什么了解,建议先阅读相关文档,比如MDN。
// 迭代器相关的方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
mutableInstrumentations[method] = createIterableMethod(method, false)
})
function createIterableMethod(method: string | symbol, isReadonly: boolean) {
return function(this: any, ...args: any[]) {
// 获取原始数据
const target = toRaw(this)
// 获取原型
const proto: any = Reflect.getPrototypeOf(target)
// 如果是entries方法,或者是map的迭代方法的话,isPair为true
// 这种情况下,迭代器方法的返回的是一个[key, value]的结构
const isPair =
method === 'entries' ||
(method === Symbol.iterator && target instanceof Map)
// 调用原型链上的相应迭代器方法
const innerIterator = proto[method].apply(target, args)
// 获取相应的转成响应数据的方法
const wrap = isReadonly ? toReadonly : toReactive
// 收集依赖
track(target, OperationTypes.ITERATE)
// return a wrapped iterator which returns observed versions of the
// values emitted from the real iterator
// 给返回的innerIterator插桩,将其value值转为响应式数据
return {
// iterator protocol
next() {
const { value, done } = innerIterator.next()
return done
? // 为done的时候,value是最后一个值的next,是undefined,没必要做响应式转换了
{ value, done }
: {
value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value),
done
}
},
// iterable protocol
[Symbol.iterator]() {
return this
}
}
}
}
这段逻辑其实还好,核心就是劫持迭代器方法,将每次next
返回的 value 用reactive
转化。唯一会让人不清楚的,其实是对于Iterator
以及Map|Set
的不熟悉。如果确实不熟悉,建议还是先看下它们的相关文档。
迭代器相关的还有一个forEach
方法。
function createForEach(isReadonly: boolean) {
// 这个this,我们已经知道了是假参数,也就是forEach的调用者
return function forEach(this: any, callback: Function, thisArg?: any) {
const observed = this
const target = toRaw(observed)
const proto: any = Reflect.getPrototypeOf(target)
const wrap = isReadonly ? toReadonly : toReactive
track(target, OperationTypes.ITERATE)
// important: create sure the callback is
// 1. invoked with the reactive map as `this` and 3rd arg
// 2. the value received should be a corresponding reactive/readonly.
// 将传递进来的callback方法插桩,让传入callback的数据,转为响应式数据
function wrappedCallback(value: any, key: any) {
// forEach使用的数据,转为响应式数据
return callback.call(observed, wrap(value), wrap(key), observed)
}
return proto.forEach.call(target, wrappedCallback, thisArg)
}
}
forEach
的逻辑并不复杂,跟上面迭代器部分差不多,也是劫持了方法,将原本的传参数据转为响应式数据后返回。
然后看写操作。
function add(this: any, value: any) {
// 获取原始数据
value = toRaw(value)
const target = toRaw(this)
// 获取原型
const proto: any = Reflect.getPrototypeOf(this)
// 通过原型方法,判断是否有这个key
const hadKey = proto.has.call(target, value)
// 通过原型方法,增加这个key
const result = proto.add.call(target, value)
// 原本没有key的话,说明真的是新增,则触发监听响应逻辑
if (!hadKey) {
/* istanbul ignore else */
if (__DEV__) {
trigger(target, OperationTypes.ADD, value, { value })
} else {
trigger(target, OperationTypes.ADD, value)
}
}
return result
}
我们发现写操作倒简单多了,其实跟baseHandlers
的逻辑是差不多的,只不过对于那些base
数据,可以通过Reflect
方便的反射行为,而在此处,需要手动获取原型链并绑定this
而已。查看set
跟deleteEntry
的代码,逻辑也差不多,就不多阐述了。
关于readonly
相关的也很简单了,我也不贴了代码了,纯粹增加文章字数。它就是将add|set|delete|clear
这几个写方法再包一层,开发环境下抛个 warning。
到这里,终于看完了collcetionsHandlers
的全部逻辑了。
再总结一下它是如何劫持 collcetion 数据的。
Set|Map
等集合数据的底层设计问题,Proxy
无法直接劫持set
或直接反射行为。get
,对于它的原始方法或属性,Reflect
反射到插桩器上,否则反射原始对象。toRaw
,获取代理数据的原始数据,再获取原始数据的原型方法,然后绑定this
为原始数据,调取相应方法。getter|has
这类查询方法,插入收集依赖的逻辑,并将返回值转为响应式数据(has 返回 boolean 值故不需要转换)。其实原理还是好理解的,只是写起来比较麻烦。
那到此为止,终于把reactive
的逻辑完全理完了。阅读本部分的代码有点儿不容易,因为涉及的底层知识比较多,不然会处处懵逼,不过这也是一种学习,探索的过程也是挺有意思的。
在这过程中,我们发现,数组的劫持目前还是存在一点点不足的,直接通过反射,会在一些情况下重复触发监听函数。感觉通过类似collection
数据的处理方式可以解决。但是这又增加了程序复杂度,而且也不知道会不会有一些其他的坑。
另外,我们发现阅读reactivity
相关的代码时,ts 涉及的没我们想象中的多,内部很多情况下是 any 的,但这是要辩证的看的。首先如小右所说,“这些数据是用户数据,本身就是any的。勉强要声明,没有什么意义”。而且那一路下来都是非常多的泛型加推导,成本非常高。反正我自己尝试了下,是无能为力的。另外当前代码还是非正式的阶段,如果维护起来过于麻烦。那对于我这种 ts 半吊子的人,如果真的想再贡献一点代码,也是举步维艰。
这篇文章有点儿繁杂,如果是慢慢看下来的,非常感谢你的阅读~~
下篇是最后的effect
相关的源码解析,终于能解开最开始targetMap
的谜团,看到track
跟trigger
的内部实现,凑上最后一块拼图了。