本博客为Vue.js设计与实现中第四章的学习笔记。详情请查阅原书。

响应式数据基本结构

响应式数据Proxy

vue3采用proxy代理进行数据的双向绑定,这是一个最基础的proxy:

1
2
3
4
5
6
7
8
9
10
const data = { name: 'John Doe', age: 25 };

const a = new Proxy(data, {
// get 拦截器:当访问属性时触发
// 参数1:原始对象,2:属性,3:代理对象
get(target, key, receiver) { /* ... */ },
// set 拦截器:当设置属性时触发
// 参数1:原始对象,2:属性,3:新值,4:代理对象
set(target, key, newVal, receiver) { /* ... */ },
});

proxy对data进行代理,在访问属性时触发getter方法,设置属性时触发setter方法。

1
2
3
4
5
6
7
8
9
10
11
// 一个最基础的proxy例子
const a = new Proxy(data,{
get(target,key){
console.log("get",target[key])
return target[key]; // 返回访问的属性
},
set(target,key,newVal){
target[key] = newVal // 设置对应属性
console.log("set",target[key])
}
})

响应式数据的副作用函数effect()

副作用函数指的是会产生副作用的函数,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。

1
2
3
4
let val = 1;		//其他函数都可以读取设置此全局变量
function effect(){
val = 2; // effect修改了全局变量,影响了其他函数执行,产生副作用
}

我们希望在响应式数据被触发/修改的同时触发一些其他函数的效果(也就是副作用函数),就需要利用proxy,对get,set方法进行修改。

为了方便使用副作用函数,我们可以将effect存储到一个桶里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 存储副作用函数的桶 
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// proxy代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
bucket.add(effect) // 将副作用函数 effect 添加到存储副作用函数的桶中
return target[key] // 返回属性值
},
// 拦截设置操作
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn()) // 把副作用函数从桶里取出并执行
return true // 返回 true 代表设置操作成功
}
})

这样就实现了一个基本的副作用函数触发机制。

为了能够正常使用,我们再将effect修改为匿名函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 用一个全局变量存储被注册的副作用函数 
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}

const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到“桶”中
if (activeEffect) { // 新增
bucket.add(activeEffect) // 新增
} // 新增
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})

这样我们就可以在get方法正确向桶里添加副作用函数,并在set里批量执行他们。

存放effect()与target目标字段联系的WeakMap()

此时的桶里存放了所有的副作用函数,在实际操作中应该针对代理对象的不同属性值分别设置对应的effect()。我们应该修改这个数据结构。

如果用 target 来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数, 那么可以为这三个角色建立如下关系:

1
2
3
4
5
6
7
8
9
target1 
└── key1
└── effectFn1
└── effectFn1
└── key2
└── effectFn1
target2
└── key3
└── effectFn2

我们尝试实现这个新的数据结构:

image-20250418210754667

其中 WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个 由副作用函数组成的 Set。

用代码实现这个数据结构:

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
// 使用WeakMap存储依赖关系
const bucket = new WeakMap();

// 用于getter的track函数封装
// 进行effect()与target关系的建立,并存储到WeakMap
function track(target, key) {
if (!activeEffect) return // 没有 activeEffect,直接 return
let depsMap = bucket.get(target) // 根据 target 从桶中取得 depsMap,它也是一个 Map 类型:key --> effects
if (!depsMap) {
bucket.set(target, (depsMap = new Map())) // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
}
let deps = depsMap.get(key) //根据 key 从 depsMap 中取得 deps,一个存储所有相关effect函数的Set类型
if (!deps) {
depsMap.set(key, (deps = new Set())) // 如果 deps 不存在,新建一个Set并关联
}
deps.add(activeEffect) //向桶里添加激活的副作用函数
}

// 在 setter的trigger函数封装
// 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target) // 根据 target 从桶中取得 depsMap,它是 key --> effects
if (!depsMap) return
const effects = depsMap.get(key) // 根据 key 取得所有副作用函数 effects
effects && effects.forEach(fn => fn()) //遍历执行副作用函数
}

const obj = new Proxy(data, {
get(target, key) {
// 利用函数track实现副作用函数的添加
track(target, key);
return target[key];
},
set(target, key, newVal) {
// 利用函数trigger实现副作用函数的遍历运行
target[key] = newVal
trigger(target, key)
}
})

