从同步阻塞到 async/await:一文梳理 JS 异步流程控制的进化史

本文从 JS 单线程模型出发,沿着「同步 → 异步 → 回调 → Promise → async/await」的演进路线,把异步流程控制讲透,并顺手理清宏任务 / 微任务的关系。


前言:为什么 JS 需要"异步"?

你有没有写过这样的代码,然后发现页面卡住了?

javascript 复制代码
// 一段"傻等"的同步代码
function wait10s() {
    const start = Date.now();
    while (Date.now() - start < 10000) {}
}
console.log('开始');
wait10s();
console.log('结束');

运行时你会发现:「开始」打完后,浏览器白屏 10 秒,「结束」才出现。这期间用户不能点按钮、不能滚动页面,整个页面"死"了。

为什么会这样?因为 JS 天生是单线程的。一行一行顺序执行,上面的代码没跑完,下面的代码就永远没机会。可现实世界里,页面有太多"慢动作":

  • 定时器 setTimeout 要等时间
  • fetch 请求要等网络
  • 点击事件要等用户操作

如果都按"同步排队",页面就没法用了。于是 JS 走上了一条"异步流程控制"的进化之路------从最原始的回调,到 Promise,再到 async/await,每次升级都是为了让异步代码更好写、更好读


一、同步时代:单线程 + 阻塞,最简单也最脆弱

1.1 JS 为什么是单线程的?

和 C++、Java 这种"可以开多线程"的系统级语言不同,JS 从诞生之初就是单线程设计。原因很务实:

  • JS 最早是浏览器脚本,用来操作 DOM。如果两个线程同时改同一个 DOM,谁来裁决?
  • 单线程模型简单,开发者不用操心锁、竞争、死锁这些"高级麻烦"

简单的代价就是:同一时间只能干一件事 。这件事没干完,后面所有事都得等着------这就是阻塞

javascript 复制代码
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c);  // 6

上面的代码就是典型的同步执行:声明 a → 声明 b → 声明 c → 打印,一步一步来,没有任何歧义。

1.2 阻塞的代价

如果所有任务都是"快"的,单线程其实很好。但一旦遇到"慢"任务(网络请求、定时器、读写文件),问题就来了:

javascript 复制代码
console.log('start');
setTimeout(() => {
    console.log('222');
}, 1000);
console.log('end');

输出顺序是什么?

sql 复制代码
start
end
222

setTimeout 没有"卡住"后面的代码------它被 JS 引擎"跳过"了,先执行了 console.log('end'),等 1 秒到了再回头执行回调。这种"让慢任务先靠边站,等有空了再处理"的机制,就是Event Loop(事件循环)


二、Event Loop:JS 异步的底层发动机

2.1 一句话理解 Event Loop

进程(董事长)分配资源,线程(经理)负责执行。JS 启动时:

  1. 浏览器 / Node 启动一个进程,有独立的 PID
  2. 进程启动一个主线程来跑 JS 代码(单线程)
  3. 同步代码立刻执行完;耗时的异步任务(定时器、fetch、事件)交给 WebAPI / 系统内核去做
  4. 异步任务完成后,其回调函数被塞进一个任务队列
  5. 主线程把同步代码全部跑完后,就去队列里把回调拿出来执行------这个"无限循环拿任务"的动作,就是 Event Loop
arduino 复制代码
   ┌──────────────────────────────────────┐
   │         JS 主线程(调用栈)            │
   │   先执行所有同步代码,空了才看队列       │
   └───────────────┬──────────────────────┘
                   │
                   ▼
   ┌──────────────────────────────────────┐
   │         任务队列(Queue)              │
   │   setTimeout / fetch / 点击事件回调    │
   └──────────────────────────────────────┘

2.2 宏任务 vs 微任务

任务队列其实不是"一条队列",而是分两类:

类型 常见成员 优先级
宏任务(MacroTask) setTimeoutsetIntervalI/ODOM 事件script 整体代码 低,一次 Event Loop 只取一个
微任务(MicroTask) Promise.then/catch/finallyMutationObserverprocess.nextTick(Node) 高,在当前宏任务结束后清空整个队列

执行顺序口诀:同步代码 → 微任务 → 下一个宏任务 → 微任务 → ......

javascript 复制代码
console.log('1');   // 同步

setTimeout(() => {
    console.log('2');   // 宏任务
}, 0);

Promise.resolve()
    .then(() => console.log('3'))  // 微任务
    .then(() => console.log('4')); // 微任务

