Javascript异步编程
在 JavaScript 的世界里,异步编程是实现高效代码执行的关键。作为一种单线程语言,JS的运行环境通过事件循环和异步机制,使得它能够在执行长时间任务时保持界面流畅,避免阻塞用户体验。从早期的回调函数
,到现代的 Promise
和 async/await
语法,异步编程技术不断演化,逐步降低了开发的复杂性,同时提升了代码的可读性和维护性
在这篇文章中,我们将探索JS异步编程的基本原理、不同阶段的实现方式以及它在实际开发中的重要性。无论是构建响应式 Web 应用,还是处理网络请求、文件操作等任务,掌握异步编程都是每个开发者必备的技能之一。让我们一同进入这充满挑战与机遇的异步编程领域,解锁更高效、更优雅的编程技巧
为什么需要异步
异步编程的需求源于其单线程模型的特性。JS的执行依赖于主线程(main thread)
,如果在主线程上执行的某些任务耗时较长(例如文件读取、网络请求、数据库操作等),可能会阻塞后续代码的执行,从而导致整个程序失去响应,严重影响用户体验
同步和异步
同步 Synchronization 和 异步 Asynchrony 是一对组合概念:
同步
:指的是任务按顺序执行,当前任务执行完毕后才能开始下一个任务。在这种方式下,每个任务都必须等待前一个任务完成(按代码顺序),整个过程是线性和阻塞的异步
:指的是任务可以在后台执行,当前任务不需要等待前一个任务完成即可继续执行其他任务。耗时任务完成后,会通过回调函数、Promise 或事件机制通知主线程
下面是一张同步执行和异步执行示意图:
异步"影子"
对于初学者来说异步编程的概念可能并没有全部搞明白,不过在开发或学习中我们都一直在与异步打交道,我们称它为 异步影子
。比如下面这段代码:
fetch("https://blog.usword.cn").then(res => console.log(res));
console.log('the logger before fetch result');
2
程序从上往下开始执行,当使用fetch
发送请求后,并没有阻塞后面的log执行,最终res的打印在log之后
再来看下面这个点击事件:
const btn = document.querySelector("#btn");
btn.addEventListener('click', () => {
console.log("click.");
});
setTimeout(() => {
Promise.resolve().then(() => console.log('promise resolve'));
btn.click();
}, 1000);
2
3
4
5
6
7
8
以上先打印click
,再打印promise
程序的执行顺序往往有时候并不是和我们的代码顺序一致的,这时候可能就发生了异步任务。JS中有很多不同的异步任务源,这些任务源用来执行自己所产生的异步任务,不同异步任务互不干扰,最后执行完毕后再返回到主线程上执行最后的结果,而主线程与异步任务源之间的调度是由背后的 EventLoop 来控制的
EventLoop我们往期文章已经讲过,这里就不介绍了,而对于异步任务我们是如何在它执行完毕后再去执行后续的代码逻辑呢❓随着技术的发展经过好多迭代,接下来我们就来看看他的经历吧
回调函数
异步回调函数是指在执行异步操作时,将一个函数作为参数传递给另一个函数,当异步操作完成时,这个回调函数会被调用,以便处理结果或继续执行后续逻辑
异步回调函数是实现异步操作的早期方式,通常用于处理诸如定时器、事件监听、网络请求等操作
function callback() { /* 回调逻辑 */ };
fetch(url, callback);
setTimeout(callback, 1000);
document.addEventListener(eventType, callback);
2
3
4
5
而在实际项目中由于业务逻辑可能在异步任务中还嵌套了很多个异步任务,当异步回调函数嵌套过多时,代码会变得复杂难读,被称为“回调地狱”
。嵌套的回调函数使得代码难以维护和调试,这是回调函数的主要缺点之一
getUserData(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
console.log("订单详情:", details);
// ... 无线嵌套
});
});
});
2
3
4
5
6
7
8
随着 JavaScript 的发展,出现了更优雅的解决异步问题的方式,接下来来看看他的优化过程吧
Promise
Promise 是 JavaScript 用于处理异步操作的对象,代表一个未来可能会完成(或失败)的操作及其结果值。它提供了一种更优雅的方式来编写和管理异步代码,避免了传统回调方式(如“回调地狱”)的问题
Promise 的三种状态:
Pending(待定
):初始状态,既没有被解决(fulfilled)也没有被拒绝(rejected)Fulfilled(已完成)
:操作成功完成,并返回结果值Rejected(已拒绝)
:操作失败,并返回失败原因
重要
Promise 的状态一旦从 Pending 变为 Fulfilled 或 Rejected,就不能再改变
一个简单例子:
const task = new Promise((resolve, reject) => {
if (true) {
resolve("success");
} else {
reject("fail");
}
});
2
3
4
5
6
7
链式调用
Promise 支持链式调用,可以通过多次 .then() 实现对异步任务的顺序处理
task
.then(res1 => console.log(res1))
.then(res2 => console.log(res2))
.then(res3 => console.log(res3))
.catch(err => console.log(err));
2
3
4
5
静态方法
- Promise.resolve()
返回一个状态为 Fulfilled 的 Promise
Promise.resolve("直接成功").then(console.log); // 输出:直接成功
- Promise.reject() 返回一个状态为 Rejected 的 Promise
Promise.reject("直接失败").catch(console.error); // 输出:直接失败
- Promise.all()
接收一个 Promise 数组,只有当所有 Promise 都完成时,返回一个包含所有结果的 Promise;如果任意一个 Promise 失败,则返回第一个失败的结果
Promise.all([
Promise.resolve("任务1完成"),
Promise.resolve("任务2完成"),
Promise.resolve("任务3完成"),
])
.then(console.log) // 输出:["任务1完成", "任务2完成", "任务3完成"]
.catch(console.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(console.log) // 输出:任务2完成
.catch(console.error);
2
3
4
5
6
缺点
尽管比回调函数更优雅,但复杂的链式调用仍然会带来一定的代码复杂性;必须通过 .catch() 手动捕获错误,稍有不慎可能遗漏
迭代器
Generator 是 JavaScript 中提供的一种特殊函数类型,用于控制函数执行的过程并实现迭代。它可以暂停函数的执行,并在未来的某个时刻从暂停的地方继续执行。这种行为使得 Generator 非常适合实现自定义的迭代逻辑
Generator 的语法:
- 定义 Generator 函数时,使用 function* 关键字
- 调用 Generator 函数时不会立即执行,而是返回一个迭代器对象
- 使用
yield
关键字来暂停执行并返回值
function* foo() {
yield 1;
yield 2;
}
let gen = foo();
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
2
3
4
5
6
7
8
9
可迭代协议
可迭代协议允许 JavaScript 对象定义或定制它们的迭代行为,例如,在一个 for..of
结构中,哪些值可以被遍历到。一些内置类型同时是内置的可迭代对象,并且有默认的迭代行为,比如 Array 或者 Map,而其他内置类型则不是(比如 Object)
要成为可迭代对象,该对象必须实现 Symbol.iterator 方法,这意味着对象(或者它原型链上的某个对象)必须有一个键为 [Symbol.iterator] 的属性,可通过常量 Symbol.iterator 访问该属性:它一个无参数的函数,其返回值为一个符合迭代器协议的对象
// 可迭代的
const arr = [1,2,3];
const map = new Map([[1, "one"], [2, "two"]]);
console.log(arr[Symbol.iterator], map[Symbol.iterator]) // [native code]
console.log(arr[Symbol.iterator]()); // Array Iterator {}
// 不可迭代的
const obj = {};
console.log(obj[Symbol.iterator]); // undefined
2
3
4
5
6
7
8
9
可以看出可迭代对象一定有一个 [Symbol.iterator]
属性函数
迭代器协议
迭代器协议表示当前对象是已经是可以迭代的,此协议用来定义具体迭代的过程。只有实现了一个拥有以下语义的 next() 方法,一个对象才能成为迭代器:
next()
: 无参数或者接受一个参数的函数,并返回符合 IteratorResult 接口的对象(见下文)。如果在使用迭代器内置的语言特征(例如 for...of)时,得到一个非对象返回值(例如 false 或 undefined),将会抛出 TypeError("iterator.next() returned a non-object value")done(可选)
: 如果迭代器能够生成序列中的下一个值,则返回 false 布尔值。(这等价于没有指定 done 这个属性。如果迭代器已将序列迭代完毕,则为 true。这种情况下,value 是可选的,如果它依然存在,即为迭代结束之后的默认返回值value(可选)
: 迭代器返回的任何 JavaScript 值。done 为 true 时可省略
我们来看下以上Array
对象的迭代器属性有哪些:
const iterator = arr[Symbol.iterator]();
console.log(Reflect.getPrototypeOf(iterator));
2
也就是说迭代器对象必须实现next
方法,迭代的值由next内部实现
自定义迭代
到这里我们来自定义一个迭代器对象,我们只要把握住两个要点就行:可迭代协议[Symbol.iterator]()
、迭代器协议next()
:
class MyIterator {
constructor(data) {
this.data = data;
this.index = 0;
}
next() {
if (this.index < this.data.length) {
return { value: this.data[this.index++], done: false };
} else {
return { done: true };
}
}
[Symbol.iterator]() {
return this;
}
}
const myIterator = new MyIterator([1,2,3]);
for (const item of myIterator) {
console.log(item); // 1 2 3
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
是不是很简单,基于迭代器的灵活性,使用迭代器可以实现很多复杂的场景,真实项目中也会用到很多,尤其是一些复杂的底层基建
缺点
尽管 Generator 提供了灵活的控制流和丰富的功能,但它也存在一些明显的局限性和缺点:
- 手动调用 next(),使用繁琐
- 缺乏内置的自动流程控制,如果需要实现复杂的逻辑流(如条件分支、嵌套迭代),开发者需要自行设计管理逻辑或借助外部库,增加了实现难度
- 状态管理复杂,Generator 的状态需要通过
yield
来维护,而状态的存储和传递可能会变得复杂,特别是当函数嵌套调用或者需要在暂停点之间传递大量数据时
function* greet() {
const name = yield "你好,请问你叫什么名字?";
yield `你好,${name}!`;
}
const greeter = greet();
console.log(greeter.next().value); // 你好,请问你叫什么名字?
console.log(greeter.next("小明").value); // 你好,小明!
2
3
4
5
6
7
8
Async/Await
Async/Await 是 JavaScript 中用于处理异步代码的现代语法,基于 Promise 提供了一种更简洁、可读性更强的方式来编写异步逻辑。它使得异步代码看起来像同步代码,易于编写、调试和维护
如何使用:
async 函数
:使用 async 关键字定义的函数,返回值是一个 Promise,即使函数中没有显式返回 Promise,如:return 10
await 关键字
:只能在 async 函数内部使用, 用于等待一个 Promise 完成,暂停代码的执行,直到 Promise 解析(resolved)
async function run() {
return await 10;
}
run.then(res => console.log(res)); // 10
2
3
4
与Promise链式调用对比:
const p1 = new Promise(resolve => setTimeout(() => resolve(1), 1000));
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 100));
const p3 = new Promise(resolve => setTimeout(() => resolve(3), 3000));
// 链式
p1.then(res => console.log(res)).then(() => p2).then(res => console.log(res));
// async/await
async function run() {
const res1 = await p1;
console.log(res1); // 1
const res2 = await p2;
console.log(res2); // 2
}
2
3
4
5
6
7
8
9
10
11
12
13
14
原理实现
async/await 原理是通过 Generator 函数 和 Promise 结合实现的,其中 async 函数会被编译成一个 Generator 函数,通过一个自动执行器(类似 co 的库)来执行异步任务
我们来简单实现下:
function run(generatorFn) {
const gen = generatorFn(); // 初始化 Generator
function step(nextFn) {
let next;
try {
next = nextFn(); // 执行 Generator 的 next() 或 throw()
} catch (e) {
return Promise.reject(e); // 捕获错误
}
if (next.done) {
return Promise.resolve(next.value); // 完成时返回结果
}
return Promise.resolve(next.value).then(
(val) => step(() => gen.next(val)), // 执行下一步
(err) => step(() => gen.throw(err)) // 捕获并处理错误
);
}
return step(() => gen.next()); // 启动执行器
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
测试示例:
run(function* fetchData() {
try {
const res = yield new Promise((resolve) => setTimeout(() => resolve({id: 1, name: '小明', age: 10 }), 1000));
const data = yield res
return data;
} catch (error) {
console.error("发生错误:", error);
}
}).then(res => console.log('res:', res)); // {id: 1, name: '小明', age: 10 }
2
3
4
5
6
7
8
9
总结
本篇文章通过对异步、同步任务的介绍,并介绍了回调函数、Promise、迭代器、async、await等多种异步编程的方式。其实在技术演变过程中都发挥着重要作用,在实际的开发中需要结合实际场景选择最合适的编程方式