08_第_4_章_响应系统的作用与实现
约 39096 字大约 130 分钟
2026-10-05
这一章是整个“响应系统”篇的地基。前面我们讨论过框架设计、声明式与命令式的区别,但一直回避了一个核心问题:为什么改了 JavaScript 里的一个变量,页面上的 DOM 就能自动更新? 答案就在这一章。
我们先聊两个核心概念——副作用函数和响应式数据——然后一步步把它们搭起来,做出一个越来越完善的“迷你响应系统”。中间会遇到各种各样的小坑(无限递归、栈溢出、忘记清依赖……),我们会一边踩坑一边填坑,最后顺便把 computed 和 watch 也实现一遍。
4.1 响应式数据与副作用函数
要理解“响应系统”,得先理解两个名词:副作用函数 和 响应式数据。这两个词听起来很吓人,但其实非常日常。
副作用函数(side effect function):
函数的执行除了返回一个值之外,还偷偷影响了外面的世界(比如改了全局变量、修改了 DOM、往数据库写了一条记录)。这种“对外界产生影响”就叫副作用,这样的函数就是副作用函数。
响应式数据(reactive data):
当你读取它时,系统会记下“是谁在读”;当你修改它时,系统会把那些“读过它的代码”自动再跑一遍。
我们看一段最直观的代码:
function effect() {
document.body.innerText = `hello vue3`
}上面这个 effect 函数里干了啥?它把 document.body(整个网页的 <body> 元素)的文字内容改成了 hello vue3。这有什么副作用?
- 它没改任何“业务数据”,但是它改了
document.body.innerText——这相当于把网页上“用户能看到的文字”换了。 - 如果还有其他函数(比如另一个 effect)也想读
body.innerText,那它拿到的就是被effect改过的值。
也就是说,effect 函数执行之后,它对“外面世界”产生了影响,这个影响就是副作用。
副作用函数的范围远不止“改 DOM”这么简单,再比如下面这段:
let val = 1 // 一个全局变量
function effect() {
val = 2 // 修改全局变量,产生副作用
}val 是个全局变量,effect 函数把它从 1 改成了 2。这同样是一个副作用。
💡 一句话总结:副作用函数就是“它的执行不仅仅返回一个值,还会动到外面东西”的函数。
再看一段响应式数据的代码:
const obj = { text: `hello world` };
function effect() {
document.body.innerText = obj.text;
}effect 函数里读取了 obj.text,并把它显示到页面上。我们的愿望是:
当 obj.text 变化时,effect 函数能自动重新执行。
如果能做到这一点,那么 obj 就叫做响应式数据。但是,从上面的代码来看,我们还做不到这一点——因为 obj 只是一个普通的 JavaScript 对象,我们手动改它的字段,除了值本身变了,什么都不会发生。下一节我们就来想办法让数据“动起来”。
4.2 响应式数据的基本实现
接着上文思考:怎么才能让 obj 变成响应式数据呢?观察上面的代码,我们能发现两个关键线索:
- 当副作用函数
effect执行时,会触发字段obj.text的读取操作; - 当修改
obj.text的值时,会触发字段obj.text的设置操作。
如果我们能拦截一个对象的读取和设置操作,事情就变得简单了:
- 当读取字段
obj.text时,把副作用函数effect存到一个“桶”里(图 4-1); - 当设置
obj.text时,把副作用函数从“桶”里取出并执行(图 4-2)。

图 4-1 将副作用函数存储到“桶”中

图 4-2 把副作用函数从“桶”内取出并执行
现在问题的关键变成了:我们怎么才能拦截一个对象属性的读取和设置操作?
ES2015(也叫 ES6):
JavaScript 在 2015 年发布的大版本更新,加了好多新特性,包括 Proxy、Map、Set、箭头函数、class、Promise 等。这一节要用的 Proxy 就是 ES2015 才有的。
在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue 3 所采用的方式。本章只讲 Vue 3 的方案。
Proxy(代理):
可以理解成一个“中间人”。你拿不到原对象,所有对原对象的读写都先经过这个“中间人”,由它决定到底要不要放行、改不改值、加不加日志……这种“中间人”模式叫做代理。Vue 3 用 Proxy 来拦截 obj 的“读取”和“设置”操作,从而知道“谁在读 obj.text”“有没有人改 obj.text”。
接下来我们就根据上面的思路,用 Proxy 来实现:
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: `hello world` }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})我们逐行看:
const bucket = new Set():创建一个“桶”,用Set(一种“自动去重”的集合,存进去的值不会重复)来装副作用函数。Set是 ES2015 引入的。bucket一词在英文里就是“桶”的意思,你可以把它想象成一个通知名单。const data = { text: 'hello world' }:这是最原始的数据,所有真实信息都在这里,没有被任何“中间人”包裹。const obj = new Proxy(data, { ... }):用Proxy把data包装一下,obj就是data的“代理人”。从今往后,所有对obj的读写都会被“中间人”拦截。get(target, key) { ... }:这是“读取拦截器”。当代码试图读obj.xxx时,get这个函数会被自动调用,参数target是原始对象data,key是要读的字段名。bucket.add(effect):当读到任何一个字段时,就把effect函数扔进桶里。注意这里写死了effect这个名字,这其实是个 bug——副作用函数不一定叫effect。下一节会修。return target[key]:拦截完之后别忘了把真正的值返回回去,不然外部代码就读不到值了。set(target, key, newVal) { ... }:这是“设置拦截器”。当代码试图写obj.xxx = ...时,这个函数会被调用。target[key] = newVal:先把新值写到原始对象上(不然数据没改啊)。bucket.forEach(fn => fn()):把桶里所有副作用函数挨个跑一遍,这就是“通知所有依赖”。return true:在Proxy的set拦截器里,返回true才表示“设置成功”,否则会抛 TypeError。
我们可以用下面的代码来测试一下:
// 副作用函数
function effect() {
document.body.innerText = obj.text;
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = `hello vue3`;
}, 1000)在浏览器中运行上面这段代码,会得到期望的结果:先在页面写上 “hello world”,1 秒后自动变成 “hello vue3”,完全不用我们手动再调一次 effect。
⚠️ 目前的实现还有不少缺陷:比如我们硬编码了副作用函数的名字
effect,一旦你把函数改名叫myEffect或是匿名函数,这段代码就不能正确工作了。下一节我们会想办法去掉这种硬编码机制,把响应系统做得更通用一点。
4.3 设计一个完善的响应系统
先回头看一眼上一节的成果:其实我们已经搭出了一个能跑的响应系统。之所以只叫它“微型”,是因为它离真正能用的状态还差得远——它靠“猜”,不靠“记”。本节要做的,就是把这个“猜”换成“记”。
从上一节的例子中不难看出,一个响应系统的工作流程始终是这两步:
- 当读取操作发生时,将副作用函数收集到“桶”中;
- 当设置操作发生时,从“桶”中取出副作用函数并执行。
听起来只有两行字,但真正麻烦的地方全在“收集”这两个字上。上一节的实现里有个硬伤:它把副作用函数的名字 effect 写死在代码里了。这意味着——只要你把函数改名成 myEffect,或者干脆用个匿名函数,整个系统立刻失灵,因为“桶”根本不知道该把谁装进去。
💡 打个比方:上一节的响应系统就像超市广播找人的方式——广播里直接喊“请 effect 同学到服务台”。你换了名字,它就永远喊不到人。正确做法是让每个人自己签到,广播只喊“请所有已签到的人到服务台”。
所以我们需要一个“注册机制”:谁想加入响应系统,就自己来报个到,报名处会记住“此刻正在跑的是谁”。这就是下面这段代码干的事:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}effect 函数(“注册副作用函数”的那道门):
本身是个普通函数,作用是“接手”一个副作用函数,把它登记到响应系统里,然后当场就让它跑一遍。跑这一遍是关键——只有真的去读数据,get 拦截器才有机会知道“这个函数读过哪些字段”。这个“先跑一遍、把依赖探清楚”的过程,在英文里叫一次 re-run(重跑)。
比方说,effect(fn) 就像健身房的前台:“我先把你的名字写进今天的到课名单(activeEffect = fn),然后请你先去练一遍(fn())。”练一遍的过程里,前台就摸清了你今天要练哪几台器械——那就是“依赖收集”。
activeEffect(“当前激活的副作用函数”):
一个全局变量,用来存放“此刻正在运行的那个副作用函数”。它的存在让 get 拦截器不需要知道函数叫什么名字,只要问一句 activeEffect:“你现在手上拿着谁?”就行。
比喻:它就像老师点名册上的“正在批改的那一份”。全班可以有很多份作业在流转,但老师手上永远只有一份正在批的。activeEffect 记的就是“手上这一份”。
我们逐行看:
let activeEffect:let声明一个变量,初始值是undefined(“还没人报名”)。注释里说的“全局变量”指的是它写在所有函数外面、任何地方都能访问到。function effect(fn):定义effect函数,接收一个参数fn——fn是要注册的副作用函数。这里的fn是“函数的名字”,function是“定义一个函数”。activeEffect = fn:这句是整段代码的灵魂。把传进来的fn记在全局变量activeEffect上,等于在名册上写下“现在正在跑的是它”。注意它只赋值、不执行,所以后面任何地方只要问activeEffect,就能拿到“当前这个”。fn():fn后面这对小括号是“调用”的意思。调用函数会真正执行函数体里的代码。这里立刻执行一次,目的就是让fn去读数据,触发get拦截器,把依赖记下来。- ⚠️ 顺带一提:
fn是参数名,可以随便叫成effect/callback/f都行。这正是本节的改进点——名字不重要,能被记下来才重要。
我们可以按照如下所示的方式使用 effect 函数:
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text;
}
)这是一个匿名函数(anonymous function,指“没有名字的函数”)。() => { ... } 是箭头函数(arrow function)的写法,ES2015 才有的语法,可以理解成“一段没有名字的代码”,调用它时才执行里面的内容。
effect(...):把那个匿名箭头函数交给effect保管。() => { ... }:一个没有名字的函数体,里面读了obj.text。- 为什么必须用匿名函数也能工作? 因为
effect根本不看名字,它只看“传进来的那个函数本身”。这段代码能跑通,就证明硬编码函数名这个坑被填上了。
可以看到,我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数:
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 拦截器里那句写死的 bucket.add(effect),被换成了“先看看 activeEffect 是不是有人,再把它装进桶”。整个响应系统从此不再依赖任何函数名。
if (activeEffect):一道守卫(guard)——只有“确实有函数正在运行”时才去收集依赖。如果这段get是你在事件回调、定时器里随手读了一下obj(此时没有任何effect在跑),activeEffect就是undefined,跳过收集,程序不会出错。bucket.add(activeEffect):真正把“当前这个函数”记进桶里。set拦截器没动:它照样把桶里的函数挨个跑一遍,只是现在桶里装的是“谁读过”,而不是“名字叫effect的那个”。- ⚠️ 隐患仍在:
bucket只有一个,不分字段、不分对象。这正是 4.3 后半段要解决的问题。
如上面的代码所示,由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。换句话说:以前是“桶”去找函数要名字,现在改成“函数进门时先在名册上签名,桶只管照名册抄。”
不过别急着庆祝——我们来做个小测试,就知道这个改动还远远不够。
effect(
// 匿名副作用函数
() => {
console.log(`effect run`); // 会打印 2 次
document.body.innerText = obj.text;
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = `hello vue3`;
}, 1000)console.log('effect run'):副作用函数第一次跑的时候打印一次。注释里说“会打印 2 次”,意思是我们期望它总共只打印 1 次,但实际会打印 2 次——这个多出来的第 2 次,就是 bug 的现场。obj.text:读text字段,触发get,把自己记进桶。obj.notExist = 'hello vue3':给对象加一个全新的字段。注意:这个副作用函数从头到尾只读过text,从没读过notExist。setTimeout(..., 1000):等 1 秒再改,纯粹是为了让你看得清效果。
可以看到,匿名副作用函数内部读取了字段 obj.text 的值,于是匿名副作用函数与字段 obj.text 之间会建立响应联系。接着,我们开启了一个定时器,一秒钟后为对象 obj 添加新的 notExist 属性。我们知道,在匿名副作用函数内并没有读取 obj.notExist 属性的值,所以理论上,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。
💡 用大白话说这个 bug:你从来没订阅过这本书的“第 3 期预告”,结果每次邮局更新第 3 期预告,你的邮箱都会响。 因为在系统眼里“第 3 期预告”和“正文”是同一个书架上的东西,只要动了那一格,整格货架的客户全被惊动。
为了解决这个问题,我们需要重新设计“桶”的数据结构。
在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的“桶”。导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个 Set 类型的数据作为“桶”了。
【“桶”为什么要重新设计?】 打个更贴近的比喻:原来的 bucket 就像小区门口的一个大信箱。你订了《人民日报》,业务员顺手在信箱上贴了张纸条“此人订过报纸”。从此你订《时尚杂志》,业务员还是在同一张纸条上加一行。等你退了所有报纸,那张纸条还在;而且——你退掉《人民日报》之后,只要有人订了《时尚杂志》,业务员一看“这信箱有订报记录”,照样给你塞广告。
毛病出在粒度太粗:一张纸条混着记了所有订阅,既分不清“订的是哪一份”,也做不到“只取消其中一份”。所以正确做法是分格:每个对象一个抽屉,每个字段一个夹子,每个夹子里再列一份订阅名单。
那么“桶”到底该长成什么样?动手设计之前,先拿一段代码当“道具”,把它里藏着的角色一个个揪出来:
effect(function effectFn() {
document.body.innerText = obj.text;
})effect(function effectFn() { ... }):这一次我们给匿名函数起了名字effectFn,方便接下来在数据结构里指着它说话。功能上跟匿名版一模一样,只是多了个代号。obj.text:读的是“哪个对象的哪个字段”——这就给出了两个关键信息:对象 和 字段。- 💡 换句话说:要建立“精确联系”,就得同时知道“哪个对象” + “哪个字段” + “哪个函数”这三样东西。
在这段代码中存在三个角色:
- 被操作(读取)的代理对象
obj; - 被操作(读取)的字段名
text; - 使用
effect函数注册的副作用函数effectFn。
三个角色层层递进:obj 是“公司”,text 是“部门”,effectFn 是“这个部门的考勤表上记着的人”。所以“桶”必须能一层一层往下钻。
为了把这三层关系写进代码,我们给三个角色各起一个代号:target 代表“代理对象所代理的那个原始对象”,key 代表“被操作的那个字段名”,effectFn 代表“被注册进来的那个副作用函数”。有了这三个名字,就可以把上面那张树直接翻译成数据结构了:
target
└── key
└── effectFn这是一种树型结构,下面我们用生活中的比喻来描述一下:
- 第一层(
target)是仓库:每个被代理的原始对象就是一个独立的“仓库”。 - 第二层(
key)是仓库里的货架:仓库里有很多货架,每个 key 对应一个货架。 - 第三层(
effectFn)是订阅了某个货架的客户名单:谁读了这个货架上的东西,就把谁的名字记下来。
💡 三层为什么一层都不能省?“哪个仓库”决定了通知的范围(别去惊动隔壁仓库的人),“哪个货架”决定了通知的精度(只通知订了这期杂志的,别惊动订报纸的)。少了任何一层,通知都会“宁可错杀”。
比如对于 obj.text 来说,它对应的关系就是:
data ← 仓库 1
└── text ← 货架 1
└── effectFn ← 客户 A下面举几个例子来对它进行补充说明。
如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})那么关系如下:
target
└── text
├── effectFn1 ← 客户 A
└── effectFn2 ← 客户 B同一个货架上可以挂着多个客户 —— 谁读过 text,谁的名字就一起排进这张名单。
如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})那么关系如下:
target
├── text1
│ └── effectFn
└── text2
└── effectFn一个客户也可以同时订阅多个货架 —— 读过 text1 和 text2,两张名单上就都有它。
如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
console.log(obj1.text1);
})
effect(function effectFn2() {
console.log(obj2.text2);
})effectFn1:第一个副作用函数,读的是obj1.text1。effectFn2:第二个副作用函数,读的是obj2.text2,跟obj1毫无关系。
那么关系会变成:
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2注意这两棵小树是并列的,各挂各的仓库,谁也不干扰谁。
总之,这其实就是一个树型数据结构。这个联系建立起来之后,就可以解决前文提到的问题了。拿上面的例子来说,如果我们设置了 obj2.text2 的值,就只会导致 effectFn2 函数重新执行,并不会导致 effectFn1 函数重新执行。
✅ 所以上一节那个“动了 notExist 却误触发”的 bug,在这一节之后就消失了:因为 notExist 是另一条货架,它的客户名单是空的。
图纸画好了,现在动手盖房子。第一步:把桶本身换掉——用 WeakMap 来替掉之前那个扁平的 Set:
WeakMap(弱映射):
一种“键必须是对象的字典”,用 map.get(键) 拿值、map.set(键, 值) 存值。它跟 Map 最大的区别在于——它对键只是“弱引用”,不会阻止垃圾回收。
生活比喻:Map 像保险柜,你把钻石(对象)存进去登记一下,这颗钻石就永远不会被扔掉,因为保险柜还“抓着”它。WeakMap 像贴在别人家门上的便利贴——“张三家有备用钥匙”。哪天张三家搬走了、门牌没人了,你那张便利贴会跟着一起被清理掉,绝不会因为一张便利贴就把别人的房子永远钉在那儿。
响应系统为什么需要 WeakMap?因为桶里存的键是用户的原始数据对象。用户不用的数据,框架不该让它永远留在内存里。用 WeakMap 等于向引擎声明:“只有当 target 还在被人用着时,桶里的信息才有意义。”
// 存储副作用函数的桶
const bucket = new WeakMap()const bucket = new WeakMap():new是“新建一个实例”的关键字。这一行把原来的new Set()换成了new WeakMap()——“桶”整体换了个容器,从“一个扁平的名单”变成了“一本按对象分区的字典”。- ⚠️ 注意
WeakMap只能拿对象当键。后面代码里会出现bucket.get(target),这里的target是Proxy拦截器传进来的原始对象,是对象,用得没问题;但如果你想写bucket.get('text')就会直接报错。
然后修改 get / set 拦截器代码:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]
// 根据 target 从"桶"中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到"桶"里
deps.add(activeEffect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})这段 get 拦截器是整个响应系统最核心的一段代码,我们慢慢拆:
if (!activeEffect) return target[key]:没人读取登记就直接把值还回去。!activeEffect在activeEffect是undefined时为true。return之后不再往下走,省掉了一堆无意义的建桶操作。let depsMap = bucket.get(target):从“仓库字典”里翻出target这间仓库的“货架清单”。翻不到会是undefined。if (!depsMap) { bucket.set(target, (depsMap = new Map())) }:翻不到就当场开一间新仓库、装上空清单,再把清单存回去。这里的(depsMap = new Map())是赋值表达式写在括号里当参数用——一种 JS 小技巧,目的是“建好之后顺手让depsMap这个变量也指向它”,省掉多写一行。let deps = depsMap.get(key):从“货架清单”里翻出text这个货架的客户名单。if (!deps) { depsMap.set(key, (deps = new Set())) }:这排货架还没有人订过?那就新建一份空名单,登记到清单上。用Set是为了自动去重——同一个函数读同一个字段 100 次,名单上也只占 1 行。deps.add(activeEffect):核心动作——把“正在跑的函数”写进这份名单。从此“这个函数依赖这个字段”这件事就被记下来了。return target[key]:老老实实把真正的值返回出去。拦截 ≠ 拒绝,拦截器必须把活干完再放行。set里的if (!depsMap) return:这个对象从来没人读过,那自然也没人需要被通知,直接收工。读取时建立的关系,触发时才有意义。effects && effects.forEach(fn => fn()):&&(与)在这里当“非空判断”用——effects存在就执行后半句,不存在(undefined)就短路跳过。这比写if更紧凑,Vue 源码里到处都是这种写法。
depsMap(依赖映射表):
第二层容器,一个普通 Map。它回答的问题是——“这个对象身上,有哪些字段被依赖过?” 键是字段名,值是对应的依赖集合。
生活比喻:仓库里有本登记册(depsMap),登记册上每行写着“货架名 → 订这份货架的客户名单”。
从这段代码可以看出构建数据结构的方式,我们分别使用了 WeakMap、Map 和 Set:
WeakMap由target --> Map构成;Map由key --> Set构成。
deps(依赖集合,dep 是 dependency 的缩写):
最内层的那个 Set,装着“所有读过 obj[key] 的副作用函数”。可以理解成订阅了某个货架的客户名单。
Map 的键(字段名)和值(依赖集合)是一一绑定的,所以 depsMap 一层层把 target → key → effects 串了起来。
其中 WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。它们的关系如图 4-3 所示。

