响应式系统
响应式系统可以说 vue 的驱动器,当读取模板中的数据时触发 getter,此时收集渲染函数;模板中的数据被修改触发 setter,此时执行收集到的渲染函数。vue 响应式的实现的核心思想是数据劫持,在 vue2 中是通过 Object.defineProperty 实现,vue3 是通过 proxy 实现。这些都是一些基本知识点,对于响应式系统除了数据劫持外还做了什么。
副作用函数和简单的响应式系统
在说明响应式系统的功能时,有提到两个关键的名词:模板中的数据和渲染函数。其中渲染函数就是副作用函数,因为该函数有访问函数外部的变量。副作用函数执行导致外部变量的改变,直接或者间接的影响到使用该外部变量的其他函数。模板中的数据的读取或修改都会和副作用函数产生关系,下面通过代码来完成一个简单的响应式系统
const data = {
str: 'hello world',
}
// 定义副作用函数 effect
function effect() {
document.body.innerText = proxy.str;
}
let fn;
// 进行数据劫持
const proxy = new Proxy(data, {
get(target, key) {
fn = effect;
return target[key]
},
set(target, key, newVal) {
target[key] = newVal;
fn();
return true;
}
})
effect();
setTimeout(()=>{
proxy.str='111'
},1000)
虽然完成了一个简单的响应式系统,但是考虑以下几种情况,代码该如何修改增加功能呢
-
情况一:如果副作用函数是一个匿名函数
function effect() { document.body.innerText = proxy.str; } // 副作用函数是一个匿名函数 function fn(()=>{ document.body.innerText = proxy.str; })
-
情况二:data 中有多个属性,每个属性都存在与之相对的副作用函数
data ={ key1:value1, key2:value2, key3:value3, } function effect1(){data.key1} function effect2(){data.key2} function effect3(){data.key3}
-
情况三:data 中的同一个属性被多个副作用函数使用
data ={ key1:value1, } function effect1(){data.key1} function effect2(){data.key1} function effect3(){data.key1}
-
情况四:data 中有多个属性,每个属性都被同一个副作用函数使用
data ={ key1:value1, key2:value2, key3:value3, } function effect1(){data.key1;data.key2;data.key3;}
-
情况五:访问了 data 中不存在的属性
data ={ key1:value1, } function effect1(){data.key2}
-
情况六:存在多个 data
data1 ={ key1:value1, } data2 ={ key1:value2, } function effect1(){data1.key1} function effect2(){data2.key1}
完善响应式系统
针对以上提到的问题,逐一完成要求的功能
副作用函数是一个匿名函数
对于情况一:副作用函数是匿名函数,可以封装一个 effect 函数,提供一个参数 fn 用来接收并注册副作用函数。同时创建一个全局变量用来保存副作用函数。
// 创建一个全局变量,用来保存被注册的副作用函数
export let activeEffect:()=>void;
// effect 函数用来注册副作用函数,而非副作用函数本身
export function effect<T>(fn:()=>T){
activeEffect = fn;
fn();
}
data 中有多个属性
上面说的情况二/三/四/五其实是实现方式都是一样的,需要做的是副作用函数和目标属性建立明确联系:用到同一个属性的所有的 effect 函数保存在同一个 Set 对象内。创建一个 Map 对象,保存对象属性和 set 对象的映射。
data ={
key:value,
}
function effect(){data.key}
// 下面表示的是 key/effect 和 Set/Map 之间的关系
// Set 对象保存所有的 effect 函数
Set: effect
// Map 对象保存属性 key 和 Set 对象的映射
Map: key → Set
按照上方所述的原理,代码如下:
// 创建一个全局变量,用来保存被注册的副作用函数
let activeEffect;
// effect 函数用来注册副作用函数,而非副作用函数本身
function effect(fn) {
activeEffect = fn;
fn();
}
// 创建一个全局的 bucket 变量,用来保存属性和 Set 对象的映射关系
const bucket = new Map();
// get 拦截函数中调用 track 记录各属性对应的副作用函数
function track(target, key) {
if (!activeEffect)
return;
let depsMap = bucket.get(key);
!depsMap && bucket.set(key, (depsMap = new Set()));
depsMap.add(activeEffect);
// 记录完副作用函数后置空
activeEffect = null;
}
function trigger(target, key) {
let depsMap = bucket.get(key);
depsMap && depsMap.forEach(cb => cb());
}
// 封装一个 reactive 函数,实现对对象的拦截
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
trigger(target, key);
return true;
}
});
}
测试代码:
<div id="person1">
<h1 class="effect1"></h1>
<h1 class="effect2"></h1>
<h1 class="effect22"></h1>
<h1 class="effect23"></h1>
<h1 class="effect3"></h1>
<h1 class="effect4"></h1>
</div>
<script>
const person1 = reactive({
name: 'tom',
age: 11,
sex: 'man'
});
const person2 = {
name: 'mary',
age: 10,
sex: 'women'
}
effect(() => {
document.querySelector('#person1 .effect1').innerText = person1.name;
});
effect(() => {
document.querySelector('#person1 .effect2').innerText = person1.age;
});
effect(() => {
document.querySelector('#person1 .effect22').innerText = person1.age + 100;
});
effect(() => {
document.querySelector('#person1 .effect23').innerText = person1.age + 200;
});
effect(() => {
document.querySelector('#person1 .effect3').innerText = person1.sex;
});
effect(() => {
document.querySelector('#person1 .effect4').innerText = person1.a
});
setTimeout(() => {
Object.assign(person1, person2);
}, 2000);
setTimeout(() => {
person1.a = 'hello world';
}, 3000);
</script>
存在多个 data
对于情况六存在多个 data 的情况,实现思想和上面类似。只需要新增一组 WeakMap 对象,表示 data 和 Map 的映射关系。
data1 ={
key1:value1,
}
data2 ={
key1:value2,
}
function effect1(){data1.key1}
function effect2(){data2.key1}
// 下面表示的是 key/effect 和 Set/Map 之间的关系
// Set 对象保存所有的 effect 函数
Set: effect
// Map 对象保存属性 key 和 Set 对象的映射
Map: key → Set
// WeakMap 对象保存 data 和 Map 对象的映射关系
WeakMap: data → Map
代码修改如下:
// 创建一个全局变量,用来保存被注册的副作用函数
let activeEffect;
// effect 函数用来注册副作用函数,而非副作用函数本身
function effect(fn) {
activeEffect = fn;
fn();
}
function setActiveEffectNull() {
activeEffect = null;
}
// 创建一个全局的 bucket 变量,用来保存 data 和 Map 对象的映射关系
const bucket = new WeakMap();
// get 拦截函数中调用 track 记录各属性对应的副作用函数
function track(target, key) {
if (!activeEffect)
return;
// 创建一个 depsMap 变量,用来保存属性和 Set 对象的映射关系
let depsMap = bucket.get(target);
// 如果 data 不存在,则新建一个以 data 为 key,value 为 Map 的映射
!depsMap && bucket.set(target, (depsMap = new Map()));
// 创建一个 dep 变量,用来保存所有的 effect 函数
let deps = depsMap.get(key);
!deps && depsMap.set(key, deps = new Set());
deps.add(activeEffect);
// 记录完副作用函数后 activeEffect 置空
setActiveEffectNull();
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap)
return;
const deps = depsMap.get(key);
deps && deps.forEach(cb => cb());
}
// 封装一个 reactive 函数,实现对对象的拦截
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
trigger(target, key);
return true;
}
});
}
响应式数据和副作用函数之间的关系
const person3 = reactive({
name: 'Andy',
age: 5,
});
effect(() => {
document.querySelector('#person3 .effectOuter').innerText = person3.name;
})
effect(() => {
document.querySelector('#person3 .effectInner').innerText = person3.age + 2;
})
effect(() => {
document.querySelector('#person3 .effectInner').innerText = person3.age + 3;
})
// WeakMap
WeakMap {{…} => Map(2)}
0: {Object => Map(2)}
key: {name: 'Tom', age: 100}
value: Map(2)
0: {"name" => Set(1)}
1: {"age" => Set(2)}
图示:
副作用函数嵌套的处理
在副作用函数 effect 函数中嵌套使用 effect,对应到 vue 的使用情形是在一个组件中使用了另一个组件。看如下情况,出现了副作用函数未被正确的属性收集的问题:
<div id="person3">
<h1 class="effectOuter"></h1>
<h1 class="effectInner"></h1>
</div>
<script>
const person3 = reactive({
name: 'Andy',
age: 5,
});
effect(() => {
console.log('effectOuter')
effect(() => {
console.log('effectInner');
document.querySelector('#person3 .effectInner').innerText = person3.age + 1;
})
document.querySelector('#person3 .effectOuter').innerText = person3.name;
})
setTimeout(() => {
person3.name = 'Tom';
}, 3000)
// effectOuter
// effectInner
// effectInner
/*
符合预期的结果,同时页面上的 name 改为 Tom
// effectOuter
// effectInner
// effectOuter
// effectInner
*/
</script>
出现以上问题的原因在于副作用函数收集出了问题:activeEffect 变量在执行嵌套 effect 函数时发生了改变。执行到内部的 effect 函数时 activeEffect 被覆盖为内部的 effect 函数并且不再改变,再之后执行外部的代码触发 track 函数此时保存的是内部的 effect 函数。知道原因后,我们可以添加 effectStack 变量,用来记录 activeEffect。 修改后的代码如下:
// 创建一个全局变量,用来保存被注册的副作用函数
let activeEffect;
// 创建变量 effectStack 用来保存 activeEffect
const effectStack = [];
// effect 函数用来注册副作用函数,而非副作用函数本身
function effect(fn) {
activeEffect = fn;
// activeEffect 入栈,当存在 effect 嵌套时保存所有的 activeEffect(副作用函数)
effectStack.push(activeEffect);
// 副作用函数执行,若存在 effect 嵌套,则会重新调用 effect,将 activeEffect 入栈
fn();
// 将已执行的副作用函数出栈
effectStack.pop();
// 还原副作用函数为之前的值
activeEffect = effectStack[effectStack.length - 1];
}
避免无限递归循环
考虑如下情况,当属性自加时,会直接报错 Uncaught RangeError: Maximum call stack size exceeded
:
const person = reactive({
name: 'Andy',
age: 5,
});
effect(() => {
console.log('effect');
document.querySelector('#person3 .effectInner').innerText = person.age++;
})
原因发生在 person.age++
上,当进行自增操作时其实会分成两步:读取 person.age
的值,加 1 后再修改 person.age
值。既触发了 get 执行 track 函数收集副作用函数,又触发 set 执行 trigger 函数执行副作用函数。解决方式很简单:在 trigger 函数中增加判断,如果trigger 函数中执行的副作用函数与需要收集的副作用函数相同,则不执行副作用函数,代码修改如下:
function trigger(target: obj, key: string) {
const depsMap = bucket.get(target);
if (!depsMap) return
const deps = depsMap.get(key);
//新增变量 effectToRun 保存需要执行的副作用函数,避免无线递归循环
const effectToRun = new Set<fn>();
deps && deps.forEach(effect => {
if (effect !== activeEffect) {
effectToRun.add(effect)
}
});
effectToRun.forEach(effect => effect())
}
整个代码部分:
// 创建一个全局变量,用来保存被注册的副作用函数
let activeEffect;
// 创建变量 effectStack 用来保存 activeEffect
const effectStack = [];
// effect 函数用来注册副作用函数,而非副作用函数本身
function effect(fn) {
activeEffect = fn;
// activeEffect 入栈,当存在 effect 嵌套时保存所有的 activeEffect(副作用函数)
effectStack.push(activeEffect);
// 副作用函数执行,若存在 effect 嵌套,则会重新调用 effect,将 activeEffect 入栈
fn();
// 将已执行的副作用函数出栈
effectStack.pop();
// 还原副作用函数为之前的值
activeEffect = effectStack[effectStack.length - 1];
}
// 创建一个全局的 bucket 变量,用来保存 data 和 Map 对象的映射关系
const bucket = new WeakMap();
// get 拦截函数中调用 track 记录各属性对应的副作用函数
function track(target, key) {
if (!activeEffect)
return;
// 创建一个 depsMap 变量,用来保存属性和 Set 对象的映射关系
let depsMap = bucket.get(target);
// 如果 data 不存在,则新建一个以 data 为 key,value 为 Map 的映射
!depsMap && bucket.set(target, (depsMap = new Map()));
// 创建一个 dep 变量,用来保存所有的 effect 函数
let deps = depsMap.get(key);
!deps && depsMap.set(key, deps = new Set());
deps.add(activeEffect);
// 记录完副作用函数后 activeEffect 置空
// setActiveEffectNull();
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap)
return;
const deps = depsMap.get(key);
//新增变量 effectToRun 保存需要执行的副作用函数,避免无线递归循环
const effectToRun = new Set();
deps && deps.forEach(effect => {
if (effect !== activeEffect) {
effectToRun.add(effect);
}
});
effectToRun.forEach(effect => effect());
}
// 封装一个 reactive 函数,实现对对象的拦截
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
trigger(target, key);
return true;
}
});
}