JavaScript异步

JavaScript异步

异步编程

定义:异步指两个或两个以上的对象或事件同时存在或发生(或多个相关事物的发生无需等待其前一事物的完成)。

异步编程技术使你的程序可以在执行一个可能长期运行的任务的同时继续对其他事件做出反应而不必等待任务完成。与此同时,你的程序也将在任务完成后显示结果。

浏览器提供的许多功能(尤其是最有趣的那一部分)可能需要很长的时间来完成,因此需要异步完成,例如:

  • 使用 fetch() 发起 HTTP 请求
  • 使用 getUserMedia() 访问用户的摄像头和麦克风
  • 使用 showOpenFilePicker() 请求用户选择文件以供访问

回调

  • 事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候被调用的函数。
  • 回调函数曾经是 JavaScript 中实现异步函数的主要方式。但是存在「回调地狱」使代码更难阅读和调试,所以现在大多数现代异步 API 都不使用回调
1
2
3
4
5
6
7
doSomething(function(result) {
doSomethingElse(result, function(newResult) {
doThirdThing(newResult, function(finalResult) {
console.log('Got the final result: ' + finalResult);
}, failureCallback);
}, failureCallback);
}, failureCallback);

Promise

  • 本质上 Promise 是一个函数返回的对象,我们可以在它上面绑定回调函数,这样我们就不需要在一开始把回调函数作为参数传入这个函数了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 成功的回调函数
function successCallback(result) {
console.log("音频文件创建成功:" + result);
}
// 失败的回调函数
function failureCallback(error) {
console.log("音频文件创建失败:" + error);
}
createAudioFileAsync(audioSettings, successCallback, failureCallback)


//现代写法:
const promise = createAudioFileAsync(audioSettings);
promise.then(successCallback, failureCallback);
//简写为:
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);
  • 在本轮 事件循环 运行完成之前,回调函数是不会被调用的。
  • 即使异步操作已经完成(成功或失败),在这之后通过 then() 添加的回调函数也会被调用。
  • 过多次调用 then() 可以添加多个回调函数,它们会按照插入顺序进行执行。
  • 链式调用:then() 函数会返回一个和原来不同的新的 Promise
  • 之前多重的异步操作导致经典的「回调地狱」,现在就可以把回调绑定到返回的 Promise 上,形成一个 Promise 链。
1
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
doSomething().then(function(result) {
return doSomethingElse(result);
})
.then(function(newResult) {
return doThirdThing(newResult);
})
.then(function(finalResult) {
console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);
//then 里的参数是可选的,
//catch(failureCallback) 是 then(null, failureCallback) 的缩略形式。

//箭头函数表示法:
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);

//async/await 语法糖表示法:
async function foo() {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch(error) {
failureCallback(error);
}
}
  • 使用一个 catch 对于在链式操作中抛出一个失败之后,再次进行新的操作会很有用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Promise((resolve, reject) => {
console.log('初始化');//初始化

resolve();
})
.then(() => {
throw new Error('有哪里不对了');

console.log('执行「这个」”');//不执行,因为抛出了错误
})
.catch(() => {
console.log('执行「那个」');//执行“那个”
})
.then(() => {
console.log('执行「这个」,无论前面发生了什么');//执行“这个”,无论前面发生了什么
});

async/await 语法糖

async 函数是使用async关键字声明的函数。async 函数是 AsyncFunction 构造函数的实例,并且其中允许使用 await 关键字

async await 关键字让我们可以用一种更简洁的方式写出基于 Promise 的异步行为,而无需刻意地链式调用 promise。

语法:

1
2
3
4
5
6
7
8
9
async function name(param0) {
statements
}
async function name(param0, param1) {
statements
}
async function name(param0, param1, /* … ,*/ paramN) {
statements
}
  • 返回值:一个 Promise,这个 promise 要么会通过一个由 async 函数返回的值被解决,要么会通过一个从 async 函数中抛出的(或其中没有被捕获到的)异常被拒绝

    • 一个 Promise 必然处于以下几种状态之一:
      • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
      • 已兑现(fulfilled):意味着操作成功完成。
      • 已拒绝(rejected):意味着操作失败。
  • Promise 对象用于表示一个异步操作的最终完成(或失败)及其结果

