响应系统的作用与实现
除了前面提到的编译器和渲染器,响应系统也是 Vue.js 的核心组成部分。响应系统的主要作用是让 Vue.js 应用程序在数据发生变化时,自动更新视图。想要提响应系统,就要从响应式数据和副作用函数这两个概念开始。
响应式数据和副作用函数
副作用函数是指那些会产生副作用的函数,即函数执行会直接或间接影响其他函数的执行或修改了全局变量。
const obj = { text: 'hello' }
function effect() {
document.body.innerText = obj.text
}var a = 1
function effect() {
a = 2
}在上述代码两个例子中,effect 函数就是一个副作用函数,例子一修改了 body 的内容导致其他函数读取 body 内容时会受到影响,例子二的函数会直接修改全局变量 a 的值。
说完副作用函数,来看看什么是响应式数据。响应式数据是指那些能够自动追踪依赖并响应数据变化的值。例如上述代码组的例子一,当 obj.text 的值发生变化时,期望 effect 函数会自动重新执行,从而更新 body 的内容。
obj.text = 'hello world'实现这个目标后对象 obj 就是响应式数据。
响应式数据的基本实现
JavaScript 中,读取对象会触发其get 方法,修改对象会触发其 set 方法。因此,可以通过重写对象的 get 和 set 方法来实现响应式数据。读取对象属性时,将副作用函数与属性进行关联;修改对象属性时,执行所关联的全部副作用函数。在 ES5 之前,只能通过 Object.defineProperty 来实现,ES6 之后可以使用 Proxy 代理。const bucketSet = new Set()
const data = { text: 'hello' }
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
bucketSet.add(effect)
return target[key]
},
// 修改操作
set(target, key, value) {
target[key] = value
bucketSet.forEach((fn) => fn())
return true
},
})const effect = () => {
document.body.innerText = obj.text
}
effect()
setTimeout(() => {
obj.text = 'hello world'
}, 1000)效果实现了,不过有一定的缺陷,如目前写死函数名为 effect ,如果使用者不传 effect ,传其他名称的函数,甚至匿名函数,功能就不生效了。因此需要去掉这些硬编码机制。
设计一个完善的响应系统
基础实现的响应系统有如下几个缺陷:
- 响应式数据和副作用函数硬编码,用户只能传
effect函数,传其他的函数则无法生效。 - 副作用函数与被操作的目标字段没有建立联系,如上方示例代码,如果为对象
obj新增一个otherText属性,effect也会触发
想要解决第一个问题,可以设计一个 effect 函数,该函数用于注册副作用函数,用户可以传入任意名称的函数,如 effectFn 或匿名函数,effect 函数内部会调用用户传入的函数,并将用户传入的函数作为副作用函数,保存在全局变量中。最后在 get 方法中直接把该变量收集保存到 Set 中即可,这样就不需要关注该函数是匿名函数还是什么名称。
let activeEffect
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
fn() // 执行副作用函数
}effect(() => {
console.log('effect run') // 执行了两次
document.body.innerText = obj.text
})
setTimeout(() => {
obj.text = 'hello world'
}, 1000)
setTimeout(() => {
obj.otherText = 'hello vue3'
}, 1000)const bucketSet = new Set()
const data = { text: 'hello' }
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
if (activeEffect) bucketSet.add(activeEffect) // 把全局保存的副作用函数收集保存
return target[key]
},
// 修改操作
set(target, key, value) {
target[key] = value
bucketSet.forEach((fn) => fn())
return true
},
})接下来看第二个问题,上方 use.js 代码 obj.text 只修改了一次,但是 effect 函数执行了两次。理论上副作用函数没有读取到 otherText ,不应该执行。因此需要修改副作用函数收集的代码,将副作用函数与被操作的目标字段进行关联。
根据前面的代码可以得知,副作用函数涉及到以下三点:
target: 被操作(读取)的对象objkey: 被操作(读取)的属性texteffect: 副作用函数effectFn
那么可以为他们三者建立联系,它们之间是一个树形结构。
effect(function effectFn1() {
obj.key
})
/**
|-target
|-key
|-effectFn1
*/effect(function effectFn1() {
obj.key
})
effect(function effectFn2() {
obj.key
})
/**
|-target
|-key
|-effectFn1
|-effectFn2
*/effect(function effectFn1() {
obj.key1
obj.key2
})
/**
|-target
|-key1
|-effectFn1
|-key2
|-effectFn1
*/effect(function effectFn1() {
obj1.key1
})
effect(function effectFn2() {
obj2.key2
})
/**
|-target1
|-key1
|-effectFn1
|-target2
|-key2
|-effectFn2
*/可以得出,根据这个树型结构建立联系后,只需要触发对应的副作用函数即可。想要实现这个功能,要用 WeakMap 替代 Set ;然后收集对象的全部属性字段 Map ,每个 Map 对应一个属性字段;最后用 Set 收集属性字段对应的副作用函数。
const bucketWeakMap = new WeakMap()
const data = { text: 'hello' }
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
track(target, key)
return target[key]
},
// 修改操作
set(target, key, value) {
target[key] = value
trigger(target, key) // 获取并执行对应的副作用函数
},
})const track = (target, key) => {
if (!activeEffect) return
let depsMap = bucketWeakMap.get(target) // 获取WeakMap映射对应的target对象
if (!depsMap) bucketWeakMap.set(target, (depsMap = new Map())) // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
let deps = depsMap.get(key) // 获取Map映射对应的key属性字段
if (!deps) depsMap.set(key, (deps = new Set())) // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
deps.add(activeEffect) // 把全局保存的副作用函数收集保存
}const trigger = (target, key) => {
const depsMap = bucketWeakMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((effect) => effect())
}目前的完整代码
<body>
<div></div>
<script>
const divEle = document.querySelector('div')
const data = { name: '张三' }
let activeEffect
const bucketWeakMap = new WeakMap() // 存储依赖函数
// track函数收集依赖
const track = (target, key) => {
if (!activeEffect) return
let depsMap = bucketWeakMap.get(target)
if (!depsMap) bucketWeakMap.set(target, (depsMap = new Map()))
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
}
// trigger函数触发依赖
const trigger = (target, key) => {
const depsMap = bucketWeakMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((fn) => fn())
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
},
})
function effect(fn) {
activeEffect = fn
fn()
}
effect(() => {
divEle.innerHTML = obj.name
})
setTimeout(() => {
obj.name = '李四'
}, 1000)
</script>
</body>分支切换与 cleanup
看下方一段代码示例:
const data = { ok: true, text: 'hello' }
const obj = new Proxy(data, {
/* ... */
})
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'is false'
})
obj.ok = false
obj.text = 'hello world'这段代码中,obj.ok 的值从 true 变为 false 后,再次修改 obj.text 的值,会发现副作用函数依旧触发了。理想情况是 obj.ok 值为 false 时,没有用到 obj.text ,因此不应该触发副作用函数。但目前代码会有遗留的副作用函数导致不必要的更新。

