Skip to content
This page has been auto-translated and may contain errors.View in English

异步 JavaScript

docs.scrimba.com

有些事情发生得很快:两个数字相加、读取一个变量、改变页面上的文本。有些事情需要时间。一个计时器倒计时三秒。一个到服务器的请求穿过网络后片刻才回来。如果 JavaScript 停下来等待每一个这样的事情,整个页面都会冻结:没有点击、没有滚动、什么都没有,直到那个缓慢的事情完成。它不是这样运作的。本章讲的是 JavaScript 如何启动某个缓慢的事情、保持页面的响应性,以及在结果准备好时回到那里处理结果。

为什么需要异步

大多数 JavaScript 从上到下运行,一行在下一行开始之前完成。这对快速工作来说很好。但是有些任务需要真实的时间,JavaScript 不会坐在那里等待。它启动缓慢的任务,然后继续下一行,在缓慢任务完成时回到它。这就是**异步**的意思:不是按顺序发生,不是现在发生,而是稍后,当结果准备好时发生。

最简单的例子就是计时器。setTimeout 在延迟后运行一段代码:

js
console.log("开始");
setTimeout(() => {
  console.log("3 秒后");
}, 3000);
console.log("结束");
// 开始
// 结束
// 3 秒后

注意顺序。"结束""3 秒后" 之前打印,即使它在代码中 setTimeout 之后。JavaScript 没有在那一行等待三秒。它设置了计时器,继续运行,并在时间到了时运行延迟的代码。

JavaScript 一次运行一个事情,从上到下。这个模式对于快速工作很有效,直到一个任务需要时间:一个计时器、一个网络请求、读取文件。如果语言在这些上面阻塞,页面会在等待时冻结,因为什么都不能运行。**异步**代码是答案:你启动缓慢的任务,JavaScript 继续运行你代码的其余部分,当结果到达时稍后处理它。

setTimeout 是最清晰的第一个例子。你给它一个函数和一个以毫秒为单位的延迟,它在延迟后运行该函数:

js
console.log("开始");
setTimeout(() => {
  console.log("3 秒后");
}, 3000);
console.log("结束");
// 开始
// 结束
// 3 秒后

你传给 setTimeout 的函数是一个回调:你交给它稍后被调用的代码,不是现在。JavaScript 注册计时器,直接移动到 "结束",只在延迟结束且当前代码完成后运行回调。那个"现在完成,稍后回来"的模式就是异步的全部想法,本章的其他所有内容都构建在它之上。

JavaScript 是单线程的:一个调用栈,一次执行一个东西。这个约束正是异步重要的原因。在网络请求上阻塞会停止单个线程,随之页面上的每一个点击、动画和渲染。所以缓慢的工作不是内联完成的。它被交给运行时(浏览器或 Node),运行时在你的线程之外进行等待,并在结果准备好时安排你的代码恢复。**异步**代码就是那个交付:你描述当你还没有的结果到达时要做什么。

setTimeout 是最小的情况,它清晰地暴露了这个模型:

js
console.log("开始");
setTimeout(() => {
  console.log("稍后");
}, 0);
console.log("结束");
// 开始
// 结束
// 稍后

这里的延迟是 0,回调仍然最后运行。那是关键:异步回调永远不会在同步代码的中间运行setTimeout 不会在零毫秒后运行它的回调,它安排回调在当前同步代码耗尽后运行。那个顺序背后的机制是事件循环,本章的最后一部分深入讨论了它。现在,记住这个规则:同步代码先运行到完成,然后排队的异步工作。

Juno为什么需要异步 有些任务需要时间,像计时器或加载数据,JavaScript 在等待时不会冻结。它启动缓慢的事情,继续运行下一行,当缓慢的事情准备好时回到它。这就是为什么 "结束"setTimeout 消息之前打印,即使计时器在代码中先出现。
Juno为什么需要异步 异步意味着现在启动缓慢的任务,稍后处理它的结果,并在两者之间保持页面的响应性。setTimeout 是最简单的版本:你给它一个回调,它在延迟后运行该回调,不是当场。一旦你看到为什么延迟的消息最后打印,本章的其余部分就是对那一个想法的变化。
Juno为什么需要异步 JavaScript 是单线程的,所以在缓慢工作上阻塞会停止一切。相反,你把等待交给运行时,并描述当结果到达时要做什么。setTimeout(fn, 0) 的情况是关键:回调仍然在同步代码之后运行,因为排队的异步工作等待当前代码先完成。

Promise

