现代JavaScript异步编程演进:从回调到Async/Await

作者:拾光

时间:2026-10-24

浏览:452

本文深入解析JavaScript异步编程的演进历程,从早期的回调函数与观察者模式,到Promise的引入,再到现代Async/Await语法的普及。文章详细对比了各阶段处理异步任务的方式,重点讲解了如何通过Promise封装回调、利用Async/Await简化代码逻辑,以及Node.js中fs.promises模块的应用,帮助开发者掌握高效、清晰的异步代码编写技巧。

文章目录

前言

本文将回顾JavaScript异步执行机制的历史演变,探讨其如何重塑了代码编写范式,并梳理从早期Web开发至现代异步模式的完整发展脉络。

JavaScript作为编程语言具备两个关键特性,对理解代码运行至关重要:一是同步执行特性,即代码按行顺序执行;二是单线程模型,确保同一时刻仅执行一条指令。

随着语言演进,支持异步执行的新机制不断涌现。开发者在应对复杂算法与数据流时尝试多种方案,催生了新的接口与设计模式。

同步执行与观察者模式

如前所述,JavaScript默认逐行执行代码。尽管早期存在少量例外(如HTTP请求、DOM事件和定时器),但这些异步操作始终存在。

通过添加事件监听器响应用户点击时,无论解释器当前执行何段代码,都会暂停并执行监听器回调中的逻辑,随后再恢复主流程。

与定时器或网络请求类似,addEventListener、setTimeout和XMLHttpRequest是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 && xhr.status === 200) {
 console.log(request.responseText);
 }
}

11request.send();

无论服务器响应如何,当服务器恢复运行时,分配给onreadystatechange的方法会在获取程序代码序列之前被调用。

用户交互响应时情况类似:


const button = document.querySelector('button');

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

你可能已注意到,我们连接外部事件并传递回调,告知代码在事件发生时的行为。十多年前,“什么是回调?”是高频面试问题,因该模式在代码库中广泛存在。

在上述场景中,我们均响应外部事件,无论是达到特定时间间隔、用户操作还是服务器响应。我们无法直接创建异步任务,只能观察事件发生。

因此此类代码被称为观察者模式,最佳表示为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开启了在不同环境(包括Web之外)编写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');
  })
 })
 })
16})

可见,因多重回调链与重复错误处理,程序编写日益复杂,代码可读性下降。

Promise、包装与链模式

当Promises最初作为JavaScript新成员宣布时,并未引起过多关注,因其他语言早已实现类似概念。然而,自出现以来,它们改变了我参与的大多数项目的语义与结构。

Promise不仅为开发者提供了内置异步代码解决方案,还开启了Web开发新阶段,成为fetch等后续Web规范的构建基础。

从回调方法迁移至基于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函数(若存在)。

注意:如需深入了解Promise工作原理,建议阅读Jake Archibald在Google Web开发博客撰写的文章“JavaScript Promises:简介”。

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


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

它提供了创建异步任务的原生方法,并以清晰接口跟踪可能结果,从而摆脱观察者模式。基于Promise的代码似乎解决了可读性差且易出错的代码问题。

在更好的语法高亮与更清晰的错误提示信息帮助下,开发者编写更易理解的代码变得更具可预测性,执行表现更好,潜在陷阱更易发现。

Promise在社区中普及程度极高,Node.js迅速发布其I/O方法的内置版本以返回Promise对象,例如从fs.promises导入文件操作。

它甚至提供promisify工具,用于包装遵循错误优先回调模式的函数,并将其转换为基于Promise的函数。

但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))

代码冗余明显减少,尤其在错误处理方面,因我们依赖catch。但Promise在某种程度上未能提供与动作串联直接相关的清晰代码缩进。

实际上,这是在调用readFile后的第一个then语句中实现的。这些代码行之后的操作需创建新作用域,以便先创建目录再将结果写入文件。这导致缩进节奏中断,乍看难以确定指令序列。

注意:此为示例程序,我们可控制某些方法,它们均遵循行业惯例,但并非总是如此。通过更复杂串联或引入不同库,代码风格易被打破。

令人欣慰的是,JavaScript社区再次从其他语言语法中汲取经验,增加了一种表示方法,能在大多数情况下帮助异步任务串联,使代码如同步代码般易于阅读。

Async与Await

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')
}

11processLess()

注意:需将所有代码移至某方法中,因我们无法在异步函数作用域外使用await。

每当异步方法找到await语句,它将停止执行,直至promise被解决。

尽管是异步执行,但async/await表示法使代码看似同步,这更易于开发人员阅读与理解。

那么错误处理呢?我们可使用语言中已存在很久的try和catch。


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')
}

try {
 processLess()
} catch (e) {
 console.error(e)
}

我们大可放心,过程中抛出的任何错误均由catch语句中的代码处理。现在我们拥有了易于阅读且规范的代码:

对返回值进行的后续操作无需存储在不会破坏代码节奏的mkdir等变量中;也无需在后续步骤中创建新作用域以访问result值。

可以肯定,Promise是该语言中引入的基本机制,对于在JavaScript中启用async/await表示法必不可少,你可以在现代浏览器和最新Node.js版本中使用它。

注意:近期在JSConf中,Node创建者与首位贡献者Ryan Dahl,对早期开发中未遵循Promise表示法表示遗憾,主要因Node目标是创建事件驱动服务器与文件管理,而观察者模式更适合此类场景。

结论

将Promise引入Web开发的目的是改变代码中顺序操作的方式,并改变我们理解代码及编写库与包的方式。

但摆脱回调链更难解决,我认为在多年习惯于观察者模式及采用方法后,将方法传递给then并不能帮助我们摆脱原有思路,例如Node.js。

正如 Nolan Lawson 在其文章“关于 Promise 级联的错误使用”【https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html】中指出,传统的回调习惯固执且难以处理!他详细阐述了规避这些陷阱的方法。

我认为Promise是中间步骤,它允许以自然方式生成异步任务,但并未帮助我们进一步改进更好的代码模式,有时你需要更适应改进的语言语法。

当尝试使用JavaScript解决更复杂难题时,我们看到了对更成熟语言的需求,并尝试了此前未在Web中看到的架构与模式。

我们仍不知ECMAScript规范几年后的样子,因我们不断将JavaScript治理扩展至Web之外,并尝试解决更复杂难题。

如今很难说我们需要从语言中将这些难题转化为更简单程序,但我对Web与JavaScript本身如何推动技术、试图适应挑战与新环境感到满意。与十年前刚开始在浏览器中编写代码时相比,我觉得现在JavaScript是“异步友好”的。

原文:https://www.smashingmagazine.com/2019/10/asynchronous-tasks-modern-javascript/

到此这篇关于如何在现代JavaScript中编写异步任务的文章就介绍到这了,更多相关JavaScript编写异步任务内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章