5.1 理解 Proxy 和 Reflect
Vue.js 3 的响应式数据基于 Proxy 实现,因此我们需要了解 Proxy 及其相关的 Reflect。 Proxy 可以对一个对象进行代理,创建代理对象,拦截并重新定义对对象的基本操作。注意,Proxy 只能代理对象,不能代理非对象值(如字符串、布尔值等)。 基本操作包括读取属性值、设置属性值等。例如:
obj.foo // 读取属性 foo 的值
obj.foo++ // 读取和设置属性 foo 的值
可以使用 Proxy 拦截基本操作:
const p = new Proxy(obj, {
get() { /* 拦截读取属性操作 */ },
set() { /* 拦截设置属性操作 */ }
})
Proxy 构造函数接收两个参数:被代理对象和一个包含拦截函数的对象(trap)。get 函数用于拦截读取操作,set 函数用于拦截设置操作。 Proxy 也可以拦截函数调用操作:
const fn = (name) => { console.log('我是:', name) };
const p2 = new Proxy(fn, {
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray);
}
});
p2('hcy'); // 输出:'我是:hcy'
Proxy 只能拦截对象的基本操作。非基本操作,如调用对象下的方法(称为复合操作),实际上是由两个基本操作组成的:get 操作获取方法属性,然后apply 操作调用该方法。 理解 Proxy 只能代理对象的基本操作对于后续实现数组或 Map、Set 等数据类型的代理至关重要。
我们来看 Reflect。Reflect 是一个全局对象,提供了一些方法,例如:
- Reflect.get()
- Reflect.set()
- Reflect.apply()
Reflect 中的方法与 Proxy 的拦截器方法同名。它们提供了对象操作的默认行为。例如,以下两个操作是等价的:
const obj = { foo: 1 };
console.log(obj.foo); // 1
console.log(Reflect.get(obj, 'foo')); // 1
eflect.get() 还接受第三个参数,指定接收者 receiver,可以理解为函数调用中的 this:
const obj = { foo: 1 };
console.log(Reflect.get(obj, 'foo', { foo: 2 })); // 输出 2 而不是 1
关于响应式数据,考虑以下代码:
const obj = {
foo: 1,
get bar() {
return this.foo
}
};
const p = new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
当我们尝试修改 p.foo 的值时,副作用函数并没有重新执行。问题在于 getter 函数内的 this 指向原始对象 obj。这时 Reflect.get 函数派上用场:
const p = new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
// 省略部分代码
})
现在,getter 函数内的 this 指向代理对象p,可以在副作用函数与响应式数据之间建立响应联系,实现依赖收集。后文将统一使用 Reflect.* 方法。
5.2 JavaScript 对象和 Proxy 的工作原理
根据规范,JavaScript中有两种对象:常规对象(ordinary object)和异质对象(exotic object)。这两种对象涵盖了JavaScript世界中的所有对象。 任何非常规对象都是异质对象。要理解常规对象和异质对象的区别,我们需要了解对象的内部方法和内部槽。 在JavaScript中,函数也是对象。假设我们有一个对象 obj,如何判断它是普通对象还是函数呢?在 JavaScript 中,对象的实际语义由其内部方法(internal method)定义。所谓内部方法,是指在对对象进行操作时,引擎内部调用的方法。这些方法对 JavaScript 使用者来说是不可见的。例如,当我们访问对象属性时:
obj.foo
引擎内部会调用 [[Get]] 这个内部方法来读取属性值。在ECMAScript规范中,使用 [[xxx]] 表示内部方法或内部槽。一个对象不仅部署了 [[Get]] 这个内部方法,规范还要求部署一系列其他必要的内部方法。
包括 [[Get]] 在内,一个对象必须部署 11 个必 要的内部方法: 还有两个额外的必要内部方法
如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[Call]],我们可以通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会。 内部方法具有多态性,类似于面向对象编程中的多态概念。这意味着不同类型的对象可能部署了相同的内部方法,但具有不同的逻辑。例如,普通对象和 Proxy 对象都部署了 [[Get]] 这个内部方法,但它们的逻辑是不同的。
内部方法 | 处理器函数 |
---|---|
[[GetPrototypeOf]] | getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf |
[[IsExtensible]] | isExtensible |
[[PreventExtensions]] | preventExtensions |
[[GetOwnProperty]] | getOwnPropertyDescriptor |
[[DefineOwnProperty]] | defineProperty |
[[HasProperty]] | has |
[[Get]] | get |
[[Set]] | set |
[[Delete]] | deleteProperty |
[[OwnPropertyKeys]] | ownKeys |
[[Call]] | apply |
[[Construct]] | construct |
当被代理的对象是函数和构造函数时,才会部署内部方法 [[Call]] 和 [[Construct]]。
当我们需要拦截删除属性操作时,可以使用 deleteProperty 拦截函数实现:
const obj = { foo: 1 };
const p = new Proxy(obj, {
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key);
}
});
console.log(p.foo); // 1
delete p.foo;
console.log(p.foo); // undefined
这里需要强调的是,deleteProperty 实现的是代理对象p的内部方法和行为。为了删除被代理对象上的属性值,我们需要使用 Reflect.deleteProperty(target, key) 来完成。通过这种方式,我们可以更灵活地控制对象的行为,从而实现更复杂的功能。
5.3 如何代理 Object
之前我们使用了 get 拦截函数来拦截属性的读取操作实现响应式数据, 然而,在响应系统中,“读取”是一个广泛的概念。例如,使用 in 操作符检查对象上是否具有给定的键也属于“读取”操作,如下面的代码所示:
effect(() => {
'foo' in obj;
});
这本质上也是在进行“读取”操作。响应系统应该拦截所有读取操作,以便在数据变化时正确地触发响应。以下是普通对象所有可能的读取操作:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的 key:key in obj
- 使用 for...in 循环遍历对象:for (const key in obj) {}
首先,通过 get 拦截器实现属性访问:
const obj = { foo: 1 }
const p = new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
})
为拦截 in 操作符,我们需要使用 has 拦截器:
const obj = { foo: 1 }
const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:
effect(() => {
'foo' in p; // 将会建立依赖关系
});
要拦截 for...in 循环,我们使用 ownKeys 拦截器:
const obj = { foo: 1 }
const ITERATE_KEY = Symbol()
const p = new Proxy(obj, {
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
在这里,我们使用 ITERATE_KEY 作为追踪的 key,因为 ownKeys 拦截器无法获取具体操作的 key。在触发响应时,也要触发 ITERATE_KEY:
trigger(target, ITERATE_KEY)
在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行?我们用一段代码来说明:
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})
effect(() => {
for (const key in p) {
console.log(key) // foo
}
})
执行副作用函数后,会与 ITERATE_KEY 建立响应联系。然后,我们尝试为对象 p 添加新属性 bar:
p.bar = 2
添加新属性 bar 会使 for...in 循环执行两次,因此需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。 但现在的实现还做不到这一点,原因是 set 拦截器在触发副作用函数时,只触发了与 'bar' 相关联的副作用函数,而没有触发与 ITERATE_KEY 相关联的副作用函数。 现在我们需要在添加属性时触发与 ITERATE_KEY 相关联的副作用函数。在 trigger 函数中,我们将同时处理与 key 相关联的副作用函数和与 ITERATE_KEY 相关联的副作用函数。
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
if (type === 'ADD') {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
然而,当我们仅修改已有属性值而非添加新属性时,触发副作用函数是不必要的,因为这不会影响 for...in 循环。为了解决这个问题,我们需要在 set 拦截函数内区分操作类型,以便确定是添加新属性还是设置已有属性:
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
trigger(target, key, type)
return res
},
// 省略其他拦截函数
})
在 trigger 函数中,我们根据操作类型 type 来判断当前操作,只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行,从而避免不必要的性能损耗。 通常,我们会将操作类型封装为一个枚举值,如:
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}
这样无论是对后期代码的维护,还是对代码的清晰度,都是非常有帮助的。
对于对象的代理,我们还需要处理删除属性操作的代理:
delete p.foo
为了代理 delete 操作符,我们需要依赖 [[Delete]] 内部方法。根据规范,我们可以使用 deleteProperty 拦截器:
const p = new Proxy(obj, {
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const res = Reflect.deleteProperty(target, key)
if (res && hadKey) {
trigger(target, key, 'DELETE')
}
return res
}
})
在这段代码中,我们首先检查被删除的属性是否属于对象自身,然后调用 Reflect.deleteProperty 函数完成属性的删除工作。只有当这两步的结果都满足条件时,才调用 trigger 函数触发副作用函数重新执行。注意,调用 trigger 函数时,我们传递了新的操作类型 'DELETE'。 由于删除操作会导致对象的键数量减少,它会影响 for...in 循环的次数,因此当操作类型为 'DELETE' 时,我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行:
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
if (type === 'ADD' || type === 'DELETE') {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
在这段代码中,我们添加了 type === 'DELETE' 判断,使得删除属性操作能够触发与 ITERATE_KEY 相关联的副作用函数重新执行。
5.4 合理触发响应
为了合理触发响应,我们需要处理一些问题。 首先,当值没有变化时,我们不应触发响应:
const obj = { foo: 1 }
const p = new Proxy(obj, { /* ... */ })
effect(() => {
console.log(p.foo)
})
// 设置 p.foo 的值,但值没有变化
p.foo = 1
为了满足需求,在调用 trigger 函数触发响应之前,我们需要检查值是否发生了变化:
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
if (oldVal !== newVal) {
trigger(target, key, type)
}
return res
},
})
在 set 函数内,先获取旧值 oldVal,比较新旧值,只有不全等时才触发响应,。但是,全等比较对 NaN 的处理有缺陷,因为 NaN === NaN 返回 false,为了解决这个问题,需要加一个条件:
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
const oldVal = target[key]
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
return res
},
})
现在,我们已经解决了对 NaN 的处理问题。当新旧值不全等且不都是 NaN 时,才触发响应。
我们还需要处理从原型上继承属性的情况。首先,我们封装一个 reactive 函数:
function reactive(obj) {
return new Proxy(obj, {
// 省略拦截函数
})
}
接下来,创建一个例子:
const obj = {}
const proto = { bar: 1 }
const child = reactive(obj)
const parent = reactive(proto)
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.bar) // 1
})
child.bar = 2 // 副作用函数执行两次
在这个例子中,我们创建了两个响应式对象 child 和 parent,并将 parent 设置为 child 的原型。 在副作用函数中访问 child.bar 时,值是从原型上继承的。当我们执行 child.bar = 2 时,副作用函数会执行两次,导致不必要的更新。 我们分析下整个过程,访问 child.bar 时,触发 child 代理对象的 get 拦截函数。在拦截函数中,引擎使用 Reflect.get(target, key, receiver) 得到结果。如果对象自身不存在该属性,会获取对象的原型,并调用原型的 [[Get]] 方法得到最终结果。 在这个例子中,由于 child 自身没有 bar 属性,所以最终得到的实际上是 parent.bar 的值。但 parent 本身也是响应式数据,因此在副作用函数中访问 parent.bar 的值时,会建立响应联系。所以,child.bar 和 parent.bar 都与副作用函数建立了响应联系。 当设置 child.bar 的值时,我们需要弄清楚为什么副作用函数会连续执行两次。在设置过程中,会触发 child 代理对象的 set 拦截函数。由于 obj 上不存在 bar 属性,会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数。这导致副作用函数被触发两次。 为了解决这个问题,我们可以在 set 拦截函数内区分这两次更新。当我们设置 child.bar 的值时,receiver 始终是 child,而 target 则会变化。我们只需要判断 receiver 是否是 target 的代理对象即可。只有当 receiver 是 target 的代理对象时才触发更新,从而屏蔽原型引起的更新。 首先,我们需要为 get 拦截函数添加一个能力,使代理对象可以通过 raw 属性访问原始数据:
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target;
}
track(target, key);
return Reflect.get(target, key, receiver);
},
// 省略其他拦截函数
});
}
然后,在 set 拦截函数中判断 receiver 是不是 target 的代理对象:
function reactive(obj) {
return new Proxy(obj, {
set(target, key, newVal, receiver) {
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, newVal, receiver);
// target === receiver.raw 说明 receiver 是 target 的代理对象
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
}
return res;
},
// 省略其他拦截函数
});
}
通过这种方式,我们只在 receiver 是 target 的代理对象时触发更新,从而避免了由原型引起的不必要的更新操作。
5.5 浅响应与深响应
事实上,我们目前实现的 reactive 是浅响应的。看以下代码:
const obj = reactive({ foo: { bar: 1 } })
effect(() => {
console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2
首先,创建了 obj 代理对象,该对象的 foo 属性值是另一个对象,即 { bar: 1 }。然后,在副作用函数内访问 obj.foo.bar 的值。但我们发现,后续对 obj.foo.bar 的修改无法触发副作用函数的重新执行。为什么呢?让我们看一下现有的实现:
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target
}
track(target, key)
// 当读取属性值时,直接返回结果
return Reflect.get(target, key, receiver)
}
// 省略其他拦截函数
})
}
上述代码显示,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。 这里我们直接使用 Reflect.get 函数返回 obj.foo 的结果。由于通过 Reflect.get 得到的 obj.foo 结果是一个普通对象,即 { bar: 1 },它不是响应式对象,因此在副作用函数中访问 obj.foo.bar 时,无法建立响应联系。 为解决此问题,我们需要对 Reflect.get 返回的结果进行一层包装:
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target
}
track(target, key)
// 得到原始值结果
const res = Reflect.get(target, key, receiver)
if (typeof res === 'object' && res !== null) {
// 调用 reactive 将结果包装成响应式数据并返回
return reactive(res)
}
// 返回 res
return res
}
// 省略其他拦截函数
})
}
如上述代码所示,当读取属性值时,我们首先检测该值是否是对象。如果是对象,就递归地调用 reactive 函数将其包装成响应式数据并返回。 这样,当使用 obj.foo 读取 foo 属性值时,得到的结果就是一个响应式数据。因此,再通过 obj.foo.bar 读取 bar 属性值时,就会自然地建立响应联系。这样,当修改 obj.foo.bar 的值时,就能触发副作用函数重新执行。
然而,并非所有情况下我们都希望深响应。这就产生了 shallowReactive,即浅响应。浅响应的是只有对象的第一层属性是响应的,例如:
const obj = shallowReactive({ foo: { bar: 1 } })
effect(() => {
console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 }
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3
在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象 obj。可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。 实现此功能并不难,如下面的代码所示:
// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target
}
const res = Reflect.get(target, key, receiver)
track(target, key)
// 如果是浅响应,则直接返回原始值
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return reactive(res)
}
return res
}
// 省略其他拦截函数
})
}
// 使用 createReactive 函数轻松实现 reactive 和 shallowReactive 函数
function reactive(obj) {
return createReactive(obj)
}
function shallowReactive(obj) {
return createReactive(obj, true)
}
在上述代码中,我们将对象创建的工作封装到一个新的函数 createReactive 中。 该函数除了接收原始对象 obj 之外,还接收参数 isShallow,它是一个布尔值,代表是否创建浅响应对象。 有了 createReactive 函数后,我们就可以使用它轻松地实现 reactive 和 shallowReactive 函数。
5.6 只读和浅只读
有时我们希望某些数据是只读的,即用户尝试修改时会收到警告。 例如,组件接收到的props应该是只读的。这时我们可以使用 readonly 函数将数据设为只读:
const obj = readonly({ foo: 1 })
// 尝试修改数据,会得到警告
obj.foo = 2
只读本质上也是对数据对象的代理,,我们可以为 createReactive 函数增加第三个参数 isReadonly 来实现:
// 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`);
return true;
}
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, newVal, receiver);
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
}
return res;
},
deleteProperty(target, key) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`);
return true;
}
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, 'DELETE');
}
return res;
}
// 省略其他拦截函数
});
}
当使用 createReactive 创建代理对象时,可以通过第三个参数指定是否创建一个只读的代理对象 同时,我们还修改了 set 拦截函数和 deleteProperty 拦截函数的实现,因为对于一个对象来说,只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。 当然,如果一个数据是只读的,那就意味着任何方式都无法修改它,所以也就不需要调用 track 函数追踪响应:
const obj = readonly({ foo: 1 });
effect(() => {
obj.foo; // 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
});
为了实现该功能,我们需要修改 get 拦截函数的实现:
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target;
}
// 非只读的时候才需要建立响应联系
if (!isReadonly) {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
if (isShallow) {
return res;
}
if (typeof res === 'object' && res !== null) {
return reactive(res);
}
return res;
}
// 省略其他拦截函数
});
}
如上面的代码所示,只有非只读的时候才需要建立响应联系。基于此,我们就可以实现 readonly 函数了:
function readonly(obj) {
return createReactive(obj, false, true /* 只读 */);
}
然而,上面实现的 readonly 函数更应该叫作 shallowReadonly,因为它没有做到深只读:
const obj = readonly({ foo: { bar: 1 } });
obj.foo.bar = 2; // 仍然可以修改
所以为了实现深只读,我们还应该在 get 拦截函数内递归地调用 readonly 将数据包装成只读的代理对象,并将其作为返回值返回:
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target;
}
if (!isReadonly) {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
if (isShallow) {
return res;
}
if (typeof res === 'object' && res !== null) {
// 如果数据为只读,则调用 readonly 对值进行包装
return isReadonly ? readonly(res) : reactive(res);
}
return res;
}
// 省略其他拦截函数
});
}
如上面的代码所示,我么判断是否只读,如果只读则调用 readonly 函数对值进行包装,并把包装后的只读对象返回。 对于 shallowReadonly,实际上我们只需要修改 createReactive 的第二个参数即可:
function readonly(obj) {
return createReactive(obj, false, true);
}
function shallowReadonly(obj) {
return createReactive(obj, true, true);
}
如上面的代码所示,在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。
5.7 代理数组
在 JavaScript 中,数组是一种特殊的对象。为了更好地实现数组代理,我们需要了解数组与普通对象的差异。 JavaScript 中的数组是一种异质对象,其 [[DefineOwnProperty]] 内部方法与常规对象不同。 但除此之外,数组的其他内部方法与常规对象相同。因此,在实现数组代理时,大部分用于代理普通对象的代码依然适用,如下:
const arr = reactive(['foo'])
effect(() => {
console.log(arr[0]) // 'foo'
})
arr[0] = 'bar' // 触发响应
我们通过索引读取或设置数组元素的值时,代理对象的 get/set 拦截函数也会执行,使得数组索引的读取和设置操作是响应式的。
然而,数组操作与普通对象操作仍有不同。数组的读取操作包括:
- 通过索引访问数组元素值:arr[0]
- 访问数组的长度:arr.length
- 将数组视为对象,使用 for...in 循环遍历
- 使用 for...of 迭代遍历数组
- 数组的原型方法,如 concat、join、every 等
数组的设置操作包括:
- 通过索引修改数组元素值:arr[1] = 3
- 修改数组长度:arr.length = 0
- 数组的栈方法:push、pop、shift、unshift
- 修改原数组的原型方法:splice、fill、sort 等
虽然代理数组相对复杂,但因为数组本身也是对象,所以大部分用于代理常规对象的代码对数组依然有效。接下来,我们将探讨如何通过索引读取或设置数组元素值。
5.7.1 数组的索引与 length
在前面的例子中,通过数组的索引访问元素值已经建立了响应关系。 但是,通过索引设置数组元素值与设置对象属性值仍存在差异,因为数组对象部署的 [[DefineOwnProperty]] 内部方法不同于常规对象。 规范明确说明,如果设置的索引值大于数组当前长度,需要更新数组的 length 属性。因此,在触发响应时,也应触发与 length 属性相关联的副作用函数重新执行。
const arr = reactive(['foo']) // 数组的原长度为 1
effect(() => {
console.log(arr.length) // 1
})
// 设置索引 1 的值,会导致数组的长度变为 2
arr[1] = 'bar'
为了实现这个目标,我们需要修改 set 拦截函数:
function createReactive(obj, isShallow = false, isReadonly = false) {
// ...
set(target, key, newVal, receiver) {
// ...
const type = Array.isArray(target)
? Number(key) < target.length ? 'SET' : 'ADD'
: Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
// ...
}
}
在判断操作类型时,我们新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。 接下来,我们可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行:
function trigger(target, key, type) {
// ...
if (type === 'ADD' && Array.isArray(target)) {
// 取出与 length 相关联的副作用函数
const lengthEffects = depsMap.get('length');
// 将这些副作用函数添加到 effectsToRun 中,待执行
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
// ...
}
这样,我们就实现了当数组长度发生变化时,正确地触发与 length 属性相关联的副作用函数重新执行。
在另一方面,实际上修改数组的 length 属性也会隐式地影响数组元素。例如:
const arr = reactive(['foo'])
effect(() => {
// 访问数组的第 0 个元素
console.log(arr[0]) // foo
})
// 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
arr.length = 0
然而,并非所有对 length 属性的修改都会影响数组中的已有元素。 当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。 为了实现这一目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:
function createReactive(obj, isShallow = false, isReadonly = false) {
// ...
set(target, key, newVal, receiver) {
// ...
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
// 增加第四个参数,即触发响应的新值
trigger(target, key, type, newVal);
}
}
// ...
}
}
接着,我们还需要修改 trigger 函数:
// 为 trigger 函数增加第四个参数,newVal,即新值
function trigger(target, key, type, newVal) {
// ...
// 如果操作目标是数组,并且修改了数组的 length 属性
if (Array.isArray(target) && key === 'length') {
// 对于索引大于或等于新的 length 值的元素,
// 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
depsMap.forEach((effects, key) => {
if (key >= newVal) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
});
}
// ...
}
5.7.2 遍历数组
既然数组也是对象,就意味着我们同样可以使用 for...in 循环遍历数组:
const arr = reactive(['foo'])
effect(() => {
for (const key in arr) {
console.log(key) // 0
}
})
但是我们应该尽量避免使用 for...in 循环遍历数组,数组对象和常规对象的不同仅体现在 [[DefineOwnProperty]] 这个内部方法上。因此,使用 for...in 循环遍历数组与遍历常规对象并无差异,可以使用 ownKeys 拦截函数进行拦截。
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 省略其他拦截函数
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
}
上述代码取自前文,我们为了追踪对普通对象的 for...in 操作,创建了 ITERATE_KEY 作为追踪的 key。 然而,这是为了代理普通对象而考虑的。对于普通对象来说,只有当添加或删除属性值时才会影响 for...in 循环的结果,这时候就需要取出与 ITERATE_KEY 相关联的副作用函数重新执行。
对于数组来说,情况有所不同。我们看看哪些操作会影响 for...in 循环对数组的遍历:
- 添加新元素:arr[100] = 'bar'
- 修改数组长度:arr.length = 0
实际上,无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。一旦数组的 length 属性被修改,那么 for...in 循环对数组的遍历结果就会改变。 所以,在这种情况下我们应该触发响应。我们可以在 ownKeys 拦截函数内,判断当前操作目标 target 是否是数组,如果是,则使用 length 作为 key 建立响应联系:
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 省略其他拦截函数
ownKeys(target) {
// 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
}
这样,无论是为数组添加新元素,还是直接修改 length 属性,都能够正确地触发响应:
const arr = reactive(['foo'])
effect(() => {
for (const key in arr) {
console.log(key
}
})
arr[1] = 'bar' // 能够触发副作用函数重新执行
arr.length = 0 // 能够触发副作用函数重新执行
现在,当我们为数组添加新元素或直接修改 length 属性时,都能正确地触发响应。这样,我们已经解决了数组在遍历时可能遇到的问题。
讲解了使用 for...in 遍历数组,接下来我们再看看使用 for...of 遍历数组的情况。 for...in 遍历数组与 for...of 遍历数组的区别在于,for...of 用于遍历可迭代对象(iterable object)。可迭代对象是实现了 @@iterator 方法的对象,例如 Symbol.iterator 方法。 下面创建一个实现了 Symbol.iterator 方法的对象:
const obj = {
val: 0,
[Symbol.iterator]() {
return {
next() {
return {
value: obj.val++,
done: obj.val > 10 ? true : false
}
}
}
}
}
for (const value of obj) {
console.log(value) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}
数组内建了 Symbol.iterator 方法的实现,我们可以手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数组可以使用 for...of 遍历的原因:
const arr = [1, 2, 3, 4, 5]
for (const val of arr) {
console.log(val) // 1, 2, 3, 4, 5
}
数组迭代器的执行会读取数组的 length 属性。如果迭代的是数组元素值,还会读取数组的索引。
const arr = reactive([1, 2, 3, 4, 5])
effect(() => {
for (const val of arr) {
console.log(val)
}
})
arr[1] = 'bar' // 能够触发响应
arr.length = 0 // 能够触发响应
注意,在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系时,需要避免发生意外的错误,以及性能上的考虑。因此需要修改 get 拦截函数:
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target
}
// 添加判断,如果 key 的类型是 symbol,则不进行追踪
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
})
}
在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。