setTimeout 对于延迟很好,但大多数异步工作产生一个你关心的值:你请求的数据,或者如果出错了一个错误。为此,JavaScript 使用一个**Promise**。一个 Promise 是一个还没有准备好的值,为它预留了一个位置。它就像一张收据:你没有有饭,但你有会变成饭的东西。

一个 Promise 以两种方式结束。当工作成功时它解决一个值,或当它失败时它拒绝一个错误。你用 .then().catch() 说出在每种情况下做什么:

js
loadUser()
  .then((user) => {
    console.log(`已加载 ${user.name}`);
  })
  .catch((error) => {
    console.log("出错了");
  });

.then() 在 Promise 解决时运行,它接收这个值。.catch() 在它拒绝时运行,它接收错误。它们内的代码稍后运行,一旦结果到达。

回调有效,但它们堆积得不好。当一个异步任务依赖另一个,那个依赖另一个时,你最后得到在回调内嵌套回调,每一步都向右漂移。那个形状有一个名字,"厄运金字塔",它变得难以阅读,并且更难在其中处理错误。一个**Promise** 修复了这个形状。

一个 Promise 是一个代表还没有准备好的值的对象。它在三种状态之一:待处理(仍在工作)、完成(用值解决)或拒绝(失败并出错)。你用 .then() 为成功和 .catch() 为失败附加处理器:

js
loadUser()
  .then((user) => {
    console.log(`已加载 ${user.name}`);
    return loadPosts(user.id);
  })
  .then((posts) => {
    console.log(`找到 ${posts.length} 篇帖子`);
  })
  .catch((error) => {
    console.log(`失败:${error.message}`);
  });

两件事使这比嵌套回调更好。首先,它是平的:每个 .then() 返回一个 Promise,所以你在一条直线上链接它们,而不是嵌套。从 .then() 返回一个值,下一个接收它;返回一个 Promise,链等待它。其次,一个在末尾的 .catch() 处理链中任何地方的失败,所以你只写一次错误处理,而不是在每一层。

在 Promise 之前,异步结果通过回调传递,依赖的异步步骤意味着在回调内嵌套回调。除了可读性成本,那个模式没有统一的错误路径:每一层处理自己的失败,忘记一个会默默地吞掉错误。一个**Promise** 通过使最终结果成为一个一级值来解决这两个问题,你可以传递、链接和在一个地方处理它。

一个 Promise 是一个在三种状态之一的对象:待处理、用值完成或用原因拒绝。一旦它安定(完成或拒绝),它永远不会再改变。你用 .then(onFulfilled).catch(onRejected) 注册反应,链接就是真正的力量所在:

js
loadUser()
  .then((user) => loadPosts(user.id)) // 返回一个 Promise,链等待它
  .then((posts) => posts.filter((post) => post.published))
  .then((published) => console.log(`${published.length} 个已发布`))
  .catch((error) => console.log(`失败:${error.message}`));

每个 .then() 返回一个新 Promise,处理器的返回值决定了它:返回一个平常的值,下一个 .then() 接收那个值,返回一个 Promise,链采用它并等待。一个抛出的错误或链中任何地方的拒绝跳过剩余的 .then() 处理器并跳到下一个 .catch(),这就是为什么一个在末尾的处理器从任何步骤捕获失败。这是对回调的一个真正改进,不只是一个更好的语法:错误自动传播,平的链按它运行的顺序读取。实际上你会读很多 Promise 链,但你会用 async/await 写你自己的大多数异步代码,那就是下一部分,坐在恰好这个机制的顶部。

JunoPromise 一个 Promise 是一个还没有准备好的值,像一张收据,一个稍后到达的结果。它用值解决当工作成功时,或用错误拒绝当它失败时。你用 .then() 说对值做什么,用 .catch() 对错误做什么,两者都在稍后一旦结果进来时运行。
JunoPromise 一个 Promise 代表仍然到来的结果,并且它打败了嵌套的回调因为你在一条平的线上链接 .then() 而不是向右漂移。从 .then() 返回一个值,下一个得到它,返回一个 Promise,链等待。一个在末尾的 .catch() 处理链中任何地方的失败,所以你只写一次错误处理。
JunoPromise 一个 Promise 是一个对象,安定一次,到完成或拒绝,在那之后永不改变。每个 .then() 返回一个新 Promise,所以返回一个值传递它,返回一个 Promise 使链等待,而任何拒绝跳过到下一个 .catch()。你会读很多链,但用 asyncawait 写你自己的大多数异步,它们坐在这个顶部。

async / await

Promise 链有效,但有一个更清晰的方式写它们,读起来几乎像普通的从上到下的代码。它使用两个关键字,async 和**await**。

你标记一个函数 async,在它内部你可以 await 一个 Promise。await 暂停函数直到 Promise 解决,然后给你值,不需要 .then()

