JavaScript异步处理(一):Parallel VS Async && Callbacks

总结 JavaScript 中的五种异步方式及比较。

Parallel VS Async

并行编程(parallel programming)的编程语言,大致方向都在 CPU 的多核调用、线程的创建和拆除。JavaScript 则是异步调用的方式。

异步调用(asynchronous) 主要就是避免程序调用中的阻塞发生。举个例子,当你需要对数据库运行查询或从本地磁盘中提取文件,这时通常需要使用异步调用。这个调用将从现存的线程中(例如I/O线程)分离,并在可能的情况下执行它。异步调用可以在同一台电脑上调用,也可以在其他地方的其他电脑上调用(例如访问网上其他服务器的API),它可以有效地防止用户界面不被 “卡住” 或对操作无响应。

在并行编程中,你仍然可以分解工作或者任务,但关键区别在你可以问每个任务块(chunk)启动新的线程,并且每个线程都可以到达公共变量池。在大多数的情况下,并行编程仅在本地计算器上进行,并且永远不会发送到其他计算机。同样,这是因为对并行任务的每次调用都会创建一个和全新的线程来执行。并行编程也可以保持用户界面快速,并且在运行挑战性的任务时不会感到 “卡住” 。

也许你现在觉得并行和异步看起来似乎像在做同一件事,事实上,他们完全是两种机制。使用异步调用意味着你将无法控制线程或者线程池(线程的集合),并且依赖系统来处理请求。使用并行编程,你可以很好的控制任务块,甚至可以创建许多线程,来充分利用处理器中可用核心来处理。然而,每次调用创建和拆除线程都是非常系统密集型的,因此编程的时候都需要十分小心。

最后总结一下,两种模式都是防止阻塞。简单来说,请记住,异步调用使用的是系统已经在使用的线程,并行编程需要开发人员将大的任务打散,注册和拆卸所需线程。

在browser环境下,可以通过使用 Web Workers 来创建新的线程,这样可以不占用 主UI线程。

Callbacks

说到回调编写异步程序 ,一定会谈到的就是 callback hell

什么是 "回调地狱"?

它就是使用回调来执行的异步 JavaScript ,是很难凭直觉取得正确的执行顺序和结果的。大多数的代码就像这样:

可以看到结尾处的 }) 和类似金字塔的结构。这被称作 回调地狱 。

造成回调地狱的原因是大家试图按照执行的顺序从头到尾的书写 JavaScript 。 JavScript 不像 C ,Ruby,或Python一样需要一行一行的执行代码。

什么是回调?

回调只是对使用 JavaScript 函数的便利的说法。JavaScript 并没有特殊的称作 “callback” 的东西。函数使用回调在一定时间后产出结果,而不是直接获取函数执行的返回值。异步表达的是,“需要一些时间” 或者是 “在将来发生”。通常使用回调的例子诸如,I/O 操作,下载东西,读文件,访问数据库等、

当你使用一个和正常的函数:

1
2
3
var result = multiplyTwoNumbers(5, 10)
console.log(result)
// 50 gets printed out

异步调用

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

上面的例子中,可能这个 gif 图需要很久才能下载下来,你不想你的程序暂停(阻塞)等待图片的下载完成。而是在函数中储存下载完成后应该运行的代码,这就是回调。

1
2
3
4
5
6
7
8
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')

大家试图理解回调最困难的部分就是理解程序执行的顺序。上面下载图片中的例子中,程序执行了三件事,分别是首先打印了 handlePhoto 函数, 然后调用 downloadPhoto 函数,并将 handlePhoto 作为其回调传递,最后打印出 “Download started”

我们需要留意到,handlePhoto 并没有被调用,它只是作为回调被创建和传递到 downloadPhoto。它在 downloadPhoto 任务完成之前都不会被调用。它所调用取决的时间依赖于网络的连接速度。

这个例子有两个核心的概念:

  • handlePhoto 回调只是一种在以后存储之后会调用的函数。

  • 事件的执行顺序不是从上到下来理解的,而是根据时间完成的时机来跳转。