1
2
3
4
5
6
7
8
9
10
async function foo() {
try {
const result = await doSomething();
const newResult = await doSomethingElse(result);
const finalResult = await doThirdThing(newResult);
console.log(`Got the final result: ${finalResult}`);
} catch(error) {
failureCallback(error);
}
}
  • async 函数可能包含 0 个或者多个 await 表达式。
  • await 表达式会暂停整个 async 函数的执行进程并出让其控制权,只有当其等待的基于 promise 的异步操作被兑现或被拒绝之后才会恢复进程。
  • promise 的解决值会被当作该 await 表达式的返回值。使用 async/await 关键字就可以在异步代码中使用普通的 try/catch 代码块。
  • await 关键字只在 async 函数内有效。如果你在 async 函数体之外使用它,就会抛出语法错误 SyntaxError 。
  • async 函数一定会返回一个 promise 对象。如果一个 async 函数的返回值看起来不是 promise,那么它将会被隐式地包装在一个 promise 中。
1
2
3
4
5
6
7
async function foo() {
return 1;
}
//等价
function foo() {
return Promise.resolve(1);
}
  • async 函数的函数体可以被看作是由 0 个或者多个 await 表达式分割开来的。
  • 从第一行代码直到(并包括)第一个 await 表达式(如果有的话)都是同步运行的。这样的话,一个不含 await 表达式的 async 函数是会同步运行的。然而,如果函数体内有一个 await 表达式,async 函数就一定会异步执行
1
2
3
4
5
6
7
async function foo() {
await 1;
}
//等价
function foo() {
return Promise.resolve(1).then(() => undefined);
}

宏任务、微任务

script是一个宏任务

在一个宏任务中,先执行同步代码,再执行微任务,再执行下一个宏任务。

同步编程

1
2
3
4
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"

这段代码:

  1. 声明了一个叫做 name 的字符串常量
  2. 声明了另一个叫做 greeting 的字符串常量(并使用了 name 常量的值)
  3. 将 greeting 常量输出到 JavaScript 控制台中。

我们应该注意的是,实际上浏览器是按照我们书写代码的顺序一行一行地执行程序的。浏览器会等待代码的解析和工作,在上一行完成后才会执行下一行。这样做是很有必要的,因为每一行新的代码都是建立在前面代码的基础之上的。

这也使得它成为一个同步程序

事实上,调用函数的时候也是同步的,就像这样:

1
2
3
4
5
6
7
function makeGreeting(name) {
return `Hello, my name is ${name}!`;
}
const name = "Miriam";
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"

在这里 makeGreeting() 就是一个同步函数,因为在函数返回之前,调用者必须等待函数完成其工作。

耗时同步函数的问题:当调用函数时长过久时,程序完全没有反应,用户不能输入任何东西,也不能点击任何东西,或做任何其他事情。

这就是耗时的同步函数的基本问题。在这里我们想要的是一种方法,以让的程序可以:

  • 通过调用一个函数来启动一个长期运行的操作
  • 让函数开始操作并立即返回,这样我们的程序就可以保持对其他事件做出反应的能力
  • 当操作最终完成时,通知我们操作的结果

这就是异步函数为我们提供的能力。

事件处理程序

我们刚才看到的对异步函数的描述可能会让你想起事件处理程序,这么想是对的。事件处理程序实际上就是异步编程的一种形式:你提供的函数(事件处理程序)将在事件发生时被调用(而不是立即被调用)。如果“事件”是“异步操作已经完成”,那么你就可以看到事件如何被用来通知调用者异步函数调用的结果的。

一些早期的异步 API 正是以这种方式来使用事件的。XMLHttpRequest API 可以让你用 JavaScript 向远程服务器发起 HTTP 请求。由于这样的操作可能需要很长的时间,所以它被设计成异步 API,你可以通过给 XMLHttpRequest 对象附加事件监听器来让程序在请求进展和最终完成时获得通知。