图 4-3 WeakMap、Map 和 Set 之间的关系
为了方便描述,我们把图 4-3 中的 Set 数据结构所存储的副作用函数集合称为 key 的依赖集合。
💡 所以完整的一个“桶”其实是这么一摞抽屉:
bucket (WeakMap)
├── data → depsMap 1 (Map)
│ ├── 'text' → deps 1 (Set) ← [effectFn]
│ └── 'foo' → deps 2 (Set) ← [effectFnA, effectFnB]
└── data2 → depsMap 2 (Map)
└── 'text2' → deps 3 (Set) ← [effectFn2]改动 data.text 时,只要沿着 data → 'text' 这一条路找下去就够了,别的抽屉一动不动。
搞清了它们之间的关系,我们有必要解释一下这里为什么要使用 WeakMap,这其实涉及 WeakMap 和 Map 的区别。我们用一段代码来讲解:
const map = new Map();
const weakmap = new WeakMap();
(function(){
const foo = {foo: 1};
const bar = {bar: 2};
map.set(foo, 1);
weakmap.set(bar, 2);
})()const map = new Map():Map可以用字符串、数字、对象当键,是最普通的“字典”。const weakmap = new WeakMap():WeakMap只能用对象当键,而且对键是弱引用。(function(){ ... })():这是一个立即执行的函数表达式(IIFE,Immediately Invoked Function Expression)。写法上最外层套了个括号,末尾紧跟一对()——意思是“这段函数刚写出来就立刻调用一次”。比喻一下:临时搭个棚子,放完东西就拆掉。const foo/const bar:两个各自独立的普通对象,它们会被当成map和weakmap的键。map.set(foo, 1):把foo登记进map,值为1。weakmap.set(bar, 2):把bar登记进weakmap,值为2。- 💡 关键点:这个立即执行函数一结束,
foo和bar这两个局部变量就没了、没人引用了。这时候就能看出两种容器的差别。
首先,我们定义了 map 和 weakmap 常量,分别对应 Map 和 WeakMap 的实例。接着定义了一个立即执行的函数表达式(IIFE)。在函数表达式内部定义了两个对象:foo 和 bar,这两个对象分别作为 map 和 weakmap 的 key。当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(garbage collector)不会把它从内存中移除,我们仍然可以通过 map 拿到对象 foo 的键。然而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除,并且我们无法获取 weakmap 的 key 值,也就无法通过 weakmap 取得对象 bar。
垃圾回收器(garbage collector,简称 GC):
浏览器会自动帮我们清理“已经没人用了”的内存。这个机制在 Java、Python、JavaScript 等带 GC 的语言里都有。简单说:没人引用的对象,GC 会自动清掉,不让它一直占着内存。
弱引用:一种特殊的引用方式。弱引用不会阻止垃圾回收——也就是说,如果某个对象只被“弱引用”指着,垃圾回收器会毫不犹豫地把它回收掉。普通的赋值、放进 Map 当 key,那都是“强引用”,被强引用指着的对象 GC 不会回收。
⚠️ 原文那句“通过
map.keys打印出对象foo”是口语化的简写,严格写要两步:map.keys()拿到一个迭代器,再展开或遍历才能看到键。console.log([...map.keys()])是最直接的一种。WeakMap则根本没有keys方法——这就是弱引用的代价:连“还剩哪些键”都问不出来。
💡 用上面的比喻收个尾:foo 放进了保险柜,保险柜死死抓着它,所以它永远不会被扔掉;bar 只是被一张便利贴提到,棚子一拆、门牌一摘,它就跟着垃圾一起被扫走了。
简单地说,WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。所以 WeakMap 经常用于存储那些“只有当 key 所引用的对象存在时(没有被回收)才有价值”的信息,例如上面的场景中:如果 target 对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。
内存溢出(memory leak / 泄漏):
该释放的内存没释放,堆(heap)越堆越大,页面越来越卡,最后浏览器直接崩溃。听起来很遥远,其实 Vue 的桶如果用 Map 而不是 WeakMap,就会实打实地踩到这个坑——用户随手 obj = null 丢掉的临时数据,会被响应系统一直死死抓着不放。
最后还有一步收尾工作。在当前这版实现里,“收集依赖”这摊事是直接塞在 get 拦截器里面的:get 一边要转发读取请求,一边还要建桶、翻桶、记账,职责混在一起。更好的做法是把这摊事单独抽出来,封装成一个叫 track 的函数——名字取 track 纯粹是为了表达“追踪”的意思。对称地,把“触发副作用函数重新执行”这摊事也抽出来,放进一个叫 trigger 的函数里:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
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()))
}
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}这一段是纯粹的“搬运”:原来塞在 get / set 拦截器里的那一大坨逻辑,被原封不动地搬进了两个独立函数。拦截器一下子从“几十行”瘦回到“3 行”,读起来清爽多了。
get里的track(target, key):把target和key递进去,让track自己去建桶、记名单。拦截器本身不再关心细节。set里的trigger(target, key):同理,改完值就喊一嗓子“该通知人了”,具体通知谁由trigger决定。track的四步走:get仓库 → 不存在就建Map→get货架 → 不存在就建Set→add记录。跟刚才一模一样。trigger的三步走:get仓库 → 没有就return→get货架 → 有就跑。- 💡 这么拆的价值:后面 4.4 到 4.11 全是在改
track和trigger这两个函数(加cleanup、加activeEffect守卫、加调度器),而get/set拦截器一次都不用再动。代码拆得干净,后面才好加戏。
如以上代码所示,分别把逻辑封装到 track 和 trigger 函数内,这能为我们带来极大的灵活性。
简单说一下这两个新名词:
track(追踪):
当 get 拦截器被触发时,调用 track 把“当前激活的副作用函数”收集到桶里。名字取的就是英文 track(跟踪、追踪)的意思——“一旦发现有人读这个数据,就跟踪一下它”。
比喻:图书馆办借阅卡。读者取书 = track,刷卡登记。 书是谁读的,系统记得清清楚楚。
trigger(触发):
当 set 拦截器被触发时,调用 trigger 把桶里的副作用函数挨个拉出来重新执行。名字取的是英文 trigger(触发)的意思——“数据变了,触发所有依赖过它的副作用重新跑”。
比喻:书被借走了,图书馆按登记名单挨个打电话通知——谁在追这本书,就告诉谁“该去还书了”。
到这一步,我们已经写出了一个“相对完善”的响应系统骨架。把这个骨架竖着连起来,就是响应系统的全部工作流。下面这张全景图把 4.4 到 4.8 会依次补上的零件都先摆上来(本章小数点后面的部分,就是一个个给这张图加零件的过程):
✅ 记住这条主线就够了:读 → track 记账;改 → trigger 翻账本找人;找到的人,先过调度器,再重跑。 4.4–4.11 全是在这条主线上“加保险”“加开关”。
4.4 分支切换与 cleanup
首先,我们需要明确分支切换的定义,如下面的代码所示:
const data = { ok: true, text: `hello world` };
const obj = new Proxy(data, { /* ... */ });
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : `not`;
})/* ... */:JavaScript 的块注释。/* 开始、*/ 结束,中间的 ... 是省略号,意思是“这里还有代码,为了例子简洁先不写出来”。它和真实代码在行为上完全等价。
const data = { ok: true, text: 'hello world' }:原始数据,一个开关加一段文字。new Proxy(data, { /* ... */ }):代理对象。第二个参数里的拦截器细节本章前面已经讲透了,这里用注释收起来。obj.ok ? obj.text : 'not':这是一个三元表达式(ternary expression,语法是条件 ? 值A : 值B)。读作“如果obj.ok是真的,就取obj.text;否则取字符串'not'”。- 💡 关键就在这个三元上:
obj.ok一变,程序走的就不再是同一条代码路径了。
在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok 值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。
分支切换(branch switching):
同一段代码在不同的时刻走了不同的那半边。生活比喻是走夜路上的岔路口——前面立个牌子写着“直行 / 右转”,牌子一旦翻面,你要走的那条路就变了。
分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值,所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之间建立的联系如下:
data
└── ok
└── effectFn
└── text
└── effectFn💡 用大白话复述一遍:effectFn 第一次跑的时候,obj.ok 是 true,于是它既读了 ok 又读了 text,所以它同时出现在两条货架的客户名单上。这没问题。
图 4-4 给出了更详细的描述。

图 4-4 副作用函数与响应式数据之间的联系
对照图 4-4 看:effectFn 这个名字,同时出现在 data.ok 和 data.text 两个字段各自的依赖集合里(一条货架一个名单,它俩的名字都记着呢)。可一旦把 obj.ok 改成 false 并让它重跑一遍,情况就变了——这次 obj.text 压根不会被读到,所以理应只有 obj.ok 那条名单还留着 effectFn,obj.text 的名单应该是空的。这正是图 4-5 画出来的理想状态。

图 4-5 理想情况下副作用函数与响应式数据之间的联系
但按照前文的实现,我们还做不到这一点。也就是说,当我们把字段 obj.ok 的值修改为 false,并触发副作用函数重新执行之后,整个依赖关系仍然保持图 4-4 所描述的那样,这时就产生了遗留的副作用函数。
【遗留的副作用函数】 也叫“冗余的依赖”。坑在这里:effectFn 重跑时因为 ok 是 false,根本没读 text——可 text 那条货架的客户名单里,它的名字还赖着不走。
生活比喻:你之前办了健身年卡,后来改成只买“单次卡”。可健身房墙上那张“年卡用户名单”上你的名字从来没被擦掉,于是你每次路过单次卡的告示栏,广播也会喊你的名字。
遗留的副作用函数会导致不必要的更新,拿下面这段代码来说:
const data = { ok: true, text: `hello world` };
const obj = new Proxy(data, { /* ... */ });
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : `not`;
})obj.ok 的初始值为 true,当我们将其修改为 false 后:
obj.ok = false;obj.ok = false:改开关。这会触发trigger,把“订阅了ok的所有函数”(此刻就是effectFn)拉出来重跑。- 重跑后会发生什么?
ok已经是false,三元表达式走:那一半,页面被写成'not',而且全程没有碰过obj.text。
这会触发更新,即副作用函数会重新执行。但由于此时 obj.ok 的值为 false,所以不再会读取字段 obj.text 的值。换句话说,无论字段 obj.text 的值如何改变,document.body.innerText 的值始终都是字符串 not。所以最好的结果是,无论 obj.text 的值怎么变,都不需要重新执行副作用函数。但事实并非如此,如果我们再尝试修改 obj.text 的值:
obj.text = `hello vue3`;这仍然会导致副作用函数重新执行,即使 document.body.innerText 的值不需要变化。
⚠️ 这就叫“白跑一趟”:页面反正会显示
not,把text改成什么都不会有任何视觉变化,但系统还是老老实实重跑了一次、重新往页面上写了一遍一模一样的'not'。这类“改了数据但界面其实没变”的开销,就是遗留依赖带来的浪费。放到真实项目里,这种浪费会被放大成百上千倍。
解决这个问题的思路很简单:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,如图 4-6 所示。

