The Hard Parts of Asynchronous JavaScript

在本课程中,你将直观理解ES6+中JavaScript的新特性:迭代器、生成器、Promise以及async/await。你将了解Promise在底层是如何实现的,从而真正帮助解决异步代码设计中的控制反转难题。此外,你还将使用迭代器和生成器来掌握异步控制流。深入底层,通过调用栈、事件循环、回调队列、微任务队列和浏览器API解决复杂的异步挑战,让你能够轻松应对复杂的异步问题!

0-introduction

课程介绍与学习风格

  • 这门课程的学习风格要求会非常高,我会频繁地叫到房间里的每一个人,以便我们能够尽可能精确和清晰地讨论我们的代码。

讲师背景 (Will Sentance)

  • Codesmith:
    • 在洛杉矶 (LA) 和纽约 (NY) 设有分部。LA 的办公室刚搬到了美丽的威尼斯海滩。
  • 个人背景:
    • 介绍了他的姐妹们,其中一位刚开始在牛津大学的期末考试。
    • 牛津的考试模式:最终成绩完全取决于课程结束时为期两周的考试,过程中的考试不计分。这导致了最后两周极其痛苦,他曾连续三晚只睡两小时。
    • 曾就职于 Icecom (一个流行的 WebRTC 库) 和 Gem (一家区块链/加密货币领域的公司)。

什么是 Codesmith?

  • 核心定位:一个卓越的软件工程中心。
  • 目标:培养学员构建事物的能力。
  • 结果:毕业生能够进入他们梦想的公司工作(如 Google, Amazon, PayPal, Microsoft),从事智力上令人满足、富有创造性且稳定的工作。
    • 大部分毕业生获得中级工程师职位,约四分之一获得高级职位。
  • 面试重点
    • 根据对毕业生的调查,被问得最多的问题是:
      1. 事件循环 (Event Loop):它是如何工作的?
      2. 闭包 (Closure):其底层工作原理是什么?
    • 本课程会深入讲解闭包,因为理解闭包是理解迭代器的基础。
  • 学员多样性
    • 学员背景各异,从普林斯顿计算机科学毕业生到没有上过大学的人都有。
    • 这正是软件工程的美妙之处:它只看重你的能力。
  • 核心理念
    • 成为一名工程师的本质是突破你理解上的障碍
    • "经验不足" 是最有力量的反馈,因为你可以花几天时间去填补知识空白,从而获得足够的经验。

公司寻找什么样的人才?

  • 公司在招聘中高级工程师时看重的品质,也是 Codesmith 培养的重点:
    1. 解决问题的能力:能否突破障碍,实现任何新功能。
    2. 技术沟通能力:本课程会通过让每个人讲解代码来锻炼这一点。
    3. 最佳实践和方法
    4. 非技术沟通能力:互相支持,在帮助他人成长的环境中实现自我成长。
    5. 语言或框架知识:这是第五位重要的。前端框架每年都在变,只懂特定框架的人是“技术员”,而不是伟大的工程师。

课程环境强调的不是炫耀,而是拥抱周围人的成长,这也是自我成长的最佳环境。


1-javascript-code-execution

JavaScript 代码如何执行?

为了理解 JavaScript 的“硬核部分”,我们必须从最基础的执行原理开始。我们将像 JavaScript 引擎一样,一行一行地在白板上模拟代码的执行过程。

执行的两个核心部分

当 JavaScript 运行代码时,主要涉及两个部分:

  1. 执行线程 (Thread of Execution)
    • 逐行处理代码的能力。从上到下,一次执行一行指令。
  2. 内存 (Memory)
    • 一个存储数据的地方。当我们声明变量或函数时,它们会被存放在这里。

代码执行步骤

让我们通过一个简单的例子来模拟:

const num = 3;
const multiplyBy2 = function (inputNumber) {
  const result = inputNumber * 2;
  return result;
};
const output = multiplyBy2(4);
const newOutput = multiplyBy2(10);
  1. 全局执行上下文 (Global Execution Context) 创建
    • 代码开始运行时,会创建一个全局环境来执行代码。这个环境包含:
      • 执行线程
      • 变量环境 (Variable Environment)内存
  2. 逐行执行:
    • const num = 3;
      • 内存中,创建一个名为 num 的常量,并存入值 3
    • const multiplyBy2 = function(...)
      • 内存中,创建一个名为 multiplyBy2 的常量,并存入整个函数定义。函数此时并不会被执行。
    • const output = multiplyBy2(4);
      • 在内存中声明 output,但它的值待定。
      • 右侧 multiplyBy2(4) 是一个函数调用 (invocation) 指令。
      • JavaScript 是同步的 (Synchronous)单线程的 (Single-threaded),它必须先完成这个函数调用,才能继续下一行。
  3. 函数调用与本地执行上下文 (Local Execution Context)
    • multiplyBy2(4) 被调用时:
      • 创建一个新的、临时的执行上下文,专门用于运行这个函数内部的代码。
      • 这个本地执行上下文也有自己的内存
      • 参数传递:在本地内存中,创建参数 inputNumber 并赋值为传入的参数 (argument) 4
      • 函数体执行
        • const result = inputNumber * 2; -> 在本地内存中创建 result 并赋值为 8
        • return result; -> 函数返回 result 的值 8
      • 函数执行完毕,这个本地执行上下文被销毁,其内部的内存(如 inputNumberresult)被清除(垃圾回收)。
      • 返回值 8 被赋值给全局作用域中的 output
  4. 调用栈 (Call Stack)
    • JavaScript 如何追踪它当前在哪个执行上下文中,以及函数执行完后应该返回到哪里?答案是调用栈
    • 调用栈是一个后进先出 (LIFO) 的数据结构,用来管理执行上下文。
      • global(): 代码开始运行时,global 上下文被压入 (push) 栈底。
      • multiplyBy2(4): 当调用该函数时,它的执行上下文被压入栈顶。JavaScript 引擎总是执行栈顶的上下文。
      • 返回: 当 multiplyBy2 执行完毕并返回时,它的上下文从栈顶被弹出 (pop)
      • 引擎回到调用栈的下一层,也就是 global 上下文,继续执行。

总结:同步 JavaScript 的三个基石

  1. 执行上下文 (Execution Context):执行代码的环境,包含执行线程和内存。
  2. 函数调用: 每次调用函数都会创建一个新的本地执行上下文。
  3. 调用栈 (Call Stack):一个数据结构,用于管理和追踪这些执行上下文的顺序。

2-introducing-asynchronicity

同步模型的局限性

我们已经知道 JavaScript 是:

  • 单线程 (Single-threaded):一次只能做一件事。
  • 同步 (Synchronous):必须等待当前行代码执行完毕,才能移至下一行。

这带来一个巨大的问题:如果某行代码需要很长时间才能完成,比如向服务器请求数据(可能需要 300 毫秒),会发生什么?

答案:整个应用程序都会被“阻塞” (block)。

  • 在等待数据返回的 300 毫秒内,用户无法进行任何其他操作(比如点击按钮、滚动页面),因为 JavaScript 的单一线程正忙于等待。这将导致非常糟糕的用户体验。

我们面临的难题 (The Conundrum)

我们陷入了一个两难的境地:

  1. 我们希望等待数据返回后,再执行某些代码(比如将数据显示在页面上)。
  2. 但我们不希望在等待期间阻塞单一的线程,影响其他功能的运行。

为了解决这个难题,我们需要引入一个全新的、与同步模型互补的异步模型。

异步功能的来源

一个有趣的事实是:处理这些耗时任务(如网络请求)的功能,并不属于 JavaScript 引擎本身

这些功能来自于 JavaScript 运行的环境,最常见的就是网页浏览器 (Web Browser)

  • 浏览器提供了大量强大的功能(API),比如:
    • DOM:操作网页内容。
    • console:打印日志。
    • setTimeout / setInterval:定时器。
    • fetch / XHR:发送网络请求。
  • JavaScript 可以通过调用这些 API 来使用浏览器提供的功能。

解决方案一(不可行的同步方案)

让我们想象一个完全同步的、用于获取数据的函数 fetchAndWait

