【译】JavaScript异步编程指南:回调地狱|技术点评

657 阅读13分钟

本文翻译自:www.17ukulele.com/15697.html

JavaScript异步编程指南

什么是回调地狱?

异步JavaScript或是使用了回调函数的JavaScript,很难直观顺畅地被阅读和理解。许多回调函数代码最终看起来像这样:

fs.readdir(source,function(err,files){
    if(err){
        console.log('Error finding files:'+ err);
    }else{
        files.forEach(function(filename,fileIndex){
            console.log(filename);
            gm(source + filename).size(function(err,values){
                if(err){
                    console.log('Error identifying file size:' + err);
                }else{
                    console.log(filename + ':' + values);
                    aspect = (values.width / values.height);
                    widths.forEach(function(width,widthIndex)=>{
                        height = Math.round(width / aspect)
                        console.log('resizing '+ filename + 'to ' + height + 'x' + height);
                        this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err){
                            if(err) console.log('Error writing file: ' + err);
                        });
                    }.bind(this));
                }
            });
        });
    }
});

看到上面这些以})结尾形成的金字塔形状了么?哎呀! 这个形状就叫做回调地狱。

而造成回调地狱的原因是,人们尝试以一种从上到下的视觉方式执行代码的方式去编写JavaScript程序。但往往很多人都会犯这个错误! 在其他语言(如:C、Ruby 或 Python)中,第一行代码的所有执行操作都是在第二行代码开始运行之前完成,依此类推到整个文件执行完成。 下面你将学习到,JavaScript中的不一样。

什么是callbacks(回调)?

callbacks回调仅仅只是使用JavaScript函数的一种通用叫法。在JavaScript语言中,并没有一种特定的东西称之为callbacks,这个只是一种方便的称呼。不同于大部分立即返回结果的函数,这些使用callback的函数需要消耗一些时间才能返回结果。asynchronous(异步),或者简称async,表示“需要花费一些时间”或者“发生在将来,而不是现在”。通常情况下, callback仅仅用户操作 I/O的时候使用到。比如 下载、读写文件、与数据库交互等。

当调用一个普通的函数的时候,你可以如下这样使用返回值:

var result multiplyTwoNumbers(5,10)
console.log(result);
//50 get printed out

然而,异步函数,也就是使用了callback函数的不会立刻返回任何东西。

var photo = downloadPhoto("http://coolcats.com/cat.gif")
// photo is 'undefined'!

在这种情况下,下载gif文件可能会花费很长的时间,而且你并不想让你的程序在等待下载完成的过程中处于“暂停”(也就是阻塞,“block”)状态。

相反,你可以把下载结束后需要执行的操作存放到一个函数中,这个就是callback(回调)!你可以提供一个downloadPhoto的函数,这个函数会在下载完成的时候执行callback函数(call you back later),并传递photo参数(或者出错的时候返回一个错误的信息)。

downloadPhoto('http://coolcats.com/cat.gif', handlePhoto);

function handlePhoto(error, photo){
    if(error) console.error('Download error!',error);
    else console.log('Download finished!',photo)
}

console.log('Download started')

人们在尝试理解callback这个概念的最大困难之处在于:程序运行中,代码是按照怎么样的规则执行的。在上面这个例子中,有三个主要的事情发生:首先是handlePhoto函数的声明;其次是downloadPhoto函数会被调用并且将handlePhoto函数作为它的回调函数(callback)的参数传递进去;最后,输出打印一句话:'Download started'。

值得注意的是,handlePhoto函数并没有立即被调用用,它只是创建了并作为一个参数(回调函数)传递给了downloadPhoto函数。但是,一旦downloadPhoto函数执行完成之后,handlePhoto函数就会被运行。handlePhoto回调函数需要多长时间才会被运行,这个取决于网络连接的速度。

这个例子是为了说明两个重要的概念:

  • 回调函数handlePhoto仅仅是一种“存放”操作的方式,而这些操作需要延迟一段时间之后才被执行。
  • 代码的运行规则并不是按照阅读代码的“自上而下”的方式去遵守的,代码执行会根据事件结束的时间跳转嵌套。

我们如何解决“回调地狱”问题?

回调地狱是由于不良的编码习惯造成的,幸运的是,编写更好的代码并不困难!

你只需要遵循三条原则:

1、保持代码浅显易读(提高代码可读性)

下面是一个杂乱的JavaScript代码,通过使用browser-request从浏览器端去发送一个ajax请求到服务器端。

var form = document.querySelector('form');

form.onsubmit = function (submitEvent) {
    var name =  document.querySelector('input').value;
    request({
        uri: "http://example.com/upload",
        body: name,
        method: "POST",
    },function(err,response,body){
        var statusMessage = document.querySelector('.status');
        if(err) return statusMessage.value = err;
        statusMessage.value = body;
    });
}

