Vue2响应式原理
Vue2 使用 defineProperty
实现响应式原理。重写对象的 getter
和 setter
函数,在 getter
中收集依赖,在 setter
中触发依赖,以此实现响应式,但是要递归观测 object
中的所有 key
,会有性能问题。
对 array
类型的数据,需要改写数组中的方法才能实现响应式,而 proxy
可以检测数组的变化。
副作用函数
副作用函数指的是会产生副作用的函数。
假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
如上面的代码所示,副作用函数 effect
会设置 body
元素的 innerText
属性,其值为 obj.text
。
响应式数据的实现
如何才能拦截一个对象属性的读取和设置操作。在 ES2015 之前,只能通过 [Object.defineProperty](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)
函数实现,这也是 Vue.js2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 [Proxy](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
来实现,这也是 Vue.js3 所采用的方式。
一个简单的响应系统的工作流程如下:
- 当读取操作发生时,将副作用函数收集到“桶”中;
- 当设置操作发生时,从“桶”中取出副作用函数并执行。
使用 Proxy
实现:
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hellow world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})
注册副作用函数
前面的代码中硬编码了副作用函数的名字(effect
),导致一旦副作用函数的名字不叫 effect
,那么代码就不能正确地工作。
因此需要提供一个用来注册副作用函数的机制,如下:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
使用方式:
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
然后将 Proxy
的实现也改一下:
const bucket = new Set()
const data = { text: 'hellow world' }
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) { // 新增
bucket.add(activeEffect) // 新增
} // 新增
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
副作用函数与被操作的目标字段建立关系
前面当读取属性时,无论设置的是哪一个属性,都会把副作用函数收集到“桶”里,当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。
解决办法:在副作用函数与被操作的字段之间建立联系。需要修改“桶”的数据结构。
如果用 **target**
来表示一个代理对象所代理的原始对象,用 **key**
来表示被操作的字段名,用 **effectFn**
来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
|__ key
|__ effectFn
需要使用 WeakMap 代替 Set 作为桶的数据结构。
为什么要使用 WeakMap。WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。如果使用 Map 来代替 WeakMap ,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。
// 存储副作用函数的桶
const bucket = new WeakMap()
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap ,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps ,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数: effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据 target 从桶中取得 depsMap ,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})
从代码中可以看出构建数据结构的方式:
WeakMap
由target --> Map
构成;Map
由key --> Set
构成。
其中 WeakMap 的键是原始对象 target ,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key , Map 的值是一个由副作用函数组成的 Set。
代码优化
将 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。同样,可以把触发副作用函数重新执行的而逻辑封装到 trigger 函数中。
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
需要解决的问题:
- 不必要的更新
- 嵌套情况
- 无限递归循环
调度器实现连续多次修改响应式数据但只会触发一次更新。
computed 与 lazy 的实现
watch的实现(立即执行、执行时机)
竞态问题
理解Proxy与Reflect
Proxy 只能代理对象,无法代理非对象值。
Reflect 是一个全局对象。能实现副作用函数与响应式数据之间建立响应联系。
JavaScript 对象及 Proxy 的工作原理
常规对象
异质对象:Proxy
在 ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽。
内部方法具有多态性。多态的概念也就是说,不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。
如何代理 Object
对一个普通对象的所有可能的读取操作:
- 访问属性:obj.foo。(使用get拦截)
- 判断对象或原型上是否存在给定的key:key in obj 。(使用has拦截)
- 使用 for…in 循环遍历对象: for(const key in obj){} 。(使用ownKeys拦截)
新增、修改、删除对象键值的代理。
合理地触发响应
当值没有发生变化,则不需要触发响应。
只有当 receiver 是 target 的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作。
浅响应与深响应
shallowReactive:浅响应。所谓的浅响应,指的是只有对象的第一层属性是响应的。
只读和浅只读
只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。
代理数组
数组是一个异质对象。因为数组对象的[[DefineOwnProperty]]内部方法与常规对象不同。
对数组元素或属性的“读取”操作:
- 通过索引访问数组元素值:arr[0]。
- 访问数组的长度:arr.length。
- 把数组作为对象,使用 for…in 循环遍历。
- 使用 for…of 迭代遍历数组。
- 数组的原型方法,如 concat/join/every/some/find/findIndex/includes 等,以及其他所有不改变原数组的原型方法。
对数组元素或属性的设置操作:
- 通过索引修改数组元素值:arr[1] = 3 。
- 修改数组长度: arr.length = 0。
- 数组的栈方法: push/pop/shift/unshift 。
- 修改原数组的原型方法: splice/fill/sort 等。
代理Set和Map
使用Proxy代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。下面总结了Set和Map这两个数据类型的原型属性和方法。
Set 类型的原型属性和方法如下。
- size:返回集合中元素的数量。
- add(value):向集合中添加给定的值。
- clear():清空集合。
- delete(value):从集合中删除给定的值。
- has(value):判断集合中是否存在给定的值。
- keys():返回一个迭代器对象。可用于 for…of 循环,迭代器对象产生的值为集合中的元素值。
- values():对于 Set 集合类型来说, keys() 与 values() 等价。
- entries():返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值 [value, value]。
- forEach(callback[, thisArg]):forEach 函数会遍历集合中的所有元素,并对每一个元素调用 callback 函数。 forEach 函数接收可选的第二个参数 thisArg ,用于指定 callback 函数执行时的 this 值。
Map 类型的原型属性和方法如下。
- size:返回 Map 数据中的键值对数量。
- clear():清空 Map。
- delete(key):删除指定 key 的键值对。
- has(key):判断 Map 中是否存在指定 key 的键值对。
- get(key):读取指定 key 对应的值。
- set(key, value):为 Map 设置新的键值对。
- keys():返回一个迭代器对象。迭代过程中会产生键值对的 key 值。
- values():返回一个迭代器对象。迭代过程中会产生键值对的 value 值。
- entries():返回一个迭代器对象。迭代过程中会产生由 [key, value] 组成的数组值。
- forEach(callback[, thisArg]):forEach 函数会遍历 Map 数据的所有键值对。并对每一个键值对调用 callback 函数。forEach 函数接收可选的第二个参数 thisArg ,用于指定 callback 函数执行时的 this 值。