响应系统作用和实现

响应式收集

1
2
3
const effectFn = () => {
document.getElementById("id").innerText = data.age;
};

首先需要收集响应式数据,比如执行副作用函数时访问了obj.age,会被proxy get拦截,这时存入响应式数据和副作用函数的映射关系

  • obj
    • age
      • effectFn1
      • effectFn2
    • name
      • effectFn1
      • effectFn2

其中,effectFn即为副作用函数,比如更新视图等。

obj放在一个WeakMap里面,key为这个proxy对象的引用地址,value为一个Map,里面存obj里面的所有被访问的属性;每个属性可能对应一个或者多个副作用函数,因此使用一个Set存入。

存储结构对应为

1
2
3
4
5
WeakMap {
key: Map {
key : Set
}
}

Map 是哈希表实现,查询复杂度O(1)。Set底层也是哈希表实现,它的key和value相等,可以认为是一种特殊的Map。

哈希表是一种特殊的数据结构,在堆内存中存在数组,数组的每一个元素都指向一个链表,这个数组就叫桶数组。

一组数据使用哈希表存储时,会用key先算hashCode,一般情况下hashCode会均匀分步在每个哈希桶中,此时每个哈希桶中存储数量为1的链表结构。在数据读取时,会使用key算出hashCode,直接去对应hashCode中取出该链表,因为一般链表长度为1,因此查询复杂度为O(1);假如哈希冲突很厉害,最坏的情况是所有key全部映射到同一个哈希桶中,此时相当于数据存在一个链表中,从中找出所需成员的时间复杂度为遍历链表的复杂度,为O(n).

副作用再次执行

当数据变化时,会被proxy set拦截,此时把数据对应的副作用函数重新执行即可

代码实现

vue3使用proxy实现响应式,vue2使用defineProperty实现,这里给出大概代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
let data = {
age: 12,
name: "jj",
};
const effectFn = () => {
document.getElementById("#id").innerText = data.age;
};
const bucket = new WeakMap();
/** 依赖收集 */
const track = (target, key) => {
bucket.set(target, (depMaps = new Map()));
depMaps.set(key, (deps = new Set()));
deps.add(effectFn);

/**
* bucket = {
* data: {
* age: {
* effectFn1,effectFn2...
* }
* }
* }
*/
};
/** 依赖执行 */
const trigger = (target, key) => {
const depsMap = bucket.get(target);
const deps = depsMap.get(key);
for (let effectFn of deps) {
effectFn();
}
};
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, val) {
target[key] = val;
trigger(target, key);
return true;
},
});

这样每次访问obj.age,就会被proxy的get拦截;每次给obj.age赋值,比如obj.age = 13,就会被proxy的set拦截。在get拦截中收集依赖关系(第一次页面挂载时会执行一遍副作用函数,此时会有依赖收集的过程),在set拦截中执行副作用函数。这样当每次重新给obj.age赋值时,都会重新执行副作用函数,实现了数据的响应式更新。

effect

vue执行过程如下:

  • setup执行

  • 创建reactive、ref

  • 解析computed、watch

  • 执行render()函数,读取里面的响应式变量,触发依赖收集

  • 生成虚拟DOM

  • beforeMount钩子执行

  • DOM挂载完成

  • mounted钩子执行

在依赖收集阶段,为了方便收集副作用函数,比如匿名的副作用函数,会声明一个activeEffect变量,每次执行副作用函数如下:

1
2
3
4
5
let activeEffect = null;
const effect = (fn) => {
activeEffect = fn;
fn();
};

这样的好处是在track和trigger阶段,都可以通过activeEffect访问到当前正在起作用的副作用函数

嵌套effect

为了解决嵌套的effect,需要把activeEffect变成一个栈结构,这样每次栈顶的effectFn就是当前正在活动的副作用函数,否则副作用函数收集会混乱。

调度执行

当trigger触发副作用函数重新执行时,有能力控制副作用函数执行的时机,次数,方式。是实现computed和watch的必要前置条件

