Skip to content

异步 JavaScript

为什么要考虑异步问题?

有些计算机程序,例如我们常用 Python 写的功能脚本输入计算密集型,这意味着这些程序会持续不断地运行,不会暂停,直到计算出结果。

不过,大多数现实的计算机程序是异步的,这意味着,程序必须暂停计算,等到数据到达某个事件触发

浏览器中的 JavaScript 程序是典型的事件驱动型程序,即程序会等待用户进行操作,比如单击或填写表单,然后程序才会真正执行。这种异步编程在 JavaScript 中是非常常见的。

JavaScript 中有三种重要的语言特性,使得编写异步代码变得更加容易。

  • ES6 新增的 Promise 对象
  • ES2017 新增的关键字 asyncawait
  • ES2018 新增的异步迭代器 for/await

JavaScript 虽然提供了编写异步代码的强大特性,但其核心语言特性中却没有一个是异步的。

回调函数

在最基本的层面上,JavaScript 异步编程是通过回调实现的。

回调就是函数,可以传给其他函数。

定时器

一种最简单的异步操作就是在一定时间后运行某些代码。

js
setTimeout(func, 600); // 600 毫秒后运行 func 函数

事件

客户端 JavaScript 编程几乎都是事件驱动的,等待用户的操作,然后响应用户的动作。事件驱动的程序在特定上下文中为特定时间注册回调函数,浏览器在指定的事件发生时调用这些函数。这些回调函数叫做事件处理程序或事件监听器,是通过 addEventListener() 注册的。

js
let okay = document.querySelector('#confirm');

okay.addEventListener('click', applyComfirm);

网络事件

JavaScript 中一个常见的异步操作来源是网络请求。浏览器中运行的 JavaScript 可以通过类似下面的代码从 web 服务器获取数据。

js
function getCurrentVersionNumber(versionCallback){
    // 向后端 api 发送一个 HTTP 请求
    let request = new XMLHttpRequest();
    request.open("GET", "http://www.example.com/api/version");
    request.send();

    // 注册一个在响应到达时调用的回调函数
    request.onload = function(){
        if(request.status === 200){
            // 请求成功,调用回调函数
            let currentVersion = parseFloat(request.responseText);
            versionCallback(null, currentVersion);
        }else{
            // 请求出错,通过回调报告错误
            versionCallback(this.response.status, null);
        }
    }

    // 注册另一个在网络出错时调用的回调函数
    request.onerror = request.ontimeout = function(e){
        versionCallback(e.type, null);
    }
}

Promise

什么是 Promise?

Promise 是一个对象,表示异步操作的结果。

用例子去解释更加清楚。自从核心 JavaScript 语言支持 Promise 后,浏览器也开始实现基于 Promise 的 API。想象一下有这样一个浏览器函数叫 getJSON() ,它把 HTTP 响应体解析为 JSON 格式并返回一个 Promise 对象,基本使用如下:

js
getJSON(URL).then(jsonData => {
	// 这是一个回调函数,它会在解析得到 JSON 值之后异步调用,并接受 JSON 值作为参数
});

getJSON() 函数向指定的 URL 发送一个异步 HTTP 请求,然后在请求结果待定期间返回一个 Promise 对象,这个 Promise 对象有一个实例方法叫 then()

回调函数传给了 Promisethen() 方法,当 HTTP 请求的响应到达时,响应体会被解析为 JSON 格式,解析后的值会被传给作为 then() 的参数的函数。

可以吧 then() 理解为客户端 JavaScript 中注册事件处理程序的 addEventListener() 方法。如果多次调用一个期约对象的 then() 方法,则指定的每个函数都会在计算完成后被调用。

.then() 方法是 Promise 独有的特性。以动词开头来命名返回 Promise 的函数以及使用 Promise 结果的函数也是一种惯例。遵循这个惯例可以增加代码的可读性。

js
function displayUserProfile(profile) {
    /* 省略实现细节 */
}

getJSON("/api/user/profile").then(displayUserProfile);

使用 Promise 处理异常