js
async function showUser() {
  const user = await loadUser();
  console.log(`已加载 ${user.name}`);
}

从上到下读那个:得到用户,然后记录名字。await 行等待 loadUser() 完成并给你 user。函数在那里暂停,但页面的其余部分继续运行,所以什么都不冻结。

为了处理错误,用 trycatch 包裹 await

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`已加载 ${user.name}`);
  } catch (error) {
    console.log("无法加载用户");
  }
}

如果 Promise 失败,代码跳到 catch 块而不是崩溃。

async/await 是现代的写异步代码的方式。它构建在 Promise 上,所以下面什么都不改变,但它让你写异步逻辑,读起来像你已经从函数知道的同步代码。

标记一个函数 async,你可以在它内部使用**await**。await 接一个 Promise,暂停函数直到它解决,并求值为解决的值:

js
async function showUser() {
  const user = await loadUser();
  const posts = await loadPosts(user.id);
  console.log(`${user.name} 有 ${posts.length} 篇帖子`);
}

比较那个到上一部分的 .then() 链:相同的工作,但平和线性,值登陆在平常的 const 绑定中你可以在下一行使用。一个 async 函数总是自己返回一个 Promise,所以从别处调用 showUser() 给你一个 Promise 你可以 await.then() 到那里。

错误是人们错过的部分。在 async 函数内一个被拒绝的 Promise 抛出,所以你用一个普通的 try/catch 捕获它:

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`已加载 ${user.name}`);
  } catch (error) {
    console.log(`失败:${error.message}`);
  }
}

你会用于一个同步错误的相同 try/catch 处理一个异步的,这是为什么 async/await 赢的很大一部分。

async/await 是 Promise 上的语法。一个 async 函数总是返回一个 Promise,await 展开一个:它暂停函数,产生线程回到运行时,并在 Promise 安定时用解决的值恢复。什么都不在等待时被阻塞。函数暂停了,但单个线程是自由的运行其他工作,这就是整个想法。

收益是异步代码恢复了两个东西回调和原始链失去的:线性读取顺序和普通的控制流。await 让你使用 const、条件和循环环绕异步值好像它们是同步的:

js
async function publishReport(userId) {
  const user = await loadUser(userId);
  if (!user.active) {
    return null; // 早期返回正常工作
  }
  const posts = await loadPosts(user.id);
  return posts.filter((post) => post.published);
}

错误处理崩溃到 try/catch 因为一个被拒绝的等待的 Promise 在 await 处抛出:

js
async function publishReport(userId) {
  try {
    const user = await loadUser(userId);
    return await loadPosts(user.id);
  } catch (error) {
    console.log(`报告失败:${error.message}`);
    return [];
  }
}

一个值得内化的细微之处:return await 在一个 try 内部很重要。如果你写 return loadPosts(...) 不带 await,函数返回 Promise 并在 Promise 安定之前退出 try,所以一个拒绝逃脱 catch。用 await,拒绝在你仍在 try 内时抛出,catch 看到它。那是那种变成"为什么我的错误处理器没有激发"bug 的细节,下一部分的事件循环和错误模式更进一步进入它。

Junoasync 和 await 标记一个函数 async,你可以在它内部 await 一个 Promise。await 暂停那个函数直到 Promise 解决并直接给你值,所以代码从上到下读取,没有 .then()。用 trycatch 包裹它处理失败,页面在函数等待时继续运行。
Junoasync 和 awaitasync/await 是 Promise 带更清晰的语法:await 暂停函数直到 Promise 解决并给你值在一个平常的 const。一个 async 函数总是返回一个 Promise,所以你可以从别处 await 它。错误抛出,所以一个普通的 try/catch 处理它们,那是这个风格赢的大多数。
Junoasync 和 awaitawait 暂停函数并产生线程,然后用解决的值恢复,所以什么都不在你等待时阻塞。你得到线性读取、普通的控制流和 try/catch 为错误。看 return await 在一个 try 内部:删除 await,一个拒绝在 catch 能看到它之前逃脱。

获取数据

你将写的最常见的异步任务是从服务器加载数据。浏览器给你 fetch 为此。你传给它一个 URL,它为响应返回一个 Promise:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  const users = await response.json();
  console.log(users);
}

有两个 await,那开始让人惊讶。第一个等待服务器响应。第二个读响应的主体并用 .json() 将其变为 JavaScript 数据,那本身是异步的。所以获取是两个步骤:得到响应,然后读它。

服务器也可以用一个错误响应,像"不找到"。fetch 自己不把那当作一个失败,所以你检查 response.ok 自己:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    console.log("请求失败");
    return;
  }
  const users = await response.json();
  console.log(users);
}

