[技术翻译]在现代JavaScript中编写异步任务

本周再来翻译一些技术文章,本次预计翻译三篇文章如下:

  • 04.[译]使用Nuxt生成静态网站(Generate Static Websites with Nuxt)

  • 05.[译]Web网页内容是如何影响电池功耗的(How Web Content Can Affect Power Usage)

  • 06.[译]在现代JavaScript中编写异步任务(https://web.dev/off-main-thread/)

我翻译的技术文章都放在一个github仓库中,如果觉得有用请点击star收藏。我为什么要创建这个git仓库?目的是通过翻译国外的web相关的技术文章来学习和跟进web发展的新思想和新技术。git仓库地址:https://github.com/yzsunlei/javascript-article-translate

在本文中,我们将探讨过去围绕异步执行的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中常见的同步执行例外的,但至关重要的是要了解该语言仍然是单线程的,并且尽管它可以将Task排队,异步运行它们然后返回主线程,但它只能一次执行一段代码。

我们的工具手册,其中Alla Kholmatova探索了如何创建有效且可维护的设计系统来设计出色的数字产品。认识Design Systems,了解常见的陷阱,陷阱和Alla多年来汲取的经验教训。

例如,让我们发送一个网络请求。

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

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

我们可以看到,由于多个回调链和重复的错误处理,随着正在编写的程序变得越来越复杂,代码变得更加难以为人所知。

Promise,包装和连锁模式

Promises最初宣布它们是JavaScript语言的新功能时,并没有引起太多关注,它们并不是一个新概念,因为其他语言在几十年前就已经实现了类似的功能。事实是,自从出现以来,他们发现我所做的大多数项目的语义和结构都发生了很大变化。

Promises不仅引入了供开发人员编写异步代码的内置解决方案,而且还为Web开发(如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 在Google的Web开发博客上写的“JavaScript Promises:Introduction”一文。

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

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某种程度上未能提供与操作串联直接相关的清晰代码缩进。

这实际上是在调用then之后的第一个语句上实现的readFile。这些行之后发生的事情是需要创建一个新的作用域,我们可以在该作用域中首先创建目录,然后将结果写入文件中。这就导致了缩进节奏的中断,乍看之下很难确定指令序列。

解决此问题的一种方法是预先处理该问题的自定义方法,并允许该方法正确连接,但是我们将向似乎已经具有实现任务所需功能的代码引入更多的复杂性。

注意:这是一个示例程序,我们可以控制某些方法,它们都遵循行业惯例,但并非总是如此。通过更复杂的串联或引入具有不同类型的库,我们可以轻松破坏代码风格。

令人高兴的是,JavaScript社区再次从其他语言语法中学到了东西,并添加了一种表示法,可以在很多情况下帮助异步任务串联而不是像同步代码那样令人愉悦或直截了当。

async和await

A Promise在执行时被定义为一个未解析的值,创建a的实例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))

在async方法内部,我们可以使用await保留字来确定a的分辨率,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。

每次async方法找到一条await语句时,它将停止执行,直到处理中的值或Promise被解析为止。

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

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

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的目标是创建事件驱动的服务器和文件管理,而Observer模式更适合于此。

结论

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

但是摆脱回调链很难解决,我认为then在多年习惯于观察者模式和主要提供商采用的方法之后,不得不通过一种方法并不能帮助我们摆脱思路。像Node.js这样的社区。

正如诺兰·劳森(Nolan Lawson)在其有关Promise串联中错误使用的出色文章中所说,旧的回调习惯会死掉!稍后,他解释了如何避免这些陷阱。

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

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

我们仍然不知道ECMAScript规范的表现如何,因为我们一直将JavaScript治理扩展到网络之外,并尝试解决更复杂的难题。

现在很难说我们需要从语言中真正地将这些难题转变成更简单的程序所需要的东西,但是我对Web和JavaScript本身如何推动事物,试图适应挑战和新环境感到满意。与十年前开始在浏览器中编写代码相比,现在我觉得JavaScript是一个更加异步的友好的地方。

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

你可能感兴趣的:(技术)