console.log('5');   // 同步

输出:1 5 3 4 2

解析:同步 1 5 先跑完;接着清空微任务队列输出 3 4;最后才去宏任务队列拿 setTimeout 输出 2。即使 setTimeout 延时写 0,也一定在微任务之后------这是 Event Loop 的硬规则。


三、回调(Callback):第一代异步流程控制

3.1 回调的思路

既然异步任务不能立刻拿结果,那我把"拿到结果后要做的事"当成参数传给你,你做完了自己调一下------这就是回调。

javascript 复制代码
// 用 setTimeout 模拟一个"请求用户数据"的异步任务
function getUser(userId, callback) {
    setTimeout(() => {
        callback({ id: userId, name: '张三' });
    }, 1000);
}

// 把"拿到数据后做什么"塞进回调里
getUser(1, (user) => {
    console.log('用户:', user.name);  // 1 秒后打印"张三"
});

回调解决了"慢任务不阻塞"的问题,但它的天花板很快就到了。

3.2 回调地狱:当业务变复杂

真实业务往往不是"一次请求就完事",而是:

A 接口拿所有用户 → B 接口拿每个用户的详情 → C 接口拿详情里的订单 → ......

每一步都依赖上一步的结果,代码就会一层嵌一层:

javascript 复制代码
getUsers((users) => {
    users.forEach((user) => {
        getUserDetail(user.id, (detail) => {
            getOrders(detail.orderIds, (orders) => {
                orders.forEach((order) => {
                    renderOrder(order, () => {
                        // 还要嵌套吗?
                    });
                });
            });
        });
    });
});

这段代码就是赫赫有名的回调地狱(Callback Hell)

  • 缩进像阶梯一样往右飘,眼睛跟不上
  • 每一层的变量名不能冲突,读起来像解密
  • 错误处理要在每层单独写,漏一层就崩

回调的问题不是"不能用",而是复杂度随着嵌套层数指数级增长。业务一复杂,回调就扛不住了。


四、Promise + then:把"回调阶梯"拍平

4.1 Promise 是什么?

ES6(2015)引入的 Promise,直译是"承诺"------承诺一段时间后给你一个结果。它有三种状态:

状态 含义 说明
Pending(进行中) 任务还没做完 初始状态
Fulfilled(已成功) 任务成功了 调用 resolve(result)
Rejected(已失败) 任务失败了 调用 reject(err)

状态一旦从 Pending → Fulfilled 或 Pending → Rejected,就不可逆

4.2 Promise 基本用法

实例化 Promise 时要传一个executor 函数 ,它会立即同步执行,里面通常放耗时任务:

javascript 复制代码
const p = new Promise((resolve, reject) => {
    console.log('许诺言');   // 同步,立刻打印
    setTimeout(() => {
        // 调用 resolve 表示成功,会触发 .then
        // resolve(666);
        reject('网络错误');  // 调用 reject 表示失败,会触发 .catch
    }, 2000);
});

executor 拿到两个能力:resolve(成功时传结果给 .then)和 reject(失败时传错误给 .catch):

javascript 复制代码
p
    .then((data) => {
        console.log(data);     // 666(如果 resolve 了)
    })
    .catch((err) => {
        console.log(err);      // '网络错误'(如果 reject 了)
    })
    .finally(() => {
        console.log('finally'); // 无论成败都会执行
    });

小技巧sleep 函数的 Promise 版本经常用到:

javascript 复制代码
function sleep(t) {
    return new Promise((resolve) => setTimeout(resolve, t));
}
sleep(2000).then(() => console.log('2s 后再做'));

4.3 链式调用:Promise 的灵魂

Promise 最伟大的设计是------每个 .then 都会返回一个新的 Promise 。这意味着 .then 可以无限链下去,把嵌套的回调拍扁成一条直线:

javascript 复制代码
// 回调地狱(反例)
getUsers((users) => {
    getUserDetail(users[0].id, (detail) => {
        getOrders(detail.orderIds, (orders) => {
            render(orders);
        });
    });
});

// Promise 链(正解)
getUsers()
    .then(users => getUserDetail(users[0].id))
    .then(detail => getOrders(detail.orderIds))
    .then(orders => render(orders))
    .catch(err => console.error(err));

层级被拍平了,阅读顺序从上到下,符合人的直觉。

4.4 fetch 与 Promise 的搭配

浏览器内置的 fetch 底层就是 Promise,所以它天然可以链式调用:

