异步编程的难点

JavaScript 中迷失对异步代码的控制轻而易举。闭包和匿名函数的原地定义让编程体验流畅,开发人员不必跳转到代码库的其他部分。这完全符合 KISS 原则(Keep It Simple, Stupid):简单易懂,保持代码流畅,并能在更短时间内完成工作。遗憾的是,为了这种便利,我们牺牲了模块化、可重用性和可维护性等特性。这将不可避免地导致回调函数嵌套失控、函数体积膨胀以及代码组织混乱。

大多数情况下,将回调函数定义为原地函数并不是严格必需的,这更多的是一个编程习惯问题,而非异步编程本身的问题。能够识别代码何时变得难以掌控,或者更好的是,能够预见到代码可能变得难以掌控并采取最合适的解决方案,这才是区分新手和专家的关键因素。

创建一个简单的 web 蜘蛛

为了解释这个问题,我们将创建一个小型网络蜘蛛,这是一个命令行应用程序,它将 Web URL 作为输入并将其内容下载到本地文件中。 在本章介绍的代码中,我们将使用几个 npm 依赖项:

  • superagent:用于简化 HTTP 调用的库 (nodejsdp.link/superagent)

  • mkdirp:用于递归创建目录的小实用程序 (nodejsdp.link/mkdirp)

此外,我们经常会引用名为 ./utils.js 的本地模块,其中包含一些我们将在应用程序中使用的帮助程序。 为了简洁起见,我们将省略该文件的内容,但您可以在官方存储库(nodejsdp.link/repo)中找到完整的实现以及包含完整依赖项列表的 package.json 文件。

我们应用程序的核心功能包含在名为 Spider.js 的模块中。 让我们看看它是什么样子的。 首先,让我们加载我们将要使用的所有依赖项:

import fs from 'fs'
import path from 'path'
import superagent from 'superagent'
import mkdirp from 'mkdirp'
import { urlToFilename } from './utils.js'

接下来,我们创建一个名为 Spider() 的新函数,它接收要下载的 URL 以及下载过程完成时将调用的回调函数:

export function spider (url, cb) {
    const filename = urlToFilename(url)
    fs.access(filename, err => { // (1)
        if (err && err.code === 'ENOENT') {
            console.log(`Downloading ${url} into ${filename}`)
            superagent.get(url).end((err, res) => { // (2)
                if (err) {
                    cb(err)
                } else {
                    mkdirp(path.dirname(filename), err => { // (3)
                        if (err) {
                            cb(err)
                        } else {
                            fs.writeFile(filename, res.text, err => { // (4)
                                if (err) {
                                    cb(err)
                                } else {
                                    cb(null, filename, true)
                                }
                            })
                        }
                    })
                }
            })
        } else {
            cb(null, filename, false)
        }
    })
}

这里发生了很多事情,所以让我们更详细地讨论每一步中发生的事情:

  1. 代码通过验证相应的文件是否尚未创建来检查 URL 是否已下载。 如果定义了 err 并且类型为 ENOENT,则该文件不存在并且可以安全地创建它:

    fs.access(filename, err => ...
  2. 如果未找到该文件,则使用以下代码行下载 URL:

    superagent.get(url).end((err, res) => ...
  3. 然后,我们确保包含该文件的目录存在:

    mkdirp(path.dirname(filename), err => ...
  4. 最后,我们将 HTTP 响应的正文写入文件系统:

    fs.writeFile(filename, res.text, err => ...

为了完成我们的网络蜘蛛应用程序,我们只需要通过提供 URL 作为输入来调用 Spider() 函数(在我们的例子中,我们从命令行参数中读取它)。 Spider() 函数是从我们之前定义的文件中导出的。 现在让我们创建一个名为 Spider-cli.js 的新文件,可以直接从命令行调用它:

import { spider } from './spider.js'

spider(process.argv[2], (err, filename, downloaded) => {
    if (err) {
        console.error(err)
    } else if (downloaded) {
        console.log(`Completed the download of "${filename}"`)
    } else {
        console.log(`"${filename}" was already downloaded`)
    }
})

现在,我们已准备好尝试我们的网络蜘蛛应用程序,但首先,请确保您的项目目录中有 utils.js 模块和包含完整依赖项列表的 package.json 文件。 然后,通过运行以下命令安装所有依赖项:

npm install

现在,让我们执行 Spider-cli.js 模块,使用如下命令下载网页内容:

node spider-cli.js http://www.example.com

我们的网络蜘蛛应用程序要求我们始终在提供的 URL 中包含协议(例如,http://)。 另外,不要期望重写 HTML 链接或下载图像等资源,因为这只是演示异步编程如何工作的简单示例。

在下一节中,您将学习如何提高此代码的可读性,以及通常如何保持基于回调的代码尽可能干净和可读。

回调黑洞

查看我们之前定义的 Spider() 函数,您可能会注意到,尽管我们实现的算法非常简单,但生成的代码有多层缩进,并且非常难以阅读。 使用直接样式阻塞 API 实现类似的函数会很简单,而且代码很可能会更具可读性。 然而,使用异步 CPS 则是另一回事,并且错误地使用就地回调定义可能会导致极其糟糕的代码。

大量的闭包和就地回调定义将代码转换为不可读且难以管理的 blob 的情况称为回调地狱。 一般来说,它是 Node.js 和 JavaScript 中最广泛认可和最严重的反模式之一。 受此问题影响的典型代码结构如下:

asyncFoo(err => {
    asyncBar(err => {
        asyncFooBar(err => {
            //...
        })
    })
})

您可以看到以这种方式编写的代码如何由于深层嵌套而呈现出金字塔的形状,这就是为什么它也被通俗地称为厄运金字塔。

诸如前面的代码片段之类的代码最明显的问题是其可读性差。 由于嵌套太深,几乎不可能跟踪一个函数在哪里结束以及另一个函数从哪里开始。

另一个问题是由每个作用域中使用的变量名称重叠引起的。 通常,我们必须使用相似甚至相同的名称来描述变量的内容。 最好的例子是每个回调收到的错误参数。 有些人经常尝试使用同名的变体来区分每个作用域中的对象,例如 err、error、err1、err2 等。 其他人更喜欢通过始终使用相同的名称来隐藏上层作用域中定义的变量,例如 err。 这两种替代方案都远非完美,并且会造成混乱并增加引入缺陷的可能性。

另外,我们必须记住,就性能和内存消耗而言,闭包的代价很小。 此外,它们还可能造成不易识别的内存泄漏。 事实上,我们不应该忘记主动闭包引用的任何上下文都会从垃圾回收中保留。

有关闭包在 V8 中如何工作的精彩介绍,您可以参阅 Vyacheslav Egorov(Google 从事 V8 工作的软件工程师)撰写的以下博客文章,您可以在 nodejsdp.link/v8-closures 上阅读该文章。

如果您查看我们的 Spider() 函数,您会发现它清楚地代表了回调地狱情况,并且具有刚才描述的所有问题。 这正是我们要使用本章以下各节中介绍的模式和技术来解决的问题。