上面代码中,有两个匿名函数。让我们来个它取个名字!

var form = document.querySelector('form');

form.onsubmit = function formSubmit(submitEvent){
    var name = document.querySelector('input').value;
    request({
        uri: "http://example.com/upload",
        body: name,
        method: "POST"
    }, function postResponse(err, response,body){
        var statusMessage = document.querySelector(".status");
        if(err) return statusMessage.value = err;
        statusMessage.value = body;
    });
}

正如你所看到的,给回调函数命名非常简单, 并且有一些直接的好处:

  • 正因这些具有描述性意义的函数名,使得代码更易于阅读
  • 当代码发生异常的时,你会在异常堆栈报错信息中看到确切的函数名称,而不是anonymouse之类的名字。(便于排查定位问题)
  • 你可以把函数抽离出来,并通过函数名去引用他们。

现在,我们把这两个函数移到我们代码都最顶层:

doucment.querySelector('form').onsubmit = formSubmit;

function formSubmit(submitEvent){
    var name = document.querySelector('input').value;
    request({
        uri: "http://example.com/upload",
        body: name,
        method: "POST"
    },postResponse);
}

function postResponse(err,response,body){
    var statusMessage = document.querySelector('.status');
    if(err) return statusMessage.value = err;
    statusMessage.value = body;
}

注意: 这两个函数都声明放在了文件的最底部,这得益于函数提升(function hoisting)(变量提升的概念)

2、模块化

这是最重要都部分:任何人都可以创建模块(又名库)(Anyone is capable of creating modules (aka libraries).) 引用Isaac Schlueter(来源于Node.js项目)的话来说:

"Write small modules that each do one thing, and assemble them into other modules that do a bigger thing. You can't get into callback hell if you don't go there."

"编写一个个小的模块,每个模块完成一件事情,然后把他们组装起来,去完成一个更大的事情,回调地狱这个坑,你不去往那走,你是不会陷进去的。"

让我们将上面的示例代码分解为几个文件,并将它们转换为一个模块(modules)。我们将展示一个既适用于浏览器代码,也适用于服务器代码(或同时适用于浏览器和服务器都代码)的模块(modules)格式:

这是一个新的文件叫formuploader.js,它包含了之前的两个函数。

module.exports.submit = formSubmit

function formSubmit(submitEvent){
    var name = document.querySelector('input').value;
    request({
        uri: "http://example.com/upload",
        body: name,
        method: "POST"
    },postResponse);
}

function postResponse(err, response, body){
    var statusMessage = document.querySelector(".status");
    if(err) return statusMessage.value = err;
    statusMessage.value = body;
}

其中module.exports节点是一个node.js模块系统的一个例子,它使用browserifynodeElectron 和浏览器中工作。我非常喜欢这种模块化,因为它可以在任何地方使用,易于理解,并且不需要复杂的配置文件或者脚本。

现在我们已经有formuploader.js(加载页面的脚本标记后 browserified),我们只需要require并使用他就可以了! 这是我们的应用程序特定代码呈现:

var formUploader = require('formuploader');
document.querySelector('form').onsubmit = formUploader.submit;

现在我们的应用程序只有两行代码,有以下好处:

  • 让新接手的开发人员更易于理解 ---- 不会让他们陷入困境,而不得不通读所有关于 formuploader功能的代码
  • formuploader模块可以在其他地方使用,而重复编写代码,并且可以轻松地在github或npm上共享。

3、处理每一个独立的异常

error错误 往往有很多种类型:syntax errors:语法错误,由开发者编写不规范引起的语法错误(通常只在第一次运行程序时发现); runtime errors: 运行时错误,程序员犯的运行时错误(代码已运行,但是有一个bug,导致一些混乱);platform errors: 平台错误,例如:无效的文件权限, 硬盘驱动器故障、无网络链接等。本节只讨论解决最后一类的错误。

前两条规则主要是关于如何使代码具有可读性、而这一条是关于代码的稳定性。当你在使用回调函数(callback)的时候,根据定义,你是在处理被分配的任务,这些任务都是被分发给回调函数的,并且回调函数会在后台执行一些操作,然后这个任务要么成功完成,要么由于失败而中止。任何有经验的开发者都会告诉你:你永远不会知道错误是谁是什么时候会发生,你只有假定他们一直会出现。

对于回调函数(callback),最流行的处理错误的方法是 Node.js的风格:其中所有的回调函数的第一个参数始终为error错误保留着。

var fs = require('fs');

fs.readFile('/Does/not/exist',handleFile)

function handleFile(error, file){
    if(error) return console.error("Uhoh,there was an error",error);
    // otherwise, continue on and use `file` in your code
}

