面试高频响应式原理
响应式 reactivity 是 Vue 3 相对于 Vue 2 改动比较大的一个模块,也是性能提升最多的一个模块。其核心改变是,采用了 ES 6 的 Proxy API 来代替 Vue 2 中的 Object.defineProperty 方法来实现响应式。那么什么是 Proxy API 呢,Vue 3 的响应式又是如何实现的?接下来将会揭晓答案。
Proxy API
Proxy API 对应的 Proxy 对象是 ES 6 就已引入的一个原生对象,用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。
从字面意思来理解,Proxy 对象是目标对象的一个代理器,任何对目标对象的操作(实例化,添加/删除/修改属性等)都必须通过该代理器。因此,我们可以对来自外界的所有操作都进行拦截、过滤、修改等操作。
基于 Proxy 的这些特性常用于:
-
创建一个 “响应式” 的对象,例如 Vue 3.0 中的 reactive 方法。
-
创建可隔离的 JavaScript “沙箱”。
Proxy 的基本语法如下:
const p = new Proxy(target, handler)
其中,target 参数表示要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组、函数,甚至另一个代理);handler 参数表示以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。常见的使用方法如下:
let foo = {
a: 1,
b: 2
}
let handler = {
get: (obj, key) => {
console.log('get')
return key in obj ? obj[key] : undefined
}
}
let p = new Proxy(foo, handler)
console.log(p.a) // 打印1
上面的代码中,p 就是 foo 的代理对象,对 p 对象的相关操作都会同步到 foo 对象上。同时,Proxy 也提供了另一种生成代理对象的方法 Proxy.revocable(),代码如下:
const { proxy,revoke } = Proxy.revocable(target, handler)
该方法的返回值是一个对象,其结构为:{"proxy": proxy, "revoke": revoke}。其中,proxy 表示新生成的代理对象本身,和用一般方式 new Proxy(target,handler) 创建的代理对象没什么不同,只是它可以被撤销掉;revoke 表示撤销方法,调用的时候不需要加任何参数就可以撤销掉和它一起生成的那个代理对象。代码如下:
let foo = {
a: 1,
b: 2
}
let handler = {
get: (obj, key) => {
console.log('get')
return key in obj ? obj[key] : undefined
}
}
let { proxy, revoke } = Proxy.revocable(foo,handler)
console.log(proxy.a) // 打印 1
revoke()
console.log(proxy.a) // 报错信息:Uncaught TypeError: Cannot perform 'get' on proxy that has been revoked
需要注意的是,一旦某个代理对象被撤销,它将变得几乎完全不可调用,在它身上执行任何的可代理操作都会抛出 TypeError 异常。
在上面的代码中,我们只使用了 get 操作的 handler,即当尝试获取对象的某个属性时会进入这个方法。除此之外,Proxy 共有接近 13 个 handler,也可以称为钩子,它们分别是:
-
handler.getPrototypeOf():在读取代理对象的原型时触发该操作,比如在执行 Object.getPrototypeOf(proxy) 时。
-
handler.setPrototypeOf():在设置代理对象的原型时触发该操作,比如在执行 Object.setPrototypeOf(proxy, null) 时。
-
handler.isExtensible():在判断一个代理对象是否可扩展时触发该操作,比如在执行 Object.isExtensible(proxy) 时。
-
handler.preventExtensions():在让一个代理对象不可扩展时触发该操作,比如在执行 Object.preventExtensions(proxy) 时。
-
handler.getOwnPropertyDescriptor():在获取代理对象某个属性的描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时。
-
handler.defineProperty():在定义代理对象某个属性的描述时触发该操作,比如在执行
Object.defineProperty(proxy, "foo", {})
时。 -
handler.has():在判断代理对象是否拥有某个属性时触发该操作,比如在执行 "foo" in proxy 时。
-
handler.get():在读取代理对象的某个属性时触发该操作,比如在执行 proxy.foo 时。
-
handler.set():在给代理对象的某个属性赋值时触发该操作,比如在执行 proxy.foo = 1 时。
-
handler.deleteProperty():在删除代理对象的某个属性时触发该操作,即使用 delete 运算符,比如在执行 delete proxy.foo 时。
· handler.ownKeys():当执行Object.getOwnPropertyNames(proxy)和Object.getOwnProperty Symbols(proxy)时触发。· handler.apply():当代理对象是一个function函数,调用apply()方法时触发,比如proxy.apply()。· handler.construct():当代理对象是一个function函数,通过new关键字实例化时触发,比如new proxy()。
结合这些 handler,我们可以实现一些针对对象的限制操作,例如:
禁止删除和修改对象的某个属性,代码如下:
let foo = {
a : 1,
b : 2
}
let handler = {
set: (obj,key,value,recevier) => {
console.log('set')
if (key == 'a') throw new Error('can not change property:' + key)
obj[key] = value
return true
},
deleteProperty: (obj, key) => {
console.log('delete')
if (key == 'a') throw new Error('can not delete property:' + key)
delete obj[key]
return true
}
}
let p = new Proxy(foo, handler)
// 尝试修改属性a
p.a = 3 // 报错信息:Uncaught Error
// 尝试删除属性a
delete p.a // 报错信息:Uncaught Error
上面的代码中,set 方法多了一个 receiver 参数,这个参数通常是 Proxy 本身(即 p),场景是当有一段代码执行 obj.name="jen" 时,obj 不是一个 proxy,且自身不含 name 属性,但是它的原型链上有一个 proxy,那么那个 proxy 的 handler 中的 set 方法会被调用,而此时 obj 会作为 receiver 参数传进来。
对属性的修改进行校验,代码如下:
let foo = {
a: 1,
b: 2
}
let handler = {
set: (obj, key, value) => {
console.log('set')
if (typeof(value) !== 'number') throw new Error('can not change property:' + key)
obj[key] = value
return true
}
}
let p = new Proxy(foo, handler)
p.a = 'hello' // 报错信息:Uncaught Error
Proxy 也能监听到数组变化,代码如下:
let arr = [1]
let handler = {
set:(obj,key,value)=>{
console.log('set') // 打印set
return Reflect.set(obj, key, value);
}
}
let p = new Proxy(arr,handler)
p.push(2) // 改变数组
Reflect.set() 用于修改数组的值,返回布尔类型,也可以用在修改数组原型链上的方法的场景,相当于 obj[key] = value。
Proxy和响应式对象reactive
在 Vue 3 中使用响应式对象的方法如下:
import {ref,reactive} from 'vue'
...
setup(){
const name = ref('test')
const state = reactive({
list: []
})
return {name,state}
}
...
在 Vue 3 中,组合式 API 中经常会使用创建响应式对象的方法 ref/reactive,其内部就是利用 Proxy API 来实现的,特别是借助 handler 的 set 方法可以实现双向数据绑定相关的逻辑,这对于 Vue 2 中的 Object.defineProperty() 是很大的改变,主要提升如下:
-
Object.defineProperty() 只能单一地监听已有属性的修改或者变化,无法检测到对象属性的新增或删除(Vue 2 中采用 $set() 方法来解决),而 Proxy 则可以轻松实现。
-
Object.defineProperty() 无法监听响应式数据类型是数组的变化(主要是数组长度的变化,Vue 2 中采用重写数组相关方法并添加钩子来解决),而 Proxy 则可以轻松实现。
正是由于 Proxy 的特性,在原本使用 Object.defineProperty() 需要很复杂的方式才能实现的上面两种能力,在 Proxy 无须任何配置,利用其原生的特性就可以轻松实现。
ref()方法运行原理
在 Vue 3 的源码中,所有关于响应式的代码都在 vue-next/package/reactivity 下,其中 reactivity/src/index.ts 中暴露了所有可以使用的方法。我们以常用的 ref() 方法为例,来看看 Vue 3 是如何利用 Proxy 的。
ref() 方法的主要逻辑在 reactivity/src/ref.ts 中,其代码如下:
...
// 入口方法
export function ref(value?: unknown) {
return createRef(value, false)
}
function createRef(rawValue: unknown, shallow: boolean) {
// rawValue表示原始对象,shallow表示是否递归
// 如果本身已经是ref对象,则直接返回
if (isRef(rawValue)) {
return rawValue
}
// 创建一个新的RefImpl对象
return new RefImpl(rawValue, shallow)
}
...
createRef 这个方法接收的第二个参数是 shallow,表示是否是递归监听响应式,这个和另一个响应式方法 shallowRef() 是对应的。在 RefImpl 构造函数中,有一个 value 属性,这个属性是由 toReactive() 方法返回的,toReactive() 方法则在 reactivity/src/reactive.ts 文件中,代码如下:
class RefImpl<T> {
...
constructor(value: T, public readonly _shallow: boolean) {
this._rawValue = _shallow ? value : toRaw(value)
// 如果是非递归,则调用toReactive
this._value = _shallow ? value : toReactive(value)
}
...
}
在 reactive.ts 中,开始真正创建一个响应式对象,代码如下:
export function reactive(target: object) {
// 如果是readonly,则直接返回,就不添加响应式了
if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
return target
}
return createReactiveObject(
target, // 原始对象
false, // 是否readonly
mutableHandlers, // proxy的handler对象baseHandlers
mutableCollectionHandlers, // proxy的handler对象collectionHandlers
reactiveMap // proxy对象映射
)
}
其中,createReactiveObject() 方法传递了两种 handler,分别是 baseHandlers 和 collectionHandlers。如果 target 的类型是 Map、Set、WeakMap、WeakSet,这些特殊对象则会使用 collectionHandlers;如果 target 的类型是 Object、Array,则会使用 baseHandlers;如果是一个原始对象,则不会创建 Proxy 对象,reactiveMap 会存储所有响应式对象的映射关系,用来避免同一个对象重复创建响应式。我们再来看看 createReactiveObject() 方法的实现,代码如下:
function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
// 如果 target 不满足 typeof val === 'object',则直接返回 target
if (!isObject(target)) {
{
console.warn(`value cannot be made reactive: ${String(target)}`);
}
return target;
}
// 如果 target 已经是 proxy 对象或者只读,则直接返回
// exception: calling readonly() on a reactive object
if (target["__v_raw" /* RAW */] &&
!(isReadonly && target["__v_isReactive" /* IS_REACTIVE */])) {
return target;
}
// 如果 target 已经被创建过 Proxy 对象,则直接返回这个对象
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
// 只有符合类型的 target 才能被创建响应式
const targetType = getTargetType(target);
if (targetType === 0 /* INVALID */) {
return target;
}
// 调用 Proxy API 创建响应式
const proxy = new Proxy(target, targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers);
// 标记该对象已经创建过响应式
proxyMap.set(target, proxy);
return proxy;
}
可以看到在 createReactiveObject() 方法中,主要做了以下事情:
-
防止只读和重复创建响应式。
-
根据不同的 target 类型选择不同的 handler。
-
创建 Proxy 对象。
最终会调用 new Proxy 来创建响应式对象。我们以 baseHandlers 为例,看看这个 handler 是怎么实现的。在 reactivity/src/baseHandlers.ts 可以看到这部分代码主要实现了这几个 handler,代码如下:
const get = /*#__PURE__*/ createGetter()
...
export const mutableHandlers: ProxyHandler<object> = {
get,
set,
deleteProperty,
has,
ownKeys
}
以 handler.get 为例,看看在其内部进行了什么操作,当我们尝试读取对象的属性时,便会进入 get 方法,其核心代码如下:
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
if (key === ReactiveFlags.IS_REACTIVE) { // 如果访问对象的key是 __v_isReactive,则直接返回常量
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {// 如果访问对象的key是 __v_isReadonly,则直接返回常量
return isReadonly
} else if (// 如果访问对象的key是 __v_raw,或者原始对象,只读对象等直接返回target
key === ReactiveFlags.RAW && receiver ===
(isReadonly
? shallow
? shallowReadonlyMap
: readonlyMap
: shallow
? shallowReactiveMap
: reactiveMap
).get(target)
) {
return target
}
// 如果target是数组类型
const targetIsArray = isArray(target)
// 并且访问的key值是数组的原生方法,那么直接返回调用结果
if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// 求值
const res = Reflect.get(target, key, receiver)
// 判断访问的key是否是Symbol或者不需要响应式的key,例如__proto__,__v_isRef,__isVue
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
// 收集响应式,为了后面的effect方法可以检测到
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
// 如果是非递归绑定,则直接返回结果
if (shallow) {
return res
}
// 如果结果已经是响应式的,则先判断类型,再返回
if (isRef(res)) {
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
}
// 如果当前key的结果也是一个对象,那么就要递归调用reactive方法对该对象再次执行响应式绑定逻辑
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res)
}
// 返回结果
return res
}
}
上面这段代码是 Vue 3 响应式的核心代码之一,其逻辑相对比较复杂,读者可以根据注释来理解。总结下来,这段代码主要做了以下事情:
-
对于 handler.get 方法来说,最终都会返回当前对象对应 key 的结果,即 obj[key],所以这段代码最终会 return 结果。
-
对于非响应式 key、只读 key等,直接返回对应的结果。
-
对于数组类型的 target,key 值如果是原型上的方法,例如 includes、push、pop 等,则采用 Reflect.get 直接返回。
-
在 effect 添加收集监听 track,为响应式监听服务。
-
当前 key 对应的结果是一个对象时,为了保证 set 方法能够被触发,需要循环递归地对这个对象进行响应式绑定,即递归调用 reactive() 方法。
handler.get 方法的主要功能是对结果 value 进行返回。接下来我们看看 handler.set 主要做了什么,其代码如下:
function createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,// 即将被设置的新值
receiver: object
): boolean {
// 缓存旧值
let oldValue = (target as any)[key]
if (!shallow) {
// 新旧值转换原始对象
value = toRaw(value)
oldValue = toRaw(oldValue)
// 如果旧值已经是一个RefImpl对象且新值不是RefImpl对象
// 例如var v = Vue.reactive({a:1,b:Vue.ref({c:3})})场景的set
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value // 直接将新值赋给旧值的响应式对象
return true
}
}
// 用来判断是新增key还是更新key的值
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
// 设置set结果,并添加监听effect逻辑
const result = Reflect.set(target, key, value, receiver)
// 判断target有没有动过,包括在原型上添加或者删除某些项
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)// 新增key的触发监听
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)// 更新key的触发监听
}
}
// 返回set的结果 true/false
return result
}
}
handler.set 方法的核心功能是设置 key 对应的值,即 obj[key] = value,同时对新旧值进行逻辑判断和处理,最后添加 trigger 触发监听 track 逻辑,以便于触发 effect。
如果读者感觉上述源码理解起来比较困难,笔者剔除一些边界和兼容判断,对整个流程进行梳理和简化,可以参考下面这段代码:
let foo = {a:{c:3,d:{e:4}},b:2}
const isObject = (val)=>{
return val !== null && typeof val === 'object'
}
const createProxy = (target)=>{
let p = new Proxy(target,{
get:(obj,key)=>{
let res = obj[key] ? obj[key] : undefined
// 添加监听
track(target)
// 判断类型,避免死循环
if (isObject(res)) {
return createProxy(res)// 循环递归调用
} else {
return res
}
},
set: (obj, key, value)=> {
console.log('set')
obj[key] = value;
// 触发监听
trigger(target)
return true
}
})
return p
}
let result = createProxy(foo)
result.a.d.e = 6 // 打印出set
当尝试去修改一个多层嵌套的对象的属性时,会触发该属性的上一级对象的 get 方法,利用这个方法就可以对每个层级的对象添加 Proxy 代理,这样就实现了多层嵌套对象的属性修改问题,在此基础上添加 track 和 trigger 逻辑,就完成了基本的响应式流程。我们将在后面的章节结合双向绑定来具体讲解 track 和 trigger 的流程。