从服务器加载数据是你最会用的异步任务,浏览器的 fetch 是你如何做它的。给它一个 URL,它返回一个解决为 Response 对象的 Promise:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`请求失败:${response.status}`);
  }
  const users = await response.json();
  return users;
}

两步性质是要记住的部分。第一个 await 在响应头到达时解决,给你一个 Response。那个响应不是数据仍是,它是到它的一个句柄。读主体是第二个异步步骤:response.json() 返回一个解决为解析的数据的 Promise。两个 await,两个阶段。

陷阱抓住每个人:fetch 只在网络失败时拒绝,不在 HTTP 错误状态时。一个 404 或 500 仍然解决;自己检查 response.ok response.ok 对 200 范围内的状态码是 true,所以一个在 !response.ok 上的防卫是如何将一个坏状态变成一个真正的错误。一旦你在那里抛出,一个在调用周围的 try/catch 在一个地方处理网络失败和坏响应。

fetch(url) 是浏览器的异步 HTTP 客户端,它返回一个解决为 Response 的 Promise。设计有一个锋利的边缘值得在前面说出来:Promise 只在一个网络层面的失败上拒绝,一个丢弃的连接或一个被阻止的请求。一个 HTTP 错误状态像 404 或 500 对 fetch 而言是一个成功的往返,所以它正常解决。检查 response.ok 不是可选的。

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`请求失败:${response.status}`);
  }
  return await response.json();
}

两阶段形状反映流。第一个 await 在响应头到达时安定,在主体必然已到达前。Response 是到一个流的句柄,response.json() 读那个流到完成和解析它,这就是为什么它本身是异步的并需要自己的 await。在函数内将一个坏状态变成一个抛出的错误意味着一个 try/catch 在调用点覆盖网络失败、坏状态和 JSON 失败解析,三个不同的失败模式路由到一个处理器:

js
try {
  const users = await loadUsers();
  render(users);
} catch (error) {
  console.log(`无法加载用户:${error.message}`);
}

那就是一个获取的全部实际解剖:一个 Promise 为响应,一个状态检查,第二个 Promise 为主体,和单个错误边界环绕一切。下一部分覆盖当你同时需要这些中的几个时会发生什么。

Juno获取数据fetch(url) 为服务器的响应返回一个 Promise,加载数据是两个步骤:await fetch(...) 为响应,然后 await response.json() 读数据出它。一个服务器错误像"不找到"不自己失败,所以检查 response.ok 在你读主体之前。两个步骤都使用 await 因为两个都需要时间。
Juno获取数据fetch 为一个 Response 返回一个 Promise,用 response.json() 读主体是第二个异步步骤,所以一个普通的获取有两个 await。陷阱:fetch 只在一个网络失败时拒绝,所以一个 404 或 500 仍然解决。检查 response.ok 并在一个坏状态上抛出,一个 try/catch 覆盖一切。
Juno获取数据fetch 在头到达时解决,不是主体,那就是为什么 response.json() 是分开的等待的步骤读那个流。它只在一个网络失败时拒绝,所以一个 404 解决得很好,你必须自己检查 response.ok。在一个坏状态上抛出,一个 try/catch 在调用点处理网络错误、坏状态和解析失败一起。

事件循环和并行运行工作

到目前为止的所有东西都有一个主题:JavaScript 启动缓慢的工作,继续运行,稍后回到它。这一部分是那背后的机制,加上为好地运行几个异步任务的模式。

这里是一张 JavaScript 如何追踪异步工作的图。你运行中的代码坐在调用栈上,正在执行什么的列表。当你调用 setTimeout 时,计时器被交给浏览器去倒计时,所以它不在栈上阻塞任何东西。当计时器完成时,它的回调进入一个任务队列,一个准备运行的代码的等待线。

**事件循环**是连接它们的部分。它有一个规则:先运行调用栈上的所有东西,只有当栈是空的时从队列拉下一个任务。这就是为什么 setTimeout(fn, 0) 仍然在你其他的代码之后运行,即使有一个零延迟。回调必须在队列中等待直到当前代码完成:

js
console.log("第一");
setTimeout(() => console.log("第三"), 0);
console.log("第二");
// 第一
// 第二
// 第三

0 的延迟不意味着"现在"。它意味着"一旦当前的代码完成"。

**事件循环**是使所有这个顺序工作的引擎。三个部分:调用栈(同步代码正现在运行),任务队列(异步回调等待它们的轮)和循环本身,它做一件事:当调用栈是空的时,从队列拉下一个任务并运行它。