解决思路是每次执行副作用函数时,先把它从所有关联的依赖集合中删除,然后在它执行完毕后重新建立联系,新的联系不会包含遗留的副作用函数。
想要实现这个功能,需要改写 effect 函数,为其添加 deps 数组属性,用于存储所有包含当前副作用函数的 key 依赖。在 track 函数中,将当前副作用函数添加到副作用函数的 deps 中,新建一个 cleanup 函数,将当前副作用函数从 deps 中删除。
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
activeEffect = fn
fn() // 执行副作用函数
}
effectFn.deps = [] // 收集当前副作用函数有关联的依赖集合
effectFn()
}const track = (target, key) => {
if (!activeEffect) return
let depsMap = bucketWeakMap.get(target) // 获取WeakMap映射对应的target对象
if (!depsMap) bucketWeakMap.set(target, (depsMap = new Map())) // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
let deps = depsMap.get(key) // 获取Map映射对应的key属性字段
if (!deps) depsMap.set(key, (deps = new Set())) // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
deps.add(activeEffect) // 把全局保存的副作用函数收集保存
activeEffect.deps.push(deps) // 把当前副作用函数添加到依赖集合中
}const cleanup = (effectFn) => {
// 遍历 effectFn.deps 数组
effectFn.deps.forEach((deps) => {
// 从依赖 Map 集合 deps 中把当前副作用函数 effectFn 删除
deps.delete(effectFn)
})
effectFn.deps.length = 0 // 清空 deps 数组
} 此时运行会发现代码程序陷入了死循环,排查后发现问题出在 trigger.js 函数中 effects && effects.forEach(fn => fn()) 这个命令。前面 effect.js 函数中已经改写了 effectFn 函数,添加了 cleanup 函数用于清除遗留的副作用函数,但是执行了副作用函数后又会导致其重新被收集到集合中。相当于下方代码示例:
const set = new Set([1])
set.forEach((item) => {
set.delete(1)
set.add(1)
console.log('遍历中')
})一边删除一边添加,自然就会造成死循环。解决方法很简单,再新建一个 Set 即可。
const trigger = (target, key) => {
const depsMap = bucketWeakMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const newEffects = new Set(effects)
newEffects.forEach((effectFn) => effectFn())
effects && effects.forEach((effect) => effect())
}目前完整代码
<body>
<div></div>
<script>
const divEle = document.querySelector('div')
const data = { name: '张三' }
let activeEffect
const bucketWeakMap = new WeakMap() // 存储依赖函数
// track函数收集依赖
const track = (target, key) => {
if (!activeEffect) return
let depsMap = bucketWeakMap.get(target)
if (!depsMap) bucketWeakMap.set(target, (depsMap = new Map()))
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
// trigger函数触发依赖
const trigger = (target, key) => {
const depsMap = bucketWeakMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const newEffects = new Set(effects)
newEffects.forEach((effectFn) => effectFn())
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
},
})
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
effectFn.deps.forEach((dep) => {
dep.delete(effectFn)
})
effectFn.deps.length = 0
}
effect(() => {
divEle.innerHTML = obj.name
})
setTimeout(() => {
obj.name = '李四'
}, 1000)
</script>
</body>