涉及网络的操作通常会有多种失败的原因。健壮的代码必须处理各种无法避免的错误。

对于 Promise 来说,可以通过 then() 方法的第二个参数实现错误处理:

js
getJSON("/api/user/profile")
    .then(displayUserProfile, handleProfileError);

这里展开说一下同步计算与Promise的一些区别:

  • 同步计算在正常结束后会向调用者返回计算结果。
  • 而基于 Promise 的异步计算在正常结束后,会把计算返回的结果传给 then() 的第一个参数。
  • 同步计算出错会抛出一个异常,该异常会沿着调用栈向上一直传播到一个处理它的 catch 子句。
  • 异步运算在运行时,它的调用者已经不在栈里,因此如果出现错误,没办法向调用者抛出异常。
  • 为此,基于 Promise 的异步计算把异常传给 then() 的第二个参数的函数。

但,实际开发中,很少看到给 then() 传两个函数的情况,取而代之的是另一种异常处理写法。

js
getJSON("/api/user/profile")
    .then(displayUserProfile)
    .catch(handleProfileError);

这个 catch() 方法只是对调用 then() 时以 null 作为第一个参数,以指定错误处理函数作为第二个参数的一种简写方式。

Promise 相关术语

想象一下,调用一个 Promisethen() 方法时传入了两个回调函数。

  • 如果第一个回调被调用,我们就说 Promise 被兑现 fulfill
  • 如果第二个回调被调用,我们就说 Promise 被拒绝 reject
  • 如果既未被兑现,也未被拒绝,那么它就是待定 pending

除此之外,Promise 也可能被解决 resolve

理解 Promise

Promise 是一种用于处理异步操作的 JavaScript 对象。它可以让我们更优雅地处理需要一段时间才能完成的任务,比如从服务器获取数据或处理文件读写。

Promise 有三种状态:

  1. 等待中(Pending):这是初始状态,表示操作尚未完成,也没有确定结果。
  2. 已完成(Fulfilled):操作成功完成,Promise 得到了期望的结果。
  3. 已失败(Rejected):操作失败,Promise 得到一个错误原因。

Promise 的优势在于,它让我们可以用一种更清晰的方式来管理异步任务,避免嵌套的回调函数带来的复杂性。我们可以使用 then 方法来处理操作成功的结果,用 catch 方法来处理操作失败的情况。

举个例子,如果我们向服务器请求数据,Promise 可以帮我们在数据成功返回时执行某些操作,在请求失败时处理错误,而不是把代码写成一层层的回调函数。

实现简易的 Promise

引例

js
let promise1 = new Promise((resolve, reject) => {
	setTimeout(() => {
		let random = Math.random();
		console.log("random", random);
		if (random > 0.5) {
			resolve(1);
		} else {
			reject(2);
		}
	}, 1000);
});
promise1.then(
	(data) => {
		console.log("resolve", data);
	},
	(e) => {
		console.log("reject", e);
	}
);

执行的结果如下:

分析这段代码的执行:

  • 创建 Promise 对象: promise1 是一个新的 Promise 对象。
  • 设置定时器: 使用 setTimeout 设置一个 1 秒(1000 毫秒)的定时器。
  • 生成随机数: 定时器到期后,生成一个 0 到 1 之间的随机数,并打印该随机数。
  • 判断随机数:
    • 如果随机数大于 0.5,调用 resolve(1),表示 Promise 成功完成,并传递值 1。Promisepending 状态变为 resolved 状态。
    • 如果随机数小于或等于 0.5,调用 reject(2),表示 Promise 失败,并传递错误信息 2。Promisepending 状态变为 rejected 状态。
  • 处理 Promise 结果:
    • 如果 Promise 状态为 resolved(成功),执行 then 方法的第一个参数注册的回调函数。
    • 如果 Promise 状态为 rejected(失败),执行 then 方法的第二个参数注册的回调函数。

结构定义

构造函数

  • Promise 是一个构造函数,该函数的参数是一个函数,我们叫他执行器函数
  • 执行器函数接收两个参数, resolvereject,他们也都是函数:
    • resolve(data) 用于将 Promise 状态设置为 fulfilled (成功),并传递结果 data。
    • reject(error) 用于将 Promise 状态设置为 rejected (失败),并传递错误信息 error。