javascript 复制代码
fetch('http://localhost:3000/users')
    .then(res => res.json())        // 第一步:把响应体解析为 JSON(返回新 Promise)
    .then(data => {                 // 第二步:拿到真正的数据
        console.log(data);          // 渲染或存储
    })
    .catch(err => {
        console.error('请求失败:', err);
    });

踩坑提醒fetch 即使收到 404 / 500,Promise 依然是 fulfilled(因为"请求完成了",只是业务状态不对)。需要自己判断 res.ok

javascript 复制代码
fetch(url).then(res => {
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return res.json();
});

4.5 Promise 的静态方法:六兄弟一次讲清

Promise 本身挂了 6 个高频静态方法,它们是面试必问、工作必用的"组合拳"。

4.5.1 Promise.resolve / Promise.reject:快速包装

把一个普通值立刻包装 成已结束的 Promise,省去写 new Promise(...) 的仪式感:

javascript 复制代码
Promise.resolve(666);          // 等价于 new Promise(r => r(666))
Promise.reject('err');         // 等价于 new Promise((_, r) => r('err'))

常见场景:函数需要"统一返回 Promise",但某分支是同步值,用 Promise.resolve(value) 兜底即可。

4.5.2 Promise.all:全员成功才 happy end

Promise.all([p1, p2, p3]) 传入一个 Promise 数组:

  • 全部成功 → 返回的 Promise resolve,结果是按顺序排列的数组
  • 只要有一个失败 → 整体立刻 reject,不再等其他 Promise,错误是第一个失败的原因
javascript 复制代码
const p1 = Promise.resolve(10);
const p2 = new Promise((r) => setTimeout(() => r(20), 500));
const p3 = Promise.resolve(30);

Promise.all([p1, p2, p3])
    .then(([a, b, c]) => console.log(a + b + c));  // 60(约 500ms 后)

只要有一个 reject,就直接进 catch:

javascript 复制代码
const good = Promise.resolve('OK');
const bad  = Promise.reject('NO');

Promise.all([good, bad])
    .then(console.log)
    .catch(console.error);  // 'NO'------直接走第一个失败的 catch

⚠️ 这是面试高频点:all 的语义是"一损俱损",整体失败,不等其它未完成的 Promise。

4.5.3 Promise.race:谁最快就用谁

Promise.race([...]) ------ 谁先改变状态(无论成功还是失败)就以它为准,后面的结果全部忽略:

javascript 复制代码
const slow = new Promise((r) => setTimeout(() => r('乌龟'), 2000));
const fast = new Promise((r) => setTimeout(() => r('兔子'), 500));

Promise.race([slow, fast]).then(console.log);  // '兔子'

典型用法:请求超时控制------把真实请求和一个"超时 reject 的定时器" race 起来:

javascript 复制代码
function fetchWithTimeout(url, ms = 3000) {
    const timeout = new Promise((_, rej) =>
        setTimeout(() => rej(new Error('请求超时')), ms)
    );
    return Promise.race([fetch(url), timeout]);
}

4.5.4 Promise.allSettled:全部跑完再说(ES2020)

all 的问题是"一损俱损",但很多场景你只想等所有任务跑完,不管成功失败都要拿结果 ------那就用 allSettled

javascript 复制代码
const ok  = Promise.resolve('成功');
const bad = Promise.reject('失败');

Promise.allSettled([ok, bad]).then(console.log);
// 输出:
// [
//   { status: 'fulfilled', value: '成功' },
//   { status: 'rejected',  reason: '失败' }
// ]

每个结果都用 { status, value/reason } 标识,你自己 filter 处理即可,非常适合批量上传 / 批量导入这种"允许部分失败"的场景。

4.5.5 Promise.any:只要有一个成功就行(ES2021)

all 正好相反:任意一个成功就 resolve ,所有都失败才 reject(并抛出 AggregateError 汇总所有失败原因):

javascript 复制代码
const urls = ['/api/a', '/api/b', '/api/c'];  // 任意一个能返回就行
Promise.any(urls.map(u => fetch(u)))
    .then(res => res.json())
    .catch(err => console.log('全部接口挂了:', err.errors));

适合做"多接口兜底 / 多 CDN 加速选最优"的场景。

4.5.6 六个静态方法的对比表

方法 成功条件 失败条件 返回结果
Promise.all 全部成功 任一失败 结果数组
Promise.race 最快的那个成功 最快的那个失败 第一个的结果
Promise.allSettled 永远成功(等所有结束) 从不 reject {status,value/reason}[]
Promise.any 任一成功 全部失败 第一个成功的值
Promise.resolve 立即成功 --- 包装好的成功 Promise
Promise.reject --- 立即失败 包装好的失败 Promise