为什么使用WeakMap?

简单地说,WeakMap 对 key 是弱引用,不影响垃圾回收器的工 作。据这个特性可知,一旦 key被垃圾回收器回收,那么对应的键和值就访问不到了。所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息,例如上面的场景中,如果 target 对象没有任何引用了,说明用户侧不再需要它了, 这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap, 那么即使用户侧的代码对 target 没有任何引用,这个 target 也不 会被回收,最终可能导致内存溢出

分支切换与遗留副作用函数的关系清理cleanup()

副作用函数的依赖关系可能是动态变化的。例这是一个分支切换的例子:

1
2
3
effect(() => {
text = a.ok ? a.name : 'default';
});

当a.ok为true时,副作用函数依赖a.ok和a.name。为false时,副作用函数仅依赖a.ok,不再依赖a.name。

但分支切换可能会产生遗留的副作用函数。在这个例子里,如果没有清理旧依赖,修改a.name时仍然会触发副作用函数,尽管此时它不再需要a.name。

解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从所有相关联的依赖集合中移除,再重新添加新的依赖集合,那么问题就迎刃而解了。

我们可以给副作用函数添加一个属性deps,一个存储所有包含当前副作用函数的依赖集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 用一个全局变量存储被注册的副作用函数 
let activeEffect
function effect(fn) {
const effectFn = () => {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

并在track函数里进行deps数组依赖集合的收集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function track(target,key){
if(!activeEffect){
return
}
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()))
}
// 桶对应的key的依赖集合,把当前激活的副作用函数添加到依赖集合deps中
deps.add(activeEffect)
// 将deps添加到 副作用函数的属性activeEffect.deps数组中
activeEffect.deps.push(deps)
}

如以上代码所示,在 track 函数中我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与 当前副作用函数存在联系的依赖集合,于是我们也把它添加到 activeEffect.deps 数组中,这样就完成了对依赖集合的收集。

我们接着实现副作用函数执行后的依赖删除函数 cleanup():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function cleanup(effectFn){
for(let i =0;i<effectFn.deps.length;i++){
const deps = effectFn.deps[i] // 依赖集合
deps.delete(effectFn) // 移除副作用函数
}
effectFn.deps.length = 0 // 重置副作用函数的deps属性
}

function effect(fn){
const effectFn = () => {
// 调用cleanup函数,在激活副作用函数前删除与它有关的所有依赖
cleanup(effectFn);
activeEffect = effectFn;
fn();
}
effectFn.deps = []
effectFn()
}

cleanup 函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps 数组,该数组的每一项都是一个依赖集合,然后将 该副作用函数从依赖集合中移除,最后重置 effectFn.deps 数组。

至此,我们的响应系统已经可以避免副作用函数产生遗留了。但 如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题 出在 trigger 函数中:

1
2
3
4
5
6
function trigger(target, key) { 
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn()) // 执行副作用函数时发生死循环
}

当副作用函数执行时,会调用 cleanup 进行清除,实际上就是从 effects 集合中将当前执行的副作用函数剔除,但是cleanup()完成后fn()的执行会触发track导致其重新被收集到集合中,又添加到foreach遍历里,导致死循环。

也就是说只要不将其添加到原来的foreach就可以。我们再设置一个桶用来遍历:

1
2
3
4
5
6
7
8
9
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const depsEffects = depsMap.get(key)
const effectsToRun = new Set() // 存储要执行的副作用函数
effectsToRun&&effectsToRun.forEach(fn => {
fn() // 执行副作用函数,此时的fn()就不会添加到effectsToRun里
});
}

effect嵌套与effect栈

在实际使用中effect()是可以发生嵌套的。比如vue.js的渲染函数就是在一个effect中执行的。effect嵌套如下所示:

1
2
3
4
effect(function effectFn1() { 
effect(function effectFn2() { /* ... */ })
/* ... */
})

我们目前的代码是不支持嵌套的。所以我们要把effect()修改成可嵌套的形式,也就是递归。而为了正确处理递归(先执行内层函数再执行外层),我们要引入另一个先进后出的数据结构,也就是栈。

在代码里,我们依旧使用activeEffect作为标识不变,通过修改activeEffect的内容来正确指向当前的副作用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let activeEffect = null; // 全局变量存储当前激活effect函数
const effectStack = []; // effect栈

