异步操作
# 07.异步操作
# 目录介绍
# 7.1 解决局限性
JavaScript 单线程与事件循环的底层原理:JavaScript 采用单线程模型,所有代码在一个主线程上执行。这是因为 JavaScript 最初设计用于操作 DOM,多线程同时修改 DOM 会导致竞态条件。为了实现非阻塞 I/O,JavaScript 引擎配合事件循环(Event Loop) 工作:主线程执行同步代码(调用栈)→ 同步代码执行完毕 → 清空微任务队列(Promise.then、MutationObserver、queueMicrotask)→ 从宏任务队列(setTimeout、setInterval、I/O、UI 渲染)中取出一个任务执行 → 重复。关键点:微任务在每个宏任务之后、下一个宏任务之前全部执行完毕,这就是为什么 Promise.then 总是比 setTimeout 先执行。
# 7.1.4 宏任务和微任务
在 JavaScript 中,异步任务分为两种:
- 宏任务(Macro Task)
- 包括
setTimeout、setInterval、I/O 操作、UI 渲染等。 - 宏任务会被放入宏任务队列,等待事件循环处理。
- 微任务(Micro Task)
- 包括
Promise、MutationObserver等。 - 微任务会被放入微任务队列,在当前宏任务执行完毕后立即执行。
# 7.1.5 Web Workers
为了充分利用多核 CPU 的性能,JavaScript 提供了 Web Workers:
多线程支持:Web Workers 允许在后台运行脚本,独立于主线程。
通信机制:通过 postMessage 和 onmessage 实现主线程与 Worker 线程之间的通信。
限制:Worker 线程不能直接操作 DOM。
# 7.1.6 整体的总结
| 特性 | 说明 |
|---|---|
| 单线程模型 | JavaScript 只有一个主线程执行代码。 |
| 异步编程 | 通过回调函数、Promise、async/await 实现非阻塞操作。 |
| 事件循环 | 管理调用栈、任务队列和微任务队列,确保异步任务有序执行。 |
| 宏任务与微任务 | 宏任务包括 setTimeout、setInterval,微任务包括 Promise。 |
| Web Workers | 允许在后台运行脚本,突破单线程限制。 |
# 7.3 异步操作模式
异步编程,下面总结一下异步操作的几种模式。
- 非阻塞操作:通过异步 API(如
setTimeout、Promise、fetch等),将耗时任务放到后台执行,避免阻塞主线程。 - 回调函数:异步任务完成后,通过回调函数通知主线程。
# 7.3.1 回调函数
回调函数是异步编程的最基本方式,将函数作为参数传递给异步操作,操作完成后调用该函数。
function fetchData(callback) {
setTimeout(() => {
const data = 'Some data';
callback(data);
}, 1000);
}
fetchData((data) => {
console.log(data); // 1秒后输出: Some data
});
2
3
4
5
6
7
8
9
10
缺点:
- 回调地狱(Callback Hell):多层嵌套回调导致代码难以维护。
- 错误处理不便。
# 7.3.2 事件监听
另一种思路是采用事件驱动模式。异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。
还是以f1和f2为例。首先,为f1绑定一个事件(这里采用的 jQuery 的写法 (opens new window))。
f1.on('done', f2);
上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}
2
3
4
5
6
上面代码中,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。
- 优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以去耦合,有利于实现模块化。
- 缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。
# 7.3.3 发布/订阅
事件完全可以理解成“信号”,如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。
首先,f2向信号中心jQuery订阅done信号。
jQuery.subscribe('done', f2);
然后,f1进行如下改写。
function f1() {
setTimeout(function () {
// ...
jQuery.publish('done');
}, 1000);
}
2
3
4
5
6
上面代码中,jQuery.publish('done')的意思是,f1执行完成后,向信号中心jQuery发布done信号,从而引发f2的执行。
f2完成执行后,可以取消订阅(unsubscribe)。
jQuery.unsubscribe('done', f2);
这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。
# 7.3.4 async/await
async/await 是 ES8 引入的语法糖,基于 Promise,使异步代码看起来像同步代码。
async function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
const data = 'Some data';
resolve(data);
}, 1000);
});
}
async function main() {
try {
const data = await fetchData();
console.log(data); // 1秒后输出: Some data
} catch (error) {
console.error(error);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
优点:1.代码更简洁,易于阅读和维护。2.结合 try-catch 实现错误处理。
# 7.3.5 异步模式的技术演变
疑惑→答疑→论证→结果展示:
疑惑:为什么 JavaScript 的异步编程经历了回调 → Promise → async/await 的演变?
答疑:每一代方案都在解决前一代的痛点——回调地狱的可读性、错误处理的一致性、代码的同步化表达。
论证:
// 第1代:回调函数(ES3 时代)
// 问题:回调地狱、错误处理分散
getUser(userId, (err, user) => {
if (err) return handleError(err);
getProfile(user.id, (err, profile) => {
if (err) return handleError(err);
// 继续嵌套...
});
});
// 第2代:Promise(ES6/2015)
// 解决:链式调用扁平化、统一错误处理
getUser(userId)
.then(user => getProfile(user.id))
.then(profile => render(profile))
.catch(err => handleError(err));
// 第3代:async/await(ES2017)
// 解决:代码看起来像同步、try/catch 捕获错误
async function loadProfile(userId) {
try {
const user = await getUser(userId);
const profile = await getProfile(user.id);
render(profile);
} catch (err) {
handleError(err);
}
}
// 第4代:顶层 await(ES2022)
// 模块可以直接使用 await
const response = await fetch('/api/config');
const config = await response.json();
export default config;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
结果展示:每一代的核心进步——
| 特性 | 回调 | Promise | async/await |
|---|---|---|---|
| 代码结构 | 嵌套金字塔 | 链式调用 | 顺序同步风格 |
| 错误处理 | 每层单独处理 | .catch() 统一 | try/catch |
| 并行控制 | 手动计数 | Promise.all | await Promise.all |
| 取消支持 | 无 | 无原生支持 | AbortController |
# 7.4 异步流程控制
# 7.4.2 串行执行
# 7.4.3 并行执行
流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数。
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}
function final(value) {
console.log('完成: ', value);
}
items.forEach(function(item) {
async(item, function(result){
results.push(result);
if(results.length === items.length) {
final(results[results.length - 1]);
}
})
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上面代码中,forEach方法会同时发起六个异步任务,等到它们全部完成以后,才会执行final函数。
相比而言,上面的写法只要一秒,就能完成整个脚本。这就是说,并行执行的效率较高,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。
# 7.4.4 并行与串行结合
所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务,这样就避免了过分占用系统资源。
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;
function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}
function final(value) {
console.log('完成: ', value);
}
function launcher() {
while(running < limit && items.length > 0) {
var item = items.shift();
async(item, function(result) {
results.push(result);
running--;
if(items.length > 0) {
launcher();
} else if(running === 0) {
final(results);
}
});
running++;
}
}
launcher();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
上面代码中,最多只能同时运行两个异步任务。变量running记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行final函数。
这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节limit变量,达到效率和资源的最佳平衡。
# 7.5 定时器
JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()和setInterval()这两个函数来完成。它们向任务队列添加定时任务。
# 7.5.1 setTimeout
setTimeout 用于在指定的延迟时间后执行一次回调函数。
语法
setTimeout(callback, delay, arg1, arg2, ...);
参数
callback:要执行的函数。delay:延迟时间,以毫秒为单位(默认值为 0)。arg1, arg2, ...:传递给回调函数的参数(可选)。
返回值
- 返回一个唯一的定时器 ID,可用于取消定时器。
示例
setTimeout(() => {
console.log("Hello after 2 seconds!");
}, 2000);
2
3
再来看一个案例
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2
2
3
4
5
6
# 7.5.2 setInterval
setInterval 用于以固定的时间间隔重复执行回调函数。
语法
setInterval(callback, delay, arg1, arg2, ...);
参数
callback:要执行的函数。delay:每次执行的时间间隔,以毫秒为单位。arg1, arg2, ...:传递给回调函数的参数(可选)。
返回值
- 返回一个唯一的定时器 ID,可用于取消定时器。
示例
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Interval count: ${count}`);
if (count === 5) {
clearInterval(intervalId); // 停止定时器
}
}, 1000);
2
3
4
5
6
7
8
# 7.5.3 取消定时器
可以使用 clearTimeout 和 clearInterval 来取消已设置的定时器。
clearTimeout
const timeoutId = setTimeout(() => {
console.log("This will not run");
}, 2000);
clearTimeout(timeoutId); // 取消定时器
2
3
4
5
clearInterval
const intervalId = setInterval(() => {
console.log("This will not run");
}, 1000);
clearInterval(intervalId); // 取消定时器
2
3
4
5
# 7.5.4 定时器执行机制
- 异步执行:定时器的回调函数是异步执行的,即使延迟时间为 0,也会被放入任务队列,等待调用栈清空后执行。
- 最小延迟时间:在浏览器中,定时器的最小延迟时间通常为 4 毫秒(即使设置为 0)。
- 事件循环:定时器的回调函数属于宏任务,会在事件循环的宏任务阶段执行。
setTimeout和setInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。
这意味着,setTimeout和setInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。
setTimeout(someTask, 100);
veryLongTask();
2
上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。
再看一个setInterval的例子。
setInterval(function () {
console.log(2);
}, 1000);
sleep(3000);
function sleep(ms) {
var start = Date.now();
while ((Date.now() - start) < ms) {
}
}
2
3
4
5
6
7
8
9
10
11
上面代码中,setInterval要求每隔1000毫秒,就输出一个2。但是,紧接着的sleep语句需要3000毫秒才能完成,那么setInterval就必须推迟到3000毫秒之后才开始生效。注意,生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。
# 7.5.5 注意事项
- 定时器不精确:定时器的执行时间可能会受到其他任务的影响,因此不能保证完全精确。
- 避免过度使用
setInterval:如果回调函数的执行时间超过间隔时间,可能会导致多个回调函数堆积。可以使用setTimeout递归调用来替代。 - 内存泄漏:如果定时器未被清除,可能会导致内存泄漏。确保在不需要时调用
clearTimeout或clearInterval。
# 7.5.6 定时器总结
| 函数 | 说明 | 取消方法 |
|---|---|---|
setTimeout | 在指定延迟后执行一次回调函数。 | clearTimeout |
setInterval | 以固定时间间隔重复执行回调函数。 | clearInterval |
# 7.5.7 思考一个问题
setTimeout的作用是将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?
答案是不会。因为上一节说过,必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行。
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// 2
// 1
2
3
4
5
6
上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。
总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f。
实际上,setTimeout(f, 0)不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。
# 7.6 Promise
Promise 是 JavaScript 中用于处理异步操作的对象。它提供了一种更优雅的方式来管理异步代码,避免了传统的回调地狱(Callback Hell)。Promise 表示一个异步操作的最终完成(或失败)及其结果值。
// 传统写法
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// ...
});
});
});
});
// Promise 的写法
(new Promise(step1))
.then(step2)
.then(step3)
.then(step4);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
从上面代码可以看到,采用 Promises 以后,程序流程变得非常清楚,十分易读。注意,为了便于理解,上面代码的Promise实例的生成格式,做了简化,真正的语法请参照下文。
总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promise 就是解决这个问题,使得异步流程可以写成同步流程。
Promise 原本只是社区提出的一个构想,一些函数库率先实现了这个功能。ECMAScript 6 将其写入语言标准,目前 JavaScript 原生支持 Promise 对象。
# 7.6.1 基本概念
状态:Promise 有三种状态:
- Pending(进行中):初始状态,既不是成功,也不是失败。
- Fulfilled(已成功):操作成功完成。
- Rejected(已失败):操作失败。
不可逆性:Promise 的状态一旦从 Pending 变为 Fulfilled 或 Rejected,就不能再改变。
这三种的状态的变化途径只有两种。 1.从“未完成”到“成功” ;2.从“未完成”到“失败”
一旦状态发生变化,就凝固了,不会再有新的状态变化。这也是 Promise 这个名字的由来,它的英语意思是“承诺”,一旦承诺成效,就不得再改变了。这也意味着,Promise 实例的状态变化只可能发生一次。
因此,Promise 的最终结果只有两种。
- 异步操作成功,Promise 实例传回一个值(value),状态变为
fulfilled。 - 异步操作失败,Promise 实例抛出一个错误(error),状态变为
rejected。
# 7.6.2 创建Promise
使用 Promise 构造函数创建一个 Promise 对象。Promise 构造函数接受一个函数作为参数,该函数有两个参数:resolve 和 reject。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve("Operation succeeded!"); // 将状态改为 Fulfilled,并传递结果值
} else {
reject("Operation failed!"); // 将状态改为 Rejected,并传递错误信息
}
}, 1000);
});
2
3
4
5
6
7
8
9
10
# 7.6.3 使用Promise
通过 then、catch 和 finally 方法来处理 Promise 的结果。
then
- 用于处理
Promise的成功状态(Fulfilled)。 - 接收两个参数: 第一个参数是成功回调函数,接收
resolve传递的值。第二个参数是失败回调函数(可选),接收reject传递的错误。
catch
- 用于处理
Promise的失败状态(Rejected)。 - 相当于
then(null, rejectionCallback)。
finally
- 无论
Promise成功还是失败,都会执行的回调函数。
示例
promise
.then((result) => {
console.log(result); // "Operation succeeded!"
})
.catch((error) => {
console.error(error); // "Operation failed!"
})
.finally(() => {
console.log("Operation completed."); // 无论成功或失败都会执行
});
2
3
4
5
6
7
8
9
10
# 7.6.4 Promise链式调用
then 方法返回一个新的 Promise,因此可以链式调用。示例
new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 1000);
})
.then((result) => {
console.log(result); // 1
return result * 2;
})
.then((result) => {
console.log(result); // 2
return result * 3;
})
.then((result) => {
console.log(result); // 6
});
2
3
4
5
6
7
8
9
10
11
12
13
14
# 7.6.5 Promise静态方法
Promise 提供了一些静态方法,用于处理多个 Promise。
Promise.resolve。返回一个已成功的 Promise。
const resolvedPromise = Promise.resolve("Success!");
resolvedPromise.then((result) => {
console.log(result); // "Success"
});
2
3
4
Promise.reject。返回一个已失败的 Promise。
const rejectedPromise = Promise.reject("Rejected!");
rejectedPromise.catch((error) => {
console.error(error); // Rejected!
});
2
3
4
Promise.all。接收一个 Promise 数组,当所有 Promise 都成功时返回结果数组;如果有一个失败,则立即返回失败。
Promise.all([
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
])
.then((results) => console.log(results)) // [1, 2, 3]
.catch((error) => console.error(error));
2
3
4
5
6
7
Promise.race。接收一个 Promise 数组,返回第一个完成(无论成功或失败)的 Promise 的结果。
Promise.race([
new Promise((resolve) => setTimeout(() => resolve(1), 1000)),
new Promise((resolve) => setTimeout(() => resolve(2), 500)),
])
.then((result) => console.log(result)) // 2
.catch((error) => console.error(error));
2
3
4
5
6
Promise.allSettled。接收一个 Promise 数组,等待所有 Promise 完成(无论成功或失败),返回结果数组。
Promise.allSettled([
Promise.resolve(1),
Promise.reject("Error"),
])
.then((results) => console.log(results));
// 输出:
// [
// { status: 'fulfilled', value: 1 },
// { status: 'rejected', reason: 'Error' }
// ]
2
3
4
5
6
7
8
9
10
# 7.6.6 async/await
异步函数与 async/await,async/await 是基于 Promise 的语法糖,使异步代码看起来像同步代码。
async 函数,声明一个异步函数,返回一个 Promise。
async function fetchData() {
return "Data";
}
fetchData().then((result) => console.log(result)); // "Data"
2
3
4
await 关键字,用于等待 Promise 完成,只能在 async 函数中使用。
async function fetchData() {
const result = await new Promise((resolve) => setTimeout(() => resolve("Data"), 1000));
console.log(result); // "Data"
}
fetchData();
2
3
4
5
顺序执行:await 会暂停当前函数的执行,直到 Promise 完成。
# 7.6.7 Promise总结
| 方法/特性 | 说明 |
|---|---|
Promise 构造函数 | 创建一个 Promise 对象。 |
then | 处理 Promise 的成功状态。 |
catch | 处理 Promise 的失败状态。 |
finally | 无论成功或失败都会执行的回调函数。 |
Promise.resolve | 返回一个已成功的 Promise。 |
Promise.reject | 返回一个已失败的 Promise。 |
Promise.all | 等待所有 Promise 成功,或第一个失败。 |
Promise.race | 返回第一个完成的 Promise。 |
Promise.allSettled | 等待所有 Promise 完成,无论成功或失败。 |
async/await | 使异步代码看起来像同步代码。 |
# 7.6.8 理解微任务
Promise 的回调函数属于异步任务,会在同步任务之后执行。
new Promise(function (resolve, reject) {
resolve(1);
}).then(console.log);
console.log(2);
// 2
// 1
2
3
4
5
6
7
上面代码会先输出2,再输出1。因为console.log(2)是同步任务,而then的回调函数属于异步任务,一定晚于同步任务执行。
但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正常任务。
setTimeout(function() {
console.log(1);
}, 0);
new Promise(function (resolve, reject) {
resolve(2);
}).then(console.log);
console.log(3);
// 3
// 2
// 1
2
3
4
5
6
7
8
9
10
11
12
上面代码的输出结果是321。这说明then的回调函数的执行时间,早于setTimeout(fn, 0)。因为then是本轮事件循环执行,setTimeout(fn, 0)在下一轮事件循环开始时执行。
# 7.6.10 Promise的底层实现原理
疑惑:Promise 内部是怎么实现的?.then() 是如何做到链式调用的?
答疑:Promise 的核心是一个状态机(pending → fulfilled/rejected)加上回调队列。每次调用 .then() 都会返回一个新的 Promise,这个新 Promise 的决议取决于回调函数的返回值。
论证——简化版 Promise 实现:
class SimplePromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.callbacks = [];
const resolve = (value) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = value;
this.callbacks.forEach(cb => cb.onFulfilled(value));
};
const reject = (reason) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.value = reason;
this.callbacks.forEach(cb => cb.onRejected(reason));
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
then(onFulfilled, onRejected) {
return new SimplePromise((resolve, reject) => {
const handle = (callback, fallback) => {
try {
const fn = typeof callback === 'function' ? callback : fallback;
const result = fn(this.value);
if (result instanceof SimplePromise) {
result.then(resolve, reject); // 如果返回 Promise,等待其决议
} else {
resolve(result);
}
} catch (e) {
reject(e);
}
};
if (this.state === 'fulfilled') {
queueMicrotask(() => handle(onFulfilled, v => v));
} else if (this.state === 'rejected') {
queueMicrotask(() => handle(onRejected, v => { throw v; }));
} else {
this.callbacks.push({
onFulfilled: () => handle(onFulfilled, v => v),
onRejected: () => handle(onRejected, v => { throw v; })
});
}
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
结果展示:.then() 返回新 Promise 是链式调用的关键。回调函数的返回值决定了新 Promise 的决议值;如果返回值是 Promise,则新 Promise "跟随"它的状态。