下面的例子展示了这样的操作。点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

我们在添加了事件监听器后发送请求。注意,在这之后,我们仍然可以在控制台中输出“请求已发起”,也就是说,我们的程序可以在请求进行同时继续运行,而我们的事件处理程序将在请求完成时被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
log.textContent = "";
const xhr = new XMLHttpRequest();
xhr.addEventListener("loadend", () => {
log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
});
xhr.open(
"GET",
"https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
);
xhr.send();
log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
log.textContent = "";
document.location.reload();
});

这就像我们在以前的模块中遇到的事件处理程序,只是这次的事件不是像点击按钮那样的用户行为,而是某个对象的状态变化。

回调

事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候被调用的函数。正如我们刚刚所看到的:回调函数曾经是 JavaScript 中实现异步函数的主要方式。

然而,当回调函数本身需要调用其他同样接受回调函数的函数时,基于回调的代码会变得难以理解。当你需要执行一些分解成一系列异步函数的操作时,这将变得十分常见。例如下面这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doStep1(init) {
return init + 1;
}
function doStep2(init) {
return init + 2;
}
function doStep3(init) {
return init + 3;
}
function doOperation() {
let result = 0;
result = doStep1(result);
result = doStep2(result);
result = doStep3(result);
console.log(`结果:${result}`);
}
doOperation();

现在我们有一个被分成三步的操作,每一步都依赖于上一步。在这个例子中,第一步给输入的数据加 1,第二步加 2,第三步加 3。从输入 0 开始,最终结果是 6(0+1+2+3)。作为同步代码,这很容易理解。但是如果我们用回调来实现这些步骤呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function doStep1(init, callback) {
const result = init + 1;
callback(result);
}
function doStep2(init, callback) {
const result = init + 2;
callback(result);
}
function doStep3(init, callback) {
const result = init + 3;
callback(result);
}
function doOperation() {
doStep1(0, (result1) => {
doStep2(result1, (result2) => {
doStep3(result2, (result3) => {
console.log(`结果:${result3}`);
});
});
});
}
doOperation();

因为必须在回调函数中调用回调函数,我们就得到了这个深度嵌套的 doOperation() 函数,这就更难阅读和调试了。在一些地方这被称为“回调地狱”或“厄运金字塔”(因为缩进看起来像一个金字塔的侧面)。

面对这样的嵌套回调,处理错误也会变得非常困难:你必须在“金字塔”的每一级处理错误,而不是在最高一级一次完成错误处理。

由于以上这些原因,大多数现代异步 API 都不使用回调。事实上,JavaScript 中异步编程的基础是 Promise

Promise

Promise 是现代 JavaScript 中异步编程的基础,是一个由异步函数返回的可以向我们指示当前操作所处的状态的对象。在 Promise 返回给调用者的时候,操作往往还没有完成,但 Promise 对象可以让我们操作最终完成时对其进行处理(无论成功还是失败)。

使用 fetch() API

fetch() API,一个现代的、基于 Promise 的、用于替代 XMLHttpRequest 的方法。

一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");

//Promise { <state>: "pending" }
//已发送请求……
//已收到响应:200

请注意,已发送请求…… 的消息在我们收到响应之前就被输出了。与同步函数不同,fetch() 在请求仍在进行时返回,这使我们的程序能够保持响应性。响应显示了 200(OK)的状态码,意味着我们的请求成功了。

链式使用 Promise

在你通过 fetch() API 得到一个 Response 对象的时候,你需要调用另一个函数来获取响应数据。这次,我们想获得 JSON 格式的响应数据,所以我们会调用 Response 对象的 json() 方法。事实上,json() 也是异步的,因此我们必须连续调用两个异步函数。

1
2
3
4
5
6
7
8
9
10
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
const jsonPromise = response.json();
jsonPromise.then((json) => {
console.log(json[0].name);
});
});

在这个示例中,就像我们之前做的那样,我们给 fetch() 返回的 Promise 对象添加了一个 then() 处理程序。但这次我们的处理程序调用 response.json() 方法,然后将一个新的 then() 处理程序传递到 response.json() 返回的 Promise 中。