function effect(fn){
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn; // 赋值activeEffect,设置当前副作用函数
effectStack.push(effectFn); // 在调用前将副作用函数压入栈中
fn(); // 执行副作用函数
effectStack.pop(); // 执行后弹出栈
activeEffect = effectStack[effectStack.length - 1]; //重新设置activeEffect,读取栈顶函数,也就是之前的值
}
effectFn.deps = []
effectFn()
}

这样栈顶指向的一定是当前执行的effect(),成功实现了effect()嵌套的递归。

防止effect()无限递归

以下是一个简单的自增函数:

1
effect(() => obj.foo++)

但这个函数会引起栈溢出。在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo的值,而这就是导致问题的根本原因。

此代码的执行流程:读取 obj.foo 的值,触发 track 操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给 obj.foo,此时会触发 trigger 操作,即把“桶”中的副作用函数取出并执行。于是此effect()再次被触发,重新开始此流程,导致无限递归调用自己。

在此问题里,读取和设置操作是在同一个副作用函数内进行的,因此导致了无限递归。此时读取时的track和设置时的trigger都是使用activeEffect进行标识的。因此,我们可以在 trigger 发生时增加守卫条件: 如果 trigger时的副作用函数与当前正在执行的副作用函数相同,则不触发执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const depsEffects = depsMap.get(key)
const effectsToRun = new Set()
depsEffects && depsEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不执行,防止死循环
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun&&effectsToRun.forEach(fn => { fn()
});
}

这样我们就能够避免无限递归调用,从而避免栈溢出。

调度配置

调度执行

可调度性是响应系统非常重要的特性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。当我们需要在不调整代码的情况下实现以上需求时,就需要相应系统支持调度。

我们可以给effect()设置一个选项参数options,允许用户配置内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
function effect(fn,options = {}){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.options = options; // 将options挂载到副作用函数上,便于之后处理
effectFn.deps = [];
effectFn ;
}

用户在调用effect()时可以指定一个调度器,包含一个scheduler 调度函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
effect(
()=>{text = a.name},
// options
{
// 调度器函数,允许用户指定调度器
scheduler(fn){
// ...example
console.log("scheduler")
setTimeout(() => {
console.log("scheduler settimeout 1000")
fn()
}, 1000);
},
})

有了调度函数,我们在 trigger 中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const depsEffects = depsMap.get(key)

const effectsToRun = new Set()
depsEffects && depsEffects.forEach(effectFn => {
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun&&effectsToRun.forEach(fn => {
// 若存在调度器函数,则执行调度器函数并将函数作为参数传递,否则直接执行副作用函数
console.log("trigger",fn.options.scheduler)
if(fn.options.scheduler){
fn.options.scheduler(fn) // 调度器函数
}else{
fn() // 执行副作用函数
}
});
}

通过以上修改,我们就可以控制副作用函数的执行了。

懒执行 lazy effect

在有些场景下,我们并不希望立即执行副作用函数,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,当 options.lazy为 true 时,不立即执行副作用函数:

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
const effectFn = effect(
()=>{text = a.name},
// options
{
lazy: true, // 延迟执行
}
)

function effect(fn,options = {}){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.options = options; // 将选项参数挂载到副作用函数上
effectFn.deps = []
if(!options.lazy){
effectFn() // 如果不是延迟执行,则立即执行副作用函数
}
return effectFn // 由于未立即执行副作用函数,返回副作用函数以手动执行

}

通过添加lazy:true,当调用 effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了:

1
2
3
4
5
6
7
const effectFn = effect(
()=>{text = a.name},
{
lazy: true, // 延迟执行
}
)
effectFn(); //手动执行

如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把这个可以手动执行的函数看作一个可以返回任何值的getter函数,就可以取到其返回值:

1
2
3
4
5
6
7
const effectFn = effect( 
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
// value 是 getter 的返回值
const value = effectFn()

为了实现这个目标,我们再对 effect 函数做一些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function effect(fn,options = {}){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn;
effectStack.push(effectFn);
const res = fn(); // 执行副作用函数并保存结果
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res; // 设置res为副作用函数的返回值,实际上就是传入的fn()的返回值
}
effectFn.options = options;
effectFn.deps = []
if(!options.lazy){
effectFn()
}
return effectFn;

}

我们将真正的副作用函数fn()的结果作为effectFn的返回值,可以拿到副作用函数的执行结果。在此基础上就可以实现计算属性了。

lazy effect实现 计算属性computed()

我们定义一个 computed函数,它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回:

1
2
3
4
5
6
7
8
9
function computed(getter){
const effectFn = effect(getter,{lazy:true})
const obj = {
get value(){
return effectFn()
}
}
return obj
}

利用computed进行属性计算:

1
2
3
4
5
6
7
const a = {
age:25
}
const sum = computed(()=>{
return a.age + 1
})
console.log("computed:",sum.value) // "computed:26"

它可以进行计算属性功能,但它还没有对值进行缓存。多次调用sum.value,每次访问都会导致重复计算。因此我们需要添加一个对值进行缓存的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function computed(getter){
let value // 缓存计算属性的值
let dirty = true // 标志计算属性是否需要重新计算,dirty == true则需要重新计算
const effectFn = effect(getter,{lazy:true})
const obj = {
get value(){
if(dirty){
value = effectFn() // 只有“脏”时才计算值,并将得到的值缓存到 value 中
dirty = false // 设置为false,表示不需要重新计算
}
return value // 返回计算属性的值
}
}
return obj
}

通过添加dirty标识属性是否需要重新计算,并利用value缓存上次计算的值。

我们接着实现computed依赖的响应式数据改变时,重置dirty为true的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function computed(getter){
let value
let dirty = true
const effectFn = effect(getter,{lazy:true,scheduler:()=>{
if(!dirty){ // 如果 dirty 为 false,则不需要重新计算
dirty = true // 设置为true,表示需要重新计算
}
}})
const obj = {
get value(){
if(dirty){
value = effectFn()
dirty = false
}
return value
}
}
return obj
}

我们为 effect 添加了 scheduler 调度器函数,它会在 getter 函数中所依赖的响应式数据变化时执行,这样我们在 scheduler 函 数内将 dirty 重置为 true,当下一次访问 sumRes.value 时,就会 重新调用 effectFn 计算值,这样就能够得到预期的结果了。

我们理一下此时的函数触发顺序:

  • const obj = computed(()=>{...})创建computed,注册副作用函数,由于lazy:true不执行effectFn();
  • obj.value首次访问,并通过调度函数scheduler执行effectFn(),进而执行getter函数;
  • getter函数访问响应式数据,触发数据(如 obj.a 和 obj.b)的track函数,将 effectFn 收集为它们的依赖;
  • 响应式数据(如 obj.a 和 obj.b)修改时,触发trigger函数,运行它的副作用函数,其中包括了effectFn;
  • 由于设置了调度函数scheduler,通过scheduler重置dirty的值;
  • 下次访问obj.value,重新计算effectFn,得到预期结果。

我们已经实现了computed()的绝大部分内容,但还有一个问题:computed依赖的响应式数据(如 obj.a 和 obj.b)改变时,不会触发副作用函数的渲染。

分析问题的原因,我们发现,从本质上看这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执 行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部 的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时, 就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。(人话:外层的obj.value不会被内层的effectFn()触发)

当读取计算属性的值时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,我 们可以手动调用 trigger 函数触发响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function computed(getter){
let value
let dirty = true
const effectFn = effect(getter,{lazy:true,scheduler:()=>{
if(!dirty){
dirty = true
trigger(obj,'value') // 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
}
}})
const obj = {
get value(){
if(dirty){
value = effectFn()
dirty = false
}
track(obj,'value') // 当读取 value 时,手动调用 track 函数进行追踪
return value
}
}
return obj
}

再理一下这里的track和trigger:

  • 访问 sum.value时,触发track(obj, 'value'),将当前激活的副作用函数(也就是外部 effect)收集到 obj.value 的依赖集合中;
  • 当计算属性的依赖(如 data.a 或 data.b)变化时,触发 trigger(obj, 'value'),obj.value 的副作用函数(如外部 effect)重新执行。

这里的track到底是怎么添加依赖的?

注意在这里,activeEffect由于effect栈的使用,会自动指向从effectFn()改成外部 effect,如以下的例子:

1
2
3
4
5
6
7
const data = reactive({ a: 1, b: 2 });
const sum = computed(() => data.a + data.b);

// 外部副作用函数(如渲染函数)
effect(() => {
console.log(sum.value); // 访问 sum.value,触发计算属性的 getter
});
  1. 外部 effect 的回调函数开始执行,此时 activeEffect 指向该回调函数。
  2. 回调函数中访问 sum.value,触发计算属性的 get value()。
  3. 在 get value() 中,首次访问会执行 effectFn()(即计算属性的 getter 函数)。
  4. effectFn() 执行时,activeEffect 暂时切换到 effectFn。
  5. effectFn() 执行完毕,activeEffect 恢复为外部的 effect 回调函数。
  6. get value() 继续执行,调用 track(obj, 'value'),此时 activeEffect 仍指向外部的回调函数(computed的渲染函数)。

在数据结构上,对于sum.value来说,他会建立以下联系:

1
2
3
computed(obj) 
└── value
└── effectFn

如下图所示:

image-20250419034737465

监听方法watch()

watch的实现原理

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项,以下是一个简单的watch实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
function watch(source,cb){
effect(
()=>source.name,
{
scheduler(){
cb() // 调用回调函数
}
}
)
}

watch(a,()=>{console.log("数据变化")})
a.name = "newname" // 修改数据,触发 watch 的回调函数

我们再对source的读取进行修改,使其任意属性值变化都会触发回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function watch(source,cb){
effect(
// traverse递归读取
()=>traverse(source),{
scheduler(){
cb()
}
})
}

function traverse(value,seen = new Set()){
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if(typeof value !== 'object' || value === null || seen.has(value)){return}
seen.add(value) // 将当前值添加到 seen 集合中,避免循环引用
for(const k in value){
traverse(value[k],seen) // 递归遍历对象的属性,value[k]会触发该属性getter
}
return value
}

实际使用中,我们有手动指定watch依赖的响应式数据的需求。我们对watch函数进行修改,不传入响应式数据,而是传入一个触发响应式数据的getter函数,当这些指定的数据变化时再触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function watch(source,cb){
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if(typeof source === 'function'){
getter = source
}
// 否则按照原来的实现调用 traverse 递归地读取
else{
getter = ()=>traverse(source)
}
effect(()=>getter(),{
scheduler(){
cb()
}
})
}

watch还有一个重要功能,在回调函数中拿到旧值和新值,我们再次修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function watch(source,cb){
let getter
if(typeof source === 'function'){
getter = source
}
else{
getter = ()=>traverse(source)
}
// 定义新旧值
let oldValue, newValue
// 调度器函数
const job = () => {
newValue = effectFn() // 重新计算新值
cb(newValue,oldValue) // 调用回调函数
oldValue = newValue // 更新旧值
}
const effectFn = effect(()=>getter(),{
lazy: true, // 开启延迟执行,稍后手动执行effectFn获取初始值
scheduler:job,// 使用 job 函数作为调度器函数
})
oldValue = effectFn() // 手动执行effectFn获取初始值
}

通过以上代码我们就能拿到新旧值了。

立即执行的watch 与回调执行时机

vue关于 watch 有两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机

首先来看立即执行的回调函数。在 Vue.js 中可以通过选项参数 immediate 来指定回调是否需要 立即执行:

1
2
3
4
5
6
watch(obj, () => { 
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})

当 immediate 选项存在并且为 true 时,回调函数会在该 watch 创建时立刻执行一次。回到watch的代码我们可以发现,在代码执行逻辑上就是直接触发了一次scheduler调度器。我们修改watch函数:

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
function watch(source,cb,options = {}){
let getter
if(typeof source === 'function'){
getter = source
}
else{
getter = ()=>traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue,oldValue)
oldValue = newValue
}
const effectFn = effect(()=>getter(),{
lazy: true,
scheduler:job
})
if(options.immediate){
// 如果 immediate 为 true,则立即执行job,触发回调
job()
}
else{
// 手动执行副作用函数,获取初始值
oldValue = effectFn()
}
}

这样就实现了回调函数的立即执行功能。由于回调函数是立即执行的,所以第一次回调执行时没有所谓的旧值,因此此时回调函数的 oldValue 值为 undefined,这也是符合预期的。

接下来我们通过其他选项参数来指定回调函数的执行时机。例如在 Vue3中使用 flush选项来指定:

1
2
3
4
5
6
1 watch(obj, () => { 
02 console.log('变化了')
03 }, {
04 // 回调函数会在 watch 创建时立即执行一次
05 flush: 'pre' // 还可以指定为 'post' | 'sync' 'pre'为组件更新前,'post'为组件更新后
06 })

flush 本质上是在指定调度函数的执行时机。 当 flush 的值为 ‘post’ 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 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
26
27
function watch(source,cb,options = {}){
let getter
if(typeof source === 'function'){getter = source}
else{getter = ()=>traverse(source)}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue,oldValue)
oldValue = newValue
}
const effectFn = effect(()=>getter(),{
lazy: true,
scheduler:()=>{
// 在调度函数中判断 flush 的值
// 如果 flush 为 post,则将其放到微任务队列中执行
if(options.flush === 'post'){
Promise.resolve().then(job)
}
// 如果 flush 为 pre,则在当前事件循环中执行回调函数
else if(options.flush === 'pre'){ job()}
// 默认立即执行回调函数
else{job()}
}
})
if(options.immediate){job()}
else{oldValue = effectFn()}
}

