初探Vue3响应式系统
😵💫如何去实现一个简易版的响应式系统呢,这是最近萦绕在我脑海中的疑问,于是就有了这一篇;主要是明确了数据结构和副作用函数遗留问题
基础背景
- 假设我们在一个副作用函数中读取了某个对象的属性,我们希望,当数值变化的时候,副作用函数能够重新执行
//意思就是,我这里的obj.text是可能别其他改变的,那么它数值变化的时候
//希望能够重新执行effect()函数
const obj = { text: '初探响应式' }
function effect() {
document.body.innerText = obj.text
}
基础实现
- 因为需要拦截到数据的读取,设置操作,所以我们需要使用Vue3也使用到的Proxy
- 在
effect
函数去读取obj.text
的时候将effect
收集起来 - 在
obj.text
被设置的时候将effect
拿出来执行
- 在
const list = new Set(); //用来存储effect函数
const obj = { text: '初探响应式' } //设置目标对象
//proxy为代理对象,代理obj
const proxy = new Proxy(obj, {
get(target, key ) {
//将effect函数收集起来
list.add(effect);
return target[key]
},
set(target, key, value, receiver) {
target[key] = value;
//将effect拿出来执行
list.forEach(fn => fn())
return true
}
})
- 测试一下:这里注意你要使用的对象一定是代理对象,这里是
proxy
,只有使用触发拦截
function effect() {
document.body.innerText = proxy.text
}
effect();
setTimeout(()=>{
proxy.text = '已经过了一秒,我要修改了'
},1000)