将第一个参数设置为error是一种简单的约定,这样有利于你记住处理错误异常。如果将这个错误参数设置为 回调函数的第二个位置的话,你在写代码的时候往往会容易忽略第二个error参数,而只关注第一个参数,如定义函数function handleFile (files){}这样。

Code linters(检查代码的小工具)也通过配置,实现提醒你要处理这些回调函数错误。 最简单的一个小工具就是standard,这个工具你只需要在你的代码文件的路径中执行$ standard命令,它就会把奶的每一个没有进行错误处理的回调函数标记出来。

总结

  1. 不要嵌套函数,给这些函数命名,并将其放于你的程序的最顶层。
  2. 利用(function hoisting) 函数提升机制,将你的函数移动到文件代码的末尾。
  3. 在每一个回调函数中去处理每一个错误。可以使用一个代码检查工具去帮你完成这个事情。如:standard
  4. 创建可重用的函数并将他们放在模块中,以减少理解代码所需的认知负担。像这样将代码分成几小段,还可以帮助您处理错误error,编写测试,强制您为代码创建一个稳定的、文档化的公共API,这样有助于代码的重构。

避免回调地狱的最重要的方面是:将回调函数抽离开,这样程序的流程就可以更容易理解。新手也不需要再费力的浏览每一个函数的细节来了解这个函数究竟是做什么的。

你可以先把这些函数移动到文件的底部,然后逐步地把函数移动到另一个文件中,然后使用类似require(./photo-helpers.js)的方式取引用关联;最后,把它们放进一个独立的模块,比如require("image-resize")中。

以下是创建模块时的一些原则:

  • 首先将反复使用的代码移动到函数中
  • 当你的函数(或一组与同一主题相关的函数)足够大时,将他们移动到另一个文件中并实用module.exports公开他们。你可以使用类似require('./photo-helper.js')的方式取关联这个文件。
  • 如果你的代码可以在多个项目中使用,你需要为其提供:readme.md文件、测试文件及package.json文件,并且把他们发布到github和npm中。(这边的好处有很多,一一例举)
  • 一个优秀的模块是很小的,而且只聚关注焦一个问题
  • 模块中单个文件不应该超过150行的JavaScript代码
  • 在整个JavaScript的文件结构组织中,一个模块模块不应该拥有超过一层的嵌套文件夹。如果有,则意味着整个模块要做的事情有点过多了。
  • 让更有经验的程序员给你演示一下好的模块构建的方式,直到你了解究竟什么是优秀的模块。如果有一个模块,你需要花上不止几分钟的时间取了解它是干什么的,那么它很可能不是一个很好的模块。

扩展阅读

请尝试于阅读我的一篇较长的关于回调的介绍 longer introduction to callbacks, 或者尝试一些 nodeschoole的教程。

也可以在browserify-handbook中找到编写模块化代码的例子。

那关于promises/generators/ES6 等等的呢?

在学习更高级的解决方案之前,请记住,回调函数(callback)是JavaScript的基本组成部分(因为他们只是函数),因此,在继续学习更高级的语言特性之前,你应该学习如何读写它们,因为他们都依赖于对回调函数的理解。如果你还不能编写可维护的回调代码,请继续努力!

如果你真的想让你的异步代码从上到下阅读,则可以尝试一些花哨的东西。

请注意:使用这些可能会带来性能/跨平台运行时兼容问题,因此在使用前务必要做好研究。

Promises

Promises 是一种编写异步代码的方式,它看起来仍然像是自上而下执行的,使用Promises的时候通常鼓励使用try/catch的方式取处理捕获异常,它可以处理更多类型的错误。

Generators

Generators可以让你‘暂停’单个函数而不暂停整个程序的状态,这以让理解代码变得稍微复杂一些为代价,从而使异步代码看起来是以自上而下的方式执行的。 点击查看watt里使用Generators的案例。

Async functions

Async functions 异步函数是ES7提出的一个特性,它将在更高的语法中封装GeneratorsPromises。乳沟你感兴趣,可以取看看。

我个人使用(callbacks)回调函数编写90%的异步代码,当事情变得复杂时,我会引入run-parallel(并行运行)或是run-series(串行运行)之类的东西。我不认为callbacks回调函数VSPromisesVS其他什么的,对我来说都是没有真正的改变,最大的影响来自保持代码的简介,而不是嵌套和拆分成小模块。

无论你实用哪一种方法,始终要记得:处理捕获每一个错误保持代码简洁

Remember, only you can prevent callback hell and forest fires

请记住,只有你可以防止回调地狱和森林火灾

你可以在github上找到callback-hell的源代码。

最后

翻译不易~对原文有翻译不妥当,或是关于技术知识点有理解不对的地方欢迎小伙伴们指出~讨论交流呀~

希望看到你留下评论|点赞|收藏 喲~ (比心♥️)

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情