图 4-6 断开副作用函数与响应式数据之间的联系
副作用函数跑完之后会重新建立联系——而这一次建立起来的新名单里,不会再有那些遗留的函数,也就是图 4-5 描述的那副样子。所以只要能做到“每次副作用函数执行之前,先把它从所有相关联的依赖集合中移除”,前面那个问题就彻底解决了。
💡 思路转成大白话就是先注销旧订阅,再订阅新的一份。相当于“退掉年卡,把名字从旧名单上擦干净,再办一张单次卡”——这样单次卡的告示栏上就再也不会喊到你了。
但这里立刻冒出一个新麻烦:要把自己从“所有与之关联的依赖集合”里删掉,前提是知道那些集合都是谁。可现在 effectFn 只知道自己读过 text,它并不知道“装着自己的那个 Set”到底摆在哪张桌子上。
【双向记账】 比喻一下记账的必要性:图书馆现在做的是单向记账——书架那边记了“谁借走了”,可读者手里没有“我借了哪些书”的条子。单向记账的问题是:想销号时,你不知道该去哪几个柜台销。只有双向记账(书架记读者、读者也记书架),销号才干净利落。
可要真去执行这个“移除”动作,光有决心没用——你得先知道:到底有哪些依赖集合装着自己? 目前的 effectFn 压根没这份情报,所以必须把副作用函数重新设计一遍。下面是改造后的代码:在 effect 内部我们另外定义了一个新的 effectFn 函数,并给它挂上了一个叫 effectFn.deps 的属性;这个属性是一个数组,专门用来存放“所有包含着当前这个副作用函数的依赖集合”:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}effectFn.deps:这是一个挂在函数身上的属性。JS 里函数也是对象,可以随便往上挂东西——这招叫“给函数加成员”。deps 是 dependencies 的缩写,意思是“依赖清单”。
💡 比喻:这个数组就是读者手里的借书条。“我借过 001 号书架、也借过 007 号书架”——拿着这条子,就知道自己该去哪些柜台销号。
const effectFn = () => { ... }:注意这里多包了一层。fn是用户写的真正副作用函数,effectFn是 Vue 自己造的一个“壳”。多包这一层的原因只有一个:壳归 Vue 管,Vue 想往上挂什么就挂什么(马上要挂deps);用户写的fn我们不敢乱动。activeEffect = effectFn:赋的是壳,不是fn。这很关键——接下来track记下的是effectFn,cleanup删的也是effectFn,必须跟桶里存的完全是同一个东西,否则销号销不掉。effectFn.deps = []:给壳挂上一个空数组当借书条。初始必须是空数组,因为此刻它还没读过任何数据。effectFn():包完壳、挂好牌,立刻跑一次。这一次跑完,deps数组里就装满了它读过的那些依赖集合。
那么 effectFn.deps 数组中的依赖集合是如何收集的呢?其实是在 track 函数中:
function track(target, key) {
// 没有 activeEffect,直接 return
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()));
}
// 把当前激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps); // 新增
}- 前四步跟之前一模一样:找仓库 → 不存在就建
Map→ 找货架 → 不存在就建Set。 deps.add(activeEffect):把函数记到书架那边(书架记读者)。activeEffect.deps.push(deps)(🆕 本节新增):把这份书架名单也记到函数那边(读者记书架)。两个方向都记上了,销号才有路可走。- 💡 为什么这里用数组而不是
Set?因为数组允许重复——同一个函数读同一个字段 10 次,deps那边因为Set去重只有 1 条,但deps数组这边会老老实实推 10 次。
如以上代码所示,在 track 函数中我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到 activeEffect.deps 数组中,这样就完成了对依赖集合的收集。图 4-7 描述了这一步所建立的关系。

图 4-7 对依赖集合的收集
💡 这张图要读懂的是双向的两支箭头:上面一支 activeEffect → deps(函数记书架),下面一支 deps → activeEffect(书架记函数)。两支箭头缺一支,销号就废了一半。
双向的联系一旦建起来,事情就好办了:以后每次副作用函数要执行之前,都可以顺着 effectFn.deps 把所有相关联的依赖集合翻出来,然后挨个把自己从里面删掉:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn); // 新增
activeEffect = effectFn;
fn();
}
effectFn.deps = [];
effectFn();
}cleanup(清理、销号):
一个专门负责“把自己从所有旧订阅里退干净”的函数。
生活比喻:进健身房前先把上一张卡的签到记录抹掉。
cleanup(effectFn);(🆕 本节新增):这一行的位置是本节的关键。 它被放在activeEffect = effectFn和fn()的前面——意思是“这次重跑开始前,先把上次留下的痕迹擦干净”。顺序要是反了(比如放到fn()后面),就变成“跑完再擦”,新建立的依赖会被自己擦掉。activeEffect = effectFn; fn();:擦干净之后,重新标记“正在跑的是我”,然后真正去跑用户函数。这次它读到什么、就重新记什么——记出来的就是“当前真实的依赖”。
下面是 cleanup 函数的实现:
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn);
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0;
}function cleanup(effectFn):接收一个参数effectFn,也就是“要销号的那个函数”。for (let i = 0; i < effectFn.deps.length; i++):普通for循环,从下标 0 走到数组长度。一个一个把借书条上的柜台挨个走一遍。(不用forEach是因为接下来要在循环体里改deps本身,而不是遍历deps。)const deps = effectFn.deps[i]:取出这条记录——“我在某个货架上留过名”。deps.delete(effectFn):销号的实际动作:回到那个货架,把自己的名字从客户名单上划掉。Set有delete方法,删掉之后这一格就不再通知它了。effectFn.deps.length = 0:把借书条清空。注意这里写的是length = 0而不是= []——直接改数组的“长度”属性,比重新赋值一个新数组更省事,也不会让别的代码持有旧引用。- 💡 串起来看一遍:
cleanup走完的效果就是**“退干净”**——货架那边没名字了,自己手里也没条子了。等fn()重新跑完、track再跑一遍,借书条上只会记上这一次真正读过的货架。遗留依赖就是这样被消灭的。
cleanup 函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps 数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置 effectFn.deps 数组。
✅ 至此,“分支切换留下冗余依赖”这个 bug 修好了。但别高兴太早——把 cleanup 接进 effectFn 之后,系统会立刻冒出一个新问题,而且是那种一运行就卡死浏览器的问题。往下看。
至此,我们的响应系统已经可以避免副作用函数产生遗留了。但如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题出在 trigger 函数中:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn()); // 问题出在这句代码
}const effects = depsMap.get(key):取出这个字段的客户名单。effects.forEach(fn => fn()):挨个请它们重跑。问题就出在这儿——forEach遍历的是effects自己,而被请来重跑的函数第一件事就是cleanup,它会把自己从effects里delete掉。
为什么会这样?答案就藏在 trigger 函数里。它会遍历 effects 这个 Set 集合,里面装的都是副作用函数;可每个函数一被执行,就会先调 cleanup 做清除——说白了就是把自己从 effects 集合里划掉。麻烦的是,执行完清理之后它马上又会被重新收集回这个集合,而这时候对 effects 的遍历还没结束。这个“边遍历边删、删完又加回来”的行为,可以用一个更短的代码来单独演示:
const set = new Set([1]);
set.forEach(item => {
set.delete(1);
set.add(1);
console.log(`遍历中`);
})new Set([1]):Set构造函数可以直接接收一个数组,把里面的元素装进去。所以这个集合里只有一个数字1。set.forEach(item => { ... }):forEach是“遍历集合”的惯用写法,回调收到当前元素。set.delete(1):把1删掉。集合此刻空了,forEach觉得“遍历完了”,正要收工……set.add(1):可你紧接着又把它加回来了!集合里又有1了。console.log('遍历中'):打印。- 💡 比喻:
forEach是个点名册老师,正一个个点名。它念到“1 号”时,1 号同学中途退学又被插回队尾——老师一看“名单上还有人”,就接着往下念。于是这个班永远点不完名。
在上面这段代码中,我们创建了一个集合 set,它里面有一个元素数字 1,接着我们调用 forEach 遍历该集合。在遍历过程中,首先调用 delete(1) 删除数字 1,紧接着调用 add(1) 将数字 1 加回,最后打印 遍历中。如果我们在浏览器中执行这段代码,就会发现它会无限执行下去。
⚠️ 顺带一提:真跑起来浏览器的标签页会直接“未响应”,因为
forEach是同步阻塞的,它会一直转下去,你连关闭按钮都点不动。
这件事在语言规范里有明确条文:用 forEach 遍历 Set 时,如果某个值已经被访问过,但它中途被删除又重新添加回集合,而此时遍历还没结束,那么这个值会被重新访问一遍。所以上面那段代码才会无限转下去。解法也很简单:另造一个 Set,遍历那个新的:
const set = new Set([1]);
const newSet = new Set(set);
newSet.forEach(item => {
set.delete(1);
set.add(1);
console.log(`遍历中`);
})const newSet = new Set(set):这就是解法的全部——复制一份。new Set(另一个集合)会造出一个内容相同但彼此独立的新集合。改原集合,跟新集合再没有任何关系。newSet.forEach(...):forEach现在遍历的是副本。原集合里删了又加、翻了天,副本根本不知情,名单上始终就那一个1,点完就散。- 💡 比喻:点名的时候拿复印件念。原件上的人跑了又回来,复印件上的人次次都在,点一次就结束。
这下就不会无限执行了。回到我们的 trigger 函数,同样的招数也得用上:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects); // 新增
effectsToRun.forEach(effectFn => effectFn()); // 新增
// effects && effects.forEach(effectFn => effectFn()); // 删除
}const effectsToRun = new Set(effects)(🆕 新增):通知之前先复印一份客户名单。这份effectsToRun就是“要通知谁”的快照。effectsToRun.forEach(effectFn => effectFn())(🆕 新增):只遍历这份快照。每个被叫来的函数都会cleanup把自己从原名单里划掉、然后又被track加回原名单——但这些动作全发生在复印纸上,复印纸纹丝不动,所以遍历稳稳当当走完一遍。- 被注释掉的那行:旧写法直接遍历
effects自己,那就是无限循环的根源,现在被原样删掉(注释保留在代码里,方便你对照前后差别)。 - 💡 换个角度理解:
cleanup打了个“快照”补丁,让“边遍历边改集合”这件事变得安全。这是响应系统里很典型的一招——要遍历一个会被自己修改的集合时,先备份。
改动看代码就一目了然:我们新造了一个 effectsToRun 集合,并且改成遍历它,不再直接遍历 effects 那个原件——这么一转,无限执行的问题就消失了。
提示
ECMA 关于
Set.prototype.forEach的规范,可参见 ECMAScript 2020 Language Specification。
4.5 嵌套的 effect 与 effect 栈
effect 是可以发生嵌套的,例如:
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ });
/* ... */
})effect(function effectFn1() { ... }):外层函数,里面装着另一个effect。effect(function effectFn2() { ... }):内嵌在effectFn1体内的另一个effect。写代码时它就缩进在effectFn1里面——“写在谁里面,就是谁的子任务”。- 💡 比喻:
effectFn1是“装修队队长”,effectFn2是“队长喊进来的油漆工”。队长干活,干到一半会叫油漆工进来一起干。
在上面这段代码中,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。那么,什么场景下会出现嵌套的 effect 呢?拿 Vue.js 来说,实际上 Vue.js 的渲染函数(render function)就是在一个 effect 中执行的:
渲染函数(render function):
组件里那个“拿到数据、算出界面长什么样”的函数。它读了你模板里写的那些数据,把它们加工成一块新的界面。你写 <div>{{ count }}</div>,背后就有一个 render 在读 count、拼字符串、生成界面。
// Foo 组件
const Foo = {
render() {
return /* ... */
}
}const Foo = { ... }:一个普通对象,当作组件用。Vue 组件本质上就是“一个带render方法的对象”。render() { ... }:这个方法是Foo组件的渲染函数。return /* ... */的意思是“返回一块界面”,具体内容为了简洁省略掉了。
在一个 effect 中执行 Foo 组件的渲染函数:
effect(() => {
Foo.render()
})effect(() => { ... }):把“调用渲染函数”这件事包成副作用函数交给响应系统。Foo.render():真正干活的那一下。Vue 正是靠这一行实现“数据变了 → 界面自动重画”。
当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}<Bar />:这是 JSX 语法——一种让你可以在 JS 文件里直接写标签的写法,需要构建工具帮忙编译成普通的函数调用。本章只关心它带来的结果:Bar.render()被调用了。- 💡 关键链条:
Foo.render()跑到一半,发现要渲染Bar→ 调用Bar.render()。外层渲染函数还没结束,内层渲染函数就开跑了。 这就是“渲染函数嵌套在渲染函数里”,也就是effect嵌套effect。
此时就发生了 effect 嵌套,它相当于:
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
});
})- 中间那个内联的
effect(() => { Bar.render() })就是把“嵌套”这件事摆到明面上——它等价于Foo.render()在执行途中悄悄做的那件事。 - ✅ 所以
effect必须支持嵌套,这不是“万一有”,而是 Vue 组件树的天然结构决定的:有父子组件,就必然有内外层渲染函数。
这个例子正好解释了:为什么 effect 非要设计成可以嵌套的。 不过光知道“能嵌套”还不够,我们得先搞清楚反面:如果 effect 不支持嵌套,会出什么事? 按前面几节的介绍和实现来看,我们现在这套响应系统恰恰就不支持嵌套。写段代码实测一下:
// 原始数据
const data = { foo: true, bar: true };
// 代理对象
const obj = new Proxy(data, { /* ... */ });
// 全局变量
let temp1, temp2;
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log(`effectFn1 执行`);
effect(function effectFn2() {
console.log(`effectFn2 执行`);
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar;
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo;
})const data = { foo: true, bar: true }:两个原始字段。let temp1, temp2:两个全局变量,纯粹用来“接住”读到的值,目的是逼着这两个读取动作真的发生。effect(function effectFn1() { ... }):外层。它先打印一行,然后在里面注册effectFn2,最后自己读obj.foo。effect(function effectFn2() { ... }):内层。它打印一行,读obj.bar。- ⚠️ 注意执行顺序!
effectFn2的注册写在effectFn1里、排在temp1 = obj.foo前面。所以真实顺序是:effectFn1 打印→effectFn2 打印→ 读bar→ 回到effectFn1读foo。内层先结束,外层后结束。
这段代码里,effectFn1 内部套着 effectFn2,effectFn1 一跑就必然带着 effectFn2 一起跑。有两个细节值得特别留意:obj.bar 是在 effectFn2 里面读的,obj.foo 是在 effectFn1 里面读的;而且 effectFn2 的执行发生在读 obj.foo 之前。如果一切正常,我们希望副作用函数与对象属性之间长成这样:
data
└── foo
└── effectFn1
└── bar
└── effectFn2💡 这就是期望的账本:foo 归 effectFn1,bar 归 effectFn2。各记各的账,一个都不多。
按这张期望的账本走:改 obj.foo 应当叫醒 effectFn1;因为 effectFn2 是嵌在 effectFn1 里面的,所以会间接把 effectFn2 也带起来;而改 obj.bar 时,则只该叫醒 effectFn2 一个。可实际跑起来完全不是这么回事——我们试着改一下 obj.foo,控制台打出来的是:
`effectFn1 执行`
`effectFn2 执行`
`effectFn2 执行`💡 对照着看:前两行是首次执行的打印,都正常。我们改的是 obj.foo,按理说该看到 effectFn1 执行——可实际打印出来的偏偏是 effectFn2 执行。
一共打印三次,前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的,问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。
⚠️ “报了 A 的账,却喊来了 B”——最糟的还不是“喊错人”,而是
foo那一格货架上一个订阅者都没有,等于foo彻底失聪,改它永远没反应。
病根在哪儿?其实就落在我们写的 effect 函数和 activeEffect 这两个东西上。把那段代码再拿出来端详端详:
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
fn();
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}let activeEffect:只有一个格子。任何时候它只能记住一个函数。activeEffect = effectFn; fn();:给“当前激活”赋值之后,立刻就跑用户函数。跑的过程中track一路问activeEffect是谁,答案永远是刚才那个effectFn——看起来没问题,前提是永远没有嵌套。
activeEffect 是个全局变量,用来存放“通过 effect 函数注册进来的副作用函数”——这就意味着同一个时刻,activeEffect 里只可能装得下一个函数。一旦副作用函数发生嵌套,内层的函数一执行就会把 activeEffect 的值覆盖掉,而且永远不会再变回原来的那个。接下来要是再有响应式数据来收集依赖,哪怕这条数据其实是在外层函数里读的,收进去的却都会是那个内层函数。这就是问题所在。
💡 用“报数”来演示就一目了然了:一屋子人挨个报数,主持人手里只有一块小白板(activeEffect),谁报就把谁的名字写上去。
一个格子装不下“当前”和“外层当前”这两层意思。 修法有两条:一是每次都擦回去;二是多准备几个格子摞起来。Vue 选了第二条。
为了解决这个问题,我们需要一个副作用函数栈(effectStack)。在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如以下代码所示:
栈(stack):
一种“先进后出”的数据结构,就像一摞盘子——最后放上去的最先被拿出来。函数调用本身就是一个栈:fn1 调 fn2,fn2 调 fn3,那 fn3 一定是先执行完才回到 fn2,再回到 fn1。
💡 本节用数组来模拟栈,因为 JS 数组自带 push(放到最上面)和 pop(从最上面拿一个)这两个方法,天生就是栈语义。
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;
// effect 栈
const effectStack = []; // 新增
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn); // 新增
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}const effectStack = [](🆕 新增):一个空数组当栈用,约定数组末尾就是栈顶。effectStack.push(effectFn)(🆕 新增):进栈。在自己正式干活之前先“签到入栈”——这样栈顶此刻就是“我”。fn():真正执行用户函数。内层effect就是在这行执行过程中被调用的,所以栈会变成“外层 + 内层”两层。effectStack.pop()(🆕 新增):出栈。自己干完了,把自己从栈里撤走。activeEffect = effectStack[effectStack.length - 1](🆕 新增):还原。length - 1是数组的最后一个下标——也就是新的栈顶。内层一退出,外层立刻自动回到“当前激活”的身份。 这是整个修复的灵魂一行。- ⚠️ 顺序很重要:
pop()必须先执行,然后才能拿新栈顶。写反了就还是错。
我们用 effectStack 数组来模拟一个栈。activeEffect 本身没什么变化,它依旧指向“当前正在执行的那个副作用函数”;真正变化的是——当前执行的副作用函数会被压到栈顶。这样一来,副作用函数一旦发生嵌套,栈底放的就是外层函数,栈顶放的就是内层函数,如图 4-8 所示。