function display(data) {
  console.log(data);
}
const dataFromAPI = fetchAndWait("<https://twitter.com/api/tweets/1>");
display(dataFromAPI);
console.log("me later");

执行流程:

  1. 代码执行到 fetchAndWait 这一行。
  2. 线程被阻塞,等待数据返回(比如花了 200 毫秒)。
  3. 200 毫秒后,数据返回,赋值给 dataFromAPI
  4. display 函数被调用,打印出数据。
  5. 最后,console.log('me later') 才被执行。

评估:

  • 优点:
    • 代码逻辑非常直观,按顺序执行,容易理解。
  • 缺点:
    • 完全不可行 (untenable)。它阻塞了主线程,导致整个应用在等待期间完全冻结。

这个方案暴露了纯同步模型的致命弱点。我们需要一种新的方式来处理耗时任务。


3-asynchronous-web-browser-apis

解决方案二:使用异步的浏览器 API

为了解决同步阻塞问题,我们必须利用浏览器提供的异步功能。

  • setTimeout 这样的函数,在 JavaScript 中被称为 “外观函数” (Facade Function)。它们看起来像是普通的 JS 函数,但其真正的功能是在 JavaScript 引擎之外的浏览器环境中执行的。

异步代码执行步骤

让我们通过一个经典的 setTimeout 例子来理解异步流程:

function printHello() {
  console.log("Hello");
}

setTimeout(printHello, 1000);

console.log("Me first");

模拟执行流程:

  1. function printHello() {...}
    • printHello 函数的定义被存储在全局内存中。
  2. setTimeout(printHello, 1000);
    • setTimeout 被调用。它不会阻塞 JavaScript 主线程
    • 它做的事情是:向浏览器发送一个指令。
    • 浏览器接收到这个指令后,会启动一个定时器 (Timer),并设置其在 1000 毫秒后完成。
    • 浏览器会记住,当定时器完成后,需要执行的是 printHello 这个函数。
    • 在 JavaScript 层面,setTimeout 的工作几乎是瞬间完成的(因为它只是把任务交给了浏览器)。
  3. console.log('Me first');
    • 由于主线程没有被阻塞,代码立即继续执行下一行。
    • "Me first" 被打印到控制台。此时大约只过了 2 毫秒。
  4. 1000 毫秒后...
    • 浏览器的定时器完成。
    • 浏览器需要一种机制,将 printHello 函数送回 JavaScript 世界去执行。
    • 浏览器将 printHello 函数推入一个待执行的队列中。
    • 当 JavaScript 的调用栈 (Call Stack) 为空时,printHello 会被从队列中取出,放入调用栈并执行。
    • "Hello" 被打印到控制台。

新模型的组成部分

这个新的异步模型引入了浏览器环境:

  • JavaScript 引擎:
    • 调用栈 (Call Stack)
    • 内存 (Memory)
    • 执行线程 (Thread of Execution)
  • 浏览器环境 (Web Browser):
    • 提供各种 API (如 setTimeout, fetch 等)。
    • 在后台处理耗时任务(如定时器、网络请求)。

评估这个方案

我们成功实现了三个目标:

  1. 执行耗时任务:我们启动了一个 1000 毫秒的定时器。
  2. 不阻塞主线程:在等待期间,'Me first' 被成功打印。
  3. 任务完成后执行代码:1000 毫秒后,printHello 函数被成功执行。

这是一个可行的解决方案,但我们还需要了解更多细节,比如:当浏览器任务完成时,函数是如何以及何时被允许回到 JavaScript 中执行的。


5-calling-the-outside-world

异步交互的严格规则

当我们与 JavaScript 引擎之外的世界(如浏览器)交互时,必须有严格的规则来管理函数的回调时机。

让我们通过一个更复杂的例子来揭示这些规则:

function printHello() {
  console.log("Hello");
}
function blockFor1Sec() {
  // 忙碌循环,阻塞线程 1000 毫秒
}

setTimeout(printHello, 0);

blockFor1Sec();

console.log("Me first");

揭示完整的异步模型

这个例子会帮助我们理解完整的异步模型,包括两个新概念:回调队列 (Callback Queue)事件循环 (Event Loop)

执行流程详解:

  1. setTimeout(printHello, 0);
    • 调用 setTimeout,延迟时间为 0 毫秒。
    • 浏览器启动一个定时器,由于延迟为 0,这个定时器几乎立即完成
    • 定时器完成后,printHello 函数并没有被立即放入调用栈。
    • 相反,它被放入一个叫做 回调队列 (Callback Queue)任务队列 (Task Queue) 的地方排队等待。
  2. blockFor1Sec();
    • JavaScript 线程继续执行同步代码。
    • blockFor1Sec 函数被调用,它被压入调用栈
    • 这个函数会阻塞主线程整整 1000 毫秒。在此期间,printHello 只能在回调队列里静静等待。
  3. 1000 毫秒后...
    • blockFor1Sec 执行完毕,从调用栈中弹出。
    • 此时,printHello 是否可以执行了?仍然不行!
    • 因为 JavaScript 引擎必须先完成当前所有的同步代码。
  4. console.log('Me first');
    • 这行同步代码被执行,"Me first" 被打印到控制台。此时大约是第 1002 毫秒。
  5. 事件循环 (Event Loop) 的登场
    • 现在,所有的全局同步代码都已执行完毕,调用栈变空了
    • 这时,事件循环开始工作。它的职责是:
      • 不断地检查调用栈是否为空。
      • 如果调用栈为空,就去检查回调队列中是否有等待执行的任务。
    • 事件循环发现调用栈是空的,并且回调队列里有 printHello 函数。
    • 于是,事件循环将 printHello 从队列中取出,并将其压入调用栈
  6. printHello() 执行
    • printHello 函数现在位于调用栈顶,于是被执行。
    • "Hello" 被打印到控制台。此时大约是第 1003 毫秒。

核心规则

事件循环只有在调用栈完全清空之后,才会从回调队列中取出任务并放入调用栈执行。

完整的异步模型(6 个部分)

  1. 内存 (Memory)
  2. 执行线程 (Thread of Execution)
  3. 调用栈 (Call Stack)
  4. 浏览器 API (Web Browser APIs)
  5. 回调队列 (Callback Queue)
  6. 事件循环 (Event Loop)

这六个部分共同构成了 JavaScript 的核心异步模型。


6-calling-the-outside-world-q-a

异步模型问答环节

这里总结了关于异步模型的一些常见问题和解答。

问:回调队列 (Callback Queue) 位于哪里?

答:它属于 JavaScript 引擎 的一部分。

问:如果我设置了多个 setTimeout,它们如何被优先处理?

答:它们按照完成的顺序被放入回调队列。先完成的先入队。如果多个任务"同时"完成(在现实中极少发生),其顺序可能因引擎实现而异,但可以认为它们会按代码出现的顺序排队。

问:回调队列是"栈"结构吗?

答:不,它是一个队列 (Queue),遵循先进先出 (FIFO - First-In, First-Out) 的原则。第一个进入队列的函数,将是第一个被事件循环取出的。

问:setInterval 是如何工作的?

答:它的工作方式与 setTimeout 非常相似,也是通过浏览器 API 实现,并将回调函数周期性地放入回调队列。

问:事件循环何时处理回调队列?

答:在当前的同步代码全部执行完毕,且调用栈为空时。可以理解为在每一轮事件循环的末尾。

问:事件循环和回调队列是浏览器独有的吗?

答:不是。其他 JavaScript 运行环境,比如 Node.js,也拥有自己的事件循环和任务队列实现,尽管具体实现细节可能与浏览器略有不同。

问:回调队列的大小有限制吗?

答:回调队列中存储的不是函数本身,而是对函数的引用。因此,它的限制实际上取决于 JavaScript 运行时的整体内存限制,而不是队列本身有一个独立的、固定的容量。

问:事件循环也是 JavaScript 引擎的一部分吗?

答:是的。

问:如果我给 setTimeout 传递一个匿名函数会怎样?

答:匿名函数同样会在内存中被定义(只是没有名字),然后对这个函数的引用会被传递给浏览器 API,并最终进入回调队列。整个机制是完全一样的。


