写在开头
希望你阅读完这篇文章后回答出以下的几个问题:
- 什么是响应式数据
- 响应式数据的实现原理
- 实现一个简单的响应式数据
核心知识
副作用函数:会直接或者间接影响其他函数执行的函数。
响应数据:会影响视图变化的数据。
- 举例:我们会希望,再次修改
obj.text时,对应的页面内容也发生变化
const obj = { text:'hello world' }
function effect(){
document.body.innerText = obj.text
}
- 解决:在修改
obj.text值后,再次执行一遍effect函数,既可实现
响应式系统的工作流程:
- 读取操作时:将副作用函数收集到「桶」中
- 设置操作时:从「桶」中取出副作用函数并执行
简单实现
- 注册副作用函数的机制
//用一个全局变量存储被注册的副作用函数
let activeEffect
//effect函数用于注册副作用函数
function effect(fn){
//当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = fn
//执行副作用函数
fn()
}
- 代理对象设置
//存储副作用函数的桶
const bucket = new Set()
//对数据进行代理
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
}
})
为什么 vue3 中使用 proxy 而不是 Object.defineProperty?Vue3 为啥用 Proxy 换掉Object.defineProperty
- 上述代码过程分析
effect(()=>{
document.body.innerText = obj.text
})
- 使用 effect,此时会匿名函数 fn 赋值给全局变量 activeEffect
- 接着执行 fn,这会触发 obj.text 的读取操作,进而被代理对象 get 拦截
- 在 get 拦截中,将副作用函数存储到桶中,并返回属性值
setTimeout(()=>{
obj.text = 'ni hao'
},1000)
- 修改 obj.text,会触发设置操作,返回新的值,并遍历执行桶中的函数,从而实现页面变化。
逐步进化
上述代码只是简单的实现,还存在以下问题
问题:被操作目标字段并没有与副作用函数直接建立联系
给响应式对象 obj 设置一个不存在的属性时,可以发现 effect 函数执行了两次。
原因: 副作用函数与对象属性之间没有明确的联系
effect(()=>{
console.log('hello')
document.body.innerText = obj.text
})
setTimeout(()=>{
obj.noExist = 'ni hao'
},1000)
期待: effect 函数中并没有读取obj.noExist值,定时器语句内的执行不应该触发匿名副作用函数重新执行。
解决: 给以下三个角色建立联系
- 被操作(读取)的代理对象 obj
- 被操作(读取)的字段名 text
- 使用 effect 注册的副作用函数 effectFn
obj
|--text
|--effectFn
const bucket = new WeekMap()
const obj = new Proxy(data,{
get(target,key){
if(!activeEffect) return target[key]
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)
return target[key]
},
set(target,key,newVal){
target[key]=newVal
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects&&effects.forEach(fn=>fn)
}
})
为什么使用 weekMap,而不使用 map?
const map = new Map()
const weekmap = new WeakMap()
(function(){
const foo = {foo:1}
const bar = {bar:2}
map.set(foo,1)
weekmap.set(bar.2)
})()
foo 对象:函数执行完毕后,依旧被 map 引用着,垃圾回收器并不会将它从内存移除
bar 对象:函数执行完毕后,由于 webpack 的 key 是弱引用,因此垃圾回收器会将它从内存中移除
在响应式系统中,可能会有许多对象被动态创建和销毁。而使用 WeakMap 可以确保当对象不再需要时,它们可以被自动回收,从而避免内存泄漏。
进一步封装
track:将 get 拦截器中编写副作用收集到「桶」的这部分逻辑提取出来
function track(target,key){
if(!activeEffect) return target[key]
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)
}
trigger:将 set 拦截器中触发副作用重新执行的逻辑提取出来
function trigger(){
const depsMap = bucket.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,newVal){
target[key]=newVal
trigger(target,key)//副作用函数取出并执行
}
})
问题:分支切换
// Example usage
const data = { a: 1, b: 2 };
const obj = new Proxy(data,{...})
effect(() => {
console.log(obj.a);
if (obj.a > 1) {
console.log(obj.b);
}
});
描述:
- 当 obj.a=2 时:obj.b 会被作为依赖,而且 effect 会被 obj.a 和 obj.b 同时收集
- 当 obj.a=1 时,并在此基础上多次修改 obj.b,虽然打印的结果始终保持不变,但是 effect 却被执行多次
- 我们并不希望在 obj.a=1 的情况下,effect 被 obj.b 收集着
解决:每次副作用执行时,都会先清除与它所有依赖集合的关联
这样可以保证,每次执行时,副作用只与当前访问的属性建立依赖关系(动态依赖管理)
代码实现
完善副作用注册函数:
//清除依赖关系
function cleanup(effectFn){
for(let i=0;i<effectFn.deps.length;i++){
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
//设置副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)//清除副作用函数
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
完善 track
const bucket = new WeakMap()
//设置副作用函数存储入桶
function track(target, key) {
if (!activeEffect) return target[key]
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()))
}
//存储包含该副作用的依赖
activeEffect.deps.push(deps)
deps.add(activeEffect)
}
完善 trigger
//触发副作用函数
function trigger() {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
//避免进入死循环
effectsToRun.forEach(effectFn => effectFn())//新增
// effects && effects.forEach(fn => fn)//删除
}
为什么会进入死循环?
比如 effectFn 函数被 obj.a 收集,且 effectFn 函数中执行了 obj.a 修改当操作。
当触发了 effectFn 函数时,obj.a 被修改,obj.a 被修改后重新出发 effectFn。
这个过程会不断重复,形成无限循环
解决:Set 数据结构会自动去重,确保每个副作用函数在一次触发过程中只被执行一次。
最后
示例代码:分支切换
以上都是实现一个完善响应式系统需要考虑的细节。阅读完这篇文章后,你是否对开篇的问题有了更明确的答案?
当然目前还存在诸多的问题比如:嵌套 effect 与 effect 栈、避免无限递归循环等,想要了解更多内容的朋友可以去阅读《vue.js 设计与实现》
如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!
接下来,我会推出一系列Vue 3.0的文章,欢迎关注,一起探索!