在现代 JavaScript 中编写异步任务

已发表: 2022-03-10
快速总结↬在本文中,我们将探讨 JavaScript 在过去时代围绕异步执行的演变,以及它如何改变我们编写和阅读代码的方式。 我们将从 Web 开发的起点开始,一直到现代异步模式示例。

JavaScript 作为一种编程语言有两个主要特征,这两个特征对于理解我们的代码如何工作都很重要。 首先是它的同步特性,这意味着代码将逐行运行,几乎就像您阅读它一样,其次它是单线程的,任何时候都只执行一个命令。

随着语言的发展,新的工件出现在场景中以允许异步执行; 开发人员在解决更复杂的算法和数据流时尝试了不同的方法,这导致了围绕它们的新接口和模式的出现。

同步执行和观察者模式

正如介绍中提到的,JavaScript 大部分时间都在逐行运行您编写的代码。 即使在最初的几年里,该语言也有这个规则的例外,尽管它们是少数并且你可能已经知道它们:HTTP 请求、DOM 事件和时间间隔。

 const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })

如果我们添加一个事件侦听器,例如单击一个元素并且用户触发了此交互,JavaScript 引擎将为事件侦听器回调排队一个任务,但将继续执行当前堆栈中存在的内容。 完成那里的调用后,它现在将运行侦听器的回调。

这种行为类似于网络请求和计时器所发生的情况,它们是 Web 开发人员访问异步执行的第一个工件。

尽管这些是 JavaScript 中常见的同步执行的例外,但重要的是要了解该语言仍然是单线程的,虽然它可以排队、异步运行它们然后返回主线程,但它只能执行一段代码一次。

跳跃后更多! 继续往下看↓

例如,让我们检查一个网络请求。

 var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();

当服务器返回时,分配给onreadystatechange的方法的任务将排队(代码在主线程中继续执行)。

注意解释 JavaScript 引擎如何排队任务和处理执行线程是一个复杂的话题,可能值得单独写一篇文章。 尽管如此,我还是建议您观看“事件循环到底是什么?” 菲利普·罗伯茨 (Phillip Roberts) 帮助您更好地理解。

在提到的每种情况下,我们都在响应外部事件。 达到一定的时间间隔,用户操作或服务器响应。 我们本身无法创建异步任务,我们总是观察到发生在我们力所能及之外的事件。

这就是为什么以这种方式形成的代码被称为观察者模式的原因,在这种情况下,它更好地由addEventListener接口表示。 很快,暴露这种模式的事件发射器库或框架蓬勃发展。

Node.js 和事件发射器

一个很好的例子是 Node.js,该页面将自己描述为“异步事件驱动的 JavaScript 运行时”,因此事件发射器和回调是一等公民。 它甚至已经实现了一个EventEmitter构造函数。

 const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');

这不仅是异步执行的可行方法,而且是其生态系统的核心模式和惯例。 Node.js 开启了在不同环境中编写 JavaScript 的新时代——甚至在网络之外。 因此,其他异步情况也是可能的,例如创建新目录或写入文件。

 const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })

您可能会注意到回调接收error作为第一个参数,如果需要响应数据,它将作为第二个参数。 这被称为错误优先回调模式,它成为作者和贡献者为他们自己的包和库采用的约定。

Promise 和无尽的回调链

随着 Web 开发面临更复杂的问题需要解决,出现了对更好的异步工件的需求。 如果我们查看最后一个代码片段,我们可以看到一个重复的回调链,随着任务数量的增加,它不能很好地扩展。

例如,让我们再添加两个步骤,文件读取和样式预处理。

 const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })

我们可以看到,随着我们编写的程序变得越来越复杂,由于多个回调链接和重复的错误处理,代码变得难以用肉眼理解。

承诺、包装和链式模式

Promises首次宣布为 JavaScript 语言的新增功能时并没有受到太多关注,它们并不是一个新概念,因为几十年前其他语言也有类似的实现。 事实是,自从它出现以来,它们改变了我从事的大多数项目的语义和结构。

Promises不仅为开发人员编写异步代码引入了内置解决方案,而且还开启了 Web 开发的新阶段,作为 Web 规范后期新功能(如fetch )的构建基础。

将方法从回调方法迁移到基于 Promise 的方法在项目(例如库和浏览器)中变得越来越普遍,甚至 Node.js 也开始慢慢迁移到它们。

例如,让我们包装 Node 的readFile方法:

 const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }

这里我们通过在 Promise 构造函数中执行来隐藏回调,当方法结果成功时调用resolve ,并在定义错误对象时reject

当一个方法返回一个Promise对象时,我们可以通过将函数传递给then来跟踪它的成功解析,它的参数是 promise 被解析的值,在本例中为data

如果在方法期间抛出错误,则将调用catch函数(如果存在)。

注意如果您需要更深入地了解 Promises 的工作原理,我推荐 Jake Archibald 的“JavaScript Promises: An Introduction”文章,该文章是他在 Google 的 Web 开发博客上写的。