7-wrapping-up-web-browser-apis

回调函数模型的优缺点总结

我们已经了解了基于浏览器 API 和回调函数的异步模型(解决方案二),现在来总结一下它的利弊。

存在的问题

  1. 控制反转 (Inversion of Control) 与 回调地狱 (Callback Hell)
    • 当你把一个函数(如 printHello)作为参数传递给另一个函数(如 setTimeout)时,你实际上是放弃了对 printHello 何时被调用的直接控制权。你将控制权“反转”交给了 setTimeout
    • 这种模式感觉很奇怪,因为我们习惯于自己编写的代码自己控制执行流程。
    • 如果需要执行一系列连续的异步操作,每个操作都依赖于前一个操作的结果,这会导致回调函数层层嵌套,形成所谓的“回调地狱”,代码难以阅读和维护。
  2. 数据可用性受限
    • 从异步操作(如网络请求)返回的数据,只能在回调函数内部才能访问。这使得在函数外部使用这些数据变得复杂。

优点

  1. 逻辑明确
    • 一旦你理解了其底层工作原理(事件循环、回调队列等),这个模型其实非常明确 (explicit) 和可预测。你知道函数会被放入队列,并在调用栈清空后执行。
  2. 异步编程的基础
    • 这种“发送任务,注册回调”的模式是异步输入/输出(I/O)架构的根本设计。它虽然不完美,但构成了所有现代异步解决方案的基础。

尽管这个模型在直觉上可能不那么友好,但理解它对于掌握 JavaScript 的异步编程至关重要。后续的解决方案(如 Promises, async/await)都是为了以更优雅、更符合直觉的方式来解决这个模型中“控制反转”和“回调地狱”的问题。


8-asynchronous-exercises

结对编程 (Pair Programming)

这是成长为一名优秀软件工程师的最佳方式之一。

为什么结对编程如此重要?

  • “简单学习” vs “困难学习”:
    • 被动地看视频(如 Pluralsight, Frontend Masters)是“简单学习”。它只有在你主动运用这些知识去解决实际问题时才有效。
    • 真正的成长来自于“困难学习”,即通过解决有挑战性的编程问题、构建项目来不断突破自己的认知障碍
  • 独自学习的陷阱:
    • 独自面对难题时,我们很容易陷入两种极端模式:
      1. 研究员 (The Researcher):试图在写代码前搞懂所有细节,结果陷入分析瘫痪,迟迟不动手。
      2. Stack Overflow 用户 (The Stack Overflower):只是复制粘贴代码让它“能跑就行”,却不理解其背后的原理。

结对编程如何解决这些问题?

结对编程通过明确的角色分工来创造平衡。

  • 角色:
    • 驾驶员 (Driver):负责操作键盘,编写代码的人。
    • 领航员 (Navigator):负责构思整体策略和逐行逻辑,并通过语言来指导驾驶员。领航员绝对不能碰键盘
  • 带来的好处:
    1. 锻炼技术沟通能力:
      • 领航员必须清晰地阐述自己的想法,让驾驶员能够理解并转化为代码。这极大地锻炼了将脑中的抽象模型清晰表达出来的能力,这是高级工程师的关键技能。
    2. 避免极端模式:
      • 领航员不能成为“研究员”,因为驾驶员在等着他/她;也不能成为“Stack Overflower”,因为他/她必须向驾驶员解释自己的逻辑。
    3. 高效的学习反馈:
      • 一个重要的实践是:如果你认为你的伙伴走错了路,让他/她把代码运行下去,看到错误,然后一起调试。这个过程是极其宝贵的学习机会。

练习说明

  • 任务:
    • 访问 csbin.io/promises 开始练习。
  • 方式:
    • 两人一组,共用一台电脑。
    • 每 10 分钟左右交换一次“驾驶员”和“领航员”的角色。

9-introducing-promises

回顾解决方案二:回调模型

  • 我们通过“外观函数”(Facade Functions),即浏览器 API,扩展了我们对 JavaScript 引擎的理解。
  • 这些函数在 JavaScript 内部几乎不做任何事,而是作为浏览器功能的接口。
  • 我们将一个回调函数传递给这些 API(如 setTimeout)。当浏览器任务完成后,这个回调函数会被放入回调队列
  • 事件循环会等到所有同步代码执行完毕、调用栈清空后,才将回调函数从队列中取出并执行。

解决方案三:引入 Promise

为了提高代码的可读性,ECMAScript 设计团队引入了一个新的概念:Promise

Promise 的“双重任务” (Two-Pronged)

setTimeout 这种只在浏览器中执行任务的函数不同,返回 Promise 的函数(如 fetch)会同时做两件事:

  1. 在浏览器中启动一个后台任务
    • 与之前的模型类似,它会启动一个浏览器功能,比如发起网络请求。JavaScript 本身无法与互联网通信,所以它需要命令浏览器去做这件事。
  2. 在 JavaScript 中立即返回一个特殊对象
    • 这是与之前模型最大的不同。它不会等到后台任务完成,而是立即在 JavaScript 世界中返回一个占位符对象 (placeholder object),这个对象就是 Promise 对象

Promise 对象的结构

这个立即返回的 Promise 对象有几个关键部分:

  • value 属性:
    • 这是一个占位符,初始值为 undefined
    • 当浏览器的后台任务完成时(例如,从 Twitter 获取到数据),返回的数据会自动填充到这个 value 属性中。
  • onFulfilled 数组 (隐藏属性):
    • 这是一个存放函数的数组。
    • 我们可以将希望在 value 被填充后执行的函数(即延迟执行的函数)添加到这个数组里。
    • value 被成功填充后,这个数组里的所有函数都会被自动触发执行。
    • 传入这些函数的参数,就是从后台任务返回的数据(即 value 的值)。

为什么需要这种设计?

  • 我们不能在同步代码中直接使用 promise.value,因为我们不知道它何时才会被后台任务填充。
  • 因此,我们需要一种机制,将处理数据的函数“附加”到 Promise 对象上,让它在数据准备好时自动执行。这就是 onFulfilled 数组和相关机制(如 .then())的作用。

总结: Promise 是一个代表未来某个时刻才会有的值的占位符。它允许我们将处理该值的函数与 Promise 本身绑定,从而在值准备好时自动执行,解决了在不确定的未来时间点处理数据的问题。


10-promises

解决方案三:使用 Promise

Promise 是一种“双重任务”的模式:它既能在浏览器中启动后台工作,也能在 JavaScript 中立即返回一个占位符对象。

Promise 代码执行详解

让我们通过 fetch 的例子来精确地模拟执行过程:

function display(data) {
  console.log(data);
}

const futureData = fetch("<https://twitter.com/will/tweets/1>");

futureData.then(display);

console.log("Me first");