4.6 穿透 / 值透传:一个反直觉的小坑

面试里经常出现这样的代码:

javascript 复制代码
Promise.resolve(100)
    .then(Promise.resolve(200))
    .then(console.log);  // 打印什么?

答案是 100 。原因:.then 要求接收的是函数 ,如果你传的不是函数(例如直接传一个 Promise 对象),JS 会把它当作忽略 ,上一个值直接"透传"到下一个 .then。正确写法是传一个函数进去:

javascript 复制代码
Promise.resolve(100)
    .then(() => Promise.resolve(200))   // ✅ 传函数
    .then(console.log);  // 200

4.7 .then 的第二个参数 vs .catch

很多人不知道 .then 其实可以接两个函数参数:

javascript 复制代码
p.then(
    (res) => console.log('成功:', res),
    (err) => console.log('失败:', err)   // 只捕获"上一个 Promise"的错误
);

它和 .catch 的关键区别:

  • .then(onFulfilled, onRejected) 里的 onRejected 只能捕获前面 Promise 本身的错误 ,捕获不到 onFulfilled 里新抛的错
  • .catch 能捕获整条链上之前任何一步的异常
javascript 复制代码
// ❌ 第二个参数捕不到 then 内部的新错误
Promise.resolve()
    .then(
        () => { throw new Error('then 里出错'); },
        (e) => console.log('A:', e)   // 不会打印
    )
    .catch((e) => console.log('B:', e));  // ✅ 这里能接住

// ✅ 统一用 .catch 做链尾兜底更稳妥

最佳实践 :链尾统一用一个 .catch 兜底,不建议在 .then 里写第二个参数(除非你明确知道只想捕获前一步的错)。


五、async / await:ES8 的终极语法糖

5.1 为什么还需要 async/await?

Promise + then 已经比回调好很多了,但当流程特别长时,链还是显得有点啰嗦:

javascript 复制代码
getUsers()
    .then(users => {
        console.log('用户数:', users.length);
        return getUserDetail(users[0].id);
    })
    .then(detail => {
        console.log('详情:', detail);
        return getOrders(detail.orderIds);
    })
    .then(orders => render(orders));

每个 .then 都是一个独立的作用域,变量不能跨层访问。ES2017(ES8)带来的 async/await,把这条链彻底拍平成顺序语句 ------异步代码同步化

5.2 两个关键字

  • async :写在函数前,让这个函数自动返回 Promise 。函数里 return 的值会被包成 Promise.resolve(value)
  • await :只能在 async 函数里用,暂停函数执行,等右边的 Promise resolve,直接拿到结果再继续。
javascript 复制代码
async function renderUsers() {
    const res  = await fetch('http://localhost:3000/users');
    const data = await res.json();
    console.log(data);
}
renderUsers();

对比一下 .then 链和 async/await

javascript 复制代码
// .then 链
fetch(url)
    .then(res => res.json())
    .then(data => { users = data; render(); });

// async/await
async function load() {
    const res  = await fetch(url);
    const data = await res.json();
    users = data;
    render();
}

整个作用域共享变量,阅读顺序就是代码顺序,可读性直接拉满

5.3 错误处理:try/catch 取代 .catch

async 函数里,.catch 换成了熟悉的 try/catch

javascript 复制代码
async function safeFetch() {
    try {
        const res  = await fetch(url);
        const data = await res.json();
        return data;
    } catch (err) {
        console.error('出错了:', err);
    }
}

不管是 fetch 失败还是 res.json() 解析失败,catch 都能兜住,一处兜底,整段安全

5.4 async/await 的本质

划重点:async/await 本质还是 Promise ,它只是语法糖------没有 Promise 就没有 async/await。await 暂停时,JS 不会真的"卡住线程",而是把 async 函数后面的部分注册成微任务(Promise.then),等同步代码跑完再回来继续------这就是"同步错觉"。

所以:await 下面的代码,比同级的 setTimeout 先执行(因为是微任务 vs 宏任务)。

5.5 并发陷阱:别把独立请求写成串行

await串行 的------写了两个 await,就一定是等第一个完成再发第二个。如果两个请求之间没有依赖 ,应该用 Promise.all 并发:

javascript 复制代码
// ❌ 串行:慢一倍(200ms)
async function slow() {
    const a = await fetch('/api/a');   // 等 100ms
    const b = await fetch('/api/b');   // 再等 100ms
}