现在我们可以使用这些新方法并避免回调链。

 asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

拥有创建异步任务的本地方式和跟踪其可能结果的清晰界面使行业摆脱了观察者模式。 基于 Promise 的似乎解决了不可读和容易出错的代码。

由于更好的语法突出显示或更清晰的错误消息有助于编码,更容易推理的代码对于阅读它的开发人员来说变得更可预测,执行路径的更好的图片更容易发现可能的陷阱。

Promises的采用在社区中是如此普遍,以至于 Node.js 迅速发布了其 I/O 方法的内置版本,以返回 Promise 对象,例如从fs.promises导入它们的文件操作。

它甚至提供了一个promisify来包装任何遵循错误优先回调模式的函数,并将其转换为基于 Promise 的函数。

但是 Promises 在所有情况下都有帮助吗?

让我们重新想象一下使用 Promises 编写的样式预处理任务。

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))

代码中的冗余明显减少,尤其是在我们现在依赖catch的错误处理方面,但 Promises 不知何故未能提供与动作串联直接相关的清晰代码缩进。

这实际上是在调用readFile之后的第一个then语句上实现的。 在这些行之后发生的事情是需要创建一个新的范围,我们可以首先在其中创建目录,然后将结果写入文件中。 这会导致缩进节奏中断,乍一看不容易确定指令顺序。

解决此问题的一种方法是预烘焙一个自定义方法来处理此问题并允许正确连接该方法,但我们将向似乎已经具备完成任务所需的代码引入更复杂的深度我们想要。

注意考虑到这是一个示例程序,我们可以控制一些方法,它们都遵循行业惯例,但情况并非总是如此。 随着更复杂的连接或引入不同形状的库,我们的代码风格很容易被打破。

令人高兴的是,JavaScript 社区再次从其他语言语法中学习并添加了一个符号,这对解决异步任务连接不像同步代码那样令人愉快或直接阅读的情况有很大帮助。

异步并等待

Promise被定义为执行时未解析的值,创建Promise的实例是对该工件的显式调用。

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))

在异步方法中,我们可以使用await保留字来确定Promise的解析,然后再继续执行。

让我们使用这种语法重新访问或代码片段。

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()

注意请注意,我们需要将所有代码移动到一个方法中,因为我们今天不能在异步函数范围之外使用await

每次异步方法找到await语句时,它都会停止执行,直到处理的值或承诺得到解决。

使用 async/await 表示法有一个明显的后果,尽管它是异步执行的,但代码看起来好像是同步的,这是我们开发人员更习惯于看到和推理的东西。

错误处理呢? 为此,我们使用语言中已经存在很长时间的语句trycatch

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()

我们放心,过程中抛出的任何错误都将由catch语句中的代码处理。 我们有一个处理错误处理的中心位置,但现在我们有一个更易于阅读和遵循的代码。

具有返回值的后续操作不需要存储在不会破坏代码节奏的mkdir等变量中; 也无需在后续步骤中创建新范围来访问result的值。

可以肯定地说,Promises 是语言中引入的基本工件,是在 JavaScript 中启用 async/await 表示法所必需的,您可以在现代浏览器和最新版本的 Node.js 上使用它。

注意最近在 JSConf 中,Node 的创建者和第一贡献者 Ryan Dahl很遗憾没有在早期开发中坚持 Promises ,主要是因为 Node 的目标是创建事件驱动的服务器和文件管理,而观察者模式更适合。

结论

将 Promises 引入 Web 开发世界改变了我们在代码中排队操作的方式,改变了我们对代码执行的推理方式以及我们编写库和包的方式。

但是摆脱回调链更难解决,我认为必须将方法传递给then并没有帮助我们摆脱多年来习惯于主要供应商采用的观察者模式和方法的思路在 Node.js 等社区中。

正如 Nolan Lawson 在他关于 Promise 连接中错误使用的优秀文章中所说,旧的回调习惯很难改掉! 他后来解释了如何摆脱其中一些陷阱。

我相信 Promise 是一个中间步骤,它允许一种自然的方式来生成异步任务,但这并没有帮助我们在更好的代码模式上前进,有时你实际上需要一种更具适应性和改进的语言语法。

当我们尝试使用 JavaScript 解决更复杂的难题时,我们看到了对更成熟语言的需求,并且我们尝试了以前在 Web 上不习惯看到的架构和模式。

我们仍然不知道 ECMAScript 规范几年后会是什么样子,因为我们一直在将 JavaScript 治理扩展到 Web 之外并尝试解决更复杂的难题。

现在很难说我们究竟需要什么语言才能让这些难题变成更简单的程序,但我很高兴网络和 JavaScript 本身正在推动事物,试图适应挑战和新环境。 与十多年前我开始在浏览器中编写代码时相比,我现在觉得 JavaScript 是一个更加异步友好的地方

延伸阅读

  • “JavaScript Promises:简介”, Jake Archibald
  • “Promise Anti-Patterns”, Bluebird 库文档
  • “我们的承诺有问题,”诺兰·劳森