图 4-8 副作用函数栈
内层副作用函数 effectFn2 一旦执行完毕,它就会被弹出栈,同时外层副作用函数 effectFn1 会被重新设置为 activeEffect,如图 4-9 所示。
💡 用“套娃”再重画一遍,报数问题立刻消失——修法就是多备几个格子摞起来,让 activeEffect 永远跟着栈顶走:

图 4-9 副作用函数从栈中弹出
💡 看完图 4-8 再看图 4-9,就明白为什么要多写那两行了:4-8 是“进去了”,4-9 是“出来了”。只有进没有出,栈就会越堆越高,activeEffect 永远停在最后一个函数上。
如此一来,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。
✅ 回到那个出错的例子:现在改 obj.foo,foo 那格货架上记着的是 effectFn1(正确),触发它重跑;effectFn1 又顺手把 effectFn2 重新跑一遍——正是期望的结果。
4.6 避免无限递归循环
如前文所说,实现一个完善的响应系统要考虑诸多细节。而本节要介绍的无限递归循环就是其中之一,还是举个例子:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(() => obj.foo++);const data = { foo: 1 }:原始数据,一个数字字段。new Proxy(data, { /* ... */ }):照旧代理。effect(() => obj.foo++):注册一个副作用函数,函数体只有一个自增操作。++是“加一”运算符。- 💡 看着人畜无害,但它同时读
obj.foo(为了算出新值)和写obj.foo(把新值写回去)——而这两件事都在同一个副作用函数里。
可以看到,在 effect 注册的副作用函数内有一个自增操作 obj.foo++,该操作会引起栈溢出:
Uncaught RangeError: Maximum call stack size exceededUncaught RangeError:报错前缀,Uncaught = “没被捕获”,RangeError = “数值范围错误”。Maximum call stack size exceeded 的意思是“调用栈超出最大容量”。
栈溢出(stack overflow):
函数一层套一层地调用自己,每一层都要占一格内存,格子用完了就崩。
💡 比喻:俄罗斯套娃一直往里塞,塞到第 1000 层纸太厚了,装不进盒子了。obj.foo++ 就是那个“一直往里塞”的动作。
为什么会这样呢?接下来我们就尝试搞清楚这个问题,并提供解决方案。
实际上,我们可以把 obj.foo++ 这个自增操作分开来看,它相当于:
effect(() => {
// 语句
obj.foo = obj.foo + 1;
})obj.foo = obj.foo + 1:这一行其实是两个动作挤在一行。赋值号=右边的obj.foo + 1必须先算出来,算的时候就要读obj.foo;等号左边又要写obj.foo。- 💡 比喻:这就像“先看一眼温度计,再把温度调到读数 +1”——看和调,在同一个动作里完成。
这一行里读和写都发生了——obj.foo 的值既被读了,也被写了——这正是问题的根本原因。我们试着把执行流程一步步推演一遍:先是读取 obj.foo 的值,这会触发 track 操作,把当前这个副作用函数收进“桶”里;接着把读到的值加 1,再赋值回 obj.foo,这会触发 trigger 操作,也就是从“桶”里把副作用函数取出来执行。问题就在这儿——这个副作用函数自己还正在执行中、根本没收尾,就要开始下一轮执行了。这样它会无限递归地调用自己,于是就产生了栈溢出。
把整个过程按“一层套一层”的样子画出来,问题一目了然:
⚠️ 注意每一层都是**“自己没跑完,又叫自己去跑一遍”**。这跟 4.4 那个
forEach的死循环不一样——那是顺序死循环(永远转同一个圈),这是嵌套死循环(圈套着圈)。所以只靠 4.4 的“复印名单”救不了它。
解决办法并不难。通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是 activeEffect。基于此,我们可以在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行,如以下代码所示:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) { // 新增
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach(effectFn => effectFn());
// effects && effects.forEach(effectFn => effectFn());
}const effectsToRun = new Set()(本节改动):这次快照集合是空的,要先从客户名单里筛一遍再往里装。上一节是“整份复印”,这节是“边筛边复印”。effects.forEach(effectFn => { ... }):挨个检查每个订阅者。if (effectFn !== activeEffect)(🆕 本节新增):这道守卫是整个修复的核心。 逐字念一遍:“如果要叫的这个函数 != 此刻正在跑的那个函数,才把它加进待执行名单。”!==是“不等于”。effectsToRun.add(effectFn):通过了筛查,才算“合格的通知对象”。effectsToRun.forEach(effectFn => effectFn()):只喊合格的人。effectFn自己被拦下了,所以永远不会自己叫自己。- 💡 生活比喻:点名的时候,如果台上的那位正在讲话,就先不点他的名(点了也没人应)。等他自己讲完、下次上台时再点。
- ⚠️ 这个守卫是有代价的:它意味着“一个函数在运行时改自己依赖的数据,这次改动不会触发它自己重跑”。Vue 接受这个代价,因为它换来的是“不会崩浏览器”。
这样我们就能够避免无限递归调用,从而避免栈溢出。
✅ 验证一下:obj.foo++ 跑的时候,activeEffect 就是 effectFn 自己,trigger 一看“要叫的人就是我自己”,直接跳过。于是这个函数只会执行一次,foo 停在 2,函数正常返回。
4.7 调度执行
可调度性是响应系统非常重要的特性。首先我们需要明确什么是可调度性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
可调度性(schedulability):
框架把“要不要跑、什么时候跑、跑几遍”的决定权交给你。 生活比喻是点外卖——你不用自己冲进厨房,而是告诉商家“30 分钟后送到”(时机)、“一次别送两份”(次数)、“放前台就行”(方式)。
没有可调度性的话,响应系统只能在“数据一变就立刻、立刻、马上重画”这一个模式里打转。而真实应用需要的是批量:一次点击里改了 10 个数据,那就重画 1 次,不是 10 次。
首先来看一下,如何决定副作用函数的执行方式,以下面的代码为例:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(() => {
console.log(obj.foo);
})
obj.foo++;
console.log(`结束了`);effect(() => { console.log(obj.foo) }):注册副作用函数,它只干一件事——把obj.foo读出来打印。注意它没有赋值任何东西,所以不会触发 4.6 那个死循环。obj.foo++:foo从1变成2。这一句会触发effectFn立刻重跑,打印出2。- **
console.log(\结束了`)**:主流程继续往下走。反引号(````)包起来的部分是字符串,和单引号、双引号的作用一样。 - 💡 关键问题来了:
obj.foo++这一句是同步跑完的(trigger里写的是effectFn(),不是setTimeout),所以打印2必然发生在打印结束了之前。
在副作用函数中,我们首先使用 console.log 语句打印 obj.foo 的值,接着对 obj.foo 执行自增操作,最后使用 console.log 语句打印 结束了。这段代码的输出结果如下:
1
2
'结束了'💡 三行分别对应:① 首次执行副作用函数,打印 foo 的初值 1;② obj.foo++ 同步触发重跑,打印新值 2;③ 主流程走到最后,打印 结束了。“结束了” 排在 2 后面,因为我们目前是“立刻重跑”模式。
💡 输出里那些引号是 console.log 打印字符串时自己加上的,你不用写。
现在假设需求有变,输出顺序需要调整为:
1
'结束了'
2💡 意思就是:界面(2)要等主流程跑完(结束了)之后才更新。
从这串打印很容易想到一个土办法:把 obj.foo++ 和 console.log('结束了') 这两句的位置对调一下就行。可问题是——有没有办法一行代码都不改,就把这个需求实现掉? 这就需要响应系统支持调度了。
💡 需求换成大白话:“数据变了,先让我把当前这段代码跑完,回头再更新界面。” 这才是真实场景的常态——你在同一个事件回调里连着改 5 个数据,界面应该等这 5 个都改完再统一刷新一次。
那就把 effect 函数升级一下:给它加一个叫 options 的选项参数,让用户可以从里面指定自己的调度器:
effect(
() => {
console.log(obj.foo);
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)options(选项):effect 新增的第二个参数,用来“捎带”一些开关和回调。本节加了两样:scheduler(调度器)和后面 4.8 的 lazy(懒执行)。比喻:点外卖时的备注栏——主食不变,备注写着“送到楼下前台”。
effect(函数, 第二个参数):现在effect收两样东西了。主函数照旧,第二项是一个“备注对象”。{ scheduler(fn) { ... } }:备注栏里的第一项——scheduler(调度器)。scheduler(fn):注意它接收一个参数fn,也就是“待执行的副作用函数”。框架不直接跑fn,而是把fn交给你的scheduler,由你决定什么时候、怎么用。这就是“把控制权交出去”。// ...:现在还是空的,等下几节再填。
上面这段代码的意思很清楚:用户在调用 effect 注册副作用函数时,可以顺手传第二个参数 options——它是个对象,里面允许写一个 scheduler 调度函数。不过光收下还不够,effect 函数内部必须把这份 options 挂到对应的副作用函数身上,否则 trigger 根本摸不着它:
💡 为什么要“挂到 effectFn 上”?因为 trigger 只认得 effectFn,它看不见别人调用 effect 时传了些什么。东西必须挂在函数身上,才能一起传过去。 比喻:外卖备注得写在订单上,配送员才看得到。
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
// 将 options 挂载到 effectFn 上
effectFn.options = options; // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}function effect(fn, options = {}):多了第二个参数options,默认值是空对象{}。参数位置上的=表示“默认值”——调用者不传时它就是{},传了就用传的。- 💡 默认给
{}而不是留undefined,是为了后面写options.scheduler时不会报“读 undefined 的属性”这种错。这招叫“兜底默认值”。
- 💡 默认给
effectFn.options = options;(🆕 新增):本节唯一的实质改动。 把“备注对象”整个挂到壳函数上。- 💡 为什么挂在这里、而不是塞进某个变量?因为只有挂在函数身上的东西,才能跟着这个函数一起被
trigger拿到。关在别处的变量出了作用域谁也看不见。
- 💡 为什么挂在这里、而不是塞进某个变量?因为只有挂在函数身上的东西,才能跟着这个函数一起被
- 其余部分(
cleanup、压栈、弹栈、deps)一个字都没改——这就是好设计的好处:加能力不必动老代码。
调度器到手了。接下来 trigger 在通知副作用函数重跑时,就可以直接调用用户传递过来的那个调度器函数,把控制权原原本本地交还给用户:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) { // 新增
effectFn.options.scheduler(effectFn); // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn(); // 新增
}
});
}effectsToRun.forEach(effectFn => { ... }):这里换成了带代码块的箭头函数(外面套{}),因为里面要写if...else,光写个表达式装不下。if (effectFn.options.scheduler)(🆕 新增):“这个函数自带备注吗?” 逐字念:先看effectFn身上挂的options,再看里面有没有scheduler这一项。effectFn.options.scheduler(effectFn)(🆕 新增):注意传了自己的参数进去——trigger不执行effectFn,它只把effectFn交出去。scheduler拿到的那个fn就是这个待执行的副作用函数。else { effectFn(); }(🆕 新增):没写备注的老用户,行为一个字都不变。 这很关键:新功能对老代码零影响。- 💡 打个比方:以前
trigger是“发号员”,号码喊出来你必须马上到。现在它变成了“叫号服务”:有备注的(scheduler)按备注来,没备注的(else)还是老规矩立刻到。
看代码就懂了:在 trigger 触发副作用函数执行的那一刻,我们优先检查这个副作用函数身上有没有调度器——有的话就直接调用它,并把当前这个副作用函数当作参数传过去,之后怎么跑全由用户说了算;没有的话,保持原样直接执行,也就是我们之前默认的那套行为。
有了这些基础设施之后,我们就可以实现前文的需求了,如以下代码所示:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(
() => {
console.log(obj.foo);
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn);
}
}
)
obj.foo++;
console.log(`结束了`);scheduler(fn) { setTimeout(fn) }:本节最短、也最关键的一个调度器,它只做一件事——把fn塞进一个宏任务(定时器)里,让它“等当前代码跑完再说”。setTimeout(fn):setTimeout是浏览器提供的定时器 API,参数 1 是要执行的函数,参数 2 是延时的毫秒数。这里省略了参数 2,等价于setTimeout(fn, 0)——“0 毫秒之后”,但“之后”到底隔多久由浏览器说了算(通常在当前这段同步代码全部跑完之后)。- 💡 比喻:不是“马上到”,而是“排到下一班车”。当前这趟车(同步代码)先开走,你坐下一班(宏任务)走。
obj.foo++:现在它触发的是scheduler,而scheduler只是“排了个队”,没有立刻跑。所以主流程畅通无阻。console.log(\结束了`)`:立刻打印。
我们用 setTimeout 开一个宏任务来执行副作用函数 fn,这样打印顺序就变成我们想要的样子了:
1
'结束了'
2💡 验证:① 首次执行打印 1(同步);② 主流程打印 结束了(同步);③ 同步代码全部跑完,浏览器才开始处理宏任务队列,打印 2。顺序正好对上。
宏任务 / 微任务(简单理解):
浏览器把所有要执行的 JavaScript 任务排成队。一个“宏任务”通常是 setTimeout、用户点击事件这种比较大的事;一个“微任务”通常是 Promise.then 这种“我答应了立刻做但现在先排队”的小事。微任务优先级比宏任务高——每次跑完一个宏任务后,会先把当前所有的微任务跑完,再去跑下一个宏任务。
💡 用机场安检打比方:宏任务是“旅客本人”(一整个安检流程做完才轮到下一个),微任务是“排队时顺手要处理的小件”。规矩是:当前旅客安检完 → 先把属于他的小件处理光 → 再叫下一位旅客。 所以微任务感觉上“几乎是立刻执行”的,但它仍然排在同步代码之后。
调度器能管的还不止“顺序”——连“次数”也能管,这一点尤其重要。我们来看这个例子:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
effect(() => {
console.log(obj.foo);
})
obj.foo++;
obj.foo++;obj.foo++; obj.foo++;:连着加两次。每一次改值都会完整触发一遍trigger,所以在“立刻重跑”模式下,这个函数会被叫醒 3 次(首次 + 两次)。- 💡 比喻:你今天收到 3 条通知,让你把门口堆的快递统一搬进屋。搬完之后屋里还是那 3 个包裹——中间那次纯属白跑。
这段代码先在副作用函数里打印 obj.foo 的值,然后连着对它做两次自增。在没有指定调度器的情况下,它的输出是这样的:
1
2
3💡 看这三行:1 是初值,2 和 3 是两次自增后各触发的一次重跑。中间那个 2 是“过渡状态”,人根本看不见(你连点两下,屏幕不会先闪一个 2 再闪一个 3)。
从这组输出能看出:obj.foo 一定会从 1 一路自增到 3,中间的 2 不过是个过渡状态。如果我们只关心最终结果、不关心中间过程,那么跑三次打印纯属浪费,我们真正想要的输出应该是:
1
3💡 大白话:改 10 次只重画 1 次。 这是性能优化里最值钱的一条。
其中不包含过渡状态,基于调度器我们可以很容易地实现此功能:
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true,代表正在刷新
isFlushing = true
// 在微任务队列中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach(job => job());
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false;
});
}
effect(() => {
console.log(obj.foo);
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn);
// 调用 flushJob 刷新队列
flushJob();
}
})
obj.foo++;
obj.foo++;jobQueue(任务队列):一个 Set,充当“待办事项清单”。谁被 trigger 通知到了,就先把自己的名字写进这张单子,等会儿统一处理。
💡 比喻:餐厅的点菜单。服务员来记单时,同一个桌号记一次就够了——Set 自动帮你把重复的桌号合并掉。这就是这里非用 Set 不可的原因。
flushJob / isFlushing:一个“开关 + 执行器”的组合。isFlushing 是开关(true = 正在处理中),flushJob 是“按下开关、并安排执行”的动作。
本章用的名字是单数 flushJob;Vue 3 真实源码里对应的函数叫 flushJobs(复数),因为它负责把一批任务刷出来。名字只差一个 s,干的是同一件事。
const jobQueue = new Set():待办清单。用Set的唯一理由就是去重。const p = Promise.resolve():一个小技巧,值得单独说。Promise.resolve()返回一个已经完成的Promise实例。作用相当于“拿到一根引线”——我们并不await它,只是借用它身上那个.then()方法。- 💡 比喻:不是“点一根鞭炮”,而是“从打火机上借个火”。
p.then(...)就是划火柴——把这件事挂到微任务队列上,让它在当前同步代码跑完之后自动烧起来。 - 为什么不直接用
queueMicrotask?因为Promise兼容性最好,而queueMicrotask是较新的 API。这是“用老办法办新事”的典型例子。
- 💡 比喻:不是“点一根鞭炮”,而是“从打火机上借个火”。
let isFlushing = false:开关初始为“关”。if (isFlushing) return:门闩。已经有人在处理队列了,第二次调用直接掉头就走——这是“去重”的第二道保险。isFlushing = true:上闩。宣布“我接管了,别的人别进来”。p.then(() => { jobQueue.forEach(job => job()) }):真正的执行放在微任务里。job => job()是“取出任务并调用它”。.finally(() => { isFlushing = false }):下闩。finally回调无论前面成功还是出错都会执行,这很适合“最后一定要恢复现场”这种收尾工作。结果就是只有开了闩的那一次能进,其余全被挡在门外。scheduler(fn) { jobQueue.add(fn); flushJob(); }:调度器只有两步——先登记,再喊人来处理。- ⚠️ 两次
obj.foo++之后,jobQueue里只有一个函数(Set去重),所以微任务里它只跑一次,而那时foo已经是3了。
💡 把 jobQueue 的去重机制画成图就一目了然了。假设同一个副作用函数被连续触发了 3 次:
✅ 两道保险,缺一不可:只有 Set 去重,能保证队列里不重复,但 flushJob 仍会被调用 3 次、排出 3 个微任务;只有 isFlushing 门闩,能保证只排 1 个微任务,但队列里仍可能有重复项。两个一起用,才既省任务、又省执行。
观察上面的代码,首先,我们定义了一个任务队列 jobQueue,它是一个 Set 数据结构,目的是利用 Set 数据结构的自动去重能力。接着我们看调度器 scheduler 的实现,在每次调度执行时,先将当前副作用函数添加到 jobQueue 队列中,再调用 flushJob 函数刷新队列。然后我们把目光转向 flushJob 函数,该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,而一旦 flushJob 函数开始执行,isFlushing 标志就会设置为 true,意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次。需要注意的是,在 flushJob 内通过 p.then 将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行。
把整段代码的效果捋一遍:连续两次对 obj.foo 做自增,会同步且连续地触发两次 scheduler。这意味着同一个副作用函数会被 jobQueue.add(fn) 添加两次——可由于 Set 的自动去重能力,最终 jobQueue 里只剩下一项,也就是当前这个副作用函数。同样地,flushJob 也会被同步且连续地调用两次,但由于 isFlushing 这个标志的存在,flushJob 在一个事件循环内实际只执行一次,也就是在微任务队列里跑那一次。等微任务队列开始执行时,它会去遍历 jobQueue 并执行里面的副作用函数;因为此时队列里只有一个函数,所以只跑一次,而它跑的时候 obj.foo 已经是 3 了。这就是我们想要的输出:
1
3你大概已经发现了,这个功能看起来很眼熟:它跟 Vue.js 里“连续改好多次响应式数据,界面却只更新一次”是一回事。Vue 内部其实实现了一套更完善的调度器,但思路和上面讲的完全一致。
✅ 本节一句话总结:调度器给了我们一个“插一脚”的位置。Vue 正是往这个位置里塞进了 jobQueue + 微任务,从而做到“改 100 次数据,只重画 1 次界面”。这是 Vue 性能优秀的重要原因之一。
4.8 计算属性 computed 与 lazy
前文介绍了 effect 函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options,例如指定 scheduler 调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的 track 函数,以及用来触发副作用函数重新执行的 trigger 函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力——计算属性(computed)。
计算属性(computed):
把一段“由别的数据算出来的值”缓存起来,依赖没变就一直复用旧结果。 生活比喻是查银行卡余额——你反复查 100 次,系统也只真正取一次数(因为期间没人往卡里打钱);一旦有人打钱,缓存立刻作废,下次查才会真的重新算。
为什么非要有它?因为同样的算式可能写 10 遍。模板里 10 处都用 first + last,没有 computed 就得算 10 遍;有了 computed,只算 1 遍,10 处共用同一个结果。
在正式讲计算属性之前,得先铺垫一个概念:懒执行的 effect,也就是带 lazy 标记的 effect。这到底是什么意思?先看个例子:目前我们这个 effect 函数,一收到传进来的副作用函数就会立刻执行,比如这样:
effect(
// 这个函数会立即执行
() => {
console.log(obj.foo);
}
)effect(...):注册函数,没有传第二个参数。// 这个函数会立即执行:这是本节要对比的关键。options是空的{},所以options.lazy是undefined(“没这个键”),下面if (!options.lazy)判断成立,当场就跑一遍。- 💡 比喻:开门就营业——客人还没来,店门已经开了。
可有些场景下我们恰恰不想让它立刻跑,只想等“真的需要的时候”再跑——计算属性就是典型例子。这时候只要在 options 里加一个 lazy 属性就能做到:
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo);
},
// options
{
lazy: true
}
)lazy(懒的、惰性的):一个开关。为 true 时表示“先别跑,等我叫你”。
💡 比喻:自动售货机的取货口——你只是“预订”了,机器不会立刻把货塞你手里;等你伸手去取(读 .value),它才出货。没人碰它,它就一直是空的。
为什么需要它?因为有些值“算了也没人看”。比如模板这次根本没渲染到那个计算属性,你每次都算一遍就是纯白算。
{ lazy: true }:备注栏里多了一条:“这个函数别立即执行”。只加了这一个键,函数体和上一段一模一样。
lazy 选项和之前介绍的 scheduler 一样,它通过 options 选项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了,当 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 = [];
// 只有非 lazy 的时候,才执行
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn; // 新增
}if (!options.lazy)(🆕 新增):一个开关决定跑不跑。!是“取反”。写了lazy就是“要求我别跑”;不写lazy(没这个要求)就照旧跑。 老代码的默认行为被完整保留。return effectFn;(🆕 新增):把“壳”交出去。 这一句让effect从“只管执行”变成了“执行 + 交付使用权”——外部现在能拿到这个函数,自己决定什么时候手动叫它跑。- 💡 比喻:售货机把“取货口”开放给你了,但要不要伸手,你自己说了算。
- ⚠️ 有个小细节:
return之前effectFn一次都没跑过,意味着它的deps还是空的、activeEffect也还没指向它。所以此刻你直接手动调effectFn(),这第一次跑才是“建立依赖”的那一次。这没问题,只是要心里有数。
有了这个判断,“不立即执行”就算实现了。可新的问题立刻冒头:那它到底该什么时候执行? 答案就藏在上面那段代码里——我们把副作用函数 effectFn 作为 effect 函数的返回值交了出来。这就意味着,调用 effect 的人可以顺着返回值拿到对应的副作用函数,于是就能自己决定何时手动执行它了:
const effectFn = effect(() => {
console.log(obj.foo);
}, { lazy: true });
// 手动执行副作用函数
effectFn();const effectFn = effect(...):effect(...)是一次函数调用,调用结果就是它返回出来的那个壳。const把它存进一个名字,方便后面叫。{ lazy: true }:简写的第二个参数。effectFn();:手动叫它跑一次。 跑完这一行,控制台才会打印出obj.foo的值——在此之前一个字都没有输出。- 💡 划重点:“登记”和“执行”被拆开了。
effect(...)只负责登记,执行要你自己喊。
光能“手动执行”其实没什么用——一个只能手动跑、还拿不到结果的东西,价值有限。 但换个角度想:假如我们把传给 effect 的那个函数当成一个 getter 来用呢?getter 是可以返回任意值的,于是:
getter(取值函数):一个“不写只取”的函数——传进去一些东西,它算出一个结果返回给你。名字 get 就是“拿”的意思。
生活比喻:水果店的称重台——你把水果放上去(输入),它报出总重量(返回值)。台子自己不存水果,它只负责“你放什么我称什么”。
💡 为什么这里突然冒出 getter 这个词?因为计算属性本质上就是“一个 getter + 一个缓存”。getter 负责“怎么算”,缓存负责“算一次就存着”。
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)() => obj.foo + obj.bar:注意这次没有花括号。effect要的是函数,而() => 表达式是最简洁的函数写法:直接把表达式当返回值。写全了就是() => { return obj.foo + obj.bar }。- 它读了
obj.foo和obj.bar,所以将来这俩任何一个变了,都算它“用到了”。
这样我们在手动执行副作用函数时,就能够拿到其返回值:
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
// value 是 getter 的返回值
const value = effectFn();const value = effectFn();:effectFn()是个调用,调用是有返回值的——这里返回值本该是1 + 2 = 3。- ⚠️ 但注意:
effect现在并不会把这个返回值往外传!effectFn内部那句fn()只是执行完就扔了结果。所以这段代码此刻value其实是undefined。要修好它,还得再动一次effect。
为了实现这个目标,我们需要再对 effect 函数做一些修改,如以下代码所示:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
// 将 fn 的执行结果存储到 res 中
const res = fn(); // 新增
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 将 res 作为 effectFn 的返回值
return res; // 新增
}
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) {
effectFn();
}
return effectFn;
}const res = fn();(🆕 新增):先把结果“接住”。 之前这句执行完就扔了,现在把返回值存进res变量里保管。- 💡 比喻:以前是“厨师炒完菜直接端走,桌子上什么也不留”;现在加了一步——“先把做好的菜放到盘子里(
res),再端出去”。
- 💡 比喻:以前是“厨师炒完菜直接端走,桌子上什么也不留”;现在加了一步——“先把做好的菜放到盘子里(
return res;(🆕 新增):再把盘子递出去。 两句合起来,调用effectFn()的人终于能拿到fn的计算结果了。- ⚠️ 位置也有讲究:
return写在effectStack.pop()之后,所以“出栈 + 还原activeEffect”这两步一定会先执行。如果return写在fn()那一行下面,函数当场就返回了,栈永远弹不出去,4.5 节的修复就白做了。
通过新增的代码可以看到,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。
现在我们既能懒执行副作用函数,又能拿到它的执行结果了,地基齐了。接下来就可以动手搭计算属性了:
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
});
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn();
}
}
return obj;
}function computed(getter):接收一个“取值函数”getter。effect(getter, { lazy: true }):把getter当成副作用函数注册进去,并要求“别立即跑”。lazy保证了只有真的有人来读,才会去算。get value() { return effectFn(); }:这是整个computed的灵魂。 写sumRes.value时,JS 并不是去取一个存好的字段,而是自动执行这个get函数,函数体里才去跑effectFn。- 💡 比喻:
sumRes.value看起来像“打开柜子拿走现成的饮料”,其实柜子里装的是一台自动出货机——每次你伸手,它才现场给你做一杯。
- 💡 比喻:
return obj;:把这个“出货机”交给调用方。
首先我们定义一个 computed 函数,它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性(getter),只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回。
访问器属性(getter / setter):
用 get 或 set 关键字定义的属性,看起来像个普通属性,但读它/写它时会自动调用你提供的函数。比如 obj.value 看起来是“读 value 这个属性”,其实背后偷偷跑了一段代码。
💡 生活比喻:温度计上的刻度。你看到的是一个“数字”,其实温度计内部一直在做“感知环境温度 → 换算成数字”这件事,你读到的永远是“当下的换算结果”。
我们可以使用 computed 函数来创建一个计算属性:
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, { /* ... */ });
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3computed(() => obj.foo + obj.bar):传入的getter就是“把两个字段加起来”。sumRes.value:第一次读取。此刻才真正发生计算:1 + 2 = 3。// 3:行尾注释,意思是“这一行的输出应该是 3”。浏览器不执行它,纯粹写给人看。
可以看到,它确实符合计算属性的特点:使用 computed 函数创建的对象,它的值并不是立刻就有的,而是要等到你读取 value 的时候才进行计算。
💡 换成生活场景:你向银行报“活期 + 定期”的总余额,柜员不会在你报完的瞬间就开始算,而是你问一次、他算一次。
但上面这个实现还有缺陷:如果多次访问 sumRes.value,effectFn 会被反复执行、反复计算,哪怕 obj.foo 和 obj.bar 的值本身并没有变化:
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3三次打印都是 3,但 effectFn 实际跑了三遍。这不是我们想要的——计算属性应该把结果缓存起来,没变就别重算。我们来优化:
function computed(getter) {
// value 用来缓存上一次计算的值
let value;
// dirty 标志,表示是否需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true
});
const obj = {
get value() {
// 如果是"脏"的,就重新计算,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn();
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false;
}
return value;
}
}
return obj;
}dirty(脏的):一个布尔标志(true / false),意思是“缓存过期了,该重算了”。
💡 生活比喻:冰箱上贴的“食材需更换”便条。没贴便条(dirty = false)就直接拿;贴了便条(dirty = true)就说明上次放的东西已经不新鲜,必须重新取。
value:就是那个“缓存”本身——上一次算出来的结果。
let value;:只声明、没赋值,所以初始是undefined。第一次读的时候会被立刻覆盖,“初始值是什么”无所谓。let dirty = true;:初始就标脏。 因为此刻还没算过一次,手里根本没有缓存,算“脏”才合情合理。if (dirty):脏才重算。这是整段代码的分水岭。value = effectFn();:算完立刻存起来。dirty = false;:算完就贴上“新鲜”标签。 下一次再读时这个if不成立,直接返回value,一步都不用算。return value;:不管走没走if分支,返回的都是value——要么是刚算出来的,要么是缓存里抄来的。- 💡 一句话记住:“脏了才算,算完就干净;干净就直接抄。”
💡 先看 lazy + dirty 这两道闸门是怎么配合的(这是理解 computed 的关键):
再接上 scheduler,加上第三道闸门:
✅ 这两张图合起来说明了三件事:① 缓存命中时一次计算都不做;② 数据变化时不急着重算,只是把“脏”标签贴上,等真有人来读再算;③ lazy 没人读就不算、dirty 没变就不算、scheduler 一变就贴标签。三道“省算力”的闸门,缺一不可。
我们新增了两个变量 value 和 dirty,其中 value 用来缓存上一次计算的值,而 dirty 是一个标识,代表“是否需要重新计算”。当我们通过 sumRes.value 访问值时,只有当 dirty 为 true 时才会调用 effectFn 重新计算值,否则直接使用上一次缓存在 value 中的值。这样无论我们访问多少次 sumRes.value,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的 value 值。
如果你够敏锐,现在应该已经嗅到问题了:这时候哪怕我们改了 obj.foo 或 obj.bar,再去读 sumRes.value,拿到的值也还是老样子:
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, { /* ... */ });
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3
console.log(sumRes.value); // 3
// 修改 obj.foo
obj.foo++;
// 再次访问,得到的仍然是 3,但预期结果应该是 4
console.log(sumRes.value); // 3💡 两次 sumRes.value 都返回 3——这一次是正确的,因为数据确实没变,第二次是命中缓存。问题出在第三行:obj.foo 已经从 1 变成 2,2 + 2 应该是 4,可打印出来的还是 3。
原因很直白:第一次读 sumRes.value 之后,变量 dirty 就被置成了 false,意思是“不需要算”。于是哪怕你把 obj.foo 改了,只要 dirty 还是 false,它就绝不会再算一遍——结果自然就是错的。
⚠️ 这里暴露了上一版设计的一个漏洞:
lazy挡住了“立刻算”,dirty挡住了“重复算”,但没人负责在数据变化时把dirty重新拉回true。标签一旦贴上“新鲜”,就再也没人来撕。
生活比喻:冰箱上那张“需更换”便条,只有新食材进来时才会被重新贴上。光有便条系统、没有“新食材进冰箱”这个提醒动作,便条就永远撕不掉了。
修法其实特别简单:只要 obj.foo 或 obj.bar 一变,就把 dirty 重新置回 true 就行了。 那这一步该由谁来做?答案就是上一节介绍过的 scheduler 选项:
function computed(getter) {
let value;
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true;
}
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
}
}
return obj;
}scheduler() { dirty = true; }(🆕 新增):就是那声“新食材进冰箱了”的提醒。scheduler会在getter依赖的数据变化时被调用,它只做一件事:把“脏”标签重新贴回去。- ⚠️ 关键点:
scheduler里绝对不能写value = effectFn()。 如果在这里就急着重算,就等于“每次数据一变就疯狂计算一次”——哪怕根本没人读sumRes.value,那也是白算,等于把lazy白加了。- 💡 比喻:门铃响了,只代表“该来收快递了”,不等于“你得现在就下楼取货”。 先标记一下,等你真要取的时候再下楼。
if/value/dirty那些代码一个字都没动——又是“只加不改”。
我们为 effect 添加了 scheduler 调度器函数,它会在 getter 函数中所依赖的响应式数据变化时执行,这样我们在 scheduler 函数内将 dirty 重置为 true,当下一次访问 sumRes.value 时,就会重新调用 effectFn 计算值,这样就能够得到预期的结果了。
✅ 到这里,computed 已经有两道省算力的闸门:lazy(没人读就不算)、dirty(没变就不重算),加上 scheduler(一变就贴脏标签)。这正是 Vue 计算属性高效的全部秘密。
现在,我们这份计算属性已经相当接近完美了,但还剩一个缺陷——它表现在“另外还有一个 effect 去读计算属性的值”的时候:
const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value);
})
// 修改 obj.foo 的值
obj.foo++;effect(() => { console.log(sumRes.value) }):另起了一个外层副作用函数,它读的不是obj.foo,而是计算属性的结果。obj.foo++:改动最底层的原始数据。我们期望外层函数因此重跑(因为sumRes变了),但实际上它纹丝不动。
上面这段代码里,sumRes 是个计算属性,而另一个 effect 的副作用函数中读了 sumRes.value。这时候如果改掉 obj.foo 的值,我们期望那个副作用函数会重新执行——就像在 Vue.js 的模板里读计算属性时,只要计算属性一变就会触发重新渲染一样。可你真去跑一遍会发现:改 obj.foo 根本唤不醒它。 这就是我们所说的那个缺陷。
💡 大白话这个 bug:底层数据变了 → 中间的计算属性知道了 → 但最外面那个等着听信的函数,压根不知道有这回事。 消息在第二层就被截断了。
💡 用 4.5 节的“套娃”画法,就清楚消息是在哪一层断的:
所以 obj.foo 变化时只会叫醒“内层”,永远叫不醒“外层”。 而内层是个 lazy 的计算属性,它自己不会重算,只会把 dirty 贴成 true——外层就这么一直傻等下去。
追根究底会发现,这本质上就是一个典型的 effect 嵌套问题。一个计算属性内部自带一个 effect,而且它是懒执行的——只有真正去读计算属性的值时,它才会跑起来。 对这个计算属性的 getter 函数来说,它内部访问到的那些响应式数据,只会把 computed 内部的那个 effect 收集成依赖。而一旦把这个计算属性拿到另一个 effect 里去用,嵌套就发生了——外层那个 effect,永远不会被内层 effect 中访问到的响应式数据收集到。
修法依旧很直接:读到计算属性的值时,我们手动调一次 track 去追踪;而当计算属性依赖的那些响应式数据发生变化时,我们手动调一次 trigger 去触发响应——把这两头补上,中间那条断掉的链子就接回来了:
💡 思路一句话:既然“读”到的人记不上账,那就让 computed 亲自替它记;既然“变”了没人通知,那就让 computed 亲自替它喊。 相当于中间人帮两边搭上线。
function computed(getter) {
let value;
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true;
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, `value`);
}
}
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, `value`);
return value;
}
}
return obj;
}if (!dirty)(🆕 新增):只在“由干净变脏”的那一刻才去喊人。 如果本来就是脏的(还没人来读),没必要再喊一遍。- 💡 比喻:火警只在“第一次报警”时响铃。 已经响了、你还没去关阀门的时候,反复按报警器只会让消防员白跑。
- **
trigger(obj, \value`)**(🆕 新增):**“计算属性变啦!订阅过它的人快来!”** 注意传的target是computed内部造出来的那个obj,key是字符串'value'`。- ⚠️ 这里用
obj传参有个小细节:它在effect之后才用const声明。但因为scheduler是延迟执行的(数据真的变了才会跑),那时obj早已赋值完毕,不会踩到“暂时性死区”。Vue 3 真实源码里会提前声明obj来规避这个坑,本章保持原书写法。
- ⚠️ 这里用
- **
track(obj, \value`)**(🆕 新增):**“刚才读这个值的人,登记一下!”** 这样一来,外层effectFn读sumRes.value时,就被记到了“计算属性obj的value` 字段”的名单上。 - 💡 一来一回,中间人就搭上了桥:读的时候
track记一笔,变的时候trigger喊一声。
代码已经把思路摆明了:读一个计算属性的 value 时,我们手动调 track,并且把计算属性返回的那个对象 obj 当作 target(也就是 track 的第一个参数)传进去。 而当计算属性依赖的响应式数据一变,调度器函数就会被执行——我们在调度器内部手动调 trigger 触发响应,这一步就够了。 这样一来,对于下面这段代码来说:
effect(function effectFn() {
console.log(sumRes.value);
})effect(function effectFn() { ... }):和 4.3 一样给函数起了名字,方便在下面的账本里指认。console.log(sumRes.value):这一次它读的是计算属性的结果,而不是原始字段。4.8 之前这里是断链的,现在被track/trigger手工接上了。
它会建立这样的联系:
💡 看清楚这棵树的层级变了:现在多出一个中转站(computed(obj))。
图 4-10 给出了更详细的描述。

图 4-10 计算属性的响应联系
✅ 至此 computed 完工:① 懒执行(lazy);② 有缓存(value);③ 变脏才重算(dirty + scheduler);④ 还能驱动别的 effect(手动 track / trigger 搭桥)。四项全能,这才是 Vue 计算属性的完整面貌。
4.9 watch 的实现原理
所谓 watch,说到底就是盯着一个响应式数据,等它一变,就通知一声、把对应的回调函数执行掉。举个例子:
watch(侦听):“盯着某个数据,一旦它变了,就叫我一声。” 生活比喻是门铃——你不用一直盯着门口,按一下门铃按钮就完事了;有人来了按一下,你再决定要不要去开门。
它和 computed 的区别很重要:computed 是“你问我答”(你不问它就不算),watch 是“有事通知”(它自己主动跑回调)。 一个是被动提供值,一个是主动搞事情。
watch(obj, () => {
console.log(`数据变了`);
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++;watch(obj, 回调):两个参数——盯谁(obj),变了干什么(console.log)。obj.foo++:手动改一次。此刻控制台会打出“数据变了”——门铃响了。
假设 obj 是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。
而 watch 的实现,说穿了无非是在玩 effect 加上 options.scheduler 这个选项,代码如下:
effect(() => {
console.log(obj.foo);
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})scheduler() { }:注意这里函数体是空的(只有一句注释)。空的scheduler照样管用——因为只要这个键存在,4.7 节那道if (effectFn.options.scheduler)就成立,副作用函数本体就不会被直接执行。- 💡 这是个很妙的点:“我存在”本身就是信号。
watch正是靠这一点,把“重跑函数”改造成了“跑一个空壳、由空壳去转调用户的回调”。
在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而 watch 的实现就是利用了这个特点。下面是最简单的 watch 函数的实现:
💡 划重点:scheduler ≡ 回调。 响应系统本来只会“重跑副作用函数”,而 watch 要的是“跑用户的回调”。中间需要一个转换器——scheduler 就是这个转换器。
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb();
}
}
);
}function watch(source, cb):source是要盯的数据,cb是回调函数(callback 的简称,意思是“回头叫你的那个函数”)。() => source.foo:这个函数体只有一件事——读source.foo。 目的不是用它的值,而是触发track,把“我要盯source.foo”这件事登记进去。- 💡 比喻:你去监控室不是去看画面的,而是去按一下“录像”按钮。按了,就等于告诉系统“这个人我要盯”。
scheduler() { cb(); }:空壳转调。 数据一变,响应系统喊的是scheduler,scheduler转手喊cb——两跳之后就是你写的代码了。- ⚠️ 注意这里硬编码了
source.foo。也就是说它只能盯foo这一个字段。这个限制下一段就解决。
我们可以如下所示使用 watch 函数:
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });
watch(obj, () => {
console.log(`数据变化了`);
})
obj.foo++;const data = { foo: 1 }:原始数据。watch(obj, () => { ... }):盯住整个obj。obj.foo++:改foo,回调照样被触发。为什么?因为上一版的watch里写着“读source.foo”——foo被登记过。
这段代码能跑通,但我们一眼就能看出问题:实现里把对 source.foo 的读取硬编码死了。换句话说,它现在只认 obj.foo 这一个字段。要让 watch 变得通用,我们就得再封装一个通用的读取操作出来:
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb();
}
}
);
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== `object` || value === null || seen.has(value)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}() => traverse(source):从“只读一个字段”升级成“把整个对象翻个底朝天”。 不管你改obj上哪个字段,都会被登记过。function traverse(value, seen = new Set()):traverse是“遍历”(traverse 的意思就是“来回走一遍”)。 它接收两个参数:当前走到哪、已经走过哪些。seen = new Set()(🆕 新增,默认值):一份“走过的路标”清单。每次递归都把同一个seen传下去,所以整次遍历共享一张清单。typeof value !== 'object' || value === null || seen.has(value):三道保险,任一命中就return收工。typeof value !== 'object':走到原始值(数字、字符串)了,没有字段可读,收工。value === null:typeof null也是'object',所以必须单独排除,否则会漏判。seen.has(value):这一条是防死循环的关键。 比喻:“这家人我刚已经拜过一遍了,不用再敲第二次门。” 没有它,碰到“对象 A 里有 B、B 里有 A”这种循环引用(circular reference),就会无限递归下去,直接栈溢出。
seen.add(value):走之前先在自己身上做个记号。for (const k in value):for...in会把对象上所有的键名一个个拿出来。const k表示每一轮的键名变量。traverse(value[k], seen):拿着键名去取值,递归地继续往下走。 注意seen被原样传下去,清单不重置。return value:原样返回最外层那个对象。 这一句不是为了返回值,而是顺手触发最外层对象的get拦截器——traverse(source)这个表达式必须真的读一下source,登记才建得起来。- 💡 合并成一句大白话:“把对象从头到尾走一遍,每走一个字段就顺手登记一次,路上留记号防止绕回原地。”
Set:JS 的集合类型,两大特性是自动去重和has() 查得快。这里两个都用上了。
如上面的代码所示,在 watch 内部的 effect 中调用 traverse 函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。
watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log(`obj.foo 的值变了`);
}
)- 第一个参数变成了
() => obj.foo:一个函数,不是数据对象。 - 💡 为什么要多这一种用法?因为有些东西压根不是响应式数据。比如“总价 = 单价 × 数量”,它是一个你临时算出来的表达式。传
getter就等于告诉watch:“我不给你数据,我给你一段'怎么算'的口诀,你自己算着盯。”- 生活比喻:
traverse是“我给你一份花名册,你照单全收”;getter是“我给你一个公式,你自己算要盯谁”。
- 生活比喻:
这段代码的意思是:传给 watch 函数的第一个参数已经不是响应式数据本身了,而是一个 getter 函数。用户可以在这个 getter 内部自己指定这个 watch 到底要依赖哪些响应式数据;只有当这些数据真的变了,回调函数才会被执行。 下面的代码就实现了这个能力:
function watch(source, cb) {
// 定义 getter
let getter;
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === `function`) {
getter = source;
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source);
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb();
}
}
);
}let getter;:先占个位,两条分支都要往里赋值。if (typeof source === 'function'):判断用户给的是不是函数。typeof是“取类型”的关键字,===是“严格相等”。'function'这个字符串是函数的类型名。getter = source;:用户自己给的就是getter,直接拿来用。getter = () => traverse(source);:用户给的是数据对象,那我们自己造一个getter,让它去“翻整个对象”。 注意这里不需要立刻执行,只是造了个函数。() => getter():执行那个函数。不管上面选了哪条分支,这里都用同一句代码去跑——这就是“归一化”的好处:effect完全不关心source到底是数据还是函数。- 💡 用一个比喻收尾:前台只认一种单据格式。你交“花名册”它就翻名册,你交“公式”它就按公式算——到了同一个窗口,后面的流程就只有一条。
首先判断 source 的类型,如果是函数类型,说明用户直接传递了 getter 函数,这时直接使用用户的 getter 函数;如果不是函数类型,那么保留之前的做法,即调用 traverse 函数递归地读取。这样就实现了自定义 getter 的功能,同时使得 watch 函数更加强大。
够细心的你大概已经发现了,当前的实现还缺一项相当重要的能力:回调函数里拿不到“旧值”和“新值”。 可我们在 Vue.js 里用 watch 时,明明能在回调里同时拿到变化前后的两个值:
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue); // 2, 1
}
)
obj.foo++;(newValue, oldValue) => { ... }:回调函数现在收两个参数了。// 2, 1:obj.foo++把它从1变成了2,所以新值2、旧值1。- ⚠️ 但上面这个
watch实现里,调用回调是cb()——一个参数都没传。所以此刻newValue和oldValue都是undefined。这个能力得靠下一段代码补上。
那新值和旧值从哪来?关键是把 effect 函数的 lazy 选项彻底吃透、用上,代码如下:
function watch(source, cb) {
let getter;
if (typeof source === `function`) {
getter = source;
} else {
getter = () => traverse(source);
}
// 定义旧值与新值
let oldValue, newValue;
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn();
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue);
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue;
}
}
);
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn();
}let oldValue, newValue;:两个变量都用let,一次声明、逗号分隔。初始都是undefined。const effectFn = effect(..., { lazy: true, ... }):lazy在这里是刚需。 因为watch要的是“我自己决定什么时候取数”,而不是被系统一改就重跑。所以这里必须关掉自动执行,改成手动叫。newValue = effectFn();(scheduler里第 1 句):数据变了 → 立刻用最新的数据算一遍。 这个算出来的就是新值。cb(newValue, oldValue);:把两个值一起交给回调。 ⚠️ 顺序是“先新后旧”——这也是 Vue 真实的回调签名顺序。oldValue = newValue;:这一行千万别漏。 它把“刚才那个新值”降级成“下一个旧值”。oldValue = effectFn();(最后一行):watch建立时手动算一次,把结果存成“初始旧值”。 比喻:新员工入职先记下他现在的状态,之后每次体检才拿新状态去比对。- ⚠️ 假如忘了
oldValue = newValue那一行,第二次回调就会变成cb(3, 1)——“旧值”永远停在第一次那个,多亏了这行注释。
这段代码里最核心的一处改动,就是用 lazy 选项创建了一个懒执行的 effect。注意代码最下面那一句:我们手动调用 effectFn 拿到的返回值就是旧值——因为那是它第一次执行时算出来的结果。一旦数据变化触发 scheduler 执行,它会再调一次 effectFn,这次拿到的是新值。新旧两个值凑齐了,再把它们作为参数传给回调 cb 就行。最后还有一件非常重要的事:别忘了用新值去更新旧值(oldValue = newValue),不然下一次真发生变化时,你会拿到一个错误的旧值。
✅ 回头看这一步的设计精髓:lazy 在 4.8 是计算属性的“省算力开关”,在 4.9 又变成了 watch 的“手动取数开关”。 同一个选项,两处受益。这正是“把功能做成可组合的零件”的价值。
4.10 立即执行的 watch 与回调执行时机
上一节中我们介绍了 watch 的基本实现。在这个过程中我们认识到,watch 的本质其实是对 effect 的二次封装。本节我们继续讨论关于 watch 的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。
💡 回忆一下这个“二次封装”:watch 自己一行响应系统的核心代码都没写。 它只是把 effect + lazy + scheduler 三个零件组装起来,再包上一层 traverse / getter 的适配。会用 effect,就等于会写 watch。
先看“立即执行”这件事。默认情况下,watch 的回调只会在数据真的变了之后才跑:
// 回调函数只有在响应式数据 obj 后续发生变化时才执行
watch(obj, () => {
console.log(`变化了`);
})- 注意此刻控制台是空的。
watch(obj, ...)只是“装了个门铃”,没有人来按,就不会响。 - 💡 生活场景:天气预报说“明天有雨”——你不会因为它说了这句话就立刻出门带伞,要等真的下雨了才带。可有时候你就是现在就想知道,那就得改成“提醒我现在就告诉我”。
immediate(立即):一个开关。为 true 时,门铃装好就自己响一声,不用等人来按。
在 Vue.js 里,可以用一个叫 immediate 的选项参数来指定回调函数要不要立即执行:
watch(obj, () => {
console.log(`变化了`);
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})- 第三个参数
{ immediate: true }:watch现在收三个参数了(源、回调、选项)。 - 💡 这就是“查一下现在是什么情况”的需求——比如进页面就先查一次用户资料,而不是等用户手动点“刷新”。
当 immediate 选项存在且为 true 时,回调函数会在这个 watch 建立的那一刻就立刻跑一次。仔细想想会发现一件事:“立即执行”和“变化后执行”,这两件事本质上没有任何区别——所以我们可以把 scheduler 调度函数抽成一个通用函数,让初始化和后续变化都去调它,代码如下:
💡 关键洞察:“立刻执行一次”和“变了之后执行一次”,干的是同一件事——都是“取新值 → 交给 cb → 更新 oldValue”。既然是同一件事,就该抽成一个函数,两处都调它。
function watch(source, cb, options = {}) {
let getter;
if (typeof source === `function`) {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
);
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job();
} else {
oldValue = effectFn();
}
}const job = () => { ... }(🆕 本节新增):“待办任务”函数。job就是“活儿”,一有情况就得干这三步:取新值 → 通知用户 → 更新旧值。scheduler: job(🆕 本节新增):注意这里不是包一层scheduler() { ... },而是直接把job当调度器用。 因为job本身就是个函数,签名也正好是“接收待执行函数”,直接赋值即可。if (options.immediate) { job(); }:立刻干一次活。 走的是和变更时完全相同的那条路径,所以回调逻辑只有一份,不会写两遍。else { oldValue = effectFn(); }:不立即执行的话,就得先把“初始值”存下来当旧值(4.9 已经做过这一步)。两个分支必须二选一,否则旧值永远对不上。- ⚠️ 一个小顺序细节:
job里用了effectFn,而effectFn是在job之后才声明的。因为job是箭头函数(有自己的作用域),而且调用时机在声明之后,所以完全没问题——闭包认的是“调用时”的effectFn。 - 💡 比喻:这是一张“标准作业流程单”。入职手续(
immediate)和年中调薪(变更)走的是同一张单子,只不过前者一入职就填,后者等到调薪才填。流程统一,执行时机不同。
“回调函数立即执行”就算实现了。因为这一次的执行是“立即”发生的,它压根不存在所谓的“之前”,所以此刻回调函数里的 oldValue 必然是 undefined——这完全符合预期,不是 bug。
💡 划重点:“立即执行”的那一次,oldValue 必然是 undefined。 因为此刻根本没有“之前”。这不是 bug,是语义正确。
除了“要不要立即执行”,我们还能用别的选项参数来指定回调函数到底在什么时机执行。比如在 Vue.js 3 里就用 flush 选项来指定:
flush(刷新时机):指定回调在什么时候执行。可以取三个值:pre / post / sync。
sync(同步):数据一变,立刻就地执行。 最快,但可能打断当前正在进行的操作。pre(更新前):默认。 回调在“组件重新渲染之前”跑。post(更新后):组件已经重画完了,回调才跑。 这时候你读到的 DOM 是最新的。- 💡 比喻:
sync是“边改边算”,pre是“改完先自己看看,再通知别人”,post是“改完、给别人都展示完了,最后再通知”。
watch(obj, () => {
console.log(`变化了`);
}, {
// 回调函数会在 watch 创建时立即执行一次
flush: `pre` // 还可以指定为 `post` | `sync`
})flush: 'pre':这里写的是默认值,意思是“不特意改,按默认来”。// 还可以指定为 post | sync:另外两种取值(用|分隔,表示“或者”)。
flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 post 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:
💡 实现思路一句话:4.7 节我们已经会“挂微任务”了,这里只要在调度器里加个 if 判断就行。 post 走微任务(异步,等 DOM 更新完),其他走直接执行(等价于 sync)。
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
() => getter(),
{
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 `post`,如果是,将其放到微任务队列中执行
if (options.flush === `post`) {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
}
}
);
if (options.immediate) {
job();
} else {
oldValue = effectFn();
}
}scheduler: () => { ... }:这次用了个带代码块的箭头函数,因为要多几步判断。if (options.flush === 'post'):是不是要“等 DOM 更新完再叫”?const p = Promise.resolve(); p.then(job);:老配方——4.7 节用过的“借根火柴”技巧,把job挂到微任务队列。微任务排在同步代码之后,所以当前这轮更新走完之后它才跑,这正好模拟出了post的效果。else { job(); }:不是post就直接跑,等价于sync。- 💡 效果对比:数据一变,
sync是“当场就响”;post是“先让界面画完,再响”。同一份job,只差一个“什么时候响”。
如以上代码所示,我们修改了调度器函数 scheduler 的实现方式,在调度器函数内检测 options.flush 的值是否为 post,如果是,则将 job 函数放到微任务队列中,从而实现异步延迟执行;否则直接执行 job 函数,这本质上相当于 sync 的实现机制,即同步执行。对于 options.flush 的值为 pre 的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机,其中 pre 和 post 原本的语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控制回调函数的更新时机。
💡 为什么“模拟不了 pre”?因为 pre 要卡的点是“组件重渲染之前”这个时刻,而我们本章的迷你响应系统根本没有组件、没有渲染过程——没有那座桥,就无处可站。 Vue 真实源码里,pre 是靠一个专门的“重渲染前”队列实现的。
✅ 本节小结:watch 的两个开关已经就位——immediate 管“要不要先响一声”,flush 管“响的时候页面是什么状态”。“响不响”和“什么时候响”,就这样被拆成了两个正交的问题。
4.11 过期的副作用
竞态问题通常在多进程或多线程编程中被提及,前端工程师可能很少讨论它,但在日常工作中你可能早就遇到过与竞态问题相似的场景,举个例子:
竞态问题(race condition):
“两个任务同时在跑,结果取决于谁先到。” 就像两个人同时抢着改同一份文档——不管过程多小心,最后留下的永远是最后落笔的那个,哪怕他改的东西“更旧”。
race这个词本身就有“赛跑”的意思。 竞态就是赛跑带来的顺序问题。- 💡 日常生活里的例子:你在搜索框输入文字,每敲一下就发一次请求。 敲得越快,发的越多。如果“ab”那次请求比“abc”那次返回更晚,屏幕上就会显示“ab”的结果——明明你最后输入的是 “abc”。
let finalData;
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch(`/path/to/request`);
// 将请求结果赋值给 data
finalData = res;
})let finalData;:用来装“最终结果”的变量。 声明在外面,是因为它要在若干次异步请求之后被写入,而每次回调都是一次全新的执行。async () => { ... }:一个async(异步)函数。它的特点是里面可以写await,也就是“我在这儿等着,等结果回来了再继续往下走”。await fetch(...):发出网络请求,然后“卡住”在这儿等。 在等待的这些时间里,JavaScript 是可以先去干别的事的——这就是问题藏身的地方。finalData = res;:请求回来了,把结果写进最终变量。 这一行本身没毛病,毛病在于:它不知道自己的结果是不是已经过时了。
这段代码用 watch 盯着 obj 的变化:obj 每变一次,就会发出一次网络请求(比如去请求接口数据),等数据回来之后,再把结果赋值给 finalData 变量。
乍一看这段代码似乎没什么毛病,可细想之下就会发现:它会踩进竞态问题。 设想这样一个过程:第一次修改 obj 的某个字段,会触发回调执行,于是发出第一个请求 A。过了一段时间,在请求 A 的结果回来之前,我们又第二次修改了 obj 的某个字段,这会发出第二个请求 B。此时 A 和 B 两个请求都在飞,那么哪一个先回来?我们不知道。 如果请求 B 比请求 A 先返回结果,那最终写在 finalData 里的就是 A 的结果——旧数据把新数据盖掉了,如图 4-11 所示。

图 4-11 请求 A 的结果覆盖请求 B 的结果
💡 用时间轴看这张图就明白了(⏱ 越往右时间越晚):
可请求 B 是后发的呀——按常理它返回的才是“最新”的那份数据;反过来说,请求 A 就该被当成“过期”的东西。所以我们期望 finalData 里装的是 B 的结果,而不是 A 的结果。
我们还可以把这个问题再抽象一层:请求 A 是副作用函数“第一次执行”所产生的副作用,请求 B 是它“第二次执行”所产生的副作用。 由于请求 B 发生在后,它的结果理应被当作“最新”的;而请求 A 已经“过期”了,它产生的结果应当被判定为无效。 做到这一点,就能避免竞态导致的错误结果。
💡 划重点:“副作用函数”本身的版本是新的(请求 B 来自第二次执行),但“它等回来的结果”是旧的(请求 A 是老请求)。 错位就错位在这里。
比喻:你让秘书去办事,秘书甲先出发、秘书乙后出发。结果乙(更近的行程)先回来汇报了,正当你要采纳时,甲才慢吞吞地回来,一开口就把刚才的结论带跑了。 问题不在于甲该不该汇报,而在于框架没告诉甲“你的消息已经过期了”。
归根结底,我们需要的是一个让副作用过期的手段。为了让问题更加清晰,我们先拿 Vue.js 中的 watch 函数来复现场景,看看 Vue.js 是如何帮助开发者解决这个问题的,然后尝试实现这个功能。
在 Vue.js 里,watch 的回调函数会接收第三个参数 onInvalidate,它本身就是一个函数,用法很像事件监听器:你可以用 onInvalidate 注册一个回调,而这个回调会在“当前这次副作用已经过期”的时候被执行:
onInvalidate(登记“作废通知”):一个让你“预约失效通知”的函数。 你把一个回调交给它,当这次副作用已经过期时,那个回调就会被调用。
invalidate的原意是“使无效、作废”,on表示“在……的时候”。合起来就是“在作废的时候(做这件事)”。- 💡 生活比喻:你办健身卡,把这张卡绑在小腿上。 一旦你换了新卡,旧卡上的小铃铛就会响——“这张卡作废了,别再用它打卡”。
onInvalidate就是注册这个铃铛的动作。 - ⚠️ 关键点:它不是让你取消请求(那是
AbortController的活),它只是告诉你“你的结果别用了”,剩下的判断由你自己做。
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false;
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true;
});
// 发送网络请求
const res = await fetch(`/path/to/request`);
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res;
}
})async (newValue, oldValue, onInvalidate) => { ... }:三个参数,第三个就是刚才说的onInvalidate。let expired = false;:先乐观地假设自己有效。onInvalidate(() => { expired = true; }):立刻挂上铃铛。 注意是“在发出请求之前就挂好”——铃铛要赶在雨来之前挂好,等雨下大了再挂就来不及了。if (!expired):取快递前先看铃铛响没响。 响了 = 这份快递已作废,扔掉;没响 = 收下。- 💡 和 4.9 那版对比一下就很清楚:4.9 解决的是“拿到新旧两个值”,4.11 解决的是“告诉我这份结果还算不算数”。
onInvalidate提供的正是后者。
代码已经把做法写清楚了:在发出请求之前,我们先定义一个 expired 标志变量,专门用来标识“当前这次副作用是不是已经过期”;接着调用 onInvalidate 注册一个过期回调,一旦这次副作用过期,就把 expired 置为 true;最后,只有在没过期的前提下才采用这个请求结果——这样就能有效地绕开前面那个问题了。
闭包(closure):
一个函数能够“记住”它定义时的那些变量,即使那个函数是在别的地方被调用的。比如上面的 expired = true 是写在箭头函数里的,但这个箭头函数“抓住了”外层的 expired 变量,外层的代码随时能读到最新的值。
那 Vue.js 到底是怎么做到的?换句话说,onInvalidate 的原理是什么?其实简单得出奇:在 watch 内部,每次检测到变更之后、副作用函数重新执行之前,先把我们通过 onInvalidate 注册的那个过期回调调用一遍——就这一件事,代码如下:
function watch(source, cb, options = {}) {
let getter;
if (typeof source === `function`) {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// cleanup 用来存储用户注册的过期回调
let cleanup;
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn;
}
const job = () => {
newValue = effectFn();
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup();
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate);
oldValue = newValue;
};
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === `post`) {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
}
}
);
if (options.immediate) {
job();
} else {
oldValue = effectFn();
}
}let cleanup;(🆕 本节新增):一个“待打的电话”。用户通过onInvalidate登记的过期回调,先存在这儿等着。function onInvalidate(fn) { cleanup = fn; }(🆕 本节新增):实现简单到让人怀疑——就一行赋值。它的全部作用是“记下你交给我的那个函数”。- ⚠️ 注意这里是直接赋值、不是
push。所以同一时刻只记一个过期回调,后登记的会顶掉先登记的——而这正是我们要的:上一轮登记的那个,恰恰是最该被作废的那个。
- ⚠️ 注意这里是直接赋值、不是
if (cleanup) { cleanup(); }(🆕 本节新增,最关键):“新的一轮开始前,先把上一轮通知作废。” 位置在cb(...)之前。- 💡 比喻:开会前先点名作废上次的委托。 上一轮你交代的事(那次发出去的请求),从现在起就不算数了——哪怕它还没回来。
cb(newValue, oldValue, onInvalidate);:第三个参数就是onInvalidate本身,用户回调里可以随时拿去登记自己的过期逻辑。- ⚠️ 这里有个容易看漏的点:虽然
newValue = effectFn()写在最前面(值已经取了),但cleanup()写在它后面。这不影响正确性——“过期”只影响用户后续await的结果是否被采纳,不影响本次取值。
- ⚠️ 这里有个容易看漏的点:虽然
scheduler/immediate那部分一个字没动。又是一次“只加不改”。
这段代码我们先定义了一个 cleanup 变量,专门用来存放“用户通过 onInvalidate 注册过来的过期回调”。可以看出 onInvalidate 的实现简单得不像话——它只是把过期回调赋值给了 cleanup 而已。 真正的关键在 job 函数里:每次调用回调函数 cb 之前,先检查一下有没有过期回调;有的话就执行掉它(cleanup())。 最后,我们把 onInvalidate 作为回调函数的第三个参数传给 cb,好让用户拿去用。
我们还是通过一个例子来进一步说明:
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false;
onInvalidate(() => {
expired = true;
});
const res = await fetch(`/path/to/request`);
if (!expired) {
finalData = res;
}
})
// 第一次修改
obj.foo++;
setTimeout(() => {
// 200ms 后做第二次修改
obj.foo++;
}, 200);let expired = false;:这一次副作用函数自己的“我还新鲜吗”标志。 默认假设自己有效。onInvalidate(() => { expired = true; }):登记一个“作废铃铛”。 一旦过期,就有人来把这个expired翻成true。const res = await fetch(...):发出请求 A,然后等着。 等的这段漫长时间里,世界还在变。if (!expired)(最终防线):回来之后先验明正身——我还有效吗?有效才写finalData。obj.foo++(第一次):立刻触发回调 → 登记铃铛 → 发出请求 A。setTimeout(..., 200):200 毫秒后第二次修改 → 又一次触发回调。- 💡 注意这里的结构:
expired是每次回调各自的一份(它定义在回调函数体内)。第一次执行有第一次的expired,第二次执行有第二次的,两者互不相干。 这一点很关键。
这段代码里我们一共改了两次 obj.foo。第一次是立刻改的,于是 watch 的回调被执行;因为回调里调了 onInvalidate,所以注册上了一个过期回调,接着发出了请求 A。假设请求 A 要 1000ms 才能返回,而我们在 200ms 时第二次修改了 obj.foo,这又会触发 watch 的回调执行。这里要注意的是:在我们的实现中,每次执行回调之前都要先检查有没有过期回调,有就先执行它。 既然第一次执行时已经注册过一个了,那么第二次执行之前,框架就会优先把上次那个过期回调执行掉——这会让第一次执行时闭包里的那个 expired 变量变成 true,也就是那次副作用的执行已经过期了。于是等请求 A 的结果回来时,它的结果会被直接丢弃,从而避免了过期副作用带来的影响,如图 4-12 所示。
💡 把两个 expired 分开画出来,就明白为什么这样写是对的:

图 4-12 请求过期
✅ 闭环完成:expired₁ 和 expired₂ 是两次回调各自独立的闭包变量,铃铛只叫“上一次”,不会误伤这一次。竞态问题就此被“作废铃铛”消掉了。 📌 顺带一提 4.4 那个“借书条用数组、允许重复”的选择:正因为 cleanup 这里用的是“直接赋值”(后来者顶掉先来者),才能干净地实现“只作废上一轮”。 两个设计细节前后呼应上了。
4.12 总结
走完这一章,我们先认识了两个主角:副作用函数和响应式数据,以及两者之间那层说不清道不明的关系。响应式数据最原始的实现手法,就是拦截对它的“读取”和“设置”,借机在副作用函数和响应式数据之间建立联系:读的时候,把当前正在执行的副作用函数存进“桶”里;写的时候,再从“桶”里把它取出来执行。 这就是响应系统最根本的实现原理。
💡 第一段在讲最核心的骨架:读的时候记账,改的时候找人。 整章所有内容都是在这两句上打补丁。
比喻:图书馆。借书(读)时登记,办卡(改)时通知。先有登记,后有通知。
然后我们动手搭出了一套相对完善的响应系统:用 WeakMap 配合 Map 重新设计了“桶”的结构,这样响应式数据和副作用函数之间就能建立精确得多的联系。顺带地,我们也讲清了 WeakMap 和 Map 这两个数据结构到底差在哪:WeakMap 对键是弱引用,不影响垃圾回收器干活;所以当用户那边的代码对一个对象已经没有任何引用时,WeakMap 不会拦着垃圾回收器把它收走。
💡 第二段在讲把“一个扁平的桶”换成“三层抽屉”,并且顺手解决了内存泄漏的隐患。
原来:Set → 所有字段共用一份名单,动谁都炸所有人
现在:WeakMap → Map → Set → target → key → effects,精确到"哪个对象的哪个字段"选 WeakMap 而不是 Map 的理由一句话:用户不要的数据,框架绝不能替他留着。
再往后,我们处理了分支切换带出来的冗余副作用问题——它会让副作用函数做一些完全没必要的更新。解法是:每次副作用函数重新执行之前,先把上一次建立的响应联系统统清掉;等它跑完,新建立的联系里自然就不含冗余成分了,问题随之解决。不过在这个过程中,我们又撞上了一个新问题——遍历 Set 会导致无限循环。它的原因在 ECMA 规范里写得很清楚:“在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”最后的解法是:另建一个新的 Set 专门用来遍历。
💡 第三段讲两件事、踩两个坑:
| 坑 | 原因 | 解法 |
|---|---|---|
| 分支切换留下冗余依赖 | 旧名单没清 | cleanup:跑之前先销号 |
遍历 Set 死循环 | “边遍历边改同一个集合” | 遍历副本(new Set(effects)) |
这两个坑其实是同一类问题的两副面孔:cleanup 顺手改了集合,forEach 正在读这个集合。 修法都是同一招——别一边遍历一边改。
之后,我们讨论了嵌套的副作用函数。在真实场景里,嵌套发生在组件嵌套(也就是父子组件关系)的时候。为了让响应式数据与副作用函数之间的响应联系不至于错乱,我们引入了一个副作用函数栈来分别存放不同的副作用函数;每个副作用函数一执行完,就把自己从栈里弹出去。 这样一来,读取某个响应式数据时,它只会和“此刻栈顶”的那个副作用函数建立联系,问题就此解决。再往后,我们又遇到了副作用函数无限递归调用自己、最终栈溢出的问题。它的根本原因在于:对同一个响应式数据的读取和设置,发生在同一个副作用函数内部。 而解法出奇地简单——如果 trigger 要触发的那个副作用函数,跟当前正在执行的那个是同一个,就不触发。
💡 第四段讲两个“认错人”的问题:
- 嵌套 effect 记错账 → 因为
activeEffect只有一个格子,被内层覆盖后回不来。解法:换成effectStack摞起来。 - 自己叫自己、栈溢出 → 因为同一个函数既读又写。解法:加一道
effectFn !== activeEffect的守卫。
一句话串起来:一个是“内存只够记一个名字”,一个是“只记了一个名字还不够,还不能叫自己”。
紧接着,我们谈到了响应系统的可调度性。所谓可调度,说白了就是:当 trigger 动作触发副作用函数重新执行时,系统有能力去决定它执行的时机、次数和方式。 要实现这个能力,我们给 effect 函数加了第二个选项参数,用户可以通过 scheduler 选项指定调用器,这样用户就能自己安排任务怎么调度。 我们还演示了怎么用调度器做任务去重——借助一个微任务队列把任务缓存起来,从而实现去重。
💡 第五段讲给响应系统装了一个“变速箱”。以前只能“立刻重跑”,现在可以:
- 时机:
setTimeout(fn)→ 排到下一班(宏任务) - 次数:
Set去重 +isFlushing门闩 → 改 N 次只跑 1 次 - 方式:微任务 → 在同步代码之后、下一个宏任务之前执行
比喻:从前是“一来数据就急刹车”,现在是“进收费站排好队,凑够一批一起放行”。
再往后,我们实现了计算属性,也就是 computed。计算属性本质上就是一个懒执行的副作用函数——我们靠 lazy 选项让它可以“懒”着不跑;而被标成懒执行的副作用函数,是可以被手动叫起来的。正是利用了这一点,我们才设计出了计算属性:读它的值的时候,只需要手动执行一下那个副作用函数就够了。 另外,当计算属性依赖的响应式数据发生变化时,scheduler 会把 dirty 标记重新置为 true,也就是标成“脏”;这样下次再读它的值时,我们才会真的重新去算一遍。
💡 第六段讲三道“省算力”的闸门:
| 闸门 | 拦住的是 |
|---|---|
lazy | 没人读时的无谓计算 |
dirty | 数据没变时的重复计算 |
scheduler 置 dirty = true | 保证前两道闸门不会永久卡死 |
比喻:三个开关分别管“要不要开”、“要不要重置”、“谁负责重置”。少一个都会算错。
再补一刀:track / trigger 手工搭桥,让外层 effect 也能被计算属性的变化唤醒。
接着我们聊了 watch 的实现原理,它本质上利用的正是副作用函数重跑时的可调度性。一个 watch 内部会创建自己的 effect;当这个 effect 依赖的响应式数据发生变化时,被执行的将是它的调度器函数 scheduler,而不是副作用函数本身。 而这个 scheduler 完全可以当成一个“回调”来理解——所以我们只要在 scheduler 里去执行用户通过 watch 注册的那个回调函数,事情就成了。此外,我们还讲解了回调会立即执行的 watch:加一个新的 immediate 选项就能实现;我们也讨论了如何控制回调的执行时机——通过 flush 选项来指定回调具体在什么时刻执行,说到底利用的还是调度器加异步微任务队列这一套。
💡 第七段讲**watch 只是三个零件的拼装**:effect(记账)+ lazy(手动取值)+ scheduler(转调回调)。
两个开关各管一件事:immediate 管“要不要先响一声”,flush 管“响的时候页面长什么样”。
顺带解释的两个新名词:traverse(递归读遍整个对象,靠 seen 这个 Set 防循环引用)与 getter(用户自定义“要盯哪几个字段”)。
最后压轴的是过期的副作用函数,它正是竞态问题的祸根。Vue.js 的解法是:给 watch 的回调函数加了第三个参数 onInvalidate,它是一个用来注册过期回调的函数。 规则很简单:每当 watch 的回调函数执行之前,框架会优先执行用户通过 onInvalidate 注册的那个过期回调。 这样用户就有机会在过期回调里把上一次的副作用标记成“过期”,竞态问题随之解决。
💡 最后一段讲给“结果”加一个有效期。竞态的本质是“新版本的任务带回了旧版本的结果”,而 onInvalidate 提供的正是“告诉我这份结果作废了”的机制。
job() 启动新的一轮
├─ 先 cleanup() 🔔 作废上一轮 → 上一轮的 expired 变 true
└─ 再 cb(new, old, onInvalidate) → 用户登记下一轮的作废铃铛比喻:旧手机收到一条“系统已停用”的通知,于是它老老实实不再上报数据。 没收到通知的旧手机,还在兴冲冲地发它的过时数据。
✅ 全章一句话:我们用“拦截读写”造了一个能自动通知的依赖系统,再给它依次加上了“精确记账、防止误触发、防止自己叫自己、可调度、可缓存、可回调、可判过期”七道工序。 每道工序都是先踩坑、再填坑。
本章小结
- 响应式数据 = 用
Proxy拦截对象属性的“读取”和“设置”,让外部代码改数据时能自动收到通知。 - 副作用函数(
effect)= 那些“会因为数据变化而需要重新跑”的函数;通过effect(...)注册到响应系统里。 - 三件套
effect/track/trigger:effect注册副作用;track收集依赖;trigger触发更新。 - 桶结构 =
WeakMap(target → Map(key → Set(effects))),三层映射让“哪个对象的哪个字段被谁依赖”清清楚楚。 - 分支切换靠
cleanup清旧依赖;嵌套 effect 靠effectStack栈来分内外层;无限递归靠trigger内 “effect !== activeEffect” 守卫避免栈溢出。 - 可调度性通过
scheduler选项实现:用户能决定副作用何时跑、跑几次。借助Set任务队列 + 微任务,可以做到“多次修改只重跑一次”。 - 计算属性(
computed)是懒执行的 effect,通过dirty标志缓存结果,依赖变化时由scheduler把dirty置true。 watch本质上是effect + scheduler + lazy的组合:能在回调里拿到新值和旧值。immediate控制是否立刻跑;flush控制跑时机;onInvalidate解决过期副作用带来的竞态问题。