但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态。这意味着我们可以(当然也应该)把上面的代码改写成这样:

1
2
3
4
5
6
7
8
9
10
11
const fetchPromise = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
.then((response) => {
return response.json();
})
.then((json) => {
console.log(json[0].name);
});

不必在第一个 then() 的处理程序中调用第二个 then(),我们可以直接返回 json() 返回的 Promise,并在该返回值上调用第二个 “then()”。这被称为 Promise 链,意味着当我们需要连续进行异步函数调用时,我们就可以避免不断嵌套带来的缩进增加。

错误捕获

Promise 对象提供了一个 catch() 方法来支持错误处理。这很像 then():你调用它并传入一个处理函数。然后,当异步操作成功*时,传递给 then() 的处理函数被调用,而当异步操作**失败***时,传递给 catch() 的处理函数被调用。

使用 catch() 添加了一个错误处理函数,并修改了 URL(这样请求就会失败)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fetchPromise = fetch(
"bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP 请求错误:${response.status}`);
}
return response.json();
})
.then((json) => {
console.log(json[0].name);
})
.catch((error) => {
console.error(`无法获取产品列表:${error}`);
});
//无法获取产品列表:TypeError: Failed to fetch

Promise 术语

Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

有时用 已敲定(settled) 这个词来同时表示 已兑现(fulfilled)已拒绝(rejected) 两种情况。

如果一个 Promise 处于已决议(resolved)状态,或者它被“锁定”以跟随另一个 Promise 的状态,那么它就是 已兑现(fulfilled)

合并使用多个 Promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

由Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序被传入 all() 的 Promise 的顺序相同
  • 会被拒绝——如果数组中有*任何一个* Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fetchPromise1 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
"https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
.then((responses) => {
for (const response of responses) {
console.log(`${response.url}${response.status}`);
}
})
.catch((error) => {
console.error(`获取失败:${error}`);
});

这里我们向三个不同的 URL 发出三个 fetch() 请求。如果它们都被兑现了,我们将输出每个请求的响应状态。如果其中任何一个被拒绝了,我们将输出失败的情况。

根据我们提供的 URL,应该所有的请求都会被兑现,尽管因为第二个请求中请求的文件不存在,服务器将返回 404(Not Found)而不是 200(OK)。所以输出应该是:

1
2
3
https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json:200
https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found:404
https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json:200

如果我们用一个错误编码的 URL 尝试同样的代码,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fetchPromise1 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
"bad-scheme://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
.then((responses) => {
for (const response of responses) {
console.log(`${response.url}${response.status}`);
}
})
.catch((error) => {
console.error(`获取失败:${error}`);
});

……然后 catch() 处理程序将被运行,我们应该看到像这样的输出:

1
获取失败:TypeError: Failed to fetch

有时,你可能需要等待一组 Promise 中的某一个 Promise 的执行,而不关心是哪一个。在这种情况下,你需要 Promise.any()。这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fetchPromise1 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
"https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
.then((response) => {
console.log(`${response.url}${response.status}`);
})
.catch((error) => {
console.error(`获取失败:${error}`);
});

值得注意的是,在这种情况下,我们无法预测哪个获取请求会先被兑现。

这两个用于组合多个承诺的函数只是额外的 Promise 函数中的两个。

async 和 await

async 关键字为你提供了一种更简单的方法来处理基于异步 Promise 的代码。在一个函数的开头添加 async,就可以使其成为一个异步函数。

1
2
3
async function myFunction() {
// 这是一个异步函数
}

在异步函数中,你可以在调用一个返回 Promise 的函数之前使用 await 关键字。这使得代码在该点上等待,直到 Promise 被完成,这时 Promise 的响应被当作返回值,或者被拒绝的响应被作为错误抛出。

