从我们一开始学习 JavaScript 的时候就听到过一段话:JS 是单线程的,天生异步,适合 IO 密集型,不适合 CPU 密集型。但是,多数 JavaScript 开发者从来没有认真思考过自己程序中的异步到底是怎么出现的,以及为什么会出现,也没有探索过处理异步的其他方法。到目前为止,还有很多人坚持认为回调函数就完全够用了。
但是,随着 JavaScript 面临的需求越来越多,它可以运行在浏览器、服务器、甚至是嵌入式设备上,为了满足这些需求,JavaScript 的规模和复杂性也在持续增长,使用回调函数来管理异步也越来越让人痛苦,这一切,都需要更强大、更合理的异步方法,通过这篇文章,我想对目前已有 JavaScript 异步的处理方式做一个总结,同时试着去解释为什么会出现这些技术,让大家对 JavaScript 异步编程有一个更宏观的理解,让知识变得更体系化一些。
回调函数大家肯定都不陌生,从我们写一段最简单的定时器开始:
setTimeout(function () {console.log('Time out');
}, 1000);
定时器里面的匿名函数就是一个回调函数,因为在 JS 中函数是一等公民,所以它可以像其他变量一样作为参数进行传递。这样看来,通过回调函数来处理异步挺好的,写着也顺手,为什么要用别的方法呢?
我们来看这样一个需求:
上面是微信小程序的登录时序图,我们的需求和它类似但又有些差别,想要获取一段业务数据,整个过程分为 3 步:
- 调用秘钥接口,获取 key
- 携带 key 调用登录接口,获取 token 和 userId
- 携带 token 和 userId 调用业务接口,获取数据
可能上述步骤和实际业务中的有些出入,但是却可以用来说明问题,请大家谅解。
我们写一段代码来实现上述需求:
let key, token, userId;
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
key = data;
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {key: key},
success: function (data) {
token = data.token;
userId = data.userId;
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {console.log('业务数据:', data);
},
error: function (err) {console.log(err);
}
});
},
error: function (err) {console.log(err);
}
});
},
error: function (err) {console.log(err);
}
});
可以看到,整段代码充满了回调嵌套,代码不仅在纵向扩展,横向也在扩展。我相信,对于任何人来说,调试起来都会很困难,我们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以查看流程,而最终的结果藏在整段代码的中间位置。真实的 JavaScript 程序代码可能要混乱的多,使得这种追踪难度会成倍增加。这就是我们常说的 回调地狱(Callback Hell)
。
为什么会出现这种现象?
如果某个业务,依赖于上层业务的数据,上层业务又依赖于更上一层的数据,我们还采用回调的方式来处理异步的话,就会出现回调地狱。
大脑对于事情的计划方式是线性的、阻塞的、单线程的语义,但是回调表达异步流程的方式是非线性的、非顺序的,这使得正确推导这样的代码的难度很大,很容易产生 Bug。
这里我们引出了回调函数解决异步的 第 1 个问题:回调地狱。
回调函数还会存在别的问题吗?
让我们再深入思考一下回调的概念:
// A
$.ajax({
...
success: function (...) {// C}
});
// B
A 和 B 发生于现在,在 JavaScript 主程序的直接控制之下,而 C 会延迟到将来发生,并且是在第三方的控制下,在本例中就是函数$.ajax(…)
。从根本上来说,这种控制的转移通常不会给程序带来很多问题。
但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候ajax(…)
,也就是你交付回调函数的第三方不是你编写的代码,也不在你的直接控制之下,它是某个第三方提供的工具。
这种情况称为 控制反转,也就是把自己程序一部分的执行控制交给某个第三方,在你的代码和第三方工具直接有一份并没有明确表达的契约。
既然是无法控制的第三方在执行你的回调函数,那么就有可能存在以下问题,当然通常情况下是不会发生的:
- 调用回调过早
- 调用回调过晚
- 调用回调次数太多或者太少
- 未能把所需的参数成功传给你的回调函数
- 吞掉可能出现的错误或异常
- ……
这种 控制反转 会导致信任链的完全断裂,如果你没有采取行动来解决这些控制反转导致的信任问题,那么你的代码已经有了隐藏的 Bug,尽管我们大多数人都没有这样做。
这里,我们引出了回调函数处理异步的 第二个问题:控制反转。
综上,回调函数处理异步流程存在 2 个问题:
- 缺乏顺序性:回调地狱导致的调试困难,和大脑的思维方式不符
- 缺乏可信任性:控制反转导致的一系列信任问题
那么如何来解决这两个问题,先驱者们开始了探索之路……
开门见山,Promise 解决的是回调函数处理异步的第 2 个问题:控制反转。
至于 Promise 是什么,大家肯定都有所了解,这里是 PromiseA+ 规范,ES6
的 Promise
也好,jQuery
的 Promise
也好,不同的库有不同的实现,但是大家遵循的都是同一套规范,所以,Promise 并不指特定的某个实现,它是一种规范,是一套处理 JavaScript 异步的机制。
我们把上面那个多层回调嵌套的例子用 Promise 的方式重构:
let getKeyPromise = function () {return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
let key = data;
resolve(key);
},
error: function (err) {reject(err);
}
});
});
};
let getTokenPromise = function (key) {return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {key: key},
success: function (data) {resolve(data);
},
error: function (err) {reject(err);
}
});
});
};
let getDataPromise = function (data) {
let token = data.token;
let userId = data.userId;
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {resolve(data);
},
error: function (err) {reject(err);
}
});
});
};
getKeyPromise()
.then(function (key) {return getTokenPromise(key);
})
.then(function (data) {return getDataPromise(data);
})
.then(function (data) {console.log('业务数据:', data);
})
.catch(function (err) {console.log(err);
});
可以看到,Promise 在一定程度上其实改善了回调函数的书写方式,最明显的一点就是去除了横向扩展,无论有再多的业务依赖,通过多个 then(…)来获取数据,让代码只在纵向进行扩展;另外一点就是逻辑性更明显了,将异步业务提取成单个函数,整个流程可以看到是一步步向下执行的,依赖层级也很清晰,最后需要的数据是在整个代码的最后一步获得。
所以,Promise 在一定程度上解决了回调函数的书写结构问题,但回调函数依然在主流程上存在,只不过都放到了 then(…)里面,和我们大脑顺序线性的思维逻辑还是有出入的。
这里我想主要讨论的是,Promise 是如何解决控制反转带来的信任缺失问题。
首先明确一点,Promise
可以保证以下情况,引用自 JavaScript | MDN:
- 在 JavaScript 事件队列的当前运行完成之前,回调函数永远不会被调用
- 通过 .then 形式添加的回调函数,甚至都在异步操作完成之后才被添加的函数,都会被调用
- 通过多次调用 .then,可以添加多个回调函数,它们会按照插入顺序并且独立运行
下面我们针对前面提过的回调函数处理异步导致的一系列信任问题来讨论,如果是用 Promise 来处理,是否还会存在这些问题,当然前提是实现的 Promise 完全遵循 PromiseA+ 规范。
调用过早
当使用回调函数的时候,我们无法保证或者不知道第三方对于回调函数的调用是何种形式的,如果它在某种情况下是立即完成以同步的方式来调用,那可能就会导致我们代码中的逻辑错误。
但是,根据 PromiseA+ 规范,Promise
就不必担心这种问题,因为即使是立即完成的Promise
(类似于new Promise(function (resolve, reject) {resolve(2);}))
,也无法被同步观察到。
也就是说,对一个 Promise
调用 then(…)
的时候,即使这个 Promise
已经决议,提供给 then(…)
的回调也总会在 JavaScript 事件队列的当前运行完成后,再被调用,即异步调用。
调用过晚
当 Promise 创建对象调用 resolve(…)
或reject(…)
时,这个 Promise
通过 then(…)
注册的回调函数就会在下一个异步时间点上被触发。
并且,这个 Promise
上的多个通过 then(…)
注册的回调都会在下一个异步时间点上被依次调用,这些回调中的任意一个都无法影响或延误对其他回调的调用。
举例如下:
p.then(function () {p.then(function () {console.log('C');
});
console.log('A');
})
.then(funtion () {console.log('B');
});
// 打印 A B C
通过这个例子可以看到,C 无法打断或抢占 B,所以 Promise 没有调用过晚的现象,只要你注册了 then(…),就肯定会按顺序依次调用,因为这就是 Promise 的运作方式。
回调未调用
没有任何东西(甚至 JavaScript 错误)能阻止 Promise 向你通知它的决议(如果它决议了的话)。如果你对一个 Promise 注册了一个成功回调和拒绝回调,那么 Promise 在决议的时候总会调用其中一个。
当然,如果你的回调函数本身包含 JavaScript 错误,那可能就会看不到你期望的结果,但实际上回调还是被调用了。
p.then(function (data) {console.log(data);
foo.bar(); // 这里没有定义 foo,所以这里会报 Type Error, foo is not defined}, function (err) {});
调用次数太多或者太少
根据 PromiseA+ 规范,回调被调用的正确次数应该是 1 次。“太少”就是不调用,前面已经解释过了。
“太多”的情况很容易解释,Promise 的定义方式使得它只能被决议一次。如果处于多种原因,Promise 创建代码试图调用多次 resolve(…)或 reject(…),或者试图两者都调用,那么这个 Promise 将只会接受第一次决议,并默默忽略任何后续调用。
由于 Promise 只能被决议一次,所以任何通过 then(…)注册的回调就只会被调用一次。
未能传递参数值
如果你没有把任何值传递给 resolve(…)或 reject(…),那么这个值就是 undefined。但不管这个值是什么,它都会被传给所有注册在 then(…)中的回调函数。
如果使用多个参数调用 resolve(…)或 reject(…),那么第一个参数之后的所有参数都会被忽略。如果要传递多个值,你就必须把它们封装在单个值中进行传递,比如一个数组或对象。
吞掉可能出现的错误或异常
如果在 Promise 的创建过程中或在查看其决议结果的过程中的任何时间点上,出现了一个 JavaScript 异常错误,比如一个 TypeError 或 ReferenceError,这个异常都会被捕捉,并且会使这个 Promise 被拒绝。
举例如下:
var p = new Promise(function (resolve, reject) {foo.bar(); // foo 未定义
resolve(2);
});
p.then(function (data) {console.log(data); // 永远也不会到达这里
}, function (err) {console.log(err); // err 将会是一个 TypeError 异常对象来自 foo.bar()这一行});
foo.bar()中发生的 JavaScript 异常导致了 Promise 的拒绝,你可以捕捉并对其作出响应。
不是所有的 thenable 都可以信任
到目前为止,我们讨论了使用 Promise 可以避免上述多种由控制反转导致的信任问题。但是,你肯定也注意到了,Promise 并没有完全摆脱回调,它只是改变了传递回调的位置。我们并不是把回调传递给 foo(…)让第三方去执行,而是从 foo(…)得到某个东西(Promise 对象),然后把回调传递给这个东西。
但是,为什么这就比单纯使用回调更值得信任呢?如何能够确定返回的这个东西实际上就是一个可信任的 Promise 呢?
Promise 对于这个问题已经有了解决方案,ES6 实现的 Promise 的解决方案就是 Promise.resolve(…)。
如果向 Promise.resolve(…)传递一个非 Promise,非 thenable 得立即值,就会得到一个用这个值填充的 Promise。
举例如下:
var p1 = new Promise(function (resolve, reject) {resolve(2);
});
var p2 = Promise.resolve(2);
// 这里 p1 和 p2 的效果是一样的
而如果向 Promise.resolve(…)传递一个真正的 Promise,就只会返回同一个 Promise。
举例如下:
var p1 = Promise.resolve(2);
var p2 = Promise.resolve(p1);
p1 === p2; // true
更重要的是,如果向 Promise.resolve(…)传递了一个非 Promise 的 thenable 值,前者就会试图展开这个值,而且展开过程中会持续到提取出一个具体的非类 Promise 的最终值。
举例如下:
var p = {then: function (cb, errCb) {cb(2);
errCb('haha');
}
};
// 这可以工作,因为函数是一等公民,可以当做参数进行传递
p.then(function (data) {console.log(data); // 2
}, function (err) {console.log(err); // haha
});
这个 p 是一个 thenable,但不是一个真正的 Promise,其行为和 Promise 并不完全一致,它同时触发了成功回调和拒绝回调,它是不可信任的。
尽管如此,我们还是都可以把这样的 p 传给 Promise.resolve(…),然后就会得到期望中的规范化后的安全结果:
Promise.resolve(p)
.then(function (data) {console.log(data); // 2
}, function (err) {console.log(err); // 永远不会到达这里
});
因为前面讨论过,一个 Promise 只接受一次决议,如果多次调用 resolve(…)
或reject(…)
,后面的会被自动忽略。
Promise.resolve(…)可以接受任何 thenable,将其解封为它的非 thenable 值。从 Promise.resolve(…)得到的是一个真正的 Promise,是一个可以信任的值。如果你传入的已经是真正的 Promise,那么你得到的就是它本身,所以通过 Promise.resolve(…)过滤来获得可信任性完全没有坏处。
综上,我们明确了,使用 Promise 处理异步可以解决回调函数控制反转带来的一系列信任问题。
很好,我们又向前迈了一步。
在 Step1 中,我们确定了用回调表达异步流程的两个关键问题:
- 基于回调的异步不符合大脑对任务步骤的规范方式
- 由于控制反转,回调并不是可信任的
在 Step2 中,我们详细介绍了 Promise 是如何把回调的控制反转又反转过来,恢复了可信任性。
现在,我们把注意力转移到一种顺序、看似同步的异步流程控制表达风格,这就是ES6 中的生成器(Gererator)。
可迭代协议和迭代器协议
了解 Generator 之前,必须先了解 ES6 新增的两个协议:可迭代协议和迭代器协议。
可迭代协议
可迭代协议 运行 JavaScript 对象去定义或定制它们的迭代行为,例如(定义)在一个 for…of 结构中什么值可以被循环(得到)。以下内置类型都是内置的可迭代对象并且有默认的迭代行为:
- Array
- Map
- Set
- String
- TypedArray
- 函数的 Arguments 对象
- NodeList 对象
注意,Object 不符合可迭代协议。
为了变成可迭代对象,一个对象必须实现 @@iterator
方法,意思是这个对象(或者它原型链 prototype chain
上的某个对象)必须有一个名字是 Symbol.iterator
的属性:
属性 |
值 |
[Symbol.iterator] |
返回一个对象的无参函数,被返回对象符合迭代器协议 |
当一个对象需要被迭代的时候(比如开始用于一个 for…of 循环中),它的 @@iterator 方法被调用并且无参数,然后返回一个用于在迭代中获得值的迭代器。
迭代器协议
迭代器协议 定义了一种标准的方式来产生一个有限或无限序列的值。
当一个对象被认为是一个迭代器时,它实现了一个 next()的方法并且拥有以下含义:
属性 |
值 |
next |
返回一个对象的无参函数,被返回对象拥有两个属性 |
- done(boolean)
- 如果迭代器已经经过了被迭代序列时为 true。这时 value 可能描述了该迭代器的返回值
- 如果迭代器可以产生序列中的下一个值,则为 false。这等效于连同 done 属性也不指定。
- value
- 迭代器返回的任何 JavaScript 值。done 为 true 时可以忽略。|
使用可迭代协议和迭代器协议的例子:
var str = 'hello';
// 可迭代协议使用 for...of 访问
typeof str[Symbol.iterator]; // 'function'
for (var s of str) {console.log(s); // 分别打印 'h'、'e'、'l'、'l'、'o'
}
// 迭代器协议 next 方法
var iterator = str[Symbol.iterator]();
iterator.next(); // {value: "h", done: false}
iterator.next(); // {value: "e", done: false}
iterator.next(); // {value: "l", done: false}
iterator.next(); // {value: "l", done: false}
iterator.next(); // {value: "o", done: false}
iterator.next(); // {value: undefined, done: true}
我们自己实现一个对象,让其符合可迭代协议和迭代器协议:
var something = (function () {
var nextVal;
return {
// 可迭代协议,供 for...of 消费
[Symbol.iterator]: function () {return this;},
// 迭代器协议,实现 next()方法
next: function () {if (nextVal === undefined) {nextVal = 1;} else {nextVal = (3 * nextVal) + 6;
}
return {value: nextVal, done: false};
}
};
})();
something.next().value; // 1
something.next().value; // 9
something.next().value; // 33
something.next().value; // 105
用 Generator 实现异步
如果我们用 Generator 改写上面回调嵌套的例子会是什么样的呢?见代码:
function getKey () {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
key = data;
it.next(key);
}
error: function (err) {console.log(err);
}
});
}
function getToken (key) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {key: key},
success: function (data) {
loginData = data;
it.next(loginData);
}
error: function (err) {console.log(err);
}
});
}
function getData (loginData) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: loginData.token,
userId: loginData.userId
},
success: function (busiData) {it.next(busiData);
}
error: function (err) {console.log(err);
}
});
}
function *main () {let key = yield getKey();
let LoginData = yield getToken(key);
let busiData = yield getData(loginData);
console.log('业务数据:', busiData);
}
// 生成迭代器实例
var it = main();
// 运行第一步
it.next();
console.log('不影响主线程执行');
我们注意 *main()
生成器内部的代码,不看 yield
关键字的话,是完全符合大脑思维习惯的同步书写形式,把异步的流程封装到外面,在成功的回调函数里面调用 it.next(),将传回的数据放到任务队列里进行排队,当 JavaScript 主线程空闲的时候会从任务队列里依次取出回调任务执行。
如果我们一直占用 JavaScript 主线程的话,是没有时间去执行任务队列中的任务:
// 运行第一步
it.next();
// 持续占用 JavaScript 主线程
while(1) {}; // 这里是拿不到异步数据的,因为没有机会去任务队列里取任务执行
综上,生成器 Generator
解决了回调函数处理异步流程的第一个问题:不符合大脑顺序、线性的思维方式。。
上面我们介绍了 Promise
和Generator
,把这两者结合起来,就是Async/Await
。
Generator 的缺点是还需要我们手动控制 next()执行 ,使用Async/Await
的时候,只要 await
后面跟着一个 Promise
,它会自动等到Promise
决议以后的返回值,resolve(…)
或者 reject(…)
都可以。
我们把最开始的例子用 Async/Await
的方式改写:
let getKeyPromise = function () {return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
let key = data;
resolve(key);
},
error: function (err) {reject(err);
}
});
});
};
let getTokenPromise = function (key) {return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {key: key},
success: function (data) {resolve(data);
},
error: function (err) {reject(err);
}
});
});
};
let getDataPromise = function (data) {
let token = data.token;
let userId = data.userId;
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {resolve(data);
},
error: function (err) {reject(err);
}
});
});
};
async function main () {let key = await getKeyPromise();
let loginData = await getTokenPromise(key);
let busiData = await getDataPromise(loginData);
console.log('业务数据:', busiData);
}
main();
console.log('不影响主线程执行');
可以看到,使用 Async/Await,完全就是同步的书写方式,逻辑和数据依赖都非常清楚,只需要把异步的东西用 Promise 封装出去,然后使用 await 调用就可以了,也不需要像 Generator 一样需要手动控制 next()执行。
Async/Await 是 Generator 和 Promise 的组合,完全解决了基于回调的异步流程存在的两个问题,可能是现在最好的 JavaScript 处理异步的方式了。
本文通过四个阶段来讲述 JavaScript 异步编程的发展历程:
-
第一个阶段 – 回调函数,但会导致两个问题:
- 缺乏顺序性:回调地狱导致的调试困难,和大脑的思维方式不符
- 缺乏可信任性:控制反转导致的一系列信任问题
-
第二个阶段 – Promise,
Promise
是基于 PromiseA+
规范的实现,它很好的解决了控制反转导致的信任问题,将代码执行的主动权重新拿了回来。
-
第三个阶段 – 生成器函数 Generator,使用
Generator
,可以让我们用同步的方式来书写代码,解决了顺序性的问题,但是 需要手动去控制 next(…),将回调成功返回的数据送回 JavaScript 主流程中。
-
第四个阶段 – Async/Await,
Async/Await
结合了 Promise
和Generator
,在 await 后面跟一个 Promise,它会自动等待 Promise 的决议值,解决了 Generator 需要手动控制 next(…) 执行的问题,真正实现了用同步的方式书写异步代码。
我们可以看到,每项技术的突破都是为了解决现有技术存在的一些问题,它是循序渐进的,我们在学习的过程中,要真正去理解这项技术解决了哪些痛点,它为什么会存在,这样会有益于我们构建体系化的知识,同时也会更好的去理解这门技术。
最后,希望大家可以通过这篇文章对 JavaScript 异步编程有一个更宏观的体系化的了解,我们一起进步。
https://developer.mozilla.org…