状态

  • 初始状态为 pending(待定)。
  • 状态只能从 pending 转变为 fulfilledrejected,且一旦改变,无法再次改变。

方法

  • .then(onFulfilled, onRejected)
    • 处理 Promise 成功状态和失败状态的回调函数。
    • 返回一个新的 Promise,所以支持链式调用。
  • .catch(onRejected)
    • 处理 Promise 失败的回调函数,相当于 .then(null, onRejected)
  • .finally(onFinally)
    • 无论 Promise 成功还是失败,都会执行的回调函数。

代码实现

实现要点:

  • Promise 构造函数会同步执行执行器函数
  • .then 方法中的第一个参数函数会在执行器函数的 resolve 执行后被调用
  • .then 方法中的第二个参数函数会在执行器函数的 reject 执行后被调用

极简实现

js
class SimplePromsie {
	constructor(executor) {
		this.state = "pending";
		this.value = undefined;
		this.error = undefined;
		this.onResolveCallbacks = [];
		this.onRejectCallbacks = [];

		const resolve = (value) => {
			if (this.state === "pending") {
				this.state = "fulfilled";
				this.value = value;
				// 执行回调函数
				this.onResolveCallbacks.forEach((callback) =>
					callback(this.value)
				);
			}
		};

		const reject = (error) => {
			if (this.state === "pending") {
				this.state = "rejected";
				this.error = error;
				// 执行回调函数
				this.onRejectCallbacks.forEach((callback) => {
					callback(this.error);
				});
			}
		};

		try {
			executor(resolve, reject); // 创建实例时立即执行
		} catch (error) {
			reject(error);
		}
	}
	
	// 注册处理成功和处理失败的回调函数
	then(onFulfilled, onRejected) {
		if (this.state === "fulfilled") {
			onFulfilled(this.value);
		} else if (this.state === "rejected") {
			onRejected(this.error);
		} else if (this.state === "pending") {
			this.onResolveCallbacks.push(onFulfilled);
			this.onRejectCallbacks.push(onRejected);
		}
		return this;
	}
}

使用这个自己定义的 SimplePromise 实现和引例中相同的功能:

js
let simplePromise = new SimplePromsie((resolve, reject) => {
	setTimeout(() => {
		let random = Math.random();
		console.log("random", random);
		if (random > 0.5) {
			resolve(1);
		} else {
			reject(2);
		}
	}, 1000);
});
simplePromise.then(
	(data) => {
		console.log("resolve", data);
	},
	(e) => {
		console.log("reject", e);
	}
);

执行代码得到结果:

参考:

Promise链

TIP

Promise 有一个最重要的优点,就是以线性 then() 方法调用链的形式表达一连串异步操作,而无需把每个操作都嵌套在前一个操作的内部。

这里以函数 fetch() 为例,传给它一个 URL 链接,它会返回一个 Promise 对象,这个 Promise 会在 HTTP 响应开始到达且 HTTP 状态和头部可用时兑现。

js
fetch("api/user/profile")
    .then(response => {
        // 在 Promise 解决时,可以访问 HTTP 状态和头部
        if (response.ok
            && response.headers.get("Content-Type" === "application/json")) {
            // 现在还没有得到响应体,在这里可以做什么?
        }
    });

fetch 返回的 Promise 兑现时,then() 方法中的函数会被调用。

在收到响应后,解析响应体的幼稚方式:

js
fetch("api/user/profile")
    .then(Response => {
        Response.json() // 获取 JSON 格式的响应体,返回一个 Promise
            .then(profile => {
                // 在响应体到达时会自动解析为 JSON 格式并传入函数
                displayUserProfile(profile);
            })
    });

之所以说这是一种幼稚方式,是因为我们像嵌套回调一样嵌套了它们,这违背了 Promise 的初衷。应该像以下代码一样写成一串 Promise 链。