那个单一规则解释了 setTimeout(fn, 0) 顺序。延迟是在回调被排队前的最小等待,不是对立即运行的许诺,一个排队的任务在当前同步代码完成并清理栈前不能运行:

js
console.log("第一");
setTimeout(() => console.log("第三"), 0);
console.log("第二");
// 第一
// 第二
// 第三

当你有几个独立的异步任务时,一个接一个地运行它们浪费时间。在一个循环中等待是常见的错误:

js
// 缓慢:每个请求等待前一个完成
const users = [];
for (const id of ids) {
  users.push(await loadUser(id));
}

如果任务不相互依赖,一起启动它们,并用 Promise.all 等待所有它们一次。它接一个 Promise 数组并返回一个解决为它们结果数组的 Promise:

js
// 快速:所有请求同时运行
const users = await Promise.all(ids.map((id) => loadUser(id)));

当你有独立的异步工作时到达 Promise.all,只在每一步真正需要前一个的结果时在一个循环中保持顺序的 await

**事件循环**是使单线程异步可能的调度器。调用栈持有同步帧正现在执行。异步回调不直接推到它;它们在一个队列中等待。循环的规则是简单的:当调用栈是空的时,出列下一个任务并运行它到完成,然后重复。一个任务不被打断地运行,这就是为什么长同步工作仍然阻塞所有东西,异步或不是。

那个模型是 setTimeout(fn, 0) 的完整解释。延迟在最小时间后将回调安排到队列,但它在栈耗尽前不能运行,所以它总是跟随同步代码:

js
console.log("第一");
setTimeout(() => console.log("第三"), 0);
console.log("第二");
// 第一,第二,第三

一个精化要保在你的头:Promise 回调(.then 处理器和一个 await 之后的所有东西)在一个分开的、更高优先级的微任务队列上走,在任务之间完全耗尽,所以一个解决的 Promise 的继续在一个排队的 setTimeout 之前运行。你很少需要推理关于这个,但它解释了偶然的惊讶一个 Promise 代码打一个计时器。

实际的收益是好地调度你自己的工作。独立的异步任务应该并行运行,不是串行。在一个循环中等待串联化它们:

js
// 串行:总时间是每个请求的和
for (const id of ids) {
  results.push(await loadUser(id));
}

// 并行:总时间大约是最慢单个请求
const results = await Promise.all(ids.map((id) => loadUser(id)));

Promise.all 立即启动每个 Promise(.map 全部启动它们),然后在所有安定时解决,只要任何一个拒绝就拒绝。对独立工作使用它;只在一个步骤需要前一个结果时保持顺序 await。一个关于竞争条件的警告:如果两个异步操作写到相同的状态并且你不控制它们的顺序,最后一个完成的赢,这可能不是最后一个你启动的。当顺序重要时,await 第一个在启动第二个前,或钥匙每个结果所以一个晚到达不能覆写一个更新的。

Juno事件循环 你运行中的代码坐在调用栈,当一个计时器完成它的回调在任务队列中等待。事件循环先运行栈上的所有东西,然后拉下一个等待的任务,这就是为什么 setTimeout(fn, 0) 仍然在你代码的其余之后运行。0 的延迟意味着"一旦当前代码完成",不"现在"。
Juno事件循环 事件循环运行调用栈到空,然后从队列拉下一个任务,那恰好是为什么 setTimeout(fn, 0) 等待你同步代码完成。当异步任务不相互依赖时,不在一个循环中 await 它们一个接一个。一起启动它们并使用 Promise.all,并只在一个步骤需要最后一个的结果时保持顺序的 await
Juno事件循环 循环运行一个任务到完成,然后下一个,所以长同步工作阻塞所有东西,setTimeout(fn, 0) 总是跟随当前代码。Promise 继续在任务之间在更高优先级微任务队列上耗尽,那解释了奇怪的时间一个 .then 打一个计时器。用 Promise.all 而不是在循环中等待运行独立工作,当两个操作触碰相同的状态时,控制顺序或一个晚完成覆写一个更新的结果。

异步从这里去往何处

异步是 JavaScript 的部分保持一个页面活着当缓慢工作在背景发生时。形状总是相同的:启动需要时间的东西,让你代码的其余部分运行,当它到达时处理结果。Promise 给那个结果一个你可以传递的值,async/await 让你用平常的从上到下代码写它,fetch 是你将最使用所有这些的地方,从服务器加载数据。

从这里,异步连接到语言的部分反应到外面的世界。你将通过DOM将获取的数据连线到页面,你将启动异步工作来响应人们在页面上做什么,那就是事件。你回得的结果几乎总是对象,所以读和重塑它们是配对所有这个的下一个技能。