在effect函数中,增多一个接受参数options,在其中传入调度器scheduler,大概代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let activeEffect = null;
let effectStack = []; // 副作用调用栈,防止effect嵌套产生的调用错误
const scheduler = (effectFn) => {
// do something
setTimeout(effectFn(), 10);
}; // trigger中会把对应的副作用函数effectFn传给scheduler
const effect = (fn, options = { scheduler }) => {
const effectFn = () => {
activeEffect = effectFn;
// 在fn执行前,把effectFn压入effectStack栈
effectStack.push(effectFn);
// fn执行时会在track中收集activeEffect副作用函数
fn();
effectStack.pop();
// fn执行完成后,把activeEffect重置为之前的副作用函数
activeEffect = effectStack[effectStack.length - 1];
};
// 在副作用函数中增加options选项,其中包含调度器,在track中,effectFn被存入weakMap表中。当trigger时判断effectFn.options.scheduler是否存在,存在则执行
effectFn.options = options;
effectFn.deps = []; // 用来存储所有与该副作用函数有关的依赖集合
effectFn();
};

调度器由外部传入,可以控制副作用函数如何执行

computed和lazy

当options中传入lazy,则副作用函数不会立即执行,并且在effect函数中,会返回effectFn。此时通过返回的effectFn,可以控制它手动执行

1
2
3
4
5
6
7
8
9
10
11
12
function computed = (getter)=>{
// getter 类似 ()=>obj.a + obj.b
const effectFn = effect(getter,{lazy:true});
const obj = {
// 当被读取时才执行effectFn,然后触发依赖收集等一系列动作
// 对象的get语法
get value(){
return effectFn();
}
}
return obj
}

上面的极简实现并没有缓存数据的功能,每次访问computed它总是会重新执行effectFn,返回对应的值。因此需要引入一个flag来标识它是否需要更新,并且更新时用调度器传入flag值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function computed(getter) {
// getter 类似 ()=>obj.a + obj.b
let flag = true;
let value;
const effectFn = effect(getter, {
lazy: true,
// 调度器中的代码在trigger以后才会执行,也就是下一次响应式数据的赋值操作时,因此当computed.value访问时,总能获取到最新的数据
scheduler() {
// 这里不是箭头函数
flag = true;
},
});
const obj = {
// 当被读取时才执行effectFn,然后触发依赖收集等一系列动作
// 对象的get语法
get value() {
if (flag) {
value = effectFn();
flag = false;
}
return value;
},
};
return obj;
}

watch

watch利用effect和scheduler来实现:在effect中会收集响应式数据依赖,在其中加入调度器,即可在响应式数据发生改变时,执行调度器中的内容,这就是watch的基本原理,监控响应式数据的变化,然后做出相应的动作。

1
2
3
4
5
6
7
8
9
10
11
12
function watch(source,cb){
let newValue,oldValue
const effectFn = effect(source,{
lazy: true
scheduler: (effectFn)=>{
newValue = effectFn();
cb(newValue,oldValue)
oldValue = newValue;
}
})
oldValue = effectFn();
}

source 可以是一个函数(getter),可以是一个响应式数据,把它放入effect中即可,通过lazy可以实现新旧值的获取,通过调度器可以实现响应式数据变化以后的cb函数执行

watch还能首次执行,此时把scheduler内部的代码封装成一个job函数,然后再首次执行时执行该函数即可,因为首次执行和后续执行其实完全一样

watch还可以控制调度函数执行的时机,即是否在vue dom变化以后执行回调函数,通过flush关键值控制。比如当flush为’post’时,会把回调函数放入微任务队列末尾执行,因此此时在回调函数中能拿到vue dom变化以后的元素(在vue中,vue dom更新操作都是在微任务队列中进行的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function watch(source, cb, options = { immediate: true, flush: "post" }) {
let newValue, oldValue;
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
const effectFn = effect(source, {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
Promise.resolve().then(() => {
job();
});
} else {
job();
}
},
});
if (options.immediate) {
job();
} else {
oldValue = effectFn();
}
}

非原始值响应式方案

原始值响应式方案