模拟执行流程:

  1. const futureData = fetch(...)
    • fetch 是一个外观函数,它同时在 JavaScript 和浏览器中执行任务。
    • 在 JavaScript 中: fetch 立即返回一个 Promise 对象。这个对象被赋值给 futureData
      • 这个 Promise 对象内部有:
        • value: undefined (等待数据填充)
        • onFulfilled: [] (一个空数组,等待函数注册)
    • 在浏览器中: fetch 启动一个网络请求 (XHR - XMLHttpRequest)
      • 浏览器会向指定的 URL (https://twitter.com/...) 发送一个 HTTP GET 请求。
      • 浏览器会记住,当这个请求成功返回数据时,需要将数据填充回 futureData 这个 Promise 对象的 value 属性。
  2. futureData.then(display)
    • .then() 是 Promise 对象上的一个方法。它的作用不是“然后执行”,而是注册回调函数
    • 它会将 display 函数的定义添加到 futureData 这个 Promise 对象的 onFulfilled 数组中。
    • 此时,display 函数并没有被执行。
  3. console.log('Me first')
    • JavaScript 线程继续执行同步代码。
    • "Me first" 被打印到控制台。此时大约只过了 2 毫秒。
  4. 网络请求返回后 (例如 200 毫秒后)
    • 浏览器的 XHR 请求完成,并成功从服务器获取到数据(比如字符串 'Hi')。
    • 浏览器将数据 'Hi' 填充到 futureData Promise 对象的 value 属性中
    • futureData.value 现在是 'Hi'
    • 这个填充动作会触发 onFulfilled 数组中的所有函数。
  5. 回调函数执行
    • display 函数被触发执行。
    • 触发时,Promise 会将它的 value(即 'Hi')作为参数传递给 display 函数。
    • display('Hi') 被执行,"Hi" 被打印到控制台。

总结

Promise 模型成功实现了我们的三个目标:

  1. 启动耗时任务:通过 fetch 发起了网络请求。
  2. 不阻塞主线程:在请求过程中,'Me first' 被成功打印。
  3. 任务完成后处理数据:当数据返回时,自动触发了我们通过 .then() 注册的 display 函数,并正确地将数据作为参数传入。

这个模型的核心思想是:用一个立即返回的对象(Promise)来代表未来的结果,并提供一种方法(.then())来“预订”当结果准备好时要执行的操作。这比直接传递回调函数看起来更像是同步代码的风格,提高了可读性。


11-promises-q-a

Promise 问答环节

这里总结了关于 Promise 模型的一些常见问题和解答。

问:Promise 的异步回调也使用事件循环吗?

答:是的,但方式非常有趣,涉及到我们稍后会讲到的一个新概念。

问:为什么 futureData 可以用 const 声明,但它的 value 属性却可以被修改?

答:const 关键字保证的是变量所指向的内存地址不会改变

  • 对于原始类型(如数字、字符串),改变值就意味着改变内存地址,所以 const 不允许。
  • 对于对象和数组futureData 指向的是 Promise 对象的内存地址。只要我们不尝试将 futureData 重新赋值为另一个全新的对象,const 的规则就没有被违反。修改对象内部的属性(如 value)或向数组添加元素是完全允许的。
  • 因此,现代 JavaScript 实践中,默认使用 const 是一个好习惯,除非你明确知道需要重新给变量赋一个全新的值。

问:当 futureData.value 被设置时,它是在 JavaScript 中还是在浏览器中触发 onFulfilled 函数的?

答:这个触发机制是在 JavaScript 中处理的。这引出了一个关键点:JavaScript 如何处理由 Promise 触发的回调,这与传统的回调(如 setTimeout)有所不同。


12-promises-microtask-queue

异步任务的优先级:微任务队列登场

Promise 触发的回调函数,与 setTimeout 触发的回调函数,在被送回 JavaScript 执行时,遵循着不同的规则。

为了揭示这一点,我们来看一个终极的、包含了所有异步情况的例子:

function display(data) {
  console.log(data);
}
function printHello() {
  console.log("Hello");
}
function blockFor300ms() {
  /* 阻塞线程 300ms */
}

setTimeout(printHello, 0);

const futureData = fetch("..."); // 假设这个请求需要 290ms
futureData.then(display);

blockFor300ms();

console.log("Me first");

执行顺序预测:

  • setTimeout 的回调 printHello 会先进队。
  • fetch 的回调 display 会在 290ms 后才准备好。
  • 那么,最终会先打印 'Hello' 还是先打印 fetch 的结果呢?

实际执行流程详解:

  1. setTimeout(printHello, 0) (第 1 毫秒)
    • 启动浏览器定时器,立即完成。
    • printHello 函数被放入 回调队列 (Callback Queue)
  2. fetch(...) (第 2 毫秒)
    • 返回一个 Promise 对象给 futureData
    • 启动浏览器网络请求。
  3. futureData.then(display) (第 3 毫秒)
    • display 函数注册到 futureDataonFulfilled 数组中。
  4. blockFor300ms() (第 4 毫秒)
    • 主线程被阻塞 300 毫秒
  5. 网络请求返回 (在阻塞期间,约第 290 毫秒)
    • fetch 请求完成,数据返回。
    • futureDatavalue 被填充,并触发 display 函数。
    • 关键点:被 Promise 触发的 display 函数并没有被放入回调队列。它被放入了一个新的、更高优先级的队列
  6. 阻塞结束 (第 304 毫秒)
    • blockFor300ms 执行完毕。
  7. console.log('Me first') (第 304 毫秒)
    • 同步代码永远优先,"Me first" 被打印。
  8. 事件循环开始工作
    • 此时调用栈为空,事件循环检查待办任务。
    • 它发现有两个队列里都有任务:回调队列里有 printHello,还有一个新队列里有 display
    • 规则:事件循环会优先处理新队列里的所有任务

微任务队列 (Microtask Queue)

这个新的、高优先级的队列被称为 微任务队列 (Microtask Queue)作业队列 (Job Queue)

  • Promise 的回调(通过 .then, .catch, .finally 注册的)会被放入微任务队列
  • 传统异步 API 的回调(如 setTimeout, setInterval)会被放入回调队列(现在可以更精确地称之为宏任务队列 (Macrotask Queue))。

最终的输出顺序

  1. 事件循环优先检查微任务队列
    • 它发现了 display 函数,将其放入调用栈执行。
    • 打印出 fetch 返回的数据。
  2. 微任务队列清空后,事件循环才检查宏任务队列
    • 它发现了 printHello 函数,将其放入调用栈执行。
    • 打印出 "Hello"。

所以,最终的输出是:

Me first
(fetch 的数据)
Hello

尽管 printHello 是第一个准备好的回调,但由于 Promise 的回调具有更高的优先级,display 函数被优先执行了。这揭示了 JavaScript 异步世界的完整图景。


13-microtask-queue-q-a

微任务队列问答环节

这里总结了关于微任务队列和事件循环优先级的一些关键问题和解答。

问:还有哪些常见的 API 会将回调放入微任务队列?

答:要确定一个 API 的回调去向,最权威的来源是 JavaScript 规范 (ECMAScript specification)

  • 规范中,微任务队列通常被称为 "Job Queue"
  • 宏任务队列被称为 "Task Queue"
  • 例如,由 DOM 变化触发的任务(如 MutationObserver)通常会进入微任务队列。
  • 需要注意的是,浏览器厂商在实现规范时可能会有差异。过去,一些非 Chrome 浏览器曾将 Promise 的回调放入宏任务队列,但现在主流浏览器已基本统一了行为。

问:Promise 的 value 是何时被设置的?它会绕过事件循环吗?

答:设置 value 的动作本身仍然受事件循环的管理,但它拥有极高的优先级,可以认为它在功能上是在任何相关回调被触发之前完成的

  • 从开发者的角度看,我们不必担心 value 的赋值过程会被其他代码阻塞。当 Promise 的回调函数被执行时,我们可以百分之百确定 value 已经准备好了。

问:如果微任务队列中的一个任务又产生了一个新的微任务,会发生什么?

答:事件循环的设计是:它会持续处理微任务队列,直到它完全清空为止,然后才会去处理宏任务队列

  • 这意味着,如果在微任务中不断地添加新的微任务,理论上可以 "饿死" (starve) 宏任务队列,导致 setTimeout 等的回调永远得不到执行。
  • 可以把事件循环对微任务队列的处理想象成一个 while (microtaskQueue.isNotEmpty()) { ... } 循环。

Promise 的其他状态和方法

  • status 属性: Promise 对象有一个可见的 status 属性,它有三种状态:
    1. pending (进行中): 初始状态,表示异步操作尚未完成。
    2. resolved (或 fulfilled, 已成功): 异步操作成功完成,value 已被填充。这个状态的转变会触发 onFulfilled 数组中的函数。
    3. rejected (已失败): 异步操作失败。
  • 错误处理:
    • 当 Promise 状态变为 rejected 时,会触发一个名为 onRejection 的函数数组。
    • 我们可以通过以下方式注册错误处理函数:
      • .catch(errorHandlingFunction): 这是专门用于注册失败回调的方法。
      • .then(successFunction, errorHandlingFunction): .then() 方法可以接受第二个参数,作为失败时的回调。

14-wrapping-up-promises

Promise 模型的最终总结

我们现在已经构建了一个完整的 JavaScript 异步执行模型,它结合了同步代码、宏任务(来自传统 Web API)和微任务(来自 Promise)。

Promise 模型的问题

  1. 底层复杂性被隐藏
    • 大多数开发者不了解 Promise 的底层工作原理。他们看到 .then(),会误以为代码会“返回”到那里然后执行,但这与事实相去甚远。
    • .then() 的真正作用是注册回调函数到一个隐藏的数组中。
    • 这种对底层机制的误解,使得在出现问题时调试变得异常困难
  2. 伪同步的假象:
    • Promise 的链式调用 (.then().then()...) 创造了一种“伪同步”的代码风格,它看起来比回调地狱整洁,但实际上掩盖了其异步的复杂性。

Promise 模型的优点

  1. 可读性提升:
    • 对于理解其工作原理的人来说,Promise 提供了比传统回调更清晰、更易读的伪同步代码风格。
  2. 优秀的错误处理机制:
    • 通过 .catch().then() 的第二个参数,Promise 提供了一个非常清晰和强大的错误处理流程。你可以将成功逻辑和失败逻辑分离开来,或者用一个 .catch() 捕获链条上的任何错误。
    • 这一点甚至比我们稍后将看到的 async/await 在某些方面更为优雅。

结论

  • 异步 JavaScript 是现代 Web 的基石,它让我们能够构建快速、流畅、非阻塞 (non-blocking) 的应用程序。
  • Promise、Web API、回调队列、微任务队列和事件循环共同协作,让我们能够在等待耗时操作(如网络请求)完成的同时,继续执行其他代码,从而提供了出色的用户体验。

15-return-function-inside-a-function

引入迭代器 (Iterators)

通常,我们写代码的模式是:存储数据,然后用函数来处理这些数据。我们把应用程序中存储的实时数据称为状态 (state)

但很多时候,我们的数据并不是单个元素,而是一个集合 (collection),比如数组、对象等。这意味着,在处理数据之前,我们还有一个额外的步骤:访问集合中的每个元素

传统的数据访问方式:for 循环

for 循环是我们访问数组元素最传统的方式。让我们来看一个例子:

const numbers = [4, 5, 6];

for (let i = 0; i < numbers.length; i++) {
  console.log(numbers[i]);
}

执行流程:

  1. 初始化 i = 0
  2. 检查 0 < 3,条件成立。
  3. 执行 console.log(numbers[0]),输出 4
  4. i++i 变为 1
  5. 检查 1 < 3,条件成立。
  6. 执行 console.log(numbers[1]),输出 5
  7. ...以此类推,直到 i 变为 33 < 3 不成立,循环结束。

for 循环的问题:

  • 过程繁琐 (imperative):我们需要手动管理索引 i,检查边界条件,并通过 numbers[i] 来取值。这个过程非常机械化,并且容易出错。
  • 关注点分散:我们被迫关注“如何获取元素”,而不是“获取到元素后要做什么”。

一种新的思维方式:数据流 (Stream of Data)

设想一下,如果我们可以将数据集合看作一个数据流,会怎么样?

  • 我们不再需要手动去一个静态的集合里抓取数据。
  • 相反,我们可以调用一个函数,这个函数会像打开水龙头一样,自动地、一次一个地把下一个元素推送给我们。

这种范式转变,将我们从“手动拉取数据”的繁琐过程中解放出来,让我们能更专注于处理数据本身。

探究新方式的基石:函数中返回函数

为了实现这种“数据流”的模式,我们需要一个函数,它能:

  1. 每次被调用时,返回数据流中的下一个元素。
  2. “记住”上一次返回到了哪个位置,以便下一次能给出正确的元素。

但我们知道,函数每次执行时,其本地内存都会被重置,它并不记得上一次的运行状态。那么,如何让一个函数拥有“记忆”呢?

答案在于 JavaScript 中一个非常强大和优美的特性:在一个函数中返回另一个函数

这正是我们要深入探讨的核心,也是理解迭代器和许多高级 JavaScript 概念的关键。


16-return-next-element-with-a-function

如何让函数拥有“记忆”?

为了构建一个能依次返回数据流中元素的函数,我们需要解决函数“失忆”的问题。这就要用到 JavaScript 中最强大的特性之一:闭包 (Closure)

让我们通过一个例子来深入理解这个过程。这个例子将为我们构建自己的迭代器奠定基础。

function createFunction(array) {
  let i = 0;
  function inner() {
    const element = array[i];
    i++;
    return element;
  }
  return inner;
}

const returnNextElement = createFunction([4, 5, 6]);

const element1 = returnNextElement(); // 期望得到 4
const element2 = returnNextElement(); // 期望得到 5

代码执行详解:

  1. const returnNextElement = createFunction(...)
    • createFunction 被调用,并传入数组 [4, 5, 6]
    • 一个新的执行上下文被创建。
      • 在其本地内存中:
        • array 被赋值为 [4, 5, 6]
        • i 被初始化为 0
        • 一个名为 inner新函数被定义
  2. inner 函数的诞生与“背包”
    • 关键时刻:当 inner 函数在其父函数 createFunction 的作用域内被定义时,它就与 createFunction本地内存建立了一个永久的链接
    • 这个链接就像给 inner 函数配备了一个 “背包” (Backpack) 。这个背包里装着 inner 函数诞生时周围的所有变量(即 arrayi)。
    • 我们可以称之为闭包 (Closure)
  3. return inner;
    • createFunction 返回了 inner 函数的定义本身
    • inner 函数被返回时,它背着它的背包一起出来了。
    • 这个返回的函数(连同它的背包)被赋值给了全局变量 returnNextElement
    • createFunction 的执行上下文被销毁,但由于 inner 函数(现在的 returnNextElement)仍然持有对 arrayi 的引用,这些变量不会被垃圾回收,而是存活在 returnNextElement 的背包里。
  4. const element1 = returnNextElement();
    • 我们调用 returnNextElement (即原来的 inner 函数)。
    • 一个新的执行上下文被创建。
    • inner 的函数体内,它需要访问 arrayi
    • 它首先在自己的本地内存中寻找,但找不到。
    • 于是,它去自己的 背包(闭包) 里寻找。
    • 它在背包中找到了 array ([4, 5, 6]) 和 i (0)。
    • const element = array[0]; -> element 被赋值为 4
    • i++; -> 它修改了背包里的 i,使其变为 1
    • return element; -> 函数返回 4,赋值给 element1
    • 这个执行上下文被销毁,但背包里的 i 已经更新为 1
  5. const element2 = returnNextElement();
    • 再次调用 returnNextElement
    • 同样,它在自己的背包里找到了 arrayi
    • 此时背包里的 i1
    • const element = array[1]; -> element 被赋值为 5
    • i++; -> 背包里的 i 被更新为 2
    • 函数返回 5,赋值给 element2

结论

通过在一个函数内部定义并返回另一个函数,我们成功地创造了一个有状态的函数。返回的函数通过闭包(它的“背包”)携带了它诞生时环境中的数据,并且可以在后续的调用中持续地访问和修改这些数据。

这正是我们需要的机制:一个函数,它捆绑了底层的数据集合 (array) 和一个追踪当前位置的变量 (i),每次调用都能返回下一个元素。这个函数就是迭代器 (Iterator)


17-iterator-function

深入理解闭包与迭代器

我们已经看到了,当一个函数在另一个函数内部被定义并返回时,它会携带一个“背包”,这个背包里装着它诞生时环境中的变量。这个机制就是闭包 (Closure)

闭包的专业术语

  • 词法作用域 (Lexical Scoping):
    • JavaScript 是一种词法作用域(或静态作用域)语言。这意味着,一个函数能访问哪些变量,是由它在代码中被定义的位置决定的,而不是被调用的位置
    • 因为 inner 函数是在 createFunction 内部定义的,所以它天生就能访问 createFunction 的变量,无论它将来在哪里被调用。
  • 闭包 (Closure) 的多种称谓:
    • “背包” (Backpack):一个直观、易于理解的比喻。
    • 持久的词法作用域引用 (Persistent Lexical Scope Reference):非常精确但拗口的官方描述。
    • 被闭合的变量环境 (Closed-Over Variable Environment, COVE):同样精确但不常用。
    • 闭包 (The Closure):最通用的术语。人们通常会说“这些变量被保存在闭包里”。

什么是迭代器 (Iterator)?

我们创建的 returnNextElement 函数,就是一个迭代器

迭代器 (Iterator) 是一个函数,它遵循一种特定的协议:每次调用它时,它会返回数据流(或集合)中的下一个元素。

这个函数完美地封装了我们需要的一切:

  1. 底层数据: 存储在它的闭包(背包)中。
  2. 当前位置: 同样通过闭包中的变量来追踪。
  3. 返回下一个元素的能力: 函数本身的核心逻辑。

迭代器的意义

  1. 将数据集合转化为数据流:
    • 迭代器改变了我们看待数据的方式。数组不再是一个需要我们手动索引的静态列表,而是一个可以按需取用的元素流
    • 我们从“去数据里拉取 (pull) 元素”的模式,转变为“让迭代器给我们推送 (push) 元素”的模式。
  2. 解耦 (Decoupling):
    • 迭代器将 “如何访问元素” 的过程与 “如何处理元素” 的逻辑完全分离开来。
    • 这使得我们的代码更清晰、更专注。例如,JavaScript 的 for...of 循环就是基于迭代器工作的,它在循环的每次迭代中直接为我们提供元素本身,而不是索引。
// for...of 循环在底层就是在使用迭代器
for (const element of [4, 5, 6]) {
  console.log(element); // 直接得到元素,无需关心索引
}

在接下来的部分,我们将看到 JavaScript 如何引入一种更强大的工具——生成器 (Generators),它能让我们更轻松地创建可以动态控制的迭代器。


18-iterators-exercise

迭代器练习

现在,我们将通过动手实践来巩固对迭代器和闭包的理解。

练习任务

  1. 访问练习平台:
    • 前往 csbin.io/iterators
  2. 练习内容:
    • 从零开始构建你自己的迭代器函数:你将亲自实践如何使用闭包来创建一个有状态的函数,该函数可以逐个返回数组中的元素。
    • 使用 JavaScript 的内置迭代器
      • 在 JavaScript 中,所有可迭代的对象(如数组、字符串、Map、Set)都有一个内置的创建迭代器的方法。
      • 这个方法隐藏在一个特殊的属性里,可以通过 Symbol.iterator 来访问。
      • 调用 array[Symbol.iterator]() 会返回一个符合迭代器协议的对象,你可以通过调用它的 .next() 方法来获取下一个元素。

结对编程提醒

  • 继续使用结对编程 (Pair Programming) 的模式。
  • 与你的伙伴轮流担任驾驶员 (Driver)领航员 (Navigator) 的角色。
  • 专注于清晰的技术沟通,解释你的思路,而不是直接上手写代码。
  • 如果你认为伙伴的思路有误,让他/她尝试运行代码,从错误中学习是最高效的方式。

19-generators

迭代器模型的演进

我们已经知道,迭代器能将数据集合转化为可按需获取的数据流 (flow of data)。但我们之前构建的迭代器是基于一个静态的、预先定义好的数组。

现在,我们将探索一种更强大的方式:使用函数来动态地生成这个数据流中的每一个元素。这意味着我们不再依赖于一个固定的数据集合,而是可以在运行时通过代码逻辑来决定下一个元素是什么。

JavaScript 内置迭代器的设计

在深入了解动态生成流之前,我们需要先了解一下 JavaScript 内置迭代器的标准设计。

  • 我们之前自己构建的 returnNextElement 是一个可以直接调用的函数
  • 而 JavaScript 的标准迭代器实际上是一个对象,这个对象上有一个名为 .next()方法

我们需要调用 iterator.next() 来获取数据流中的下一个元素。这只是一个微小的结构变化,但为了与标准保持一致,我们先来重构一下我们的迭代器。

重构:返回一个带 .next() 方法的对象

function createFlow(array) {
  let i = 0;
  const inner = {
    next: function () {
      if (i < array.length) {
        const element = array[i];
        i++;
        return { value: element, done: false };
      } else {
        return { value: undefined, done: true };
      }
    },
  };
  return inner;
}

const returnNextElement = createFlow([4, 5, 6]);

const element1 = returnNextElement.next(); // 返回 4

执行流程分析:

  1. createFlow 被调用,创建了一个本地作用域,其中包含 arrayi
  2. createFlow 内部,我们定义了一个名为 inner对象
  3. 这个对象的 next 属性是一个函数。关键点:这个 next 函数在定义时,同样通过闭包捕获了其外部作用域中的 arrayi
  4. createFlow 返回了这个 inner 对象。
  5. 返回的对象被赋值给 returnNextElement。现在,returnNextElement 是一个拥有 .next 方法的对象,并且这个 .next 方法背着一个包含 arrayi 的“背包”。
  6. 每次调用 returnNextElement.next(),都会执行背包里的函数,它会访问并修改背包里的 i,然后从背包里的 array 中返回正确的元素。

内置迭代器返回值的标准格式

另外一个需要注意的标准是,内置迭代器的 .next() 方法返回的并不仅仅是元素值本身,而是一个包含两个属性的对象:

  • value: 当前元素的值。
  • done: 一个布尔值,表示迭代是否已结束。

例如:

  • returnNextElement.next() 会返回 { value: 4, done: false }
  • 当所有元素都迭代完毕后,再次调用会返回 { value: undefined, done: true }

这个 done 属性非常有用,它可以帮助我们准确地判断迭代是否完成,避免了当数组中恰好有 undefined 元素时可能产生的混淆。

即将到来的革命:生成器函数 (Generator Functions)

到目前为止,我们仍然是从一个已有的数据集合中生成数据流。

接下来,我们将学习一种全新的函数类型——生成器函数 (Generator Function),它允许我们不依赖任何预先存在的集合,而是通过函数体内的代码来凭空生成数据流中的每一个元素。

这将彻底改变我们对函数执行模型的认知,并为处理复杂的异步流程提供了前所未有的强大能力。


20-generator-functions-with-dynamic-data

引入生成器函数 (Generator Functions)

生成器函数是 ES6 引入的一种全新的函数类型,它允许我们以前所未有的方式控制函数的执行流程。

  • 定义: 使用 function* 语法定义的函数。
  • 核心特性: 生成器函数可以暂停 (suspend) 执行,并在稍后从暂停处恢复 (resume)

生成器函数的基本工作流程

function* createFlow() {
  yield 4;
  yield 5;
  yield 6;
}

const returnNextElement = createFlow();

const element1 = returnNextElement.next(); // 得到 { value: 4, done: false }
const element2 = returnNextElement.next(); // 得到 { value: 5, done: false }

执行详解:

  1. const returnNextElement = createFlow()
    • 调用生成器函数 createFlow()不会立即执行其函数体。
    • 相反,它会返回一个生成器对象 (Generator Object)。这个对象就是我们之前所说的迭代器,它上面有一个 .next() 方法。
  2. const element1 = returnNextElement.next()
    • 第一次调用 .next() 方法。
    • createFlow 函数的执行上下文被创建,代码开始执行
    • 代码执行到第一个 yield 关键字处。
    • yield 的作用:
      1. yield 右侧的值(这里是 4)作为 .next() 调用的返回值(包裹在 { value: 4, done: false } 对象中)。
      2. 暂停 createFlow 函数的执行。函数的执行上下文(包括所有本地变量和当前的执行位置)被“冻结”并保存下来。
  3. const element2 = returnNextElement.next()
    • 第二次调用 .next() 方法。
    • createFlow 函数从上次暂停的地方恢复执行
    • 代码执行到第二个 yield 关键字处,暂停并返回 5
    • 这个过程会一直持续,直到函数执行完毕。

生成器函数的革命性意义:动态数据流

生成器函数最强大的地方在于,我们可以用代码逻辑来动态地决定 yield 出来的值。

function* createFlow() {
  const num = 10;
  const newNum = yield num; // 第一次暂停,yield出10
  yield 5 + newNum; // 第二次暂停
  yield 6;
}

与外部世界的交互:

  • .next() 方法可以接受一个参数,这个参数会成为上一个 yield 表达式的返回值,并被传递回生成器函数内部。

执行详解:

  1. const returnNextElement = createFlow();
    • 返回生成器对象。
  2. const element1 = returnNextElement.next(); // -> { value: 10, done: false }
    • 代码开始执行,num 被设为 10
    • 遇到 yield num,函数暂停,并向外 yield10
    • newNum 此时尚未被赋值,因为赋值操作在 yield 的右侧,函数在此之前就暂停了。
  3. const element2 = returnNextElement.next(2); // 向生成器内部传入 2
    • 函数从暂停处恢复。
    • 我们传入的参数 2 成为了上一个 yield num 表达式的结果
    • 因此,newNum 被赋值为 2
    • 代码继续执行,遇到 yield 5 + newNum,即 yield 7
    • 函数再次暂停,并向外 yield7
    • element2 的值会是 { value: 7, done: false }

总结

生成器函数提供了一种前所未有的能力:

  • 暂停和恢复执行: 打破了函数必须一次性从头执行到尾的传统模型。
  • 双向通信: 不仅可以从函数内部 yield 数据出来,还可以通过 .next(value) 从外部向函数内部传递数据。

这为我们控制复杂流程(特别是异步流程)提供了巨大的灵活性和强大的工具。我们现在可以创建一个可以被外部世界动态影响的数据流。


21-generators-q-a

生成器函数问答环节

这里总结了关于生成器函数的一些深入问题和解答。

问:这种生成器/迭代器模式是 JavaScript 独有的吗?

答:不是。许多其他现代编程语言,如 Python,也拥有类似的概念。这是一种从"命令式"编程(告诉计算机每一步具体怎么做)向更"声明式"或"抽象"编程(告诉计算机我想要什么)演进的体现。

问:生成器的 .next() 方法返回的总是 { value: ..., done: false } 吗?

答:是的,这是一个非常重要的澄清。为了简化教学,之前的图示中只画出了 value 本身。但实际上,每次 .next() 调用都严格遵循迭代器协议,返回一个包含 valuedone 两个属性的对象。

问:yield 后面可以跟复杂的表达式吗?比如 yield (condition ? a : b)

答:可以。yield 关键字的行为与 return 非常相似,它会先计算其右侧表达式的值,然后再将该值 yield 出去。

问:生成器对象上除了 .next 方法还有其他属性吗?

答:有。如果你在控制台检查一个生成器对象,会发现它上面有一些内部属性,用于追踪生成器的状态,例如:

  • [[GeneratorState]]: suspended, closed 等。
  • [[GeneratorLocation]]: 记录了函数暂停时在代码中的具体位置。
  • 还有可能在原型链上找到如 .return().throw() 等方法,用于从外部控制生成器的流程。

问:生成器可以用来创建无限序列吗?

答:是的,这是一个非常强大的应用。因为生成器是按需计算(惰性求值)的,我们可以很容易地创建一个永不结束的循环,并在其中 yield 值,比如用来生成斐波那契数列或所有素数。


22-introducing-async-generators

终极目标:使用生成器处理异步流程

我们已经掌握了生成器最核心的两个超能力:

  1. 暂停/恢复执行: 通过 yield 关键字,我们可以让一个函数在执行中途“休息”一下。
  2. 双向通信: 通过 .next() 的调用和参数,我们可以控制函数何时恢复,并向其内部传递数据。

现在,让我们把这两个能力应用到异步编程中。

回顾异步编程的挑战

异步编程的核心难题是:

  1. 发起一个耗时任务(如网络请求)。
  2. 在等待任务完成时,不阻塞主线程,可以继续执行其他同步代码。
  3. 当任务完成后,能够获取其结果,并执行后续的逻辑。

传统的回调和 Promise 链虽然解决了这个问题,但代码在逻辑上是分离的。发起任务的代码和处理结果的代码分布在不同的地方(例如,fetch 在一行,处理逻辑在 .then 的回调里),这使得代码流程不直观。

生成器的革命性方案

设想一下,如果我们能这样写代码:

  1. 在一个函数(生成器)中,发起一个异步任务。
  2. 立刻使用 yield 关键字,暂停这个函数的执行。
  3. 当异步任务(比如一个 Promise)完成时,我们再手动调用 .next(),并把任务的结果作为参数传回,恢复这个函数的执行。
  4. 函数恢复后,就可以在下一行代码中,像同步代码一样直接使用这个结果了。

这个模型允许我们用看似同步的、线性的方式来书写异步代码。发起任务和处理结果的代码可以紧挨在一起,逻辑流程一目了然。

构建 async/await 的蓝图

这正是 async/await 的底层工作原理。async/await 本质上就是生成器 + 自动执行器的语法糖。

  • async 函数 ≈ 生成器函数 (function*)
  • await 关键字 ≈ yield 关键字
  • JavaScript 引擎在背后为我们扮演了一个“自动执行器”的角色,它会自动处理 await 后面的 Promise,并在 Promise 完成后,自动调用 .next() 来恢复函数的执行。

在接下来的最终环节,我们将亲手使用生成器来构建一个 async/await 的简化版本,彻底揭开这个现代 JavaScript 最重要特性之一的神秘面纱。


23-async-generators

手动实现 async/await

现在,我们将整合所有学过的知识——闭包、Promise、微任务队列、生成器——来手动实现一个 async/await 的核心逻辑。

这将是整个课程的巅峰,展示了所有概念如何协同工作,构成现代 JavaScript 的基石。

function doWhenDataReceived(value) {
  returnNextElement.next(value);
}

function* createFlow() {
  const data = yield fetch("...");
  console.log(data);
}

const returnNextElement = createFlow();
const futureData = returnNextElement.next();

futureData.value.then(doWhenDataReceived);

代码执行详解 (The Mega Diagram):

  1. const returnNextElement = createFlow()
    • 调用生成器 createFlow,返回一个生成器对象(迭代器)并赋值给 returnNextElement。函数体此时不执行
  2. const futureData = returnNextElement.next()
    • 第一次调用 .next()
    • createFlow 的执行上下文被创建,代码开始执行
    • 执行到 yield fetch('...')
    • fetch('...') 被执行:
      • 在浏览器中启动一个网络请求 (XHR)
      • 在 JavaScript 中立即返回一个 Promise 对象
    • yield 关键字将这个 Promise 对象作为 .next() 调用的结果返回。
    • createFlow 函数在此处暂停
    • 返回的结果是 { value: aPromiseObject, done: false },它被赋值给了 futureData
  3. futureData.value.then(doWhenDataReceived)
    • 我们现在在全局作用域中,拿到了那个 Promise 对象(即 futureData.value)。
    • 我们调用它的 .then() 方法,注册 doWhenDataReceived 函数作为成功时的回调。
  4. 异步等待...
    • 全局同步代码执行完毕。
    • 主线程空闲,等待网络请求的结果。
  5. 网络请求完成 (例如 200 毫秒后)
    • 浏览器获取到数据(比如 'Hi')。
    • 浏览器将这个数据 'Hi'resolve 之前返回的那个 Promise 对象。
    • Promise 的状态变为 resolved,它的 value 被填充为 'Hi'
    • 这个状态的转变,触发了其 .then() 中注册的回调。
  6. 回调进入微任务队列
    • doWhenDataReceived 函数被放入微任务队列,等待执行。
  7. 事件循环处理微-任务
    • 事件循环发现调用栈为空,于是从微任务队列中取出 doWhenDataReceived 并执行它。
    • Promise 的值 'Hi' 作为参数被传入。
  8. doWhenDataReceived('Hi') 被执行
    • doWhenDataReceived 函数内部,我们执行了最关键的一步:
    • returnNextElement.next('Hi')
  9. 恢复生成器
    • 第二次调用 .next(),并传入了从 Promise 拿到的数据 'Hi'
    • createFlow 函数从之前暂停的 yield恢复执行
    • 传入的 'Hi' 成为了 yield fetch('...') 整个表达式的结果
    • const data = 'Hi'; -> data 被成功赋值。
    • 代码继续向下执行。
  10. console.log(data)
    • "Hi" 被打印到控制台。

历史性的时刻

我们成功了!我们用生成器手动编排了一个异步流程,实现了:

  • 在一个函数中发起异步任务。
  • 暂停该函数,不阻塞主线程。
  • 在异步任务完成后,携带结果恢复该函数。
  • 在函数内部,以同步、线性的方式处理异步结果。

这就是 async/await 的魔法。async/await 只是将上述所有手动步骤(创建生成器、调用 .next、用 .then 链接)自动化了,让我们可以用更简洁的语法写出同样逻辑的代码。


24-async-generators-q-a

异步生成器问答环节

这里总结了关于使用生成器处理异步流程的一些最终思考和澄清。

问:这个模型仍然是异步的吗?如果在 futureData.value.then(...) 之后有同步的 console.log,会先执行哪个?

答:绝对是异步的。

  • console.log 是同步代码,它会立即执行。
  • .then() 中的回调 doWhenDataReceived 会被放入微任务队列,必须等待所有同步代码执行完毕后才有机会运行。
  • 我们只是通过生成器改变了代码的书写风格,让它看起来像同步的,但其底层的执行机制仍然严格遵守事件循环和任务队列的规则。

问:我们到底获得了什么?这和解决方案二(回调模型)有什么本质区别?

答:从引擎的角度看,底层机制(事件循环、任务队列)是相通的,并没有"本质"上的颠覆。

  • 真正的收获是可读性和代码组织方式的巨大提升
  • 我们可以将原本需要分散在回调函数中的逻辑,组织在一个单一的、线性的函数体内。
  • yield 后面的代码,可以看作是传统模型中被我们放入 .then() 回调的那些代码。但现在,我们可以把它们写在发起异步操作的代码的紧下方,就像写同步代码一样,这极大地增强了代码的逻辑连贯性。

问:我们获得了更多的控制权吗?

答:是的,在某种意义上。我们获得了手动控制函数流程恢复的能力。

  • 我们自己决定何时调用 .next() 来"唤醒"暂停的函数。
  • 然而,触发这一切的起点(Promise 的 resolve)仍然是异步的,是"超出我们控制"的。这就是异步编程的本质:在一个单线程环境中,你将任务"抛出去",然后以一种自动的、非阻塞的方式等待它们"回来"并触发后续逻辑。

最终结论:从复杂到优雅

  • 我们从最基础的回调模型出发,它虽然能工作,但带来了“回调地狱”和“控制反转”的问题。
  • Promise 通过引入一个代表未来值的对象,让我们可以用链式调用的方式来组织代码,提升了可读性。
  • 最终,生成器让我们能够“暂停”和“恢复”函数,这为我们编写看似同步的异步代码打开了大门。

async/await 就是这趟旅程的终点。它将生成器的强大能力封装在简洁的语法之下,让我们能够以最优雅、最直观的方式来处理 JavaScript 中的异步操作。

理解了从回调到 Promise 再到生成器的演进过程,你就真正理解了 async/await 的工作原理,也掌握了现代 JavaScript 异步编程的精髓。


25-async-await

终极解决方案:async/await

我们刚刚手动用生成器实现了一个复杂的异步流程,虽然功能强大,但过程繁琐。async/await 正是为了简化这一切而生。

  • async/await 是生成器和自动执行器的语法糖
  • 它将我们手动执行的所有步骤(创建迭代器、调用 .next()、用 .then() 链接回调、再调用 .next() 恢复执行)完全自动化了。

如果你在面试中被问到“你能从零开始实现 async/await 吗?”,我们刚刚完成的异步生成器练习就是完美的答案。

async/await 的代码示例与执行详解

async function createFlow() {
  console.log("Me first");
  const data = await fetch("...");
  console.log(data);
}

createFlow();
console.log("Me second");

执行流程:

  1. createFlow()
    • 调用 async 函数会立即执行其函数体,这与生成器不同。
    • createFlow 的执行上下文被创建并推入调用栈。
  2. console.log('Me first')
    • "Me first" 被同步打印到控制台。
  3. const data = await fetch('...')
    • 遇到 await 关键字。
    • await 右侧的表达式 fetch('...') 被执行。
      • 在浏览器中启动网络请求。
      • 立即返回一个 Promise 对象
    • await 的核心作用:
      1. 它会暂停 createFlow 函数的执行,就像 yield 一样。
      2. 它会将 createFlow 函数的执行权交还出去,允许函数外部的同步代码继续执行。
      3. 它会“暗中”等待 await 后面的 Promise 完成。
  4. console.log('Me second')
    • createFlow 暂停后,全局同步代码继续执行。
    • "Me second" 被打印到控制台。
  5. 异步等待与恢复
    • 网络请求完成后,Promise 被 resolve,例如,值为 'Hi'
    • JavaScript 引擎会自动将恢复 createFlow 函数的这个任务放入微任务队列
  6. 事件循环处理微-任务
    • 事件循环从微任务队列中取出“恢复 createFlow”这个任务。
    • createFlow 函数从之前暂停的 await恢复执行
  7. 赋值与继续执行
    • Promise 的 resolve 值(即 'Hi')成为整个 await fetch(...) 表达式的结果
    • const data = 'Hi'; -> data 被成功赋值。
    • console.log(data) 被执行,"Hi" 被打印到控制台。

总结

async/await 提供了一种革命性的方式来书写异步代码:

  • 它看起来像同步代码:代码从上到下执行,逻辑清晰,没有回调嵌套。
  • 它本质上是异步的await 关键字巧妙地暂停和恢复函数,使得主线程在等待期间完全不会被阻塞。
  • 它解决了控制反转:我们不再需要将代码的控制权交给一个我们无法控制的回调函数。我们自己通过 await 来决定代码流程的暂停点。

async/await 是我们从回调地狱到 Promise 链,再到生成器手动控制这一整个演进过程的最终、最优雅的解决方案。


26-wrapping-up

课程回顾与总结

今天,我们踏上了一段深入探索 JavaScript 核心机制的旅程。你们不仅掌握了知识,更重要的是,你们通过清晰的技术沟通,将复杂的概念内化为了自己的心智模型。

我们学到了什么?

  1. JavaScript 基础: 我们从内存、线程、执行上下文和调用栈这些基础概念出发,构建了对 JavaScript 同步执行的理解。
  2. 浏览器与异步: 我们将模型扩展到了浏览器环境,引入了 Web API、回调队列和事件循环,揭示了 JavaScript 是如何处理 setTimeout 等异步任务的。
  3. Promise 与微任务队列: 我们学习了 Promise 如何通过一个占位符对象来改进异步编程,并发现了高优先级的微任务队列,它解释了为什么 Promise 的回调会比 setTimeout 的回调先执行。
  4. 迭代器与数据流: 我们转变了思维方式,将数据集合看作是可按需获取的“数据流”,并学习了如何使用迭代器闭包来创建能“记住”状态的函数,从而逐个获取流中的元素。
  5. 生成器与流程控制: 我们掌握了生成器函数 (function*),它通过 yield 关键字赋予了我们暂停和恢复函数执行的超能力,让我们能够以前所未有的方式控制代码流程。
  6. async/await 的终极形态: 我们将所有知识融会贯通,理解了 async/await 正是建立在生成器和 Promise 之上的语法糖。它让我们能够以最直观、最优雅的同步风格来书写异步代码,同时又不失异步编程的非阻塞优势。

核心收获

  • 技术沟通能力: 成为一名优秀的资深开发者的关键,在于能够将复杂的概念清晰地传达给团队,从而赋能整个团队。今天,你们通过图解和讨论,极大地锻炼了这项能力。
  • 共享心智模型: 当你能够清晰地解释代码的底层工作原理时,你的个人知识就变成了团队的共享资源,这将极大地提升团队的开发效率和协作水平。
  • 掌握未来: ES6 和 ES7 的这些特性是现代 JavaScript 的基石。深入理解它们,意味着你已经准备好应对未来的技术挑战,并能够构建出更强大、更可靠的应用程序。

感谢每一位参与者。你们今天所展现出的求知欲、专注和技术沟通能力的进步,令人印象深刻。希望这次深入的探索能成为你们开发者生涯中一个宝贵的里程碑。