再走一步
- 我们首先要明确数据结构
- 首先按照我们上面的结构来说,无论是设置对象哪个属性,我们都会将所有副作用函数拿出来执行一遍,这显然不是我们要的效果,即我们需要明确 副作用函数与被操作的目标字段的联系
- 也就是说全局中
- 可能有多个被代理的对象
- 一个对象里面可以有多个属性
- 每个属性又可以对应多个副作用函数,副作用函数我们期望是不重复的,所以用
Set
存储
- 数据结构图,
obj
表示被代理的目标对象,key
表示对象的键,effect
表示副作用函数
const list = new Map()
const obj = { name :'123'}
const proxy = new Proxy(obj, {
get(target, key) {
//如果没有副作用事件的话直接返回即可
if(!effect) return target[key];
//从list中获取desMap
let desMap = list.get(target);
//如果不存在的话则初始化,同时将其赋值desMap
if(!desMap) list.set(target,(desMap = new Map()));
//从desMap取出deps,它是一个Set类型:因为我们不希望存储重复的事件
let deps = desMap.get(key);
//如果不存在的话则初始化,同时将其赋值deps
if(!deps) desMap.set(key, (deps = new Set()) );
//向deps存储副作用函数
deps.add(effect);
return target[key]
},
set(target, key, value) {
target[key] = value;
const desMap = list.get(target);
if(!desMap) return;
//从desMap中取中存放副作用事件的Set结构
const effects = desMap.get(key);
//如果存在的话就挨个执行
effects && effects.forEach(fn => fn())
}
})
再走一步优化
- 书中谈及可以用
WeakMap
替代Map
去存储键为被代理的目标对象,值为Map
结构 - 也就是说改成如图:
- 原因就是说:如果使用
Map
的话,那么即使用户侧的代码对被代理的这个目标对象没有任何引用,这个目标对象也不会被回收,最终就可能导致内存的溢出
逻辑封装
- 将
get
拦截函数中副作用收集的逻辑封装到track
函数中
function track(target,key) {
//如果没有副作用事件的话直接返回即可
if(!effect) return target[key];
//从list中获取desMap
let desMap = list.get(target);
//如果不存在的话则初始化,同时将其赋值desMap
if(!desMap) list.set(target,(desMap = new Map()));
//从desMap取出deps集合,它是一个Set类型:因为我们不希望存储重复的事件
let deps = desMap.get(key);
//如果不存在的话则初始化,同时将其赋值deps
if(!deps) desMap.set(key, (deps = new Set()) );
//向deps集合存储副作用函数
deps.add(effect);
}
- 将
set
拦截函数中副作用执行的逻辑封装到trigger
函数中
function trigger(target,key) {
const desMap = list.get(target);
if(!desMap) return;
//从desMap中取中存放副作用事件的Set结构
const effects = desMap.get(key);
//如果存在的话就挨个执行
effects && effects.forEach(fn => fn())
}
- 总体代码
const list = new Map()
const obj = { name :'123'}
const proxy = new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key]
},
set(target, key, value) {
target[key] = value;
trigger(target,key);
}
})
测试
- 当你要测试的时候,你会发现,并不是函数都叫做
effect
呀,那么这个时候我们就得再进行处理 - 也就是说,提供一个用来注册副作用函数的机制
//用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
//注册
activeEffect = fn;
//执行
fn();
}
既然修改了这里那么上面track函数中设计effect的也要换成activeEffect喔
- 测试:对
data
进行代理,设置两个函数分别获取proxy.text
和proxy.text1
const data = { ok: true, text: 'hello world', text1: '国庆快乐'};
function test() {
document.querySelector('.first').innerHTML = proxy.text;
}
function test1 () {
document.querySelector('.second').innerHTML = proxy.text1;
}
effect(test);
effect(test1);
console.log(list);
- 打印出来的东西跟我们之前分析的数据结构就一样啦
分支切换
先看问题
const data = { ok: true, text: 'hello world', text1: '国庆快乐'};
function question() {
document.body.innerHTML = proxy.ok? proxy.text: 'not';
}
effect(question);
- 按照我们的代码来说,副作用函数
question
会分别被proxy.ok
和proxy.text
收集,因为既获取了proxy.ok
的值,又获取了proxy.text
的值
data
|___ok
|__question
|___text
|__question
- 当我们将
obj.ok
的值修改为false
之后,按照理想情况,副作用函数question
不应该再被字段proxt.text
所对应的依赖集合收集
//理想情况
data
|___ok
|__question
- 所以说这个时候就产生了遗留的副作用函数,当我们改变
proxy.text
的值的时候还会去调用该函数
解决思路
- 每次副函数执行的时候,先把它从所有与之关联的依赖集合中删除
- 当副作用执行完毕的时候,它会重新建立联系,那么自然就不会包含遗留的副作用函数了
- 要实现这个操作,首先我们需要知道哪些集合有包含它
- 给
effectFn
添加了deps
属性,用来存储所有包含当前副作用函数的依赖集合
//用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
//当effectFn执行的时候,把它设置为当前激活的副作用函数
//这样的话又可以成功执行传入函数,又可以加上deps属性
activeEffect = effectFn;
fn();
}
effectFn.deps = [];
effectFn(); //在这里执行
}
- 有数组存储数据的结构后应该就去寻找要去哪里收集它
- 找到
track
函数,deps
不就刚好是副作用的集合吗,so
//添加
activeEffect.deps.push(deps);
- 收集完就到了刚刚提到删除步骤了
- 在调用之前执行,所以我们加上
cleanup
函数负责清除工作
function effect(fn) { const effectFn = () => { cleanup(effectFn); //当effectFn执行的时候,把它设置为当前激活的副作用函数 activeEffect = effectFn; fn(); } effectFn.deps = []; effectFn(); }
cleanup
函数
function cleanup(effectFn) { for(let i = 0; i<effectFn.deps.length;i++) { const deps = effectFn.deps[i]; //将effectFn从依赖集合移除 deps.delete(effectFn); } //重置数组 effectFn.deps.length = 0; }
- 在调用之前执行,所以我们加上
- 你以为完了?其实没有,这里这个时候已经可以避免副作用函数的遗留问题了,但是目前的实现还是会导致无限循环执行,问题就出在
trriger
函数里面 - 当副作用函数执行的时候,会执行
cleanup
,将当前的剔除,但是执行又会让他重新被收集,所以遍历一直在继续 - 那么就构造另外一个集合去遍历它
// effects && effects.forEach(fn => fn()) ----这一句不要啦
//新增
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn=> effectFn());
- 注:这篇是基于个人学习 《Vue.js设计与实现》 这本书过程的一种梳理和整理输出吧
- 今天就先到这里,over over
转载自:https://juejin.cn/post/7149189717366898724