如何修复回调地狱

回调地狱来源于编码经验不足所书写的代码,幸运的是书写好的代码并不是很难。你需要遵守下列三点:

避免你的代码过度嵌套

这里有一些混乱的在浏览器中运行的请求代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
})
}

现在让我们给这两个匿名函数命名,

1
2
3
4
5
6
7
8
9
10
11
12
13
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
})
}

可以看到给具名函数有直观的一些好处:

  • 由于描述性的函数名称,使代码更容易阅读。

  • 当异常发生时,您将获得引用实际函数名称而不是“匿名”的堆栈跟踪。

  • 允许你移动函数并按名称引用它们。

由于函数声明,我们甚至能随意改变函数的位置,放到文件的底部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
document.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
}

模块化

这是最重要的一部分,任何人都能够创建模块(即 库 )。引用 Isaac Schlueter (node.js项目):

编写小模块来,每个模块都只做一件事,并将它们组装成其他模块来做更大的事。如果你这么做,你永远都不会进入到回调地狱。

我们接着看上面那个例子,然后将其分成多个文件转换成模块。下面将展示适用于浏览器或服务器的模块模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/** 新建一个文件 formupload.js */

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.submit = formSubmit

现在我们能在别处引用代码了:

1
2
3
var formUpload = require('formuploader')

document.querySelector('form').onsubmit = formUploader.submit

我们的主程序调用仅仅只需要两行代码了,而且还有以下的一些好处:

  • 新来的开发者更容易理解,不会因为需要去读冗长的回调代码产生疑惑。

  • formUploader 可以再其他地方使用而无需复制代码,可以在 github 或 npm 上轻松共享。

处理每一个错误

程序运行中,会产生许多错误:语法错误,运行时错误等等。

前两条准则是保持你的程序更加可读,这条规则则是保证你的程序的可靠性。处理回调时,你可以根据定义处理每一个已分配任务,可以在后台执行某些操作,来监测程序成功完成或者因为错误终止。有经验的开发者常常会告诉你,你不可能知道错误什么时候发生,所以你要做的就是假想他们已经发生。

Node.js 中常用的处理错误的方式就是 error-first 风格,其回调的第一个参数始终为错误保留。

1
2
3
4
5
6
7
8
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
}

让第一个参数成为错误是一种简单的约定,鼓励你记住处理错误,如果是第二个参数来作为错误处理,就很容易忘记。

使用一个代码检查工具也能够很好的避免处理错误。

小结

  1. 不要嵌套函数,为函数命名并将它们放到程序顶层。

  2. 使用 函数提升(function hoisting),有利于你将函数转移到程序底部。

  3. 处理回调中 每一个错误 。使用 standard 帮助你来处理。

  4. 创建可重用的函数并将它们放在一个模块中,以减少理解代码所需的认知负担。将代码拆分成这样的小块也可以帮助您处理错误,编写测试,强制你为代码创建稳定且记录的公共API,并有助于重构。

避免回调地狱最有效的方式就是将函数分离出来。这样程序流程可以更易于理解,新接手程序的人不必去了解功能的所有模块也能尝试理解函数正在做什么。

你可以将函数移到文件底部,或者创建成模块来引用。

关于创建模块也有一些建议:

  • 将重复使用的代码移到一个函数中。

  • 当你的函数(或者相关的函数)变得足够大时,将它们移到另一个文件并使用 module.exports 公开它们,以便于在其他地方加载它们。

  • 如果你有一些代码需要在多个项目中使用,请为它书写 readme, tests 和 package.json,并将它发布到 github 和 npm 上。

  • 一个好的模块是足够小并且关注于解决一个问题。

  • 模块中的单个文件建议不超过150行。

  • 一个模块最好不具有超过一层以上的 JavaScript 文件的文件夹,如果超过了,或许它做了过多的事情。

  • 了解更多经验丰富的程序员所书写的代码。简洁的模块都是易于理解的。

引用

评论

加载中,最新评论有1分钟延迟...