一、为什么异步是 JavaScript 的必修课
假设你打开一个网页,点击“加载更多”按钮。如果浏览器必须等服务器返回数据之后才能响应你的下一次点击、滚动或输入,那么在等待的那几秒钟里,整个页面就会完全卡死——按钮点不动,文字选不了,滚动条拖不动。
这就是同步阻塞的后果。JavaScript 从诞生之初就面临这个问题:它是单线程语言,同一时间只能做一件事。如果这件事是读取文件、请求网络、定时等待等耗时操作,单线程模型就会暴露出致命的弱点——用户界面完全冻结。
JavaScript 的解决方案不是“变成多线程”,而是异步编程:把耗时操作交给浏览器的其他线程(网络线程、定时器线程、I/O 线程)去处理,主线程继续响应用户交互。等耗时操作完成了,再通过回调机制通知主线程处理结果。
这个模型看似简单,但它衍生出了一整套复杂的机制:回调函数、事件循环、任务队列、Promise、生成器、async/await。这一篇,我们将从底层的事件循环开始,一直讲到最现代的异步语法,把整条演进脉络彻底理清。
二、事件循环:JavaScript 异步的底层发动机
事件循环(Event Loop)是 JavaScript 运行时的心脏。理解它,你就理解了所有异步行为的调度规则。
2.1 为什么会有事件循环
JavaScript 引擎本身只做两件事:执行代码和管理内存(堆和调用栈)。它没有定时器、没有网络请求能力、没有文件读写能力。这些能力是宿主环境(浏览器或 Node.js)提供的。
宿主环境通过事件循环来协调 JS 引擎和这些外部能力:当 JS 引擎执行完当前任务后,事件循环从任务队列中取出下一个待执行的任务,交给 JS 引擎执行。如此往复,形成“循环”。
2.2 宏任务与微任务:两个队列的优先级之争
任务队列并不是单一的。规范中定义了两种任务源,它们进入不同的队列,优先级不同:
| 宏任务 | 微任务 | |
|---|---|---|
| 包含哪些 | script(整体脚本)、setTimeout、setInterval、I/O、UI 渲染、postMessage |
Promise.then/catch/finally、MutationObserver、queueMicrotask |
| 队列 | 宏任务队列(可以有多个) | 微任务队列(只有一个) |
| 执行时机 | 每个宏任务执行完后,下一个宏任务执行前 | 当前宏任务执行完毕后,立即清空整个微任务队列 |
| 优先级感受 | 低 | 高 |
2.3 事件循环的完整流程
浏览器中每一轮事件循环的简化流程如下:
- 从宏任务队列中取出一个宏任务执行(首次执行时,整个
<script>就是一个宏任务)。 - 执行过程中如果遇到微任务(如
Promise.then),将其加入微任务队列。 - 当前宏任务执行完毕后,立即依次执行并清空微任务队列中的所有微任务。
- 如果微任务执行过程中又产生了新的微任务,它们也会在这一轮被清空,直到微任务队列为空。
- 判断是否需要渲染更新(浏览器会根据刷新率决定,通常 60fps 即每 16.6ms 一次)。
- 开始下一轮事件循环,取下一个宏任务执行。
2.4 用代码验证宏任务与微任务的执行顺序
console.log('1: script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('3: promise then 1');
})
.then(() => {
console.log('4: promise then 2');
});
console.log('5: script end');
输出顺序:
1: script start
5: script end
3: promise then 1
4: promise then 2
2: setTimeout
逐行解析:
console.log('1'):同步代码,立即执行。输出1。setTimeout:将回调放入宏任务队列(即使延时是 0ms)。Promise.resolve().then():将回调放入微任务队列。console.log('5'):同步代码,立即执行。输出5。- 当前宏任务(整个 script)执行完毕。
- 开始清空微任务队列:执行第一个
.then,输出3;这个.then又返回一个 Promise,其后续.then再次进入微任务队列;继续清空,输出4。 - 微任务队列清空后,下一轮事件循环开始,从宏任务队列取出
setTimeout回调执行,输出2。
核心结论:即使 setTimeout 的延时是 0ms,它的回调也必须等当前宏任务和所有微任务执行完毕后才能执行。Promise 的 .then 属于微任务,所以总是比同轮的 setTimeout 先执行。
三、回调函数:异步的最初形态
在 Promise 出现之前,JavaScript 处理异步的唯一方式是回调函数:把“异步操作完成后要做什么”封装成一个函数,作为参数传给异步 API。
// 读取文件(Node.js 环境)
fs.readFile('/path/to/file', 'utf-8', function(err, data) {
if (err) {
console.error('读取失败', err);
return;
}
console.log('读取成功', data);
});
这种模式直观但有一个致命缺陷:当多个异步操作有依赖关系时(第一个请求的结果是第二个请求的参数,第二个请求的结果是第三个请求的参数……),代码会陷入层层嵌套,形如金字塔。这就是臭名昭著的回调地狱:
getUser(userId, function(user) {
getPosts(user.id, function(posts) {
getComments(posts[0].id, function(comments) {
getReplies(comments[0].id, function(replies) {
console.log(replies);
// 越来越深的缩进,越来越难以阅读和维护
});
});
});
});
回调地狱带来的问题不只是格式上的丑陋:
- 错误处理割裂:每个回调都需要单独处理错误,无法统一捕获。
- 流程控制困难:并行执行多个异步操作、按顺序执行、竞速处理——这些在回调模式下需要自己写复杂的控制逻辑。
- 代码不可组合:回调函数彼此独立,无法像同步代码那样用
return传递结果或用try...catch捕获异常。
这些痛点直接催生了 Promise 的诞生。
四、Promise:异步流程控制的第一次统一
Promise 是 ES6(2015)正式引入的标准,它提供了一个可组合、可链式调用、统一错误处理的异步模型。
4.1 Promise 的三种状态
一个 Promise 对象在任何时刻必然处于以下三种状态之一:
- pending(进行中):初始状态,异步操作尚未完成。
- fulfilled(已成功):异步操作成功完成,有了结果值。
- rejected(已失败):异步操作失败,有了失败原因。
关键规则:状态只能从 pending 变为 fulfilled(不可逆),或从 pending 变为 rejected(不可逆)。一旦状态确定,就永远凝固,不能再改变。这意味着一个 Promise 最多只会被决议一次。
4.2 基本用法:创建与消费
// 创建 Promise
let promise = new Promise(function(resolve, reject) {
// 异步操作
setTimeout(() => {
let success = true; // 模拟成功或失败
if (success) {
resolve('数据获取成功'); // 将状态改为 fulfilled,并传递结果值
} else {
reject(new Error('数据获取失败')); // 将状态改为 rejected,并传递错误原因
}
}, 1000);
});
// 消费 Promise
promise
.then(function(result) {
console.log('成功:', result); // resolve 的值会传给 then 的第一个回调
return result + ' + 额外处理'; // return 的值会被包装成新的 Promise,传给下一个 then
})
.then(function(processedResult) {
console.log('进一步处理:', processedResult);
})
.catch(function(error) {
console.error('失败:', error.message); // reject 的值或任何中间抛出的异常会传到 catch
})
.finally(function() {
console.log('无论成功还是失败都会执行'); // 清理操作,如隐藏 loading 动画
});
then 的链式调用机制:.then() 总是返回一个新的 Promise。这个新 Promise 的决议值取决于回调函数的返回值:
- 如果回调返回一个普通值,新 Promise 立即以该值
fulfilled。 - 如果回调返回一个 Promise,新 Promise 会“跟随”它,等它决议后采用相同的状态和值。
- 如果回调抛出了异常,新 Promise 会以该异常
rejected。
这套规则使得 Promise 可以像管道一样串联异步操作,每个 .then 都是管道上的一个处理节点。
4.3 Promise 的静态方法:编排多个异步操作
Promise.all(iterable):等待所有 Promise 都成功,返回它们的结果数组。只要有一个失败,立即失败。
let p1 = fetch('/api/user');
let p2 = fetch('/api/posts');
let p3 = fetch('/api/comments');
Promise.all([p1, p2, p3])
.then(([user, posts, comments]) => {
console.log('三个请求都成功', user, posts, comments);
})
.catch(error => {
console.error('至少一个请求失败', error);
});
Promise.allSettled(iterable)(ES2020):等待所有 Promise 都完成(无论成功或失败),返回每个结果的状态和值。
Promise.allSettled([p1, p2, p3])
.then(results => {
results.forEach(r => {
if (r.status === 'fulfilled') {
console.log('成功', r.value);
} else {
console.log('失败', r.reason);
}
});
});
Promise.race(iterable):竞速——返回第一个完成的 Promise 的结果(无论成功还是失败)。
let timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), 5000)
);
Promise.race([fetch('/api/data'), timeout])
.then(response => response.json())
.catch(error => console.error(error));
Promise.any(iterable)(ES2021):返回第一个成功的 Promise。如果全部失败,则返回一个聚合错误。
Promise.resolve(value) 和 Promise.reject(reason):快速创建已决议的 Promise。
Promise.resolve('立即成功').then(console.log); // 输出 '立即成功'
Promise.reject('立即失败').catch(console.error); // 输出 '立即失败'
4.4 手写 Promise:理解其内核
实现一个符合 Promises/A+ 规范的简易 Promise,是检验你对异步和闭包理解程度的试金石。
class MyPromise {
constructor(executor) {
this.state = 'pending'; // 当前状态
this.value = undefined; // 成功值或失败原因
this.onFulfilledCallbacks = []; // 成功回调队列
this.onRejectedCallbacks = []; // 失败回调队列
const resolve = (value) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = value;
// 异步执行所有成功回调(微任务)
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.value = reason;
// 异步执行所有失败回调(微任务)
this.onRejectedCallbacks.forEach(fn => fn());
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
// 值穿透:如果未传回调,则传递值
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; };
return new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
queueMicrotask(() => {
try {
let result = onFulfilled(this.value);
result instanceof MyPromise ? result.then(resolve, reject) : resolve(result);
} catch (error) {
reject(error);
}
});
};
const handleRejected = () => {
queueMicrotask(() => {
try {
let result = onRejected(this.value);
result instanceof MyPromise ? result.then(resolve, reject) : resolve(result);
} catch (error) {
reject(error);
}
});
};
if (this.state === 'fulfilled') {
handleFulfilled();
} else if (this.state === 'rejected') {
handleRejected();
} else {
// pending 状态,将回调存入队列
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
finally(onFinally) {
return this.then(
value => MyPromise.resolve(onFinally()).then(() => value),
reason => MyPromise.resolve(onFinally()).then(() => { throw reason; })
);
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
}
核心设计要点:
- 状态机:
state确保 Promise 只能决议一次。 - 回调队列:当
executor是异步操作时,then可能在resolve之前就被调用。此时需要将回调存入队列,等resolve或reject执行时再触发。 queueMicrotask:确保then的回调以微任务形式执行,符合规范。- 链式调用:
then返回新的 Promise,根据回调的返回值决定新 Promise 的状态,支持嵌套 Promise 的自动展开。
五、async/await:异步代码的同步写法
ES2017 引入的 async/await 是 Promise 的语法糖。它让你用同步风格的代码编写异步逻辑,彻底消除了 .then 链的视觉噪音。
5.1 基本语法
// async 函数总是返回一个 Promise
async function fetchUser(id) {
// await 暂停函数执行,等待 Promise 决议后取出结果值
let response = await fetch('/api/user/' + id);
let user = await response.json();
return user; // 等价于 return Promise.resolve(user)
}
// 调用 async 函数,像使用 Promise 一样
fetchUser(1)
.then(user => console.log(user))
.catch(error => console.error(error));
5.2 错误处理:try...catch 回来了
Promise 的错误需要通过 .catch() 处理,而 async/await 恢复了 try...catch 的使用:
async function loadData() {
try {
let user = await fetchUser(1);
let posts = await fetchPosts(user.id);
console.log(posts);
} catch (error) {
// 统一捕获任何一个 await 抛出的异常
console.error('加载失败:', error.message);
} finally {
console.log('清理工作');
}
}
如果不用 try...catch,也可以在调用 async 函数的地方用 .catch(),因为 async 函数本身返回 Promise,内部抛出的异常会被转换为 Promise 的 rejected 状态。
5.3 串行与并行:性能的关键
串行执行(一个等一个,总耗时累加):
// 总耗时 = fetchUser 耗时 + fetchPosts 耗时
let user = await fetchUser(1);
let posts = await fetchPosts(user.id);
并行执行(同时发起,总耗时取最长的那个):
// 总耗时 = max(fetchUser 耗时, fetchPosts 耗时)
let [user, config] = await Promise.all([
fetchUser(1),
fetchConfig()
]);
当多个异步操作彼此独立、没有依赖关系时,应该用 Promise.all 让它们并行执行,而不是无意义地串行等待。
5.4 async/await 的底层本质
async/await 并不是什么全新的机制,它是生成器 + Promise + 自动执行器的语法封装。理解这一点,能帮你穿透语法糖的迷雾。
下面这段生成器代码在行为上等价于一个 async 函数:
// 用生成器模拟 async/await
function* fetchUserGenerator(id) {
let response = yield fetch('/api/user/' + id);
let user = yield response.json();
return user;
}
// 自动执行器:递归驱动生成器的 yield
function asyncToGenerator(generatorFunc) {
return function(...args) {
let generator = generatorFunc.apply(this, args);
return new Promise((resolve, reject) => {
function step(key, arg) {
let result;
try {
result = generator[key](arg); // 执行 next() 或 throw()
} catch (error) {
return reject(error);
}
let { value, done } = result;
if (done) {
return resolve(value);
} else {
// 将 yield 出的值包装成 Promise,等待决议后递归
return Promise.resolve(value).then(
val => step('next', val),
err => step('throw', err)
);
}
}
step('next');
});
};
}
// 使用
let fetchUserAsync = asyncToGenerator(fetchUserGenerator);
fetchUserAsync(1).then(user => console.log(user));
这个自动执行器的核心逻辑:
- 调用生成器函数,拿到迭代器对象。
- 调用
generator.next(),执行到第一个yield,得到yield后面的 Promise。 - 等待这个 Promise 决议,然后将决议值作为
next()的参数传回生成器(这对应了let response = await fetch(...)中的赋值)。 - 重复步骤 2-3,直到生成器执行完毕(
done: true)。 - 返回最终结果的 Promise。
async/await 就是把这个自动执行器内建到了 JavaScript 引擎中,让开发者无需手动编写生成器和执行器。
六、综合演示:从回调到 async/await 的四种写法对比
假设有一个需求:获取用户信息 → 根据用户 ID 获取其文章列表 → 获取第一篇文章的评论列表。我们用四种方式分别实现:
// 模拟异步 API
function fetchUser(id) {
return new Promise(resolve => setTimeout(() => resolve({ id, name: '用户' + id }), 1000));
}
function fetchPosts(userId) {
return new Promise(resolve => setTimeout(() => resolve(['文章1', '文章2', '文章3']), 1000));
}
function fetchComments(postTitle) {
return new Promise(resolve => setTimeout(() => resolve(['评论A', '评论B']), 1000));
}
// ===== 方式一:回调地狱 =====
function callbackHell(userId) {
fetchUserCallback(userId, function(user) {
fetchPostsCallback(user.id, function(posts) {
fetchCommentsCallback(posts[0], function(comments) {
console.log('回调地狱结果:', comments);
});
});
});
}
// ===== 方式二:Promise 链 =====
function promiseChain(userId) {
return fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0]))
.then(comments => console.log('Promise 链结果:', comments))
.catch(error => console.error('出错:', error));
}
// ===== 方式三:async/await 串行 =====
async function asyncSerial(userId) {
try {
let user = await fetchUser(userId);
let posts = await fetchPosts(user.id);
let comments = await fetchComments(posts[0]);
console.log('async/await 串行结果:', comments);
} catch (error) {
console.error('出错:', error);
}
}
// ===== 方式四:async/await + 并行优化 =====
async function asyncParallel(userId) {
try {
let user = await fetchUser(userId);
// fetchPosts 和 fetchComments 可以并行(如果知道 posts 是固定的,实际场景可能需要先获取 posts)
let [posts, _] = await Promise.all([
fetchPosts(user.id),
fetchConfig() // 假设有一个独立操作
]);
let comments = await fetchComments(posts[0]);
console.log('async/await 并行结果:', comments);
} catch (error) {
console.error('出错:', error);
}
}
演进的价值一目了然:从嵌套地狱到链式调用,再到同步风格书写,每一步都在提升代码的可读性和可维护性。
七、本篇小结
这一篇我们系统梳理了 JavaScript 异步编程的完整知识体系:
- 事件循环:JavaScript 单线程模型的异步基础。宏任务(
script、setTimeout、I/O)和微任务(Promise.then、queueMicrotask)构成两级任务队列。每轮循环取一个宏任务,执行完毕后清空所有微任务,然后可能渲染 UI,再进入下一轮。 - 回调函数:异步的最初形态,简单直观但容易陷入“回调地狱”,错误处理和流程控制困难。
- Promise:三种不可逆状态(pending → fulfilled/rejected),链式调用(
then返回新 Promise),统一错误处理(catch捕获前面任一环节的异常),以及编排多个异步操作的静态方法(all、race、allSettled、any)。 - 手写 Promise:核心在于状态机管理、回调队列的异步执行、以及
then的链式返回新 Promise 的机制。 async/await:Promise 的语法糖,底层是生成器 + 自动执行器。恢复了try...catch的异常处理方式。注意区分串行和并行,避免性能浪费。- 生成器 + 自动执行器:
yield挂起函数,外部通过next()注入值并恢复执行,这是async/await的底层原理。
异步编程是 JavaScript 的核心竞争力。从浏览器的事件循环到 Node.js 的事件驱动,从前端的用户交互到后端的并发请求,异步无处不在。把这一篇吃透,你就能在任何异步场景下写出正确、高效、可维护的代码。
系列至此小结
至此,《前端圭臬》JS 篇的核心内容已经覆盖:
- 简史与面向对象:原型链、
prototypevs__proto__、class语法糖、this绑定。 - 数据类型:栈与堆、
typeof的坑、instanceof的原理、类型转换规则、浮点数精度。 - 对象剖析:属性描述符、访问器属性、对象状态控制、属性遍历、浅拷贝与深拷贝。
- 执行上下文(上下篇):创建阶段与执行阶段、词法环境与变量环境、作用域链、暂时性死区、闭包、
with和eval的陷阱。 - 异步编程:事件循环、宏任务与微任务、回调 → Promise → async/await 的演进、手写 Promise、生成器自动执行器。
这些内容是 JavaScript 语言的“内核”。掌握它们,你不再是“会用框架写页面”的开发者,而是“理解语言本质、能独立分析和解决问题”的工程师。
专栏后续预告
《前端圭臬》专栏后续将继续深入浏览器原理、性能优化、前端工程化等领域。具体选题和更新节奏,敬请关注专栏公告。
前端,每周更新。













暂无评论内容