1 基本实现原理
实现响应式的原理就是:拦截一个对象的读取和设置操作,读取时,将副作用函数存储起来,设置时,将副作用函数取出来执行。
副作用函数:会产生副作用的函数,会直接或间接影响其他函数的执行。
响应式数据:值发生变化后,副作用函数会自动重新执行数据。
当副作用函数effect
执行时,会触发响应式数据的读取操作
当修改响应式数据的值时,会触发响应式数据的设置操作。
1.1 如何拦截对象的读取和设置操作
Vue2:在ES2015之前,只能通过Object.defineProperty()
实现
Vue3:在ES2015+中,可以使用代理对象Proxy
来实现
Proxy
Proxy可以创建一个代理对象,实现对其他对象的代理,也就是说Proxy只能代理对象,无法代理非对象,如字符串,数值等
代理:指的是对一个对象基本语义的代理,允许我们拦截并重新定义一个对象的基本操作。
基本操作:例如读取属性值、设置属性值等
1.2 数据结构设计
当obj
(代理target的代理对象).key1
发生改变时,应该只重新执行effectFn1
,不会导致effectFn2
的重新执行
WeapMap
的键是原始对象target
,值是一个Map
实例
Map
的键是原始对象target
的key
,值是一个副作用函数组成的Set
WeapMap
对key
时弱引用,不影响垃圾回收器的工作。一旦key
被垃圾回收器收回,对应的键和值就访问不到了。
对一些关键步骤进行封装
- 把读取属性时,在
get
拦截函数里收集副作用函数的逻辑封装到track
函数中,意为追踪的含义 - 把设置属性时,触发副作用函数重新执行的逻辑封装到
trigger
函数中
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
1.3 分支切换产生的遗留副作用函数问题
const target = {
ok: true, text: 'hello' }
const obj = new Proxy(target, {
/* ... */})
effect(function effectFn() {
document.body.innerText = obj.ok? obj.text : 'no no no'
})
分支切换是指当字段obj.ok
的值发送变化时,代码执行的分支会跟着变化。
分支切换可能会产生遗留的副作用函数。如上面一段代码,obj.ok
的初始值为true
,会读取字段obj.text
的值,所以此时副作用函数会与响应式数据建立如下联系:
当ok
的值修改为false
,并触发副作用函数重新你执行后,obj.text
的值不会再被读取,所以此时副作用函数effectFn
不应该再被字段obj.text
所对应的依赖集合收集。而遗留的副作用函数会导致不必要的更新。
解决方案:在每次副作用函数执行时,先把它从所有预支关联的依赖集合中删除,当副作用函数执行完毕后,再重新建立联系。此时重新建立的联系中就不会再包含遗留的副作用函数了。
实现方案:
- 给
effectFn
添加一个deps
属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合。通过track
函数进行收集。
// 在 get 拦截函数内调用 track 函数追踪变化
export const track = (target, key) => {
if (!activeEffect || !shouldTrack) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key从depsMap中取得deps,里面存储着所有与当前key相关联的副作用函数effects
let deps = depsMap.get(key)
// 如果不存在,同样新建一个Set并与key关联
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把当前激活的副作用函数添加到依赖集合deps中
deps.add(activeEffect)
// deps就是一个与当前副作用函数存在联系的依赖集合
activeEffect.deps.push(deps)
}
- 每次触发副作用函数时,对依赖进行清除
// 清除依赖
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps是依赖集合
const deps = effectFn.deps[i]
// 将effectFn从依赖集合中移除
deps.delete(effectFn)
}
// 重置effectFn.deps
effectFn.deps.length = 0
}
// 注册副作用函数
export const effect = (fn) => {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
const res = fn()
// 执行完毕后,将当前副作用函数弹出栈,并还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
// 将副作用函数作为返回值返回
return effectFn
}
1.4 无限递归循环
const target = {
foo: 1 }
const obj = new Proxy(target, {
/* ... */})
effect(() => {
obj.foo++; // obj.foo = obj.foo + 1
})
自增的语句既会引发读取操作,也会引发设置操作。
解决方案:在trigger
动作发生时增加守卫条件:如果trigger
触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
// 在 set 拦截函数内调用 trigger 函数触发变化
export const trigger = (target, key, type, newVal) => {
// 根据target从桶中取得depsMap
const depsMap = bucket.get(target)
if(!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key)
// 把副作用函数从桶里取出来执行
const effectsToRun = new Set()
// 将与 key 相关联的副作用函数添加到 effectsToRun
effects && effects.forEach(effectFn => {
// 如果触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {