本博客为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.oktrue时,副作用函数依赖a.oka.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.aobj.b)的track函数,将 effectFn 收集为它们的依赖;
  • 响应式数据(如 obj.aobj.b)修改时,触发trigger函数,运行它的副作用函数,其中包括了effectFn
  • 由于设置了调度函数scheduler,通过scheduler重置dirty的值;
  • 下次访问obj.value,重新计算effectFn,得到预期结果。

我们已经实现了computed()的绝大部分内容,但还有一个问题:computed依赖的响应式数据(如 obj.aobj.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.adata.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