两小时学习讲义:Promise 深入与 async / await
配套《学习计划.md》使用,本讲义把每个知识点打碎到颗粒级 ,并逐点给出类比 + 例子 + 真实开发场景 。 学习方法建议:先猜后跑。看到异步代码先写下输出/结果,再运行验证。 全程约 120 分钟,与学习计划的时间轴一一对应。
⏱️ 时间轴总览
| 时间段 | 环节 | 本讲义位置 |
|---|---|---|
| 20:00--20:10 | 🔁 回顾与预习 | 第 1 段 |
| 20:10--20:25 | 📚 知识块①:Promise 状态机与链式调用 | 第 2 段 |
| 20:25--20:40 | 📚 知识块②:all / allSettled / race / any | 第 3 段 |
| 20:40--20:50 | 📚 知识块③:async/await 本质与错误处理 | 第 4 段 |
| 20:50--21:00 | 📝 费曼复述 | 第 5 段 |
| 21:00--21:20 | 🛠️ 练习①:手写 myPromiseAll | 第 6 段 |
| 21:20--21:40 | 🛠️ 练习②③:sleep 串并行 + withTimeout | 第 7 段 |
| 21:40--21:50 | 💭 思考提高题 | 第 8 段 |
| 21:50--22:00 | ✅ 测验 | 第 9 段 |
第 1 段(10min)回顾与预习:接住昨天的线
1.1 先接昨日(事件循环)的线
昨天你学了 Promise.then 是微任务 、await 之后的代码会进微任务队列。今天把这些"知道怎么用"的东西推向"懂原理":
javascript
周一 闭包 变量看定义处
周二 this 调用看调用处
周三 原型链 属性看原型链
周四 事件循环 代码看事件循环(谁排队、谁先走)
周五 Promise 异步的控制流怎么组织(状态、组合、语法糖)
周四你记的"铁律"今天会反复用到:微任务先于下一个宏任务。Promise 的所有时序行为都建立在这条之上。
1.2 先凭直觉回答 4 个小问题(不用对,先写下答案)
js
// 问题 1:Promise 状态能改第二次吗?
const p = new Promise((resolve, reject) => {
resolve('成功');
reject('失败'); // 这行有效吗?
});
p.then(v => console.log(v), r => console.log('rejected', r));
// 输出?
// 问题 2:new Promise 的回调什么时候执行?
console.log('1');
new Promise(() => console.log('2'));
console.log('3');
// 顺序?
// 问题 3:下面会输出什么?
Promise.resolve()
.then(() => console.log('A'))
.then(() => console.log('B'));
console.log('C');
// 顺序?
// 问题 4:await 用在普通函数里?
function f() { await Promise.resolve(); } // 会怎样?
学完第 2、4 段再回来对照。第 1 段只做一件事:记住 Promise 是"承诺"------先发凭证,落定后不可更改。
第 2 段(15min)知识块①:Promise 状态机与链式调用
2.1 三种状态与"不可逆"
知识点(细化)
- Promise 有三个状态:
pending(进行中)→fulfilled(已成功,持有 value)或rejected(已失败,持有 reason)。 - 状态只能落定一次,不可逆 :
pending一旦变成 fulfilled 或 rejected 就永久锁定,再调用 resolve/reject 都无效。 - 这保证了
.then的回调最多执行一次------不会出现"又成功又失败"的乱象。 new Promise(executor)的 executor(执行器)是同步执行的------new 的那一瞬间就跑。
例子:先猜后跑
js
const p = new Promise((resolve, reject) => {
resolve('成功'); // 状态 → fulfilled
reject('失败'); // 无效:已落定,不可再改
});
p.then(v => console.log(v), r => console.log('rejected', r));
// 只输出:成功
// executor 同步执行
console.log('1');
new Promise(() => console.log('2'));
console.log('3');
// 1 2 3(executor 是同步的)
类比(泼出去的水 / 发出去的合同) :resolve/reject 像签合同------签完就生效,不能反悔。就算你后面再撕掉重签,第一次签字的内容也作废不了。
真实场景:为什么 fetch 的结果只能用一次?因为响应 Promise 一旦落定就锁定。这也是为什么"缓存一个 fetch 的结果"常常要存数据而不是存 Promise。
2.2 链式调用:.then 永远返回新 Promise
知识点(细化)
.then会返回一个新的 Promise ,所以可以无限.then串联。- 链上的每个回调的返回值会传给下一个
.then。 - 拍平(assimilation) :如果回调返回的是一个 Promise ,外层不会直接把它当结果,而是等它落定后再继续------这正是"把异步流程串起来"的根基。
- 失败不会自动跳过:rejected 会跳过后续
.then的成功回调,直接落到最近的失败处理(第二个参数 /.catch)。
例子:先猜后跑
js
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
delay(1000)
.then(() => { console.log('1s'); return delay(1000); }) // 返回 Promise → 被拍平
.then(() => console.log('2s')); // 等它完成才执行
// 1s(等1秒)→ 2s(再等1秒)
类比(接力棒) :.then 像接力跑------上一棒(回调)把"下一段任务"(返回的 Promise)交给你,你拿着它跑完(等它落定),再递给下一棒。每一棒都是一个新的 Promise,所以想接多少棒接多少棒。
真实场景:登录 → 拉取用户信息 → 拉取权限 → 渲染页面,四步异步流程用链式串联:
js
login()
.then(token => fetchUser(token))
.then(user => fetchPerms(user.id))
.then(perms => render(perms));
如果没有"拍平",你写不了这种链------每步都要手动嵌回调,就是"回调地狱"。
2.3 值穿透与 finally
知识点(细化)
- 不带回调的
.then()会把 value 原样透传下去。 finally无论成功失败都执行(适合收尾),但透传结果(不改 value/reason)。
例子
js
Promise.resolve('A')
.finally(() => console.log('finally 一定会执行'))
.then(v => console.log('值仍透传:', v));
// finally 一定会执行
// 值仍透传:A
真实场景 :loading 开关、清理定时器/事件监听这种"无论成败都要做"的收尾,放进 finally:
js
showLoading();
fetchData()
.then(render)
.catch(showError)
.finally(() => hideLoading()); // 成也执行、败也执行
第 3 段(15min)知识块②:all / allSettled / race / any
3.1 四兄弟总表(背下来)
知识点(细化)
| 方法 | 等谁 | 失败行为 | 返回值 | 适用 |
|---|---|---|---|---|
Promise.all |
等全部成功 | 任一失败立即 reject(快速失败) | 成功时的结果数组(按传入顺序) | 全部成功才继续(并行初始化) |
Promise.allSettled |
等全部落定(不论成败) | 永不 reject | 每项 {status, value/reason} |
想要每个任务的最终结果 |
Promise.race |
最先落定的那个(成功失败都算) | 先落定者决定结果 | 先落定者的 value/reason | 超时控制、竞速 |
Promise.any |
第一个成功的 | 全部失败才 reject(抛 AggregateError) |
第一个成功的 value | 只要有一个成功即可 |
共同点 :传入的数组里可以混普通值(会自动 Promise.resolve 包装);传非迭代对象会报错。
类比(四种开会方式)
- all = 团队全体到齐才开工;有一个人请假(失败)就整组解散返工。
- allSettled = 每位成员的最终报告都收齐------有人搞砸了也照样收,标注"失败"。
- race = 赛跑,谁先到终点算谁(到就行,不管它是不是摔了)。
- any = 海选,只要有一个晋级就收工;全部淘汰才重来。
3.2 逐个验证(先猜后跑)
Promise.all:快速失败 + 顺序一致
js
// 顺序一致:结果数组按传入顺序,不按完成先后
const slow = new Promise(r => setTimeout(() => r('慢'), 1000));
const fast = new Promise(r => setTimeout(() => r('快'), 100));
Promise.all([slow, fast]).then(([a, b]) => console.log(a, b)); // '慢 快'
// 慢先完成?不------结果是按传入顺序 [slow, fast],所以 a='慢' b='快'
// 快速失败:一个 reject,整组 reject
Promise.all([Promise.resolve(1), Promise.reject('boom')])
.then(v => console.log('不会到这', v))
.catch(e => console.log('快速失败:', e)); // 快速失败: boom
Promise.allSettled:永不 reject
js
Promise.allSettled([Promise.resolve(1), Promise.reject('err')]).then(console.log);
// [{status:'fulfilled',value:1}, {status:'rejected',reason:'err'}]
Promise.race:先落定者胜
js
Promise.race([new Promise(r => setTimeout(() => r('慢'), 1000)), Promise.resolve('快')])
.then(console.log); // 快
Promise.any:第一个成功者胜;全败才 AggregateError
js
Promise.any([Promise.reject('a'), Promise.resolve('b')]).then(console.log); // b
Promise.any([Promise.reject('a'), Promise.reject('b')])
.catch(e => console.log(e instanceof AggregateError)); // true
3.3 关键坑:all 的"快速失败"不会取消其他请求
知识点(细化) :Promise.all 遇到一个失败立即 reject,但其余 Promise 仍在后台执行 ------它们的结果只是不再被采纳。要"真正取消",all 做不到,需要 AbortController(第 8 段思考题①展开)。
真实场景
- all:页面初始化需要"配置 + 用户信息"都成功才渲染。
- allSettled:批量上报埋点,个别失败不影响整体(仍要拿到每条的成败)。
- race:请求超时控制、图片加载超时。
- any:CDN 多源取资源、头像多源获取------有一个成功就用它。
第 4 段(10min)知识块③:async/await 本质与错误处理
4.1 async 函数:总是返回 Promise
知识点(细化)
async function声明的函数总是返回一个 Promise(return 普通值会被自动包装)。- 内部
throw→ 返回的 Promise 变成 rejected。
例子
js
async function f() { return 42; }
f() instanceof Promise; // true ------ 普通值被包装
async function g() { throw new Error('boom'); }
g().catch(e => console.log(e.message)); // boom ------ 抛错变 rejected
4.2 await:.then 的语法糖,后续代码进微任务
知识点(细化)
const r = await p≈p.then(r => ...):等右侧 Promise 落定,把结果取出来。await之后的代码会被包装成微任务(这就是昨天学的"await 后续是微任务")。await只暂停当前 async 函数 ,不阻塞主线程------等待期间其他代码照常执行。- 语法限制:
await只能用在 async 函数内(或 ES 模块顶层),普通函数里用直接SyntaxError。
例子:await 不阻塞主线程(先猜后跑)
js
async function test() {
console.log('A');
await Promise.resolve();
console.log('B'); // 微任务
}
console.log('C');
test();
console.log('D');
// C A D B
类比(排队的你把流程暂停,但队伍照走) :你在餐厅点了菜(发起 Promise),await = 你坐下等菜------只是你自己暂停点菜流程,别人(主线程的其他代码、渲染、事件)照常进行。等菜上了(Promise 落定),你接着做(微任务)。
真实场景 :await 不会卡 UI------发起一个 3 秒请求时,页面还能滚动、按钮还能响应,正是因为 await 让出了主线程。
4.3 错误处理:try/catch 是首选
知识点(细化)
await一个 rejected Promise → 当场抛异常 ,可被try/catch捕获。- async 函数内的
throw→ 变成 rejected Promise(可在调用处.catch)。 - 两种兜底:函数内部
try/catch(推荐,就近处理)+ 调用处.catch(兜住漏网的)。
例子
js
async function load() {
try {
const res = await fetch('/api');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data;
} catch (err) {
console.error('加载失败', err);
return null; // 兜底返回,不让错误上抛
}
}
类比(安全气囊) :try/catch 是安全气囊------撞了(异步失败)自动弹开,驾驶员(程序)不至于车毁人亡(未捕获的 rejection)。
真实场景 :网络请求层几乎必有 try/catch + finally 关 loading + 错误提示,这是每个项目的"标配骨架"。
4.4 串行 vs 并行:看 Promise 何时被创建
知识点(细化)
- 连续
await(const a = await f1(); const b = await f2();)是串行:f2 要等 f1 完成后才发起,总耗时 = 各任务之和。 Promise.all([f1(), f2()])是并行 :两个 Promise 先创建后等待,同时开始,总耗时 ≈ 最慢者。- 判断标准:发起(调用 f())的时刻在 await 之前还是之后。
例子
js
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// 串行:约 3s
await sleep(1000);
await sleep(1000);
await sleep(1000);
// 并行:约 1s
await Promise.all([sleep(1000), sleep(1000), sleep(1000)]);
类比(上菜):串行 = 一道菜吃完再点下一道;并行 = 一次点三盘,厨房同时做。
第 5 段(10min)费曼复述:讲给"零基础同学"听
5.1 复述提纲(出声讲一遍,卡壳处回去重看)
- Promise 状态怎么流转? ------ pending → fulfilled / rejected,一次落定不可逆 ,
.then回调最多跑一次。 - 链式怎么串起来的? ------
.then永远返回新 Promise;回调返回 Promise 会被"拍平"------等它完成再继续。 - 四个组合方法怎么分? ------ all 全成或快败;allSettled 全收报告不中断;race 先到者胜;any 第一个成功者胜(全败抛 AggregateError)。
- async/await 是什么? ------
.then的语法糖;await之后是微任务;只暂停当前函数、不阻塞主线程。 - 异步错误怎么接? ------
try/catch就近处理,或调用处.catch兜底。
5.2 自测清单(每条能写出例子才算过)
- 能画出 Promise 状态流转图,并解释"不可逆"保证什么
- 能说清"拍平"是什么、为什么链式依赖它
- 能默写 all / allSettled / race / any 的"等谁 + 失败行为"
- 能解释
await为什么不阻塞主线程、为什么串行 vs 并行看"创建时机" - 能写出 async 函数的 try/catch 兜底模板
第 6 段(20min)练习①:手写 myPromiseAll(code\myPromiseAll.js)
目标:先自己写 ,卡住再看提示。写完用验证清单测。 📄 参考实现已放好:
code\myPromiseAll.js(含注释 + 多组测试用例)。
6.1 分步提示(不要一上来就看)
- 第 1 步 :签名
function myAll(iterable) { return new Promise((resolve, reject) => {...}) }。 - 第 2 步 :把 iterable 转成数组(
Array.from),处理空数组 →resolve([])。 - 第 3 步 :顺序一致性 是核心难点。用
const results = new Array(len)按下标存结果------别用 push(push 会把"先完成"的排前面,破坏顺序)。 - 第 4 步 :用计数器
remaining统计未完成数,每次成功--remaining,归零就resolve(results)。 - 第 5 步 :快速失败------每个子任务 reject 时直接调用外层的
reject(注意普通值要Promise.resolve(p)包装)。 - 第 6 步:对照参考实现,重点看第 3、4 步的写法。
6.2 验证清单(每一条都应通过)
js
myAll([1, 2, 3]); // 直接传普通值也能工作
myAll([]).then(v => console.log(v)); // [](空数组)
// 顺序一致:先慢后快的两个任务,结果仍按传入顺序
myAll([sleep(1000), sleep(100)]) → ['慢', '快'];
// 快速失败:含一个 reject 立即 reject,且不等待其他完成
myAll([sleep(1000), Promise.reject('boom')]).catch(...); // 快速失败: boom
6.3 常见错误排查
| 报错/现象 | 原因 | 修法 |
|---|---|---|
| 结果顺序乱了 | 用了 push 按完成先后存 |
用 results[i] = value 按下标存 |
| 空数组永远 pending | 没处理 length === 0 |
开头直接 resolve([]) |
| 遇到 reject 不触发整体 reject | 没在子任务失败回调里调用外层 reject | then(v=>..., reject) 把 reject 传下去 |
| 传入的普通值报"不是函数" | 忘了 Promise.resolve(p) 包装 |
每个子项先 Promise.resolve(p) |
第 7 段(20min)练习②③:sleep 串并行 + withTimeout
7.1 练习②:sleep 串并行对比(code\sleep.js)
分步提示
- 写
sleep(ms) { return new Promise(r => setTimeout(r, ms)); }。 - 串行 :
const t1 = Date.now(); await sleep(1000); await sleep(1000); await sleep(1000);打印耗时(约 3s)。 - 并行 :
const t2 = Date.now(); await Promise.all([sleep(1000), sleep(1000), sleep(1000)]);打印耗时(约 1s)。 - 最后打印差值对比,并在注释里用"创建时机"解释差异。
7.2 练习③:race 超时封装(code\withTimeout.js)
分步提示
withTimeout(promise, ms):造一个timeoutPromise,setTimeout到点reject(new Error('请求超时'))。- 用
Promise.race([promise, timeout])返回结果。 - 别忘了
finally(() => clearTimeout(timer))清掉定时器(否则即使请求先成功,定时器也会在后台继续跑、白白挂 3 秒)。 - 用"2 秒的请求 + 1 秒的超时"验证:约 1 秒报"请求超时"。
- 关键理解 :超时后原请求仍在后台运行 ------
race只是"不再采纳它的结果",并没有真正中止底层请求(要真中止需AbortController,第 8 段思考题①)。
7.3 验证清单
-
sleep.js:串行约 3s、并行约 1s,注释能解释原因 -
withTimeout.js:2s 请求 + 1s 超时 → 约 1s 报"请求超时" - 能说出"为什么超时后原请求还在跑"
第 8 段(10min)思考提高题:先独立想,再看提示
思考题①:Promise.all 快速失败后,剩余 Promise 还在执行吗?能"取消"吗?
提示
- 在:
all只是"不再采纳"其余结果,任务仍在后台跑。原因:Promise 本身没有"取消"概念,创建了就一定会执行完。 - 想"失败后取消其他请求":
all做不到。正解是AbortController------它把信号传给fetch,才能真正中断底层网络请求。 - 追问:为什么 JS 不内置取消?因为"取消"涉及"到底谁有权力取消、取消后资源怎么回收",语义复杂;框架(axios、Vue)大多用 AbortController 或
race模拟"忽略结果"式取消。
思考题②:手写 all 时怎么保证顺序一致?如果某个任务先完成怎么处理?
提示
- 不能
push------先完成的任务会占前面的位置。要用results[i] = value按下标预分配数组。 - 先完成的任务:存进它自己的下标
i即可,不关心顺序;只有remaining归零才整体 resolve。 - 想想:为什么"先完成"不影响顺序?因为数组位置由下标决定,不是由完成时刻决定。
思考题③:async/await"看着同步",和真正的同步执行本质区别是什么?
提示
- 真正的同步:主线程阻塞到结束,期间什么都不能做。
await:只暂停当前 async 函数,主线程空闲------其他代码、事件、渲染照常。- 设计一个"阻塞 vs 不阻塞"的对比:一个用同步
while睡 3 秒(页面卡死),一个用await sleep(3000)(页面可交互)------直观感受差异。
第 9 段(10min)测验与收尾
9.1 测验
- 打开
test\测验.md,限时 15 分钟完成 10 题(巩固 5 + 提升 3 + 横向 2)。 - 做完再翻答案,把错题按"概念错误 / 实现失误 / 表达不清"分类标记,回到讲义对应章节重读。
9.2 今日能力自检(打分 1--5,低于 3 分明天补)
| 能力 | 自评 |
|---|---|
| 能画出 Promise 状态机并解释不可逆 | ☐ |
| 能默写四个静态方法的"等谁 + 失败行为" | ☐ |
| 能徒手写出 myPromiseAll(顺序一致 + 快速失败) | ☐ |
| 能解释 await 不阻塞主线程、串行 vs 并行 | ☐ |
| 能用 try/catch + finally 写出健壮的异步封装 | ☐ |
📌 今日核心速记卡(可截图/摘抄带走)
javascript
状态机:pending → fulfilled / rejected,一次落定不可逆(.then 回调最多一次)
executor 是同步执行的(new 的那一刻就跑)
.then 返回新 Promise;回调返回 Promise → 被"拍平",等它落定再继续(链式根基)
finally 无论成败都执行,但透传结果
Promise.all 全部成功 → 按传入顺序返回数组;任一失败 → 立即 reject(不取消其他)
Promise.allSettled 全部落定 → 永不 reject,每项 {status, value/reason}
Promise.race 最先落定者胜(成功失败都算)→ 超时控制
Promise.any 第一个成功者胜;全败才 reject(AggregateError)
async 函数总是返回 Promise;内部 throw → rejected Promise
await = .then 语法糖;await 之后 = 微任务;只暂停当前 async 函数,不阻塞主线程
错误处理:try/catch 就近 + .catch 兜底
串行 vs 并行:看 Promise 何时创建(连续 await 串行,Promise.all 并行)
❌ 常见误区清单(学完自查,哪个说过/想过就划掉)
- "Promise 可以 resolve 后又 reject" ------ ❌ 状态一次落定不可逆
- "new Promise 的回调是异步执行的" ------ ❌ executor 是同步执行的
- "Promise.all 会取消失败的其余请求" ------ ❌ 只忽略结果,任务仍在后台跑
- "Promise.race 取第一个成功的" ------ ❌ 是第一个"落定"(失败也算)
- "async 函数返回的是 return 的值" ------ ❌ 返回的是包装后的 Promise
- "await 会阻塞主线程" ------ ❌ 只暂停当前 async 函数
- "连续 await 是并行的" ------ ❌ 是串行,并行要用 Promise.all
- "await 可以写在任意函数里" ------ ❌ 只能 async 函数内 / ES 模块顶层