前言
JavaScript 是一门异步、非阻塞的单线程编程语言。之所以说单线程是指 JS 的主线程是单线程,通过异步解决主线程之外的工作
JavaScript 单线程的特点带来的问题是只要有一个任务耗时很长,后面的任务都必须要排队等待,会拖延整个程序的运行。常见的浏览器页面卡死,往往就是因为一段 JavaScript 代码长时间运行 (比如死循环),导致整个页面卡在这个地方,其它任务无法执行。 为了解决这个问题,JavaScript 将任务分为同步(sync)和异步(async)。
异步模式中,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序执行顺序与任务执行顺序是不一致的、异步的。
异步模式非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是 Ajax 操作。在服务器端,” 异步模式” 甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有 http 请求,服务器性能会急剧下降,很快就会失去响应。
所以异步是 JS 语言中非常重要的概念,在 JS 二十多年的发展历程里,TC39 对异步语法不断改进
回调函数
在JavaScript中,使用回调函数处理异步操作是一种最常见最简单的方式。比如网络请求、文件读取等场景
下面是一个简单的示例,展示了如何使用回调函数处理异步操作:
import fs from 'fs';
// 异步读取文件
fs.readFile('./example.txt', 'utf8', function(err, data) {
if (err) {
console.error('读取文件时发生错误:', err);
return;
}
console.log('文件内容:', data);
});
console.log('文件读取操作进行中...');
在这个例子中,我们使用了 fs.readFile 方法来异步读取文件内容。它接受三个参数:文件路径、编码(在此示例中为 'utf8',表示将文件内容解析为 UTF-8 编码的文本),以及一个回调函数。回调函数有两个参数,第一个参数是可能出现的错误,第二个参数是读取的文件内容
使用回调函数处理异步场景主要优点是灵活简单,方便在异步操作完成后执行任何逻辑。但是回调函数的缺点非常明显,主要有下面的几点问题:
- 回调地狱: 当多个异步操作依赖于彼此的结果时,回调函数嵌套会导致代码结构混乱、难以维护,形成所谓的回调地狱。这种情况下,代码会变得难以阅读和理解,而且容易出错。
- 错误处理困难: 错误处理在回调函数中可能会变得很复杂,特别是当多个异步操作嵌套时,必须小心处理每一个可能发生的错误,否则会导致错误被忽略或者不正确地处理。
- 代码可读性差: 大量的回调函数嵌套会导致代码的可读性变差,因为代码结构变得杂乱,难以理解。
由于回调函数的缺点很多很明显,TC39 之后提出很多新方案解决这些问题,请见下文
事件监听
除回调函数外,另一个实现异步的方式就是事件监听。事件监听基于事件驱动模型,允许代码在某个事件发生时执行特定的回调函数。事件监听常用于 DOM 操作中
比如页面上有一个点击按钮,它的实现逻辑如下
const oBtn = document.querySelector('button')
oBtn.addEventListener('click', () => {
// do something
})
与回调函数相比,事件监听具有以下优点:
- 解耦性: 事件监听可以将异步操作的触发和处理逻辑解耦,使得代码更加模块化和可维护。
- 多次监听: 可以多次监听同一个事件,每个监听器都可以独立执行相应的操作,提高了代码的灵活性和可扩展性。
- 事件流: 在事件监听模型中,事件可以在不同的对象之间传递,形成事件流,使得代码逻辑更加清晰。
Promise
Promise 是 ES6 新增的一个状态机容器,表示异步任务(网络请求、读取文件等)将来会完成,在任务完成时会发出一个通知根据这个通知执行对应的逻辑
Promise 有三种状态:进行中、已完成和已失败
当异步任务完成后,Promise 会从进行中状态转变为已完成状态,并返回一个结果;如果异步任务失败,则会从进行中状态转变为已失败状态,并返回一个错误。Promise 的状态变化只有两种,要么从进行中变成已完成,要么从进行中变成已失败
Web API 的 Fetch 方法就是基于 Promise 实现的,所以使用 Fetch 处理网络请求如下
fetch(url)
.then((users) => {
// handle business
})
.catch((err) => {
console.error(err);
});
从上面的例子中可以看出,Promise 提供链式调用方式,使得多个异步操作可以按顺序执行,使用 then 和 catch 分别处理完成或失败的操作,由于 Promise 是异步的,产生的错误只能使用 .catch 方法捕获
但是虽然 Promise 提供了链式调用的语法,但当链式调用过多时,代码可能会变得难以阅读和理解,形成所谓的 Promise Hel。
生成器 Generator
在 Promise 之后,TC39 又提出 Generator 函数
Generator 是 ES6 引入的一种特殊的函数,它可以在执行过程中暂停并且之后可以从暂停的位置继续执行。Generator 函数使用 function* 声明,内部使用 yield 关键字来定义暂停点。
Generator 函数的主要目的是解决 JavaScript 中异步编程的问题。在异步编程中,经常会遇到需要等待异步操作完成后才能继续执行的情况。传统的回调函数和 Promise 在处理这种情况时可能会导致回调地狱(Callback Hell)或者过多的 Promise 嵌套,使得代码难以维护和理解。
Generator 函数通过使用 yield 关键字,使得在执行过程中可以暂停并等待外部信号再继续执行。这种特性使得 Generator 函数成为编写更加清晰、易于理解和维护的异步代码的一种工具
下面是一个简单的 Generator 函数的示例:
function* generatorFunction() {
console.log('开始执行');
yield 1;
console.log('继续执行');
yield 2;
console.log('结束执行');
}
const gen = generatorFunction();
console.log(gen.next()); // 输出:{ value: 1, done: false }
console.log(gen.next()); // 输出:{ value: 2, done: false }
console.log(gen.next()); // 输出:{ value: undefined, done: true }
在这个例子中,generatorFunction 是一个 Generator 函数,内部使用了 yield 关键字定义了两个暂停点。当调用 gen.next() 方法时,Generator 函数会执行至下一个 yield 关键字处,并返回一个包含当前执行结果的对象 { value: xxx, done: xxx }。当 Generator 函数执行结束时,done 属性会变为 true,并且 value 属性为 undefined
但在项目上我们很少直接使用 Generator 函数,更多的是直接使用 Promise 和 async/await。但是理解 Generator 函数依旧很重要
async/await
async/await 是异步的终极解决方案。同时也是 Generator 的语法糖。它将异步的代码彻底以同步的代码形式表示,使代码逻辑更清晰易读,更利于维护
下面使用 async/await 改写 Promise 中的例子
try {
const response = await fetch(url).then((res) => res.json());
} catch (e) {
console.error(e);
}
对比 Promise 的链式调用,在 async/await 中,不再需要使用 then 和 catch 方法。对于异步逻辑,只需要添加 await 关键字,表示此处是一个异步操作,等待此处的业务逻辑执行完,所以 await 会阻塞后面的代码执行。那么整个代码逻辑看起来就像同步代码的形式,非常清晰易懂
async/await 以同步的形式执行代码,异步操作产生的错误使用 try...catch...捕获