这使你能够编写像同步代码一样的异步函数。例如,我们可以用它来重写我们的 fetch 示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function fetchProducts() {
try {
// 在这一行之后,我们的函数将等待 `fetch()` 调用完成
// 调用 `fetch()` 将返回一个“响应”或抛出一个错误
const response = await fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
if (!response.ok) {
throw new Error(`HTTP 请求错误:${response.status}`);
}
// 在这一行之后,我们的函数将等待 `response.json()` 的调用完成
// `response.json()` 调用将返回 JSON 对象或抛出一个错误
const json = await response.json();
console.log(json[0].name);
} catch (error) {
console.error(`无法获取产品列表:${error}`);
}
}

fetchProducts();

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try…catch 块来处理错误,就像我们在写同步代码时一样。

但请注意这个写法只在异步函数中起作用

异步函数总是返回一个 Pomise,所以你不能做这样的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function fetchProducts() {
try {
const response = await fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
if (!response.ok) {
throw new Error(`HTTP 请求错误:${response.status}`);
}
const json = await response.json();
return json;
} catch (error) {
console.error(`无法获取产品列表:${error}`);
}
}

const json = fetchProducts();
console.log(json[0].name); // json 是一个 Promise 对象,因此这句代码无法正常工作

相反,你需要做一些事情,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function fetchProducts() {
try {
const response = await fetch(
"https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
if (!response.ok) {
throw new Error(`HTTP 请求错误:${response.status}`);
}
const json = await response.json();
return json;
} catch (error) {
console.error(`无法获取产品列表:${error}`);
}
}

const jsonPromise = fetchProducts();
jsonPromise.then((json) => console.log(json[0].name));

你可能会在需要使用 Promise 链地方使用 async 函数,这也使得 Promise 的工作更加直观。

请记住,就像一个 Promise 链一样,await 强制异步操作以串联的方式完成。如果下一个操作的结果取决于上一个操作的结果,这是必要的,但如果不是这样,像 Promise.all() 这样的操作会有更好的性能。

实现基于 Promise 的 API

讨论了如何使用返回 promises 的 APIs。在本文中,我们将研究另一方面——如何实现返回 promises 的 APIs。跟使用基于 promise 的 APIs 相比,这是一个不太常见的任务,但它仍然值得了解。

通常情况下,当你实现一个基于 promise 的 API 时,你会使用事件、普通回调,或者消息传递模型包裹一个异步操作。你将会使用一个 Promise 对象来合理的处理操作的成功或者失败。

实现 alarm() API

在这个示例中我们将会实现一个基于 promise 的 alarm API,叫做 alarm() 。它将以被唤醒人的名字和一个在人被唤醒前以毫秒为单位的延迟作为参数。在延迟之后,本函数将会发送一个包含需要被唤醒人名字的 “Wake up!” 消息。

用 setTimeout() 包裹

我们将会使用 setTimeout() 来实现 alarm() 函数。setTimeout() 以一个回调函数和一个以毫秒为单位的延迟作为参数。当调用 setTimeout() 时,它将启动一个设置为给定延迟的计时器,当时间过期时,它就会调用给定的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
<button id="set-alarm">Set alarm</button>
<div id="output"></div>

const output = document.querySelector("#output");
const button = document.querySelector("#set-alarm");

function setAlarm() {
window.setTimeout(() => {
output.textContent = "Wake up!";
}, 1000);
}

button.addEventListener("click", setAlarm);

Promise() 构造器

alarm() 函数返回一个在定时器过期时才会被兑现的 Promise。它将会传递一个 “Wake up!” 消息到 then() 处理器中,也会在当调用者提供一个延迟值时拒绝这个 promise。

这里的关键组件是 Promise() 构造器。Promise() 构造器使用单个函数作为参数。我们把这个函数称作执行器(executor)。当你创建一个新的 promise 的时候你需要实现这个执行器。

这个执行器本身采用两个参数,这两个参数都是函数,通常被称作 resolve 和 reject。在你的执行器实现里,你调用原始的异步函数。如果异步函数成功了,就调用 resolve,如果失败了,就调用 reject。如果执行器函数抛出了一个错误,reject 会被自动调用。你可以将任何类型的单个参数传递到 resolve 和 reject 中。

1
2
3
4
5
6
7
8
9
10
function alarm(person, delay) {
return new Promise((resolve, reject) => {
if (delay < 0) {
throw new Error("Alarm delay must not be negative");
}
window.setTimeout(() => {
resolve(`Wake up, ${person}!`);
}, delay);
});
}

此函数创建并且返回一个新的 Promise。对于执行器中的 promise,我们:

  • 检查 delay(延迟)是否为负数,如果是的话就抛出一个错误。
  • 调用 window.setTimeout(),传递一个回调函数和 delay(延迟)。当计时器过期时回调会被调用,在回调函数内,我们调用了 resolve,并且传递了 “Wake up!” 消息。

使用 alarm() API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const name = document.querySelector("#name");
const delay = document.querySelector("#delay");
const button = document.querySelector("#set-alarm");
const output = document.querySelector("#output");

function alarm(person, delay) {
return new Promise((resolve, reject) => {
if (delay < 0) {
throw new Error("Alarm delay must not be negative");
}
window.setTimeout(() => {
resolve(`Wake up, ${person}!`);
}, delay);
});
}

button.addEventListener("click", () => {
alarm(name.value, delay.value)
.then((message) => (output.textContent = message))
.catch((error) => (output.textContent = `Couldn't set alarm: ${error}`));
});

在 alarm() API 上使用 async 和 await

自从 alarm() 返回了一个 Promise,我们可以对它做任何我们可以对其他任何 promise 做的事情:Promise.all(),和 async / await:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const name = document.querySelector("#name");
const delay = document.querySelector("#delay");
const button = document.querySelector("#set-alarm");
const output = document.querySelector("#output");

function alarm(person, delay) {
return new Promise((resolve, reject) => {
if (delay < 0) {
throw new Error("Alarm delay must not be negative");
}
window.setTimeout(() => {
resolve(`Wake up, ${person}!`);
}, delay);
});
}

button.addEventListener("click", async () => {
try {
const message = await alarm(name.value, delay.value);
output.textContent = message;
} catch (error) {
output.textContent = `Couldn't set alarm: ${error}`;
}
});

worker

在本模块的第一篇文章中,我们看到了当在你的程序中具有一个长期运行的的同步任务时发生了什么 ── 整个窗口变得完全没有响应。从根本上讲,出现这种情况的原因是程序是单线程的。一个线程是程序遵循的一系列指令。因为程序由一个线程组成,它在同一时间只能做一件事情:所以如果它正在等待我们的长期运行的同步调用返回,它就不能做其他任何事情。

Workers 给了你在不同线程中运行某些任务的能力,因此你可以启动任务,然后继续其他的处理(例如处理用户操作)。

但是这是要付出代价的。对于多线程代码,你永远不知道你的线程什么时候将会被挂起,其他线程将会得到运行的机会。因此,如果两个线程都可以访问相同的变量,那么变量就有可能在任何时候发生意外的变化,这将导致很难发现的 Bug

为了避免 Web 中的这些问题,你的主代码和你的 worker 代码永远不能直接访问彼此的变量。Workers 和主代码运行在完全分离的环境中,只有通过相互发送消息来进行交互。特别是,这意味着 workers 不能访问 DOM(窗口、文档、页面元素等等)。

三种不同类型的 workers:

  • dedicated workers
  • shared workers
  • service workers

使用 web workers(dedicated workers)

用 worker 进行质数生成:

目录下有四个文件:

  • index.html
  • style.css
  • main.js
  • generate.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<script type="text/javascript" src="main.js" defer></script>
<link href="style.css" rel="stylesheet" />
</head>

<body>
<label for="quota">Number of primes:</label>
<input type="text" id="quota" name="quota" value="1000000" />

<button id="generate">Generate primes</button>
<button id="reload">Reload</button>

<textarea id="user-input" rows="5" cols="62">
Try typing in here immediately after pressing "Generate primes"</textarea
>

<div id="output"></div>
</body>
</html>
1
2
3
4
textarea {
display: block;
margin: 1rem 0;
}

“main.js” 和 “generate.js” 文件是空的。我们将向 “main.js” 中添加主代码,向 “generate.js” 中添加 worker 代码

首先,我们可以看到 worker 代码被保存在一个与主代码隔离的脚本中。我们还可以看到,在上面的 “index.html” 中,只有主代码被包含在 <script> 标签中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 在 "generate.js" 中创建一个新的 worker
const worker = new Worker("./generate.js");

// 当用户点击 "Generate primes" 时,给 worker 发送一条消息。
// 消息中的 command 属性是 "generate", 还包含另外一个属性 "quota",即要生成的质数。
document.querySelector("#generate").addEventListener("click", () => {
const quota = document.querySelector("#quota").value;
worker.postMessage({
command: "generate",
quota: quota,
});
});

// 当 worker 给主线程回发一条消息时,为用户更新 output 框,包含生成的质数(从 message 中获取)。
worker.addEventListener("message", (message) => {
document.querySelector("#output").textContent =
`Finished generating ${message.data} primes!`;
});

document.querySelector("#reload").addEventListener("click", () => {
document.querySelector("#user-input").value =
'Try typing in here immediately after pressing "Generate primes"';
document.location.reload();
});
  • 首先,我们使用 Worker() 构造函数创建 worker。我们传递一个指向 worker 脚本的 URL。只要 worker 被创建了,worker 脚本就会执行。

  • 其次,与同步版本一样,我们向 “Generate primes” 按钮添加一个 click 事件处理器。但是现在,我们不再调用 generatePrimes() 函数,而是使用 worker.postMessage() 向 worker 发送一条消息。这条消息可以携带一个参数,在本示例中我们传递一个包含两个属性的 JSON 对象:

    • command:一个用于标识我们希望 worker 所做事情的字符串(以防我们的 worker 可以做多个事情)。
    • quota:要生成的质数的数量。
  • 然后,我们向 worker 添加一个 message 消息处理器。这样 worker 就能告诉我们它是什么时候完成的,并且传递给我们任何结果数据。我们的处理器从消息的 data 属性获取数据,然后将其写入 output 元素(数据与 quota 是完全相同的,这虽然没有意义,但是这展示了其中原理)。

  • 最后,我们为 “Reload” 按钮实现了 click 事件处理器。这与同步版本完全相同。

1
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
// 监听主线程中的消息。
// 如果消息中的 command 是 "generate",则调用 `generatePrimse()`
addEventListener("message", (message) => {
if (message.data.command === "generate") {
generatePrimes(message.data.quota);
}
});

// 生成质数 (非常低效)
function generatePrimes(quota) {
function isPrime(n) {
for (let c = 2; c <= Math.sqrt(n); ++c) {
if (n % c === 0) {
return false;
}
}
return true;
}

const primes = [];
const maximum = 1000000;

while (primes.length < quota) {
const candidate = Math.floor(Math.random() * (maximum + 1));
if (isPrime(candidate)) {
primes.push(candidate);
}
}

// 完成后给主线程发送一条包含我们生成的质数数量的消息消息。
postMessage(primes.length);
}

请记住,只要主脚本创建 worker,“generate.js”这些代码就会运行。

worker 要做的第一件事情就是开始监听来自主脚本的消息。这通过使用 addEventListener() 实现,它在 worker 中是一个全局函数。在 message 事件处理器内部,事件的 data 属性包含一个来自主脚本的参数的副本。如果主脚本传递 generate 命令,我们就调用 generatePrimes(),传入来自消息事件的 quota 值。

generatePrimes() 函数与同步版本类似,只不过我们在完成后向主脚本发送一条消息,而不是返回一个值。我们对此使用 postMessage() 函数,就像在 worker 中 addEventListener是全局函数一样。如我们所见,主脚本正在监听这条消息并且将会在收到消息后更新 DOM

其他类型的 worker

其他类型的 worker:

  • SharedWorker 可以由运行在不同窗口中的多个不同脚本共享
  • Service worker 的行为就像代理服务器,缓存资源以便于 web 应用程序可以在用户离线时工作。他们是渐进式 Web 应用关键组件
作者

冷冷

发布于

2019-11-09

更新于

2021-01-10

许可协议

CC BY-NC-SA 4.0

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×