如以上代码所示,我们修改了调度器函数 scheduler的实现方式,在调度器函数内检测 options.flush 的值是否为 post,如果 是,则将 job 函数放到微任务队列中,从而实现异步延迟执行;否则直接执行 job 函数,这本质上相当于 ‘sync’ 的实现机制,即同步执行。对于 options.flush 的值为 ‘pre’ 的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机,其中 ‘pre’ 和 ‘post’ 原本的 语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控 制回调函数的更新时机。

过期的副作用

竞态问题通常在多进程或多线程编程中被提及,举一个竞态问题的简单例子:

1
2
3
4
5
let finalData
watch(obj,async ()=>{
const res = await axios.get('/react666/')
finalData = res.data
})

如果我们发送了两次请求,且第二次请求比第一次请求先返回,就会导致data最终存储的是第一次请求的结果

,但我们实际上需要最新的数据,也就是第二次请求的结果。为了解决此问题,我们需要一个让第一次请求的结果过期的手段,我们来看看vue的watch是如何实现的。

在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,通过 onInvalidate 函数注册一个回调,副作用函数过期时就会执行此回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
watch(a, async (newValue, oldValue, onInvalidate)=>{
// 定义一个过期标志
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(()=>{
// 当过期时,将 expired 设置为 true
expired = true
})
const res = await axios.get('/react666/')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作
if(!expired){
finalData = res.data
}
})

接下来我们来看watch是如何处理过期的副作用函数的。在 watch 内部每次检测到变更后,在副作用函数重新执行之前,会先调用onInvalidate函数注册的过期回调函数:

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
function watch(source,cb,options = {}){
let getter
if(typeof source === 'function'){getter = source}
else{getter = ()=>traverse(source)}
// cleanup 用来存储用户注册的过期回调函数
let cleanup
function onInvalidate(fn){
cleanup = fn // 存储过期回调函数()=>expired = true
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
if(cleanup){
// 在调用回调函数 cb 之前,先调用过期回调函数
// 对上面的回调来说就是触发()=>expired = true
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,将cleanup赋值为用户注册的过期回调函数
cb(newValue,oldValue,onInvalidate)
oldValue = newValue
}

const effectFn = effect(()=>getter(),{
lazy: true,
scheduler:()=>{
if(options.flush === 'post'){Promise.resolve().then(job)}
else if(options.flush === 'pre'){job()}
else{job()}
}
})
if(options.immediate){
job()
}
else{
oldValue = effectFn()
}
}

onInvalidate这一块有点绕,我们理一下:

  • watch需要在副作用函数过期时执行用户自定义的过期回调函数,于是设置变量cleanup
  • watch设置一个onInvalidate()函数,获取用户的过期回调函数并赋值给cleanup
  • 用户通过onInvalidate()注册自己的过期回调函数

注意用户在使用时,watch(obj, async (newValue, oldValue, onInvalidate) => {... },虽然第三个参数为onInvalidate,但实际上我们真正的回调函数应该填进onInvalidate()里,onInvalidate这个参数是给watch方法获取回调用的。

整个流程是这样,假设watch监听的数据变化两次,会触发两次异步操作:

image-20250424013947823