什么是回调地狱
异步的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))
}
})
})
}
})
有没有看到这些以”})”结尾的金字塔结构?这个形状为“亲切地”称为回调地狱。
二、什么是callback(回调)?
“callback”仅仅只是一种使用JavaScript函数的一种通用称呼。在JavaScript语言中,并没有一种特定的东西称之为“callback”,这个只是一种方便的称呼。不同于大部分立即返回结果的函数,这些使用callback的函数需要消耗一些时间才能返回结果。“asynchronous”(异步),或者简称为“async”仅仅表示需要花费一些时间,或者是“在未来发生,而不是现在”。通常情况下,callback仅仅用于操作I/O的时候使用到。比如下载、读写文件、与数据库交互等。
当调用一个普通的函数的时候,你可以这样使用返回值:
var result = multiplyTwoNumbers(5, 10)
console.log(result)
// 50 gets 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“就会运行。这个取决于网络连接到底有多快。
这个例子试图说明两个重要的概念:
(1)回调函数“handlePhoto”仅仅是一种“存放”操作的方式,而这些操作需要延迟一段时间之后进行。
(2)代码的执行规则并不是按照阅读代码的“从上到下”的方式去遵守的,代码执行会根据事情结束的时间跳转嵌套。
三、我们如何解决“回调地狱”?
回调地狱的产生往往来源于对编码练习的缺乏,幸运的是,写出更好的代码并不困难!
我们只需要遵循如下三个原则:
(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
})
}
正如你看到的,给函数进行命名是一件超级简单的事情,而且会立刻体验到几个好处:
1)感谢这些具有描述性意义的函数名称把,这些名称使代码更加容易地阅读;
2)当异常发生的时候,你会在异常堆栈中看到确切的函数名称,而不是“anonymous”之类的名字;
3)你可以把函数移动出去,并且通过名字去引用他们;
现在,我们把这两个函数移动到我们程序的最顶层:
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
}
注意一下,函数声明在这里被移动到了文件最底部,这个要感谢 function hoisting.
(2)模块化
这个是最重要的部分:任何人都有能力创建模块。引用 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.
“编写一个个小的模块,每个模块完成一件事情,然后把他们组装起来,去完成一个更大的事情,回调地狱这个坑,你不去往那走,你是不会陷进去的”。
让我们从上面的代码中提取模板代码,然后通过拆分到一组文件中的方式,将这些模板代码组装成module。我会展示一个module的格式,这种格式既可以用于浏览器的代码,也可以用在服务端。
这个是一个新的文件,叫做“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模块系统的一个例子,electron 和 浏览器 使用browserify 。我非常喜欢这种模块化,因为它可以工作在任何地方,而且非常简单,并且不需要复杂的配置文件或者脚本。
现在我们有了“formuploader.js”(并且已经作为一个脚本,在页面加载完成之后载入到了页面中),我们只需要“require”这个模块并且使用它!这个是一个我们的程序中具体代码的样子:
var formUploader = require(‘formuploader’)
document.querySelector(‘form’).onsubmit = formUploader.submit
这样,我们的程序仅仅需要两行代码,并且有如下的好处:
1)对于一个新的开发者来说,更加容易理解了 —— 他们不用深陷于“被迫通读全部“formuploader”函数”。
2)“formuploader”可以用于其他地方,不用重复编写代码,并且可以轻松地分享到github或者npm上去。
(3)处理每一个独立的异常
错误(error)有很多种类型:程序员犯的语法错误(往往在第一次尝试运行程序的时候会被发现);程序员犯的运行时错误(程序可以运行但是里面有一个bug会把事情弄糟);其他情况下的平台错误比如无效的文件权限,硬件驱动失效、网络连接异常等问题。这个部分主要针对最后一类错误(error)。
前面两条原则可以让你的代码具有可读性,但是这一条可以让你的代码保持稳定健壮。当你在使用回调函数(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(file){}”。
Code linters(检查代码的小工具)也可以通过配置,实现提醒你要处理这些回调函数错误。最简单的一个小工具就是 standard。这个工具你仅仅只需要在你的代码文件的路径中执行 “$ standard”命令,它就会把你每一个没有进行错误处理的回调函数标记出来。
四、总结
1、不要嵌套函数。给这些函数进行命名,并且放到你的程序的最顶层。
2、使用 function hoisting(函数提升)机制将你的函数移到文件的末尾。
3、在每一个回调函数中去处理每一个错误。可以使用一个代码检查工具去帮你完成这个事情。
4、创建可以服用的函数,并且把他们放置在一个模块中,这样可以提高代码可读性。把代码分割成一个个小的部分,可以帮助你更好的处理error,测试,强迫你去为你的代码创建一个稳定的、文档完善的公共API模块,而且有助于代码的重构。
最重要的避免回调地狱的方面就是,移出你的函数,这样程序的流程可以更容易理解,新手也就不用去啃每一个函数究竟是干什么的。
从现在开始,你首先就可以把函数移到文件的底部,然后逐渐地把函数移到另一个文件中并且使用类似“require(‘./photo-helpers.js’)”的方式去关联,最终,把他们放进一个独立的模块比如“require(‘image-resize’)”.
下面是一些创建模块的一些原则:
1、通过把一些经常重复使用的代码封装成一个函数;
2、当你的函数(或者一组具有类似主题功能的函数)足够大的时候,移动到另一个文件,并且通过“module.exports“的方式去发布,你可以使用类似“require(‘./photo-helpers.js’)”的方式去关联这个文件。
3、如果你的代码可以用于很多个项目的时候,你需要提供“readme”文件、测试以及“package.json”文件,并且把他们发布到github和npm中。
4、一个优秀的模块是很小的而且只聚焦于一个问题;
5、JavaScript的模块中的一个独立的文件行数不应该超过150行;
5、在整个JavaScript的文件结构组织中,一个模块不应该拥有超过一层的嵌套文件夹。如果这种情况发生了,那么意味着整个模块要做的事情有点过多了。
6、让更有经验的程序员给你演示下好的模块构建的方式,直到你了解究竟什么是优秀的模块。如果有一个模块,你需要花不止几分钟的时间去了解它是干嘛的,那么这个模块并不是一个多么好
在我们需要对一个异步操作进行频繁的调用的时候,且要保证一步操作的顺序,可能会出现
回调地狱(callback)的情况 例如:
var fs = require(‘fs’)
fs.readFile(‘../data/a.txt’,’utf8’,function (err,data) {
if (err) {
throw err
}
console.log(data)
fs.readFile('../data/b.txt','utf8',function (err,data) {
if (err) {
throw err
}
console.log(data)
fs.readFile('../data/c.txt','utf8',function (err,data) {
if (err) {
throw err
}
console.log(data);
})
}) })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
解决这个问题让我们的代码看起来更加直观,我们可以用promise 解决这个问题
小栗子
var p1 = new Promise(function (resolve,reject) {
fs.readFile(‘../data/a.txt’,’utf8’,function (err,data) {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
var p2 = new Promise(function (resolve,reject) {
fs.readFile(‘../data/b.txt’,’utf8’,function (err,data) {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
var p3 = new Promise(function (resolve,reject) {
fs.readFile(‘../data/c.txt’,’utf8’,function (err,data) {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
//console.log(p1) //Promise {
p1.then(function (data) {
console.log(data)//读取成功
return p2 //把下一个要读取的promise返回
},function (err) { //第二个参数表示读取失败 reject 拒绝了
console.log(err+’p1失败’);
})
.then(function (data) {
console.log(data);
return p2
},function (err) {
console.log(err+'p2失败');
})
.then(function (data) {
console.log(data);
},function (err) {
console.log(err+'p3失败');
})
// 这样子就实现了promise 链式编程,但是重复的代码太多,
//下面稍微改造一下封装一个方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 但是这样子的代码复用太多,我们可以给他稍微封装一下 var fs = require('fs')
function PreadFile (filePath) {
return new Promise(function (resolve,reject) {
fs.readFile(filePath,’utf8’,function (err,data) {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
}
PreadFile(‘../data/a.txt’)
.then(function (data) {
console.log(data)
return PreadFile(‘../data/b.txt’)
},function (err) {
console.log(err+’a文件失败’)
})
.then(function (data) {
console.log(data)
return PreadFile('../data/c.txt')
},function (err) {
console.log(err+'b文件失败')
})
.then(function (data) {
console.log(data)
},function (err) {
console.log(err+'c文件失败')
}) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 输出:a b c ,当然 这里的 function 用箭头函数代替更简洁,但涉及我书写blog的前后顺序和结构进性,在写es6前就尽量不使用es6语法糖
Promise.all和Promise.race
Promise.all
Promise.all可以将多个Promise实例包装成一个新的Promise实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被reject失败状态的值。
具体代码如下:
let p1 = new Promise((resolve, reject) => {
resolve(‘成功了’)
})
let p2 = new Promise((resolve, reject) => {
resolve(‘success’)
})
let p3 = Promse.reject(‘失败’)
Promise.all([p1, p2]).then((result) => {
console.log(result) //[‘成功了’, ‘success’]
}).catch((error) => {
console.log(error)
})
Promise.all([p1,p3,p2]).then((result) => {
console.log(result)
}).catch((error) => {
console.log(error) // 失败了,打出 ‘失败’
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Promse.all在处理多个异步处理时非常有用,比如说一个页面上需要等两个或多个ajax的数据回来以后才正常显示,在此之前只显示loading图标。
代码模拟:
let wake = (time) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(${time / 1000}秒后醒来
)
}, time)
})
}
let p1 = wake(3000)
let p2 = wake(2000)
Promise.all([p1, p2]).then((result) => {
console.log(result) // [ ‘3秒后醒来’, ‘2秒后醒来’ ]
}).catch((error) => {
console.log(error)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
需要特别注意的是,Promise.all获得的成功结果的数组里面的数据顺序和Promise.all接收到的数组顺序是一致的,即p1的结果在前,即便p1的结果获取的比p2要晚。这带来了一个绝大的好处:在前端开发请求数据的过程中,偶尔会遇到发送多个请求并根据请求顺序获取和使用数据的场景,使用Promise.all毫无疑问可以解决这个问题。
Promise.race
顾名思义,Promse.race就是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])里面哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态。
let p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(‘success’)
},1000)
})
let p2 = new Promise((resolve, reject) => {
setTimeout(() => {
reject(‘failed’)
}, 500)
})
Promise.race([p1, p2]).then((result) => {
console.log(result)
}).catch((error) => {
console.log(error) // 打开的是 ‘failed’
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
原理是挺简单的,但是在实际运用中还没有想到什么的使用场景会使用到。
总结
Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。
所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise对象有以下两个特点。
(1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
注意,为了行文方便,本章后面的resolved统一只指fulfilled状态,不包含rejected状态。
有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。
Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
如果某些事件不断地反复发生,一般来说,使用 Stream 模式是比部署Promise更好的选择。
调地狱 是什么?
JS异步编程,或使用大量回调函数时,其代码阅读起来晦涩难懂,并不直观。许多代码往往这样:
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))
}
})
})
}
})
这个形状像不像倒下的金字塔?你看到后面那一大堆})了吧?这就是让很多人熟知的回调地狱(Callback Hell)了。
代码的执行从顶部第一行开始,一直顺序执行到最后一行;JS程序员们则试图以一种显而易见的方式,在书写时呈现这种代码执行的顺序。这就是引起回调地狱的原因所在,而很多程序员恰恰犯了这种错误。其他的语言中,诸如C、Python和Ruby,程序员们期待的是在第二行语句执行之前,第一行的所有行为都要结束,然后一直这样执行到文件结尾。当然如你将要知晓得,JavaScript并不一样。
回调函数(Callback)是什么?
回调函数仅仅是JS函数使用方式中的一个概念的名字。JS中并没有什么特殊的东西叫回调函数,它只是一个概念,或者大家约定俗成的叫法。与大部分立即返回结果的函数不同,套用回调函数的函数会花费一定的时间来处理即将得到的结果。单词asynchronous异步回调处理,也被简称做async异步,就是指“花费一些时间”,或者“发生在将来,而不是此刻”的意思。回调函数通常只用来处理与I/O相关的事件,例如下载文件、文档读取、与数据库通信等。
当调用一个普通的函数时,你能及时的使用它的返回值。比如:
var result = multiplyTwoNumbers(5, 10)
console.log(result)
// 50 gets printed out
然而在异步处理,使用回调函数时,你就不能立即使用函数的返回值。比如:
var photo = downloadPhoto(‘http://coolcats.com/cat.gif’)
// photo is ‘undefined’!
在这个例子中,gif图片的下载可能需要一定的时间;但是你又不想因等待图片下载,而中断程序运行。
取而代之的是,你将这段在下载行为完成之后要运行的代码,托管在一个函数中。这就是回调的概念。你把这个回调函数放在函数downloadPhoto内,当图片下载完毕再来运行它;显示图片,或者报一个错误。
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此时并没有执行;我们仅仅是定义了handlePhoto函数,并把它当做一个参数传入downloadPhoto函数中。但它会在downloadPhoto函数完成其下载任务后执行,而下载时间则取决于网络状况。
上述例子的目的在于说明两个重要的概念:
handlePhoto回调函数是一种代码延后执行的方式,目的在于一定时间之后进行相应的操作;
代码执行的顺序并不是由上到下,而是根据任务完成情况在回调函数之间跳转。
怎样解决回调地狱?
回调地狱主要源于糟糕的代码风格,幸而培养良好的编程习惯并不是一件难事。
你只需要遵循以下三条金律:
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
})
}
上面代码中,有两个匿名函数(分别在第二、第八行)。让我们分别给他们起个名字,formSubmit和postResponse。
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
})
}
如你所见,给匿名函数起个名字如此简单;不仅如此,它还有不少立马见效的效果:
得益于我们使用描述性的函数名,极大地增强了代码的可读性;
你可以依据函数名称来追踪代码执行的过程,而不是仅仅看到一堆anonymous;
同时也允许在别处声明函数,并通过函数名来引用它们。
下面我们就将函数声明放到程序的顶层:
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
}
需要注意的是,函数的function声明放在文件的末尾。这有赖于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模块系统中的一个事例,你可以在node, electron和引用了browserify库的浏览器端使用它。我超级喜欢这种风格的模块,因为它可以用在每一个你想用到的地方;而且它非常容易理解,不需要复杂的配置文件或引用脚本。
现在我们已经有了formuploader.js模块(并且通过script标签引入到页面中),我们要做的就仅仅是请求引用和使用它。接下来就是我们应用程序特定代码部分的样子:
var formUploader = require(‘formuploader’)
document.querySelector(‘form’).onsubmit = formUploader.submit
如此我们的应用程序就只有两行代码,并且有如下优点:
便于新手理解 —— 新手们不必因为要通读formuploader.js模块而懵逼;
formuploader可以很好地移用到其他地方,而不需要反复的复制代码;当然,也便于在GitHub或是npm上分享。
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
}
将错误信息作为第一个参数传入回调函数,是一种简单易用的通常做法,有助于提醒你时刻注意可能的错误信息。而如果将其当做第二个参数,那你很有可能会把回调函数写成function handleFile (file) { }这样的形式,这样更容易忽略掉错误信息的处理。
代码检查器也可以通过相关配置,提醒你在使用回调函数时处理错误信息。其中最简单的一款,叫作standard。你需要做的仅仅是在相应的文件夹下运行$ standard命令;然后它就会将每一个未进行报错处理的回调函数为你显示出来。
总结
不要多层嵌套函数。将函数命名,并且放置在程序的顶层;
好好利用JS的函数声明提升这一特性,将函数放置在文件末尾;
处理好函数回调过程中的每一个可能的报错信息,可以通过检查器比如 standard来帮助你做这件事情;
编写可复用的函数并将其模块化,从而降低用于阅读、理解代码的消耗;将代码拆分成多个小组件,有利于处理错误信息、编写测试程序,也有利于你编写稳定的、文档化的API以及代码重构。
避免回调地狱最重要的一方面,应该是将函数抽离出来。这么做可以让整个程序流更便于阅读和理解,也让新接触该程序的人不必在乎所有的细枝末节而把握住程序真正的目的。
刚开始的话,你可以先试着如何将回调函数移到文件末尾;在做好这一步的基础上,再将这些函数转移到另一个文件中,并使用require(‘./photo-helpers.js’)这样的语句来加载它们;最后,再将它们独立成单独的模块,并使用require(‘image-resize’)这样的语句来将该模块引入。
在写模块的过程中,以下几条很好的建议以供参考:
首先,将重复使用的代码,写成一个函数;
当函数(或一系列相关函数)足够大时,将它们移入单独的文件。并使用module.exports语句,将它们作为模块接口暴露出来。你可以使用相关引用来加载它们;
假若说你的代码可以跨项目移植使用,那就给它写一个单独的README文件吧,以及单独的测试和package.json文件。然后把它发布到GitHub和npm。这么做有太多好处了,在这里不能一一列举;
一个好的模块,体量小的同时,能够专注于解决一个问题;
模块中的单独文件,应当不多于大概150行的JS代码;
模块内包含JS文件的文件夹,嵌套不应超过一层。如果超过一层文件夹嵌套,那该模块实现的功能可能太多了;
向更多你认识的老资格程序员们请教,让他们为你展示优秀的模块直到你对这些模块有一个很好的理解。如果某个模块要耗费你好几分钟的时间去理解它做了什么,那这个模块可能并不是写的很好;
Node.js需要按顺序执行异步逻辑时一般采用后续传递风格,也就是将后续逻辑封装在回调函数中作为起始函数的参数,逐层嵌套。这种风格虽然可以提高CPU利用率,降低等待时间,但当后续逻辑步骤较多时会影响代码的可读性,结果代码的修改维护变得很困难。根据这种代码的样子,一般称其为”callback hell”或”pyramid of doom”,本文称之为回调大坑,嵌套越多,大坑越深。
坑的起源
后续传递风格
为什么会有坑?这要从 后续传递风格(continuation-passing style–CPS) 说起。这种编程风格最开始是由 Gerald Jay Sussman 和 Guy L. Steele, Jr. 在 AI Memo 349上提出来的,那一年是1975年, Schema语言 的第一次亮相。既然JavaScript 的函数式编程设计原则主要源自Schema,这种风格自然也被带到了Javascript中。
这种风格的函数要有额外的参数:“后续逻辑体”,比如带一个参数的函数。CPS函数计算出结果值后并不是直接返回,而是调用那个后续逻辑函数,并把这个结果作为它的参数。从而实现计算结果在逻辑步骤之间的传递,以及逻辑的延续。也就是说如果要调用CPS函数,调用方函数要提供一个后续逻辑函数来接收CPS函数的“返回”值。
在JavaScript中,这个“后续逻辑体”就是我们常说的 回调(callback) 。这种作为参数的函数之所以被称为回调,是因为它一般在主程序中定义,由主程序交给库函数,并由它在需要时回来调用。而将回调函数作为参数的,一般是一个会占用较长时间的异步函数,要交给另一个线程执行,以便不影响主程序的后续操作。如下图所示:
在JavaScript代码中,后续传递风格就是在CPS函数的逻辑末端调用传入的回调函数,并把计算结果传给它。但在不需要执行处理时间较长的异步函数时,一般并不需要用这种风格。我们先来看个简单的例子,编程求解一个简单的5元方程:
x+y+z+u+v=16
x+y+z+u-v=10
x+y+z-u=11
x+y-z=8
x-y=2
对于x+y=a;x-y=b这种简单的二元方程我们都知道如何求解,这个5元方程的运算规律和这种二元方程也没什么区别,都是两式相加除以2求出前一部分,两式相减除以2求出后一部分。5元方程的前一部分就是4元方程的和值,依次类推。我们的程序写出来就是:
代码清单1. 普通解法-calnorm.js
var res = new Int16Array([16,10,11,8,2]),l= res.length;
var variables = [];
for(var i = 0;i < l;i++) {
if(i === l-1) {
variables[i] = res[i];
}else {
variables[i] = calculateTail(res[i],res[i+1]);
res[i+1] = calculateHead(res[i],res[i+1]);
}
}
function calculateTail(x,y) {
return (x-y)/2;
}
function calculateHead(x,y) {
return (x+y)/2;
}
方程式的结果放在了一个整型数组中,我们在循环中依次遍历数组中的头两个值res[i]和res[i+1],用calculateTail计算最后一个单值,比如第一和第二个等式中的v;用calculateHead计算等式的”前半部分”,比如第一和第二个等式中的x+y+z+u部分。并用该结果覆盖原来的差值等式,即用x+y+z+u的结果覆盖原来x+y+z+u-v的结果,以便计算下一个tail,直到最终求出所有未知数。
如果calculateTail和calculateHead是CPU密集型的计算,我们通常会把它放到子线程中执行,并在计算完成后用回调函数把结果传回来,以免阻塞主进程。关于CPU密集型计算的相关概念,可参考本系列的上一篇 Node.js软肋之CPU密集型任务。比如我们可以把代码改成下面这样:
代码清单2. 回调解法-calcb.js
var res = new Int16Array([16,10,11,8,2]),l= res.length;
var variables = [];
(function calculate(i) {
if(i === l-1) {
variables[i] = res[i];
console.log(i + “:” + variables[i]);
process.exit();
}else {
calculateTail(res[i],res[i+1],function(tail) {
variables[i] = tail;
calculateHead(res[i],res[i+1],function(head) {
res[i+1] = head;
console.log(‘—————–‘+i+’—————–’)
calculate(i+1);
});
});
}
})(0);
function calculateTail(x,y,cb) {
setTimeout(function(){
var tail = (x-y)/2;
cb(tail);
},300);
}
function calculateHead(x,y,cb) {
setTimeout(function(){
var head = (x+y)/2;
cb(head);
},400);
}
跟上一段代码相比,这段代码主要有两个变化。第一是calculateTail和calculateHead里增加了setTimeout,把它们伪装成CPU密集型任务;第二是弃用for循环,改用函数递归。因为calculateHead的计算结果会影响下一轮的calculateTail计算,所以calculateHead计算要阻塞后续计算。而for循环是无法阻塞的,会产生错误的结果。此外就是calculateTail和calculateHead都变成后续传递风格的函数了,通过回调返回最终计算结果。
这个例子比较简单,既不能充分体现回调在处理异步非阻塞操作时在性能上的优越性,坑的深度也不够恐怖。不过也可以说明“用后续传递风格实现几个异步函数的顺序执行是产生回调大坑的根本原因”。下面有一个更抽象的回调样例,看起来更有代表性:
module.exports = function (param, cb) {
asyncFun1(param, function (er, data) {
if (er) return cb(er);
asyncFun2(data,function (er,data) {
if (er) return cb(er);
asyncFun3(data, function (er, data) {
if (er) return cb(er);
cb(data);
})
})
})
}
像function(er,data)这种回调函数签名很常见,几乎所有的Node.js核心库及第三方库中的CPS函数都接收这样的函数参数,它的第一个参数是错误,其余参数是CPS函数要传递的结果。比如Node.js中负责文件处理的 fs 模块,我们再看一个实际工作中可能会遇到的例子。要找出一个目录中最大的文件,处理步骤应该是:
用 fs.readdir 获取目录中的文件列表;
循环遍历文件,获取文件的 stat ;
找出最大文件;
以最大文件的文件名为参数调用回调。
这些都是异步操作,但需要顺序执行,后续传递风格的代码应该是下面这样的:
代码清单3. 寻找给定目录中最大的文件
var fs = require(‘fs’)
var path = require(‘path’)
module.exports = function (dir, cb) {
fs.readdir(dir, function (er, files) { // [1]
if (er) return cb(er)
var counter = files.length
var errored = false
var stats = []
files.forEach(function (file, index) {
fs.stat(path.join(dir,file), function (er, stat) { // [2]
if (errored) return
if (er) {
errored = true
return cb(er)
}
stats[index] = stat // [3]
if (–counter == 0) { // [4]
var largest = stats
.filter(function (stat) { return stat.isFile() }) // [5]
.reduce(function (prev, next) { // [6]
if (prev.size > next.size) return prev
return next
})
cb(null, files[stats.indexOf(largest)]) // [7]
}
})
})
})
}
对这个模块的用户来说,只需要提供一个回调函数function(er,filename),用两个参数分别接收错误或文件名:
var findLargest = require(‘./findLargest’)
findLargest(‘./path/to/dir’, function (er, filename) {
if (er) return console.error(er)
console.log(‘largest file was:’, filename)
})
介绍完CPS和回调,我们接下来看看如何平坑。
解套平坑
编写正确的并发程序归根结底是要让尽可能多的操作同步进行,但各操作的先后顺序仍能正确无误。服务端的代码一般逻辑比较复杂,步骤多,此时用嵌套实现异步函数的顺序执行会比较痛苦,所以应该尽量避免嵌套,或者降低嵌套代码的复杂性,少用匿名函数。这一般有几种途径:
最简单的是把匿名函数拿出来定义成单独的函数,然后或者像原来一样用嵌套方式调用,或者借助流程控制模块放在数组里逐一调用;
用Promis;
如果你的Node版本>=0.11.2,可以用generator。
我们先介绍最容易理解的流程控制模块。
流程控制模块
Nimble 是一个轻量、可移植的函数式流程控制模块。经过最小化和压缩后只有837字节,可以运行在Node.js中,也可以用在各种浏览器中。它整合了underscore 和 async 一些最实用的功能,并且API更简单。
nimble有两个流程控制函数,.parallel和.series。顾名思义,我们要用的是第二个,可以让一组函数串行执行的_.series。下面这个命令是用来安装Nimble的:
npm install nimble
如果用.series调度执行上面那个解方程的函数,代码应该是这样的:
…
var flow = require(‘nimble’);
(function calculate(i) {
if(i === l-1) {
variables[i] = res[i];
process.exit();
}else {
flow.series([
function (callback) {
calculateTail(res[i],res[i+1],function(tail) {
variables[i] = tail;
callback();
});
},
function (callback) {
calculateHead(res[i],res[i+1],function(head) {
res[i+1] = head;
callback();
});
},
function(callback){
calculate(i+1);
}]);
}
})(0);
…
.series数组参数中的函数会挨个执行,只是我们的calculateTail和calculateHead都被包在了另一个函数中。尽管这个用流程控制实现的版本代码更多,但通常可读性和可维护性要强一些。接下来我们介绍Promise。
Promise
什么是 Promise 呢?在 纸牌屋 的第一季第一集中,当琳达告诉安德伍德不能让他做国务卿后,他说:“所谓Promise,就是说它不会受不断变化的情况影响。”
Promise不仅去掉了嵌套,它连回调都去掉了。因为按照Promise的观点,回调一点也不符合函数式编程的精神。回调函数什么都不返回,没有返回值的函数,执行它仅仅是因为它的副作用。所以用回调函数编程天生就是指令式的,是以副作用为主的过程的执行顺序,而不是像函数那样把输入映射到输出,可以组装到一起。
最好的函数式编程是声明式的。在指令式编程中,我们编写指令序列来告诉机器如何做我们想做的事情。在函数式编程中,我们描述值之间的关系,告诉机器我们想计算什么,然后由机器(底层框架)自己产生指令序列完成计算。Promise把函数的结果变成了一个与时间无关的值,就像算式中的未知数一样,可以用它轻松描述值之间的逻辑计算关系。虽然要得出一个函数最终的结果需要先计算出其中的所有未知数,但我们写的程序只需要描述出各未知数以及未知数和已知数之间的逻辑关系。而CPS是手工编排控制流,不是通过定义值之间的关系来解决问题,因此用回调函数编写正确的并发程序很困难。比如在代码清单2中,caculateHead被放在caculateTail的回调中执行,但实际上在计算同一组值时,两者之间并没有依赖关系,只是进入下一轮计算前需要两者都给出结果,但如果不用回调嵌套,实现这种顺序控制比较麻烦。
当然,这和我们的处理方式(共用数组)有关,就这个问题本身而言,caculateHead完全不依赖于任何caculateTail。
这里用的Promis框架是著名的 Q ,可以用npm install q安装。虽然 可用的Promis框架有很多 ,但在它们用法上都大同小异。我们在这里会用到其中的三个方法。
第一个负责将Node.js的CPS函数变成Promise。Node.js核心库和第三方库中有非常多的CPS函数,我们的程序肯定要用到这些函数,要解决回调大坑,就要从这些函数开始。这些函数的回调函数参数大多遵循一个相同的模式,即函数签名为function(err, result)。对于这种函数,可以用简单直接的Q.nfcall和Q.nfapply调用这种Node.js风格的函数返回一个Promise:
return Q.nfcall(FS.readFile, “foo.txt”, “utf-8”);
return Q.nfapply(FS.readFile, [“foo.txt”, “utf-8”]);
也可以用Q.denodeify或Q.nbind创建一个可重用的包装函数,比如:
var readFile = Q.denodeify(FS.readFile);
return readFile(“foo.txt”, “utf-8”);
var redisClientGet = Q.nbind(redisClient.get, redisClient);
return redisClientGet(“user:1:id”);
第二个是then方法,这个方法是Promise对象的核心部件。我们可以用这个方法从异步操作中得到返回值(履约值),或抛出的异常(拒绝的理由)。then方法有两个可选的参数,分别对应Promis对象的两种执行结果。成功时调用的onFulfilled函数,错误时调用onRejected函数:
var promise = asyncFun()
promise.then(onFulfilled, onRejected)
Promise被解决时(异步处理已经完成)会调用onFulfilled 或onRejected 。因为只会有一种可能,所以这两个函数中仅有一个会被触发。尽管then方法的名字让人觉得它跟某种顺序化操作有关,并且那确实是它所承担的职责的副产品,但你真的可以把它当作unwrap来看待。Promise对象是一个存放未知值的容器,而then的任务就是把这个值从Promise中提取出来,把它交给另一个函数。
var promise = readFile()
var promise2 = promise.then(readAnotherFile, console.error)
这个promise表示 onFulfilled 或 onRejected 的返回结果。既然结果只能是其中之一,所以不管是什么结果,Promise都会转发调用:
var promise = readFile()
var promise2 = promise.then(function (data) {
return readAnotherFile() // if readFile was successful, let’s readAnotherFile
}, function (err) {
console.error(err) // if readFile was unsuccessful, let’s log it but still readAnotherFile
return readAnotherFile()
})
promise2.then(console.log, console.error) // the result of readAnotherFile
因为then 返回的是Promise,所以promise可以形成调用链,从而避免出现回调大坑:
readFile()
.then(readAnotherFile)
.then(doSomethingElse)
.then(…)
第三个是all和spread方法。我们可以把几个Promise放到一个数组中,用all将它们变成一个Promise,而spread跟在all后面就相当于then,只是它同时接受几个结果。如果数组中的N个Promise都成功,那spread的onFulfilled参数就能收到对应的N个结果;如果有一个失败,它的onRejected就会得到第一个失败的Promise抛出的错误。
下面是用Q改写的解方程程序代码:
….
var Q = require(‘q’);
var qTail = Q.denodeify(calculateTail);
var qHead = Q.denodeify(calculateHead);
(function calculate(i) {
Q.all([
qTail(res[i],res[i+1]),
qHead(res[i],res[i+1])])
.spread(function(tail,head){
variables[i] = tail;
res[i+1] = head;
return i+1;
})
.then(function(i){
if(i === l-1) {
variables[i] = res[i];
process.exit();
}else {
calculate(i);
}
});
})(0);
function calculateTail(x,y,cb) {
setTimeout(function(){
var tail = (x-y)/2;
cb(null,tail);
},300);
}
function calculateHead(x,y,cb) {
setTimeout(function(){
var head = (x+y)/2;
cb(null,head);
},400);
}
…
注意calculateTail和calculateHead中的cb调用,为了满足denodeify的要求,我们给它增加了值为null的err参数。此外还用到了上面提到的denodeify、all和spread、then。其实除了流程控制,Promise在异常处理上也比回调做得好。甚至有些开发团队坚决反对在代码中使用CPS函数,将Promise作为编码规范强制推行。
再来看一下那个找最大文件的例子用Promise实现的样子:
var fs = require(‘fs’)
var path = require(‘path’)
var Q = require(‘q’)
var fs_readdir = Q.denodeify(fs.readdir) // [1]
var fs_stat = Q.denodeify(fs.stat)
module.exports = function (dir) {
return fs_readdir(dir)
.then(function (files) {
var promises = files.map(function (file) {
return fs_stat(path.join(dir,file))
})
return Q.all(promises).then(function (stats) { // [2]
return [files, stats] // [3]
})
})
.then(function (data) { // [4]
var files = data[0]
var stats = data[1]
var largest = stats
.filter(function (stat) { return stat.isFile() })
.reduce(function (prev, next) {
if (prev.size > next.size) return prev
return next
})
return files[stats.indexOf(largest)]
})
}
这时这个模块的用法变成了:
var findLargest = require(‘./findLargest’)
findLargest(‘./path/to/dir’)
.then(function (er, filename) {
console.log(‘largest file was:’, filename)
})
.fail(function(err){
console.error(err);
});
因为模块返回的是Promise,所以客户端也变成了Promise风格的,调用链中的所有 异常 都可以在这里捕获到。不过Q也有可以支持回调风格函数的 nodeify 方法。
generators
generator科普
在计算机科学中, generator 实际上是一种 迭代器 。它很像一个可以返回数组的函数,有参数,可以调用,并且会生成一系列的值。然而generator不是把数组中的值都准备好然后一次性返回,而是一次yield一个,所以它所需的资源更少,并且调用者可以马上开始处理开头的几个值。简言之,generator看起来像 函数 ,但行为表现像 迭代器 。
Generator也被称为半协程,是协程的一种特例(别协程弱),它总是把控制权交回给调用者(同时返回一个结果值),而不是像协程一样跳转到指定的目的地。如果要说得具体一点儿,就是虽然它们两个都可以yield多次,暂停执行并允许多次进入,但协程可以指定yield之后的去向,而generator不行,它只能把控制权交回给调用者。因为generator主要是为了降低编写迭代器的难度的,所以generator中的yield语句不是用来指明程序要跳到哪里去的,而是用来把值传回给父程序的。
2008年7月, Eich 宣布开始ECMAScript Harmony(即ECMAScript 6)项目,从2011年7月开始,ECMAScript Harmony草案开始定期公布,预计到2014年12月正式发布。generator就是 在这一过程中出现在ECMAScript中的 ,随后不久就 被引入了V8引擎中 。
Node.js对generator的支持是从 v0.11.2 开始的,但因为Harmony还没正式发布,所以需要指明–harmony或–harmony-generator参数启用它。我们用node –harmony进入REPL,创建一个generator:
function* values() {
for (var i = 0; i < arguments.length; i++) {
yield arguments[i];
}
}
注意generator的定义,用的是像函数可又不是函数的function*,循环时每次遇到yield,程序就会暂停执行。那么暂停后,generator何时会再次执行呢?在REPL中执行o.next():
var o = values(1, 2, 3);
o.next();
{ value: 1, done: false }
o.next();
{ value: 2, done: false }
o.next();
{ value: 3, done: false }
o.next();
{ value: undefined, done: true }
第一次执行o.next(),返回了一个对象{ value: 1, done: false },执行到第四次时,value变成了undefined,状态done变成了true。表现得像迭代器一样。next()除了得到generator的下一个值并让它继续执行外,还可以把值传给generator。有些文章提到了send(),不过那是老黄历了,也许你看这篇文章的时候,本文中也有很多内容已经过时了,IT技术发展得就是这样快。我们再看一个例子,还是在REPL中:
function* foo(x) {
yield x + 1;
var y = yield null;
return x + y;
}
再次执行next():
var f = foo(2);
f.next();
{ value: 3, done: false }
f.next();
{ value: null, done: false }
f.next(5);
{ value: 7, done: true }
注意最后的f.next(5),value变成了7,因为最后一个next将5压进了这个generator的栈中,y变成了5。如果要总结一下,那么generator就是:
yield可以出现在任何表达式中,所以可以随时暂停执行,比如foo(yield x, yield y)或在循环中。
调用generator只是看起来像函数,但实际上是创建了一个generator对象。只有调用next才会再次启动generator。next还可以向generator对象中传值。
generator返回的不是原始值,而是有两个属性的对象:value和done。当generator结束时,done会变成true,之前则一直是false。
可是generator和回调大坑有什么关系呢?因为yield可以暂停程序,next可以让程序再次执行,所以只需稍加控制,就能让异步回调代码顺序执行。
用generator平坑
Node.js社区中有很多借助generator实现异步回调顺序化的库,比如 suspend 、co 等,不过我们重点介绍的还是Q。它提供了一个 spawn 方法。这个方法可以立即运行一个generator,并将其中未捕获的错误发给Q.onerror。比如前面那个解方程的函数,用spawn和generator实现就是:
….
(function calculate(i) {
Q.spawn(function* () {
i = yield Q.all([qTail(res[i],res[i+1]),qHead(res[i],res[i+1])])
.spread(function(tail,head){
variables[i] = tail;
res[i+1] = head;
return i+1;
})
if(i === l-1) {
variables[i] = res[i];
console.log(i + “:” + variables[i]);
process.exit();
}else {
calculate(i);
}
});
})(0);
…
代码和前面用Promise实现时并没有太大变化,只是去掉了then,看起来更简单了。不过记得执行时要用>=v0.11.2版本的Node.js,并且要加上–harmony或–harmony-generator。你会看到和前面相同的结果。至于寻找最大文件那个例子,在spawn里定义一个generator,然后在有then的地方放上yield就行了。具体实现就交给你了。
Callback Hell 就是异步回调函数过多的嵌套,导致的代码可读性下降以及出错率提高的问题。比如这样一段代码:
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))
}
})
})
}
})复制代码
嵌套了这么多层的回调之后,我们已经很难清晰的看出这段代码的逻辑了。这就是我们所说的 Callback Hell 了,除了降低代码的可读性之外,还会增加我们代码出错的几率,并且增大调试难度。
如何解决
我们了解这个问题之后,接下来就要看看怎么解决它。 首先咱们先来思考一下软件开发的一个基本原则。 无论是非常复杂的操作系统, 还是到我们一个简单的应用级 App, 其实都遵循这样一个思路, 就是把一个复杂的问题分解成逐个简单的问题, 然后再各个击破。
太多的理论咱们就不再重复了, 就用一个实际的例子来说明问题。
相信大家的 App 或多或少都会需要处理一些数据读取的逻辑, 比如开发一个新闻 App,就会涉及到新闻数据接口的读取。 但任何接口读取都会面临一个问题,就是用户第一次启动 App 的时候,你是没有任何缓存数据的, 这就意味着用户必须进行一个数据加载的过程。 如果用户的网络状况不好, 可能就会在第一次使用后就放弃了你的 App 了。
所以大多数 App 都会采用一个通用做法, 就是每次发布应用之前,都会抓取一份最新的数据放到应用包里面, 这样用户再第一次启动的时候, 就不会出现屏幕上空空如也等待加载的情景了。
我最近也在处理一个类似的情况,如果每次都手动去抓取这些数据,肯定会费时费力,而且容易出错。所以我的计划是来写一个 NodeJS 脚本处理所有的数据抓取和保存流程。 这样,每次发版之前,只需要跑一遍这个脚本就完成了本地数据的更新。
如果你的 App 迭代频率比较高的话, 这个脚本还是很能够提升效率的。
开始规划
开始之前, 首先需要构建好 NodeJS 运行环境。 这个并不复杂, 可以到 https://nodejs.org 主页上面自行补脑, 这里不多赘述。
配置好环境后我们就可以开始了。 如果你没有 NodeJS 的相关经验,也没关系, 更重要分享这个思路。
首先我们想一下, 如果要完成这个数据更新脚本, 那么我们首先应该处理网络请求的逻辑, 那么我们可以这样:
request(url, function(error, response, body){
if(!error && response.statusCode == 200) {
//处理成功
} else {
//报告错误
}
});复制代码
看起来很简单是不是, 数据读取完之后, 我们就该处理 JSON 解析了。 解析完 JSON 后, 如果我们的数据文件中还有要抓取的图片的话,我们还要再次调用网络库去抓取相关的图片, 等等。
如果我们要处理完这一系列后续的逻辑, 我们的代码就会变成和开始提到的 Callback Hell 一样了。而且只会比那段代码更加庞大。假如过段时间后,你再想给这段代码增加后者修改一下功能,那就真的和末日一样了~
Callback Hell 几乎是所有闭包类语言的普遍问题。 JavaScript 中更是体现的淋漓尽致。 还好, 大牛们为我们找到了一些解决方案。 比如 Promise。
Promise
Promise 简单来说,就是把嵌套的式的闭包传递,修改为线性的调用方式。 比如我们刚才的网络请求功能, 我们可以先把它封装成一个 Promise 对象:
function getAPI(url) {
return new PromiseKit(function(fullfill, reject) {
request(url, function(error, response, body){
if(!error && response.statusCode == 200) {
fullfill(body);
} else {
reject(error);
}
});
});
};复制代码
getAPI 方法返回一个 Promise 对象。 PromiseKit 会接受两个闭包, fullfill 和 reject。 fullfill 代表 Promise 执行成功, reject 代表执行失败。 先不需要思考为什么要这样,只要按照 Promise 的规范构造这个对象即可。 像我们上面这样, 如果网络请求成功,就调用 fullfill 并传入得到的数据。 如果请求失败, 则调用 reject 并传入错误信息。
接下来我们这样调用后,就会得到一个 Promise 对象:
var promiseObj = getAPI(“http://swiftcafe.io/xxx”);复制代码
接着我们调用 then 方法来进行后续处理:
promiseObj.then(function(body){
JSON.parse(body);
});复制代码
我们看到,then 方法同样接受一个闭包, 而这个闭包的 body 参数其实就是 getAPI 中 PromiseKit 的 fullfill 闭包调用传递进来的。 也就是说 then 方法之后再 Promise 执行成功的时候才会执行, 还记得之前我们的 Promise 对象接受两个闭包 fullfill 和 reject 么。 只有 Promise 内部的代码执行成功并调用 fullfill 的时候才会执行 then 方法。
then 方法带来的好处是在 callback 的嵌套层数比较多的时候,能给我们提供一个线性的语法结构:
promiseObj.then(function(body){
//…
}).then(function(){
//…
}).then(function(){
//…
}).then(function(){
//…
});复制代码
这样我们的代码逻辑结构就清晰很多了。 相比 callback 模式的这种:
function func(cb) {
//…
request(“network”, function(data){
parse(data, function(parsedData){
//…
cb(parsedData)
});
//…
});
}复制代码
这还只是 callback 模式的一个基本结构, 如果加入了相关的逻辑代码, 代码的就够就会越来越复杂。 这点就是 Promise 模式为我们提供最大的便利。
代码架构设计
Promise 解决了我们遇到的 Callback Hell 的问题, 这属于代码层面的问题。 当我们解决了代码单元测组织问题, 我们不妨再向上思考一下。 现在我们又面临一个新的问题, 就是如何有效的规划逻辑单元,并把它们有效的组织起来。
开发任何程序,最核心的一点就是要把一个复杂的,模糊的逻辑,拆分成多个细小的,具体的逻辑的能力。 还拿我们这个更新数据的小程序为例, 它的整体逻辑是从我们指定的地址读取数据并保存下来。 这个整体逻辑是比较复杂的, 并且模糊的。 我们首先要做的就是需要把这个整体的逻辑进行拆分。
我们可以思考一下,如果完成这个逻辑,我们首先要做什么。 首先,我们需要进行网络通信读取数据,然后我们还需要对读取的数据进行解析。接下来,就需要对解析后的数据进行分析, 如果数据中包含图片地址,我们是否需要把这些图片一同缓存下来?
当这些解析操作处理完成后, 我们还需要确定这些数据我们如何保存下来, 比如写文件。 当这些基本架构确定后, 我们可以规划一下代码结构, 这里使用 JavaScript 的面向对象特性:
function PromiseHelper() {
}
PromiseHelper.prototype.requestJSON = function (url) {
}
PromiseHelper.prototype.downloadImage = function(item, imageFolder, funcImgURL, funcImgName, funcSetImgURL) {
}
PromiseHelper.prototype.readDir = function(path) {
}
PromiseHelper.prototype.unlinkFile = function(filePath) {
}
PromiseHelper.prototype.clearFolder = function(folderPath) {
}复制代码
大家看到,我们这里定义了一个 PromiseHelper 类。 并指定了一些逻辑单元,把他们封装成方法。 这些方法都会返回 Promise 对象, 所以不会发生 callback hell。 我们这里只说明思路,所以具体方法内部的代码就省略啦。
基本逻辑单元定义完成后,我们就需要将这些逻辑单元有效的组织起来,所以我们再定义一个 DataManager 类:
/**
首先我们调用 promise.requestJSON(apiURL) 来异步请求 JSON 数据,接着使用 then 方法继续接下来的操作, 首先调用:
promise.clearFolder(imageFolder)复制代码
用来在更新数据前,清理图片目录。 然后继续调用 then 方法:
PromiseKit.all(items.map(function(item){
return promise.downloadImage(item, imageFolder, cbImgURL, cbImgName, cbSetImgURL);
})).done(function(){
//…
});复制代码
items 是解析后的数据条目, 比如我们的数据是新闻, items 就是每一个新闻条目,包括它的标题, 图片地址等等。
PromiseKit.all 这个方法也很有意思, 它会接受一个数组, 并且会等待这个数组中的所有 Promise 对象都成功完成后,才会继续执行。
我们这里比较灵活的运用了它的这个特性, 我们使用 map 方法,对 items 中的所有条目都调用 promise.downloadImage 方法,而这个方法在结束后会返回一个 Promise 对象。
也就是说我们会对所有的新闻条目都调用下载图片的逻辑, 并且返回一个 Promise 对象的数组,只有这个数组中的所有对象都成功完成后, PromiseKit.all 才会调用后面的 done 方法进行后续操作。
我们再来看看 done 方法中都干了什么:
fs.writeFile(path.join(jsonPath, jsonFileName), JSON.stringify(items), function(err) {
if (err) {
console.log(“保存文件失败。”);
reject();
} else {
console.log(“成功”);
fullfill();
}
});复制代码
也并不复杂, 当所有新闻的图片都下载完成后, done 方法会将 JSON 数据保存到本地文件中。
到这里,我们看到了如何将一个模糊的逻辑拆分成一些具体的明确的简单逻辑单元。 并且通过控制层将这些逻辑单元有效的组织起来。这样的方式会让我们的代码变得更加健壮, 相比直接开发整个逻辑,开发一个细小的逻辑单元可以非常大的降低人的出错几率。 并且合理的划分逻辑单元, 还可以将这些单元再次重组成新的逻辑流程,帮助我们提升开发效率。
即将完成
现在,我们的代码结构都设计好了, DataManager 类提供了我们这个小程序的完整逻辑。 当然,我们还可以把它设计的更完善一些。 比如我们要更新处理的数据结构不止一个的时候, 就更能体现出模块化设计的好处了。 我们只需要简单的将不同数据接口的配置维护好, 我们可以定义这样一个函数:
var dm = new DataManager();
function enqueue(queue, options) {
queue.push(function(cb){
dm.updateContent(options).then(function(){ cb(); });
});
}复制代码
这里我们用了 nodejs 的 queue 库。 无序过多考虑细节, 我们只需要知道 queue 可以实现任务队列即可。 enqueue 函数的实现也不复杂, 只是将 DataManager 的任务封装到 queue 队列中。
接下来我们就可以将需要执行的任务添加进来了:
enqueue(taskQueue, {
“url”: “http://api.swiftcafe.io/news”,
“SavedJSONFolder”: “../data/”,
“SavedImgFolder” : “../images/”,
“savedFileName” : “news.json”,
“cbImgURL” : function(item){
//…
},
“cbImgName” : function(item){
//…
},
“cbSetImgURL” : function(item){
//…
}
});
enqueue(taskQueue, {
“url”: “http://api.swiftcafe.io/videos”,
“SavedJSONFolder”: “../data/”,
“SavedImgFolder” : “../videoImages/”,
“savedFileName” : “videos.json”,
“cbImgURL” : function(item){
//…
},
“cbImgName” : function(item){
//…
},
“cbSetImgURL” : function(item){
//…
}
});复制代码
当队列配置完成后, 我们就可以开始执行队列任务了:
taskQueue.start(function(){
console.log(“全部完成”);
});复制代码
同样也很简单。 这样,我们的整个任务就开始执行了。 并且当他们都完成后,taskQueue.start 所接受的闭包就会被调用,最后输出任务完成的消息。
结尾
这次主要跟大家分享的是这种开发思维, 通过一定的模块设计, 不但能够让我们的程序变得更加易懂,而且会显著的增加我们的开发效率。 它适用于任何具体的语言或者平台。 经过几次的优化,其实并没有结束, 一个好的程序会一直都在不断的优化,变得越来越好。 最后奉上完整代码, 大家可以在 Github 上面自由研究 https://github.com/swiftcafex/updator, 或者提出你的优化方案。