1. 第 79 天:学习进入关键转折点
如果你也正在自学的路上,你应该能体会那种感觉——在坚持了两三个月之后,学习状态会出现一次明显的分层。前三十天靠热情,中间三十天靠习惯,而走到第七十多天的时候,能不能突破"什么都会一点、什么都不精"的瓶颈,就看这个阶段的选题和心态了。
我给自己定的方向是前端开发,从 HTML、CSS 起步,走到第 79 天时,JavaScript 已经学完基础语法、DOM 操作、ES6 常用特性,也做了几个静态页面和一个小型 Todo 应用。但说实话,写出来的代码都是"串行思维"——等一个请求回来,再发下一个,页面卡顿、请求排队、状态混乱的问题时有发生。说到底,是因为一直绕开了 JavaScript 最核心也最抽象的部分:异步机制。
所以第 79 天我没有急着继续向下推进新框架,而是把这一天整块留给"异步编程"这个主题。计划分三块:先把事件循环的机制彻底理清,然后用一个实际的小项目把 Promise 用熟,最后复盘 79 天以来的学习路线,看看接下来的方向怎么调整。这篇文章就是当天学习过程的完整记录,原始笔记比较零散,我整理成了由浅入深的结构化内容,把踩过的坑和推导过程也一并写清楚,希望能给同样卡在异步这里的人一点参考。
这一天的计划其实并不复杂,但安排上有个讲究:上午只做输入,下午只做输出。上午看资料、画执行流程图、抄示例代码,下午完全不看新知识,只把手头的"并发请求调度器"用 Promise 从零实现一遍,遇到问题先回忆原理再查资料。这种"输入和输出隔离"的做法,是我从第 60 天开始坚持的,也是今天能把这几个知识点真正内化的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 重头戏:事件循环到底是怎么回事
2.1 先从"为什么需要异步"说起
JavaScript 是单线程语言,这意味着同一时刻只能做一件事。如果所有操作都排队执行,遇到网络请求、文件读取这样的耗时任务,整个页面就会卡死,用户点了按钮半天没反应。为了解决这个问题,JavaScript 设计出一套"先干完能干的,再回过头处理耗时任务"的调度机制,这就是事件循环。
理解事件循环,最关键的是分清两套队列:宏任务队列(macrotask queue)和微任务队列(microtask queue)。很多初学者有一个误区,认为异步任务就是"扔到一边,等有结果了再拿回来执行"。这个说法方向对了,但细节不对——异步任务并不是"有了结果再执行",而是"到点了就执行"。比如 setTimeout 设置为 0 毫秒,并不是立即执行,而是至少等待当前同步代码跑完,再排到宏任务队列里。
我整理了一下自己终于看明白事件循环时的笔记,核心就四句话:
- 同步代码永远最先执行,执行完才轮到队列里的任务。
- 每个宏任务执行完之后,必须把当前微任务队列里的所有任务全部清空,才能执行下一个宏任务。
- 微任务包括 Promise 的 then/catch/finally、queueMicrotask、MutationObserver。宏任务包括 setTimeout、setInterval、I/O 操作、UI 渲染。
- 事件循环不是"两个队列交替执行",而是"宏任务一个一个执行,微任务一堆一堆清空"。
这四句话看起来简单,但真正遇到复杂嵌套的异步代码时,很多人还是会栽跟头。
2.2 用一段代码验证自己的理解
我建议所有学到这里的人,不要只看资料,一定要亲手动笔画出执行顺序。当天我在纸上推演了下面这段代码,第一次推演结果全错,后来才逐步纠正了理解:
javascript复制console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve()
.then(() => {
console.log('C');
return Promise.resolve();
})
.then(() => {
console.log('D');
});
queueMicrotask(() => {
console.log('E');
});
console.log('F');
我在纸上先写了一遍答案,当时写的是 A、F、B、C、D、E,原因是想当然地认为 queueMicrotask 和 Promise 是两类东西。实际跑出来的结果是 A、F、C、E、D、B。这里的关键点有两个:
第一个关键点,Promise 的 then 回调注册时机。Promise.resolve() 执行的时候,状态已经变为 fulfilled,then 里的回调会立刻被放进微任务队列,而不是等下一轮。所以 C 和 E 都在当前同步代码 F 之后、第一个宏任务 B 之前执行。
第二个关键点,为什么 D 在 E 后面?这涉及到 then 链的一个隐藏行为:当第一个 then 的回调返回一个新的 Promise 时,这个新 Promise 的决议过程会额外产生一次微任务排队。也就是说,C 执行之后,要等内部那个 Promise.resolve() 完成决议,D 才会被排进微任务队列。而 E 在 C 之后的同步过程中就已经被排队了,所以 E 排在 D 前面。
这种嵌套微任务的执行顺序,是笔试和面试里最常见的考点,也是实际开发中引发隐性 bug 的高频来源。当天我用 Chrome DevTools 的 Performance 面板录制了一段执行过程,观察了任务在 Timeline 上的真实排布,发现纸面推导和实际渲染的排队顺序完全一致时,算是真正把这块机制装进脑子里了。
2.3 宏任务和微任务的优先级陷阱
我后来重新审视为什么最初会推演错,发现核心问题在于:我一直以为微任务队列会被宏任务"插队",即每执行完一个任务就去宏任务里看看时间到了没有。其实不是。事件循环的顺序逻辑是:当前宏任务执行完毕后,先检查微任务队列是否为空,不为空就全部执行;等到微任务队列空了,才会去宏任务队列里取下一个任务。这意味着,如果微任务里不断添加新的微任务,宏任务就可能永远得不到执行。
javascript复制setTimeout(() => {
console.log('宏任务终于执行了');
}, 0);
function loopMicrotask() {
queueMicrotask(() => {
console.log('微任务');
loopMicrotask();
});
}
loopMicrotask();
这段代码会无限循环打印"微任务",而 setTimeout 里的回调永远没机会执行。这个例子让我彻底记住了微任务和宏任务的优先级关系:微任务拥有绝对的清空优先权,而不是简单的"先来后到"。
这个知识点在真实项目中有什么用?最典型的场景是 loading 状态的切换。如果你在一个同步任务里先显示 loading,然后回头执行一个异步任务,由于微任务的插队机制,loading 状态可能还没来得及渲染就被移除了,界面上的表现就是 loading 闪都不闪。解决这个问题,通常需要在宏任务里触发 loading 的关闭,或者用 requestAnimationFrame 把状态更新推迟到渲染帧。这类问题排查起来特别隐蔽,因为它不是报错,只是视觉效果不对。
3. 实战演练:用 Promise 手写一个带并发限制的请求调度器
3.1 为什么选择这个练习
理论学习如果只停留在"看得懂"的层面,很快就会忘。当天下午我给自己定的题目是:实现一个请求调度器,要求最多同时发起 N 个请求,每个请求是异步的,当一个请求结束时,自动从待处理队列里取出下一个请求执行,直到全部完成。
这个练习为什么选得好?因为它几乎覆盖了异步编程的所有核心概念:Promise 的创建与决议、队列操作、错误处理、递归(或者循环)调用、以及异步边界的竞态问题。更重要的是,它是真实业务中非常常见的一个需求——比如上传多张图片时限制并发数,批量查询接口时防止打爆服务器,或者爬虫抓取时控制请求速率。
我给自己定的约束条件是:不依赖任何第三方库,不能用 async/await 的现成便利(可以用,但最后要能用 Promise 解释清楚每一步),手写出来,然后跑一组测试用例验证。
3.2 第一版实现:就着直觉写
我最开始的思路很简单:维护一个任务队列,按顺序启动前 N 个,每个任务完成时从队列里再取一个补上。代码如下:
javascript复制class Scheduler {
constructor(limit) {
this.limit = limit;
this.queue = [];
this.activeCount = 0;
}
add(promiseCreator) {
this.queue.push(promiseCreator);
this.run();
}
run() {
while (this.activeCount < this.limit && this.queue.length > 0) {
const creator = this.queue.shift();
this.activeCount++;
creator()
.then(() => {
this.activeCount--;
this.run();
})
.catch(() => {
this.activeCount--;
this.run();
});
}
}
}
这个版本能跑通最基本的场景,但有个很明显的隐患:如果 promiseCreator 抛出的异常没有被 catch 捕获,那么 activeCount 就不会递减,整个调度器就会停滞。所以 catch 分支必不可少。
还有一个细节,我当时没意识到后来被测试抓住的问题:run() 在初始化时会直接启动 N 个任务,但如果在任务执行过程中继续 add 新的任务,while 循环的判断条件可能已经结束了,新加入的任务怎么样才能被调度到呢?我第一版的思路是每次 add 都调用一次 run(),相当于每次都尝试补位。这种方式可行,但在并发较高的情况下会有一个隐患:例如 limit 是 3,当前已经有 2 个任务在跑,此时连续 add 了 5 个任务,每一次 add 都会触发一次 run(),而 run() 里的 while 循环会一直执行到 activeCount 达到 limit 为止。这其实没问题,但要注意每次 add 触发 run() 不是重复启动,因为一旦 activeCount 达到上限,while 条件不满足,自然就退出了。
3.3 第二版实现:错误处理与任务去重
第一版跑通了基本测试之后,我做了两处改进。第一处是错误处理:我不想让失败的请求导致调度器停止,但同时我希望外部调用者还能感知到每个任务各自的失败。这里有个设计取舍,有人喜欢把错误处理放在调度器内部统一吞掉,有人希望错误抛给调用方。我选择了后者——把 catch 里的逻辑拆出来,同时保留调用链的完整性。
改完之后是这样的:
javascript复制class Scheduler {
constructor(limit) {
this.limit = limit;
this.queue = [];
this.activeCount = 0;
}
add(promiseCreator) {
return new Promise((resolve, reject) => {
this.queue.push({ promiseCreator, resolve, reject });
this.run();
});
}
run() {
while (this.activeCount < this.limit && this.queue.length > 0) {
const { promiseCreator, resolve, reject } = this.queue.shift();
this.activeCount++;
promiseCreator()
.then((result) => {
this.activeCount--;
resolve(result);
this.run();
})
.catch((error) => {
this.activeCount--;
reject(error);
this.run();
});
}
}
}
这个版本的关键改进是:add 方法现在返回一个 Promise,调用者可以拿到每个任务自己对应的结果或错误。因为在队列中存下了 resolve 和 reject,当任务真正执行完时,才把结果传给外部等待者。
我当时想不通的一个问题是:为什么不能在 add 里直接执行 promiseCreator,然后把它返回的 Promise 直接链式操作?后来想明白了——如果直接在 add 里执行,那就没有并发控制的意义了,所有任务会立即同时发起。调度器的本质是"延迟执行",在把 creator 放入队列时,它对应的resolve/reject 就已经被创建出来,但真正调用 creator 的时刻,由 run() 决定。这种"把决议推迟到实际执行时"的模式,在异步编程里很常用,值得记住。
3.4 用 async/await 改写:更直观的版本
手写 Promise 版本之后,我又用 async/await 写了一个更直观的版本,用来对比两种思路的差异:
javascript复制class Scheduler {
constructor(limit) {
this.limit = limit;
this.queue = [];
this.activeCount = 0;
}
async add(promiseCreator) {
if (this.activeCount >= this.limit) {
await new Promise((resolve) => this.queue.push(resolve));
}
this.activeCount++;
try {
return await promiseCreator();
} finally {
this.activeCount--;
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
}
}
}
这里用了一个经典的"等待队列"技巧:当活动任务达到上限时,add 会创建一个新的 Promise,并把 resolve 存入队列;当前任务完成后,从队列取出一个 resolve 并调用,唤醒对应的等待者。这个版本的逻辑比第一个版本更加清晰,因为不再需要 run() 的递归调用,而是用"排队等待 + 唤醒"的方式实现了同样的效果。
不过这个版本也有它的缺点:每个 add 都是一个 async 函数,当一个任务等待时,它不会阻塞其他任务的调用,因为 await 会立即让出执行权。这在逻辑上没问题,但对初学者来说,这行 await new Promise(...) 挺绕的,需要多看几遍才能理解"把 resolve 存起来以后调用"的精妙之处。我个人建议,第一遍学习时用 Promise 版本理解底层,第二遍用 async/await 版本体会代码简化,两个都要写一遍。
4. 调试过程中踩到的三个坑
4.1 坑一:并发数为什么会超过设置的上限
第一次跑测试的时候,我把 limit 设置为 2,然后一次性 add 了 6 个任务。为了验证并发控制,每个任务的内部打印了开始时间和结束时间。结果发现,在某些次运行时,同时执行的任务数居然有三个。我反复查看代码,逻辑上明明限制了 activeCount < this.limit,怎么可能突破?
后来发现,问题出在测试代码本身。我在循环里 add 了 6 次,但 add 的调用是同步的,而且每次 add 内部都会执行 run()。run() 里的 while 循环会一口气把 activeCount 填满到 limit。当某个任务完成时,它会再次调用 run(),这时候如果同时有多个任务完成,它们会依次调用 run(),而每次 run() 都可能启动新的任务。
真正导致并发超过上限的玩法,是我在使用 Scheduler 时,用了一个异步函数来添加任务,并且在 await 之后继续 add。举个例子:
javascript复制async function addTasks() {
for (let i = 0; i < 10; i++) {
scheduler.add(() => fakeRequest(i));
await delay(0);
}
}
这里的 await delay(0) 看起来人畜无害,但它会让出执行权,导致之前 add 的任务可能还没有真正启动,循环又添加了新的任务。我的调度器实现里,run() 是同步执行的,所以正常情况下不会超过 limit。但假设我在 add 里做了异步等待,比如等待上一个任务完成后再 add,就可能导致一些预想不到的时序。
排查了半天,最终发现这个坑其实不在于调度器,而在于"测试环境里对任务开始时间的打印和真实并发判断之间的偏差"。因为 fakeRequest 内部的 setTimeout 本身有排队延迟,打印"开始"并不等于实际发起网络请求。所以我后来改了测试方式:不再用打印时间戳的方式判断并发,而是用一个计数器,在任务真正执行时自增,结束时自减,用 Math.max 记录历史峰值。这样得到的并发数才是真实的。
4.2 坑二:Promise 的 resolve 被重复调用
第二版 Scheduler 里,我最初写的队列元素是 { promiseCreator, resolve, reject } 这样的对象。问题出现在一个边界场景:如果同一个 promiseCreator 被 add 了两次,而外部逻辑对这两个 add 返回的 Promise 都做了处理,会不会出现某个 resolve 被触发两次?
我实际遇到的情况是,一个任务在极短时间内完成,它的 then 回调还没执行完,run() 已经在执行了,然后 new 了一个相同的任务,又触发了对同一个 resolve 的调用。虽然 Promise 规范规定 resolve 只能生效一次,后续调用会被忽略,但代码执行到的时候心里还是没底,总觉得哪里不对。
这个问题的根源在于:我一开始写了一个工厂函数,创建了同一个 Promise 实例的引用,然后把它当成 creator 传入。调度器内部执行的是 creator(),如果 creator 返回的是同一个 Promise 实例,那么多个任务共享同一个 Promise 的结果。这是使用方的问题,不是调度器的 bug。但这也提醒了我,在写异步库时,类型的健壮性同样重要——如果答应的是"接收一个返回 Promise 的函数",那么就应该只信任函数,不信任返回值的唯一性。
4.3 坑三:任务失败后调度器"卡死"
最让我抓狂的一个 bug 是:当一个任务 reject 时,调度器整个就停了,后续任务全部不再执行。我一开始以为是 catch 里的逻辑写错了,反复检查发现 catch 分支确实有调用 run(),而且 activeCount 也减了。但为什么后面没有任务继续跑?
后来我打印了堆栈,发现真正的原因藏在 add 返回的 Promise 的 reject 行为中:当我在测试代码里用 await scheduler.add(...) 接收结果时,如果这个 await 不包裹 try/catch,那么异常会导致外层 async 函数中断,循环后续的 add 调用根本不会执行。这不是调度器的问题,而是调用方的异常处理缺失。但是,这个问题值得所有写异步库的人注意——如果你的库把错误"抛出"给调用方,而调用方没有处理,会导致整个调用链中断,而这个中断在事件循环里表现得就像"调度器卡死"一样。
我最终的修正方案是:在测试代码中,所有 await scheduler.add 的地方都包裹了 try/catch;同时在调度器的 catch 分支里,把错误通过 reject 抛出的同时,不中断调度器自身的执行。这两个层面缺一不可。
5. 从第 1 天到第 79 天:学习路线的复盘与调整
5.1 每 20 天做一次"断舍离"
第 79 天的学习内容在下午四点半左右就全部完成了,剩下的时间我用来做整个学习阶段的复盘。我发现自己前 79 天最大的问题不是不够努力,而是学得太杂。第 20 天的时候我在学 CSS 动画,第 30 天开始写 JavaScript,第 45 天又去碰了 Node.js,第 60 天差点开始学 Python。每个方向都只学了皮毛,然后被新的"看起来很有趣的东西"吸引走。
我的调整策略是:每 20 天为一个周期,只保留一个主攻方向,其他全部放弃或延迟。第 79 天正好是第四个周期的起点,我把方向定为"JavaScript 异步编程 + 浏览器渲染机制",预计用 20 天把这个领域学透,期间可以涉及 Node.js 和浏览器 API,但只是作为工具,不作为主攻方向。这个"20 天断舍离"是我目前试过最有用的学习方法,它避免了"什么都学、什么都不会"的困境。
5.2 输出倒逼输入的正确姿势
我之前一直听说"以输出倒逼输入"这个说法,但前 60 天执行得很差——因为我的"输出"只是写笔记,本质上还是抄书,没有真正逼自己去解决问题。第 61 天开始,我把输出升级成了"做一个可以被别人使用的工具",比如今天的并发调度器,比如上周写的错误日志格式化工具,比如前几天的 localStorage 封装库。
这种方式和写笔记有本质区别:写笔记只需要"我理解了",做工具需要"它能跑、能处理边界情况、能应对别人奇怪的使用方式"。今天这个调度器,如果只是阅读文档,我绝对不会想到要处理 activeCount 恢复失败、任务重复、队列唤醒时序这些问题。但写代码的时候,这些全变成了绕不开的细节。所以我不建议任何人用"抄代码"代替练习,抄十遍不如自己从零写一遍。
5.3 记录"错误认知"比记录"正确知识"更有价值
今天的学习日记里,我用了整整两页纸记录的是那些错误的推演结果和错误认知,而不是正确的结论。比如前面提到的事件循环推演,我第一次写出的答案是错的,我把错误的答案和正确的答案并列放在一起,旁边标注了自己为什么错、错在哪里。这种记录方式看起来有点蠢,但实际上效果极好——因为当我再次遇到类似问题时,那个错误答案会立刻跳出来提醒我,这样记忆比背正确答案深刻得多。
这一点也推荐给所有自学者:做一个"错误集",每次做题、写代码、调试时把错误的认知记下来,标注当时的思考过程和纠正后的理解。这个错误集几个月后再回看,会发现自己的成长轨迹比任何学习计划都清晰。
5.4 下一阶段的具体安排
回到第 79 天的节点上,我给自己定的接下来 20 天计划是:第一周深入事件循环的边界场景,包括浏览器和 Node.js 两种环境的差异;第二周开始写一个小型 Promise 库,涵盖 Promise.all、Promise.race、Promise.allSettled 的实现;第三周把并发调度器扩展成一个支持重试、超时、取消的完整异步工具库;最后几天用来总结和写一篇详细的使用文档。
这个计划里最核心的原则是:每一个新知识点都必须落在一个能够运行的项目里。学习和应用之间不隔夜,这是我 79 天以来最重要的心得。如果你也走到类似的学习阶段,不妨试试把节奏放慢一点,把某一天完全留给某一个主题,而不是每天蜻蜓点水地学几个知识点。深度比速度重要,尤其是进入了异步编程这个分水岭之后,一次彻底的理解胜过十次囫囵吞枣。
