你有没有想过,你写出来的代码可能你自己都无法预测,而这常常出现在有另外的“黑箱子”(第三方库或自己曾封装过的代码)参与进来的情况。当然,如果这个“黑箱子”是同步运行的还好,毕竟只要“黑箱子”内部的逻辑正确,那么就不会有什么太大问题。可是,如果“黑箱子”内部有异步的代码,这时候就会有麻烦了,因为你可能不会知道“黑箱子”内部是如何对异步进行处理的,即便知道,“黑箱子”内部对回调函数的处理也会让你不得不遵从,因为你没法控制“黑箱子”对回调函数的处理。当然,以上的情况仅仅是发生在单纯地使用传入回调函数的方式,而不是 Promise……
什么是控制权?
要说什么是控制权,就免不了谈谈 JavaScript 的单线程以及 JavaScript 的异步原理,虽然大家可能都明白了,但这里还是详细说明一下。
单线程的 JavaScript
JavaScript 语言本身是单线程的,也就是说“只有一个线程去执行 JavaScript 代码”,但现代浏览器光执行 JavaScript 代码没什么意思,毕竟 JavaScript 本身没有网络库、也没有监控浏览器的能力、也没有自己的定时器,无法完成丰富的浏览器交互。
这怎么办呢?浏览器是这么做的:既然 JavaScript 本身没有网络库,那么浏览器就开辟一个线程去请求网络数据,JavaScript 执行线程只要告诉网络线程请求完数据调用什么函数即可,网络线程在请求到数据后则将数据作为函数的参数,并把函数放到“事件循环队列中去”,这个函数就等待着 JavaScript 执行线程回过来调用它(所以这个函数才叫回调函数)。这一切都表现为一个 API,只要 JavaScript 执行线程内调用了这个 API,就会发生这么一个过程。
为了进一步理解,我们来举一个看得见摸得着的例子:
function apple () {
setTimeout(function foo () {
console.log('apple')
}, 1000)
}
apple()
// 其他代码
首先,浏览器为执行 JavaScript 开辟一个线程,代码在这个线程里执行:定义 apple 函数,调用 apple 函数,调用浏览器暴露的 API setTimeout(到这里,我们先标记一下这个位置),然后 JavaScript 线程就去执行其他代码了。
然后,在上面标志的位置处,浏览器根据 JavaScript 执行线程里面调用的 API 在浏览器里面开辟了一个定时器线程,这个线程的作用是用来计时的,过了 1000ms 之后呢,这个线程要把 foo 函数放入到一个叫“事件循环队列”的地方去。
最后,JavaScript 执行线程会回过来调用 foo。
正是因为浏览器里面很多不同线程与 JavaScript 执行线程之间的配合,才有了多彩多姿的交互。
控制权的转移
从上面的讲述中,我们仔细看一下,就能发现一些问题,完成一个异步的过程,需要经过至少两个线程:
- 网络请求过程分别经过了 JavaScript 执行线程 —— 网络请求线程 —— JavaScript 执行线程
- 定时回调的过程分别经过了 JavaScript 执行线程 —— 定时器线程 —— JavaScript 执行线程
JavaScript 执行线程的状况由我们的代码控制,网络请求线程和定时器线程我们是无法控制的,由浏览器去控制,所以上述过程实际上也是控制权转移的过程,我们把类似于控制权从 JavaScript 执行线程转移到网络请求线程的状态变化称为“控制反转”。如果我们回调的时候不做审核,那么代码实际上也是不可控了,毕竟控制权不在自己手上,其他线程万一出错了,你也就糊里糊涂跟着错了,比如定时器产生了误差,回调函数也就不会准时调用了。这也是单纯的使用回调函数所遇到的问题!
广义上说,凡是某个过程中,发生了失去对过程控制的情况,都可以被认为控制权转移了,比如,你使用了第三方库,你使用了自己封装的模块,可能你根本已经不清楚内部会做什么处理,这时候,你调用这些东西的时候,你就会失去对代码的控制,失去对代码的控制是相当可怕的事,这意味着你根本无法预测代码会如何执行。如果说你使用的第三方库或模块是同步的,那还好些,至少你可以从输入输出方面确定这个库或模块是否可靠,毕竟同步库或模块有唯一的输入就能够确定唯一的输出,因为 JavaScript 是单线程的,情况非常好判断。但是如果你的库或模块是异步的,那么就非常不好控制了,你的控制权落在这些库的手里就麻烦了。
失去控制权的悲剧
由于控制权落入到同步的库和模块没那么悲剧,我们就专门讨论一下控制权落入异步库和模块会发生的事情,当然,我们也会有办法拿回控制权。首先,异步库或模块肯定会要传一个“回调函数”进去,而这个“回调函数”将会在异步库或模块调用,调用逻辑我们全然不知,毕竟调用的控制权在库或模块那里;其次,如果这个回调函数调用过程中出错了,那么我们也没法控制;最后如果这个异步库有时候是同步的行为,我们也会遭殃!
调用回调函数过早
这种情况发生在什么时候呢?我们来模拟一下:
// 结果已经被缓存,模拟的
var cache = 'apple'
/**
* 模拟网络请求的异步库,这个库的特点是根据有无缓存再确定是否会发起请求
* @param {string} url 请求的 URL
* @param {Function} callback 回调函数
* @return {string} 返回数据
*/
var ajax = function ajax (url, callback) {
if (url === 'a.com') {
// 根据是否有缓存确定方案
if (cache) {
callback(cache)
} else {
setTimeout(function apple () {
callback(cache = 'apple')
}, 1000)
}
}
}
/* --------------------------------------- */
// 正式的代码
ajax('a.com', function (response) {
// 我们认为这是异步执行,name 是全局的
console.log(name + response)
})
// ... 其他代码
// 我们认为上面是异步的代码,假设 name 在这里得到
var name = '我的名字叫'
我们以为库 ajax 一直表现为异步的状态,却未曾想过其内部会做缓存处理,所以,这次调用中,它表现为同步调用回调函数,可以说回调函数被过早调用了,因为我们还没有获得 name 的值呢,所以它输出的是 apple 而不是我们认为的 我的名字叫apple,这只是个简单的例子,却经常遇到:我们要在回调函数里面调用上级作用域的变量,但你能保证那个变量已经拥有了吗?你能保证这个回调函数不被过早调用吗?你根本不知道异步库会干什么。
当然,你可以通过改造回调函数的代码来避免这个问题:
ajax('a.com', function (response) {
// 我们认为这是异步执行,name 是全局的
// 加这个 hack 就能保证一直异步执行
setTimeout(function () {
console.log(name + response)
}, 0)
})
多么丑陋的代码,不过这个思路很好,相当于把控制权拿回来了:我不知道你异不异步,反正回调的时候,我自己异步一次。我想,你可以用 Promise 解决这个问题:
new Promise((resolve, reject) => {
ajax('a.com', function (response) {
resolve(response)
})
})
// then 里面的函数总是异步调用
.then((res) => {
console.log(name + res)
}, (err) => {
console.log(err)
})
我们这里只看 Promise 给我们带来的帮助解决这个问题的好处,无论库或模块是异步还是同步返回结果,回调函数都会返回一个 Promise 决议,而通过 then 注册的方法永远都只会异步调用,正因为 then 这个特性,帮我们解决了“调用过早”的问题。
当然,上面的代码依然很不好看,如果 ajax 本身支持 Promise 会好得多,当然,对于不支持的 Promise 的库,你可以自己写个函数转换一下,变成支持 Promise 的库,一般第三方 Promise 库都会有 Promise.wrap 提供转换。
重复调用回调函数
假设一个库里面做了一种这样的机制:发出一个请求的同时设定一个定时器,如果超过 100ms 请求还没有返回数据,那么再发出一个请求;如果请求有返回了,就取消定时器。那么,回调函数就有可能被调用两次!还是模拟一下吧:
// 模拟的库
var ajax = function ajax (url, callback) {
// 不知道请求要多久
var delay = Math.random() * 200
if (url === 'a.com') {
// 设置超时定时器
var number = setTimeout(function () {
// 重新发起请求
ajax(url, callback)
}, 100)
// 发起请求
setTimeout(function () {
// 得到请求后取消定时器
clearTimeout(number)
// 调用回调
callback('apple')
}, delay)
}
}
/* --------------------------------------- */
// 正式的代码
// 你很信任这个库,可是结果总是不确定的
ajax('a.com', function (res) {
console.log(res)
})
控制权在库和网络线程手里,你必须把控制权拿回来,不能让结果不确定,你可以这样写:
// 把控制权拿回来,如果这个值存在了
// 回调就相当于调了个空函数
var isCall
ajax('a.com', function (res) {
if (!isCall) {
console.log(res)
// 执行过了
isCall = true
}
})
终于让结果唯一了,等等,Promise 能帮我解决吗?当然可以,你只需要和上面一模一样的代码(这里就不贴了),为什么呢?因为 Promise 一旦决议,那么值就不会发生变化,也不会再决议一次,所以,你的回调函数只会执行一次!(关于 Promise 假定大家都知道怎么用了,限于篇幅,就不细讲了)
迟迟不调用回调函数导致挂起
我们有时候也会遇到回调函数一直不被调用,这是什么情况呢?这是因为你的库没有做超时处理:网络请求线程迟迟没有响应,库也不做超时处理,就会导致回调函数里面的异步调用一直不被执行。控制权不再自己手里,真的很悲剧:
// 模拟的可恶的库
var ajax = function (url, callback) {
if (url === 'a.com') {
// 模拟特别耗时的请求
setTimeout(function () {
callback('apple')
}, 100000000000000000)
}
}
/* --------------------------------------- */
// 正式的代码
ajax('a.com', function (res) {
// 迟迟未调用
console.log(res)
})
我们仍然需要把控制权拿回来,即使回调函数还没被调用,但已经过了我们规定的时间的话,我们就需要让回调没有意义。我们来用 Promise 处理一下:
// 定义一个计时函数,返回 Promise
var timeOuted = function (time) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
reject('超时')
}, time)
})
}
// 定义一个请求函数,返回 Promise
var request = function (url) {
return new Promise(function(resolve, reject) {
ajax(url, function (res) {
resolve(res)
})
})
}
// 设定一个请求后的决议
var p1 = request('a.com')
// 设定一个超时后的决议
var p2 = timeOuted(1000)
// 竞时
Promise.race([p1, p2]).then((res) => {
console.log(res)
}, (err) => {
console.log(err)
})
这是一个竞时的过程,Promise.race 参数数组里边是很多单独的 Promise,如果其中有一个 Promise 决议了,那么其他 Promise 都会被忽略,而 Promise.race 返回的就是最先决议的 Promise,然后使用 then 方法的时候会异步调用里面的回调函数,这就有效防止了超时的情况。其实,自己创建的超时函数也是个异步函数,两个异步线程同时在进行的情况我们称为 “并行”,这个后面再说。
调用回调函数的过程中出错
库或模块在调用回调函数的时候,如果库对返回参数判断有误,或者做了不当的限制,那么就有可能回调的时候出现错误。更常见的错误是调用回调函数的时候,库对错误进行了隐藏,其实回调函数已经执行失败了,但是,你却在外部看不到,这个就不进行模拟了,总之,你需要一些技巧来避免。当然,你可以使用 Promise 来规避这些问题,为什么呢?因为 Promise 要求你必须有个决议,而且 Promise 也有错误传递机制,并且 Promise 决议的值只能是一个,也就是 resolve(1, 2, 3) 或 resolve(1); resolve(2) 都只会将决议值设为一,其余的全部忽略,对于错误,then 方法里面会注册一个错误处理回调,只要你的决议是拒绝,就会进行错误处理,这样优秀的 API 机制,根本不用担心一些问题!
结语
异步过程中控制权经常不在我们自己写的代码里,回调函数这种机制并没有原生的帮我们解决,但是 Promise 这种机制很好的帮我们拿回了许多控制权,使得代码变得可预测,这也是我们为什么要用 Promise 的原因之一。讲到这里,我们一直都在围绕单个异步过程进行讨论,当然我们也发现了 Promise 在其中起到的举足轻重的作用,但是,Promise 的功劳远不止于此,对于多个异步线程并行时的处理,Promise 也很优秀,下次再说吧!