// ✅ 并发:同时发(100ms)
async function fast() {
    const [a, b] = await Promise.all([
        fetch('/api/a'),
        fetch('/api/b')
    ]);
}

判断标准 :请求之间有依赖 → 串行 await;独立 → Promise.all 并发。


六、进化史全景图

一张表总结 JS 异步流程控制的四个阶段:

阶段 方案 优点 缺点
1 同步阻塞 简单、直观 慢任务卡死线程
2 回调 + Event Loop 不阻塞主线程 嵌套深 → 回调地狱
3 Promise + .then 链式拍平、错误分离、静态方法组合灵活 长链略啰嗦,作用域割裂
4 async/await(ES8) 顺序语句、可读性最高 独立请求要注意别写成串行

核心规律 :每一代方案都是在上一代的基础上,优化可读性降低心智负担------底层机制(单线程 + Event Loop + 宏微任务)从未变过。


七、Promise 高频面试题速解

7.1 说一下 Promise 的三种状态?

  • Pending(进行中)→ Fulfilled(已成功,调用 resolve
  • Pending(进行中)→ Rejected(已失败,调用 reject
  • 状态一旦变更就不可逆,也不能再次变更

7.2 Promise.all / race / allSettled / any 的区别?

见 4.5.6 的对比表。抓关键词就不会答错:

  • all:全成才成,一损俱损
  • race:看谁最快,成败都算
  • allSettled:全部跑完,从不 reject
  • any:成一个就行,全败才 AggregateError

7.3 .then 不传函数会怎样?(穿透 / 值透传)

.then / .catch 如果传的不是函数,会被当作"忽略处理",上一步的值原样透传给下一级------典型面试题代码见 4.6 节。

7.4 .then 的第二个参数和 .catch 有啥区别?

  • .then(res, rej)rej 只捕捉前一步 Promise 的错,捕捉不到同层 res 里抛出的错
  • .catch 能捕捉它之前整条链上任意一步 的异常 → 日常推荐链尾统一 .catch 兜底

7.5 Promise 和 setTimeout 谁先执行?

经典题,2.2 节已讲:先同步代码,再清空微任务队列(Promise.then / catch / finally),最后才执行宏任务(setTimeout)------即使 setTimeout 延时写 0 也是如此。


八、结语:怎么选?

到这里你应该已经对 JS 异步流程控制和 Promise 有了完整的认知:

  • 写简单、独立的异步逻辑 → 直接 Promise.then/.catch
  • 写长流程、有业务依赖的代码 → 优先 async/await,社区主流写法
  • 多个独立请求全成功才继续Promise.all
  • 请求允许部分失败也要拿结果 (如批量导入) → Promise.allSettled
  • 多个接口任取一个最快的成功 (如多 CDN) → Promise.any
  • 请求超时控制Promise.race([fetch, timeout])
  • 面试问到 Event Loop → 口诀「同步 → 清空微任务 → 下一个宏任务 → 清空微任务 → ...」+ 宏任务 / 微任务典型 API

一句话收尾:Promise 把"未来才有的结果"变成了"现在就能链式处理的对象",而 async/await 把这条链"拍平"成了像同步代码一样的顺序语句------它们做的是同一件事,只是写法一代比一代优雅。

相关推荐
xiaominlaopodaren1 小时前
three.js最小地图运行时(八): 瓦片调试网格
javascript·gis·three.js
circuitsosk1 小时前
智能体任务拆解与执行:基于ReAct+Plan-and-Execute框架的行业Skill构建实录
前端·javascript·python·react.js·react·llm agent·智能体编排
daols881 小时前
vue 表格vxe-table 实现紧凑型表格的方式
前端·javascript·vue.js
Java搬码工2 小时前
VUE3使用教程
前端·javascript·vue.js
小白勇闯网安圈2 小时前
Vue 3 核心进阶:组合式 API、响应式系统与 <script setup>
前端·javascript·vue.js
雪芽蓝域zzs2 小时前
Vue3 + JS + pnpm 创建项目
开发语言·javascript·ecmascript
蜡台2 小时前
Uniapp 开发H5 页面跳转拦截
前端·javascript·uni-app
IT爱学堂2 小时前
Three.js可视化企业实战WEBGL课,2023年全新WEB 3D THREEJS技术
前端·javascript·webgl
rockey62711 小时前
C#脚本引擎之AScript与Jurassic、Jint对比
javascript·c#·.net·js·script·动态脚本