js
fetch("api/user/profile")
    .then(Response => {
        return Response.json(); // 手动返回一个 Promise
    })
    .then(profile => {
        displayUserProfile(profile);
    });

我们可以将上面的代码抽象为:

js
fetch(theURL)          // 任务1,返回期约1
    .then(callback1)   // 任务2,返回期约2
    .then(callback2)   // 任务3,返回期约3

下面我们逐步剖析这个代码:

  1. 第一行,调用 fetch 并传入一个 URL ,这个方法会向该 URL 发送一个 HTTP GET 请求并返回一个 Promise ,我们称这个 HTTP 请求为任务一、称这个 Promise 为期约一。
  2. 第二行,调用期约一的 then() 方法,传入 callback1 函数,这个函数会在期约一被兑现时调用。这个 then() 方法会把 callback1 保存在某个地方,并返回一个新期约,我们称其为期约二,并说任务二在 callback1 被调用时开始。
  3. 第三行,调用期约二的 then() 方法,传入 callback2 函数,将 callback2 保存,并返回期约三。
  4. 当这个表达式一开始执行,前三步同步发生,然后在第一步创建的 HTTP 请求通过互联网发出时有一个异步暂停。
  5. 终于,HTTP 请求到达, fetch() 调用的异步逻辑将 HTTP 状态和头部包装到一个 Response 对象中,并将这个对象作为值兑现期约1。
  6. 期约 1 兑现后,它的值会传给 callback1 ,此时任务 2 开始。
  7. 假设任务 2 正常结束,即成功解析并生成了一个 JSON 对象,这个对象就被用于兑现期约 2.
  8. 兑现期约 2 的值在传入 callback2 函数时变成了任务 3 的输入。

解决期约 Resolve

Promise 链中提到了三个任务以及三个期约,但实际上这里有第四个 Promise 对象。

js
fetch("api/user/profile") // 任务1,期约1
    .then(Response => { // 任务2,期约2
        return Response.json(); // 手动返回一个 Promise
    })
    .then(profile => { // 任务3,期约3
        displayUserProfile(profile);
    });

上面这个示例中,任务 2 调用 .json 方法解析响应体并返回它的值,但由于响应体可能并未到达,因此这一方法返回的是 Promise 对象。也就是第四个 Promise 对象。

我们再重写一次抓取 URL 的代码,这一次使用一种非常冗余的方法,使回调和期约更加明显:

js
function c1(response){                 // 回调1
    let p4 = response.json();
    return p4;                         // 期约4
}

function c2(profile){                  // 回调2
    displayUserProfile(profile);
}

let p1 = fetch("/api/user/profile");   // 期约1,任务1
let p2 = p1.then(c1);                  // 期约2,任务2
let p3 = p2.then(c2);                  // 期约3,任务3

为了让期约链有效工作,任务 2 的输出必须成为任务 3 的输入。

在该示例中,任务 3 的输入是从 URL 抓取到的响应体后又解析生成的 JSON 对象。

但是,正如刚刚所说,回调 c1 的返回值不是 JSON 对象,而是表示该 JSON 对象的期约 p4。

在 p1 兑现后,c1 被调用,任务 2 开始;而当 p2 兑现时,c2 被调用,任务 3 开始。

这句话有些难以理解,拆分来看,c1 被调用代表任务 2 开始,c1 返回后,任务 2 却并没有结束。这是由于任务 2 是异步的。

我们再分析一下期约 2 和任务 2 :当把回调函数 c1 传给方法 p1.then() 的时候,then() 返回期约 p2,安排好在将来某个时刻(收到响应头的时候)执行 c1。到了这个时候,c1 执行,c1 返回返回值 v,如果 v 是非期约值,p2 就以 v 这个值得到了兑现(fulfill),而如果 v 是期约值,正如上面的例子中,此时 p2 就会得到解决(resolve ) 而未兑现(fulfill)。此时, p2 要等到 v 落定了才能落定。

在这个例子中,当 c1 返回 p4 的时候,p2 得到解决,但解决不等同于兑现,因此任务 3 还不会开始。

async/await