概论
由于 JavaScript 是一门单线程执行的语言,所以在我们处理耗时较长的任务时,异步编程就显得尤为重要。
js 处理异步操作最传统的方式是回调函数,基本上所有的异步操作都可以用回调函数来处理;
为了使代码更优雅,人们又想到了用事件监听、发布/订阅模式和 Promise 等来处理异步操作;
之后在 ES2015 语言标准中终于引入了Promise
,从此浏览器原生支持 Promise ;
此外,ES2015 中的生成器generator
因其中断/恢复执行和传值等优秀功能也被人们用于异步处理;
之后,ES2017 语言标准又引入了更优秀的异步处理方法async
/await
......
异步处理方式
为了更直观地发现这些异步处理方式的优势和不足,我们将分别使用不同的方式解决同一个异步问题。
问题:假设我们需要用原生 XMLHttpRequest 获取两个 json 数据 —— 首先异步获取广州的天气,等成功后再异步获取番禺的天气,最后一起输出获取到的两个 json 数据。
前提:假设我们已经了解了Promise
,generator
和async
。
回调函数
我们首先用最传统的回调函数来处理:
var xhr1 = new XMLHttpRequest();
xhr1.open('GET', 'https://www.apiopen.top/weatherApi?city=广州');
xhr1.send();
xhr1.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) {
data1 = JSON.parse(this.response);
var xhr2 = new XMLHttpRequest();
xhr2.open('GET', 'https://www.apiopen.top/weatherApi?city=番禺');
xhr2.send();
xhr2.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) {
data2 = JSON.parse(this.response);
console.log(data1, data2);
}
}
}
};
优点:简单、方便、实用。
缺点:易形成回调函数地狱。如果我们只有一个异步操作,用回调函数来处理是完全没有任何问题的。如果我们在回调函数中再嵌套一个回调函数,问题也不大。但是如果我们要嵌套很多个回调函数,问题就很大了,因为多个异步操作形成了强耦合,代码将乱作一团,无法管理。这种情况被称为"回调函数地狱"(callback hell)。
事件监听
使用事件监听的方式:
var events = new Events();
events.addEvent('done', function(data1) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.apiopen.top/weatherApi?city=番禺');
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) {
data1 = JSON.parse(data1);
var data2 = JSON.parse(this.response);
console.log(data1, data2);
}
}
});
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.apiopen.top/weatherApi?city=广州');
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) {
events.fireEvent('done', this.response);
}
};
上述代码需要实现一个事件监听器 Events。
优点:与回调函数相比,事件监听方式实现了代码的解耦,将两个回调函数分离了开来,更方便进行代码的管理。
缺点:使用起来不方便,每次都要手动地绑定和触发事件。
而发布/订阅模式与其类似,就不多说了。
Promise
使用 ES6 Promise 的方式:
new Promise(function(resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.apiopen.top/weatherApi?city=广州');
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) return resolve(this.response);
reject(this.statusText);
};
}).then(function(value) {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.apiopen.top/weatherApi?city=番禺');
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) {
const data1 = JSON.parse(value);
const data2 = JSON.parse(this.response);
console.log(data1, data2);
}
};
});
优点:使用Promise
的方式,我们成功地将回调函数嵌套调用变成了链式调用,与前两种方式相比逻辑更强,执行顺序更清楚。
缺点:代码冗余,异步操作都被包裹在Promise
构造函数和then
方法中,主体代码不明显,语义变得不清楚。
generator + 回调函数
接下来,我们使用 generator 和回调函数来实现。
首先用一个 generator function 封装异步操作的逻辑代码:
function* gen() {
const data1 = yield getJSON_TH('https://www.apiopen.top/weatherApi?city=广州');
const data2 = yield getJSON_TH('https://www.apiopen.top/weatherApi?city=番禺');
console.log(data1, data2);
}
看了这段代码,是不是感觉它很直观、很优雅。实际上,除去星号和yield
关键字,这段代码就变得和同步代码一样了。
当然,只有这个 gen 函数是没有用的,直接执行它只会得到一个generator
对象。我们需要用它返回的 generator 对象来恢复/暂停 gen 函数的执行,同时传递数据到 gen 函数中。
用getJSON_TH
函数封装异步操作的主体代码:
function getJSON_TH(url) {
return function(fn) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.setRequestHeader("Accept", "application/json");
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
let err, data;
if(this.status === 200) {
data = this.response;
} else {
err = new Error(this.statusText);
}
fn(err, data);
}
}
}
有的同学可能觉得直接给getJSON_TH
函数传入 url 和 fn 两个参数不就行了吗,为什么非要返回一个函数。其实这正是奥妙所在,getJSON_TH
函数返回的函数是一个Thunk
函数,它只接收一个回调函数作为参数。通过Thunk
函数或者说Thunk
函数的回调函数,我们可以在 gen 函数外部向其内部传入数据,同时恢复 gen 函数的执行。在 node.js 中,我们可以通过 Thunkify 模块将带回调参数的函数转化为 Thunk 函数。
接下来,我们手动执行 gen 函数:
const g = gen();
g.next().value((err, data) => {
if(err) return g.throw(err);
g.next(data).value((err, data) => {
if(err) return g.throw(err);
g.next(data);
})
});
其中,g.next().value 就是 gen 函数中yield
输出的值,也就是我们之前提到的Thunk
函数,我们在它的回调函数中,通过 g.next(data) 方法将 data 传给 gen 函数中的 data1,并且恢复 gen 函数的执行(将 gen 函数的执行上下文再次压入调用栈中)。
方便起见,我们还可以将自动执行 gen 函数的操作封装起来:
function run(gen) {
const g = gen();
function next(err, data) {
if(err) return g.throw(err);
const res = g.next(data);
if(res.done) return;
res.value(next);
}
next();
}
run(gen);
优点:generator 方式使得异步操作很接近同步操作,十分的简洁明了。另外,gen 执行 yield 语句时,只是将执行上下文暂时弹出,并不会销毁,这使得上下文状态被保存。
缺点:流程管理不方便,需要一个执行器来执行 generator 函数。
generator + Promise
除了Thunk
函数,我们还可以借助Promise
对象来执行 generator 函数。
同样优雅的逻辑代码:
function* gen() {
const data1 = yield getJSON_PM('https://www.apiopen.top/weatherApi?city=广州');
const data2 = yield getJSON_PM('https://www.apiopen.top/weatherApi?city=番禺');
console.log(data1, data2);
}
getJSON_PM
函数返回一个 Promise 对象:
function getJSON_PM(url) {
return new Promise((resolve, rejext) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = "json";
xhr.setRequestHeader("Accept", "application/json");
xhr.send();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(this.status === 200) return resolve(this.response);
reject(new Error(this.statusText));
};
});
}
手动执行 generator 函数:
const g = gen();
g.next().value.then(data => {
g.next(data).value.then(data => g.next(data), err => g.throw(err));
}, err => g.throw(err));
自动执行 generator 函数:
function run(gen) {
const g = gen();
function next(data) {
const res = g.next(data);
if(res.done) return;
res.value.then(next);
}
next();
}
run(gen);
generator + co 模块
node.js 中的co
模块是一个用来自动执行generator
函数的模块,它的入口是一个co(gen)
函数,它预期接收一个 generator 对象或者 generator 函数作为参数,返回一个Promise
对象。
在参数 gen 函数中,yield
语句预期接收一个 generator 对象,generator 函数,thunk 函数,Promise 对象,数组或者对象。co
模块的主要实现原理是将 yield 接收的值统一转换成一个Promise
对象,然后用类似上述 generator + Promise 的方法来自动执行 generator 函数。
下面是我根据 node.js co 模块源码修改的 es6 co 模块,让它更适合自己使用:
https://github.com/lyl123321/...
yield
接收thunk
函数:
import co from './co.mjs'
function* gen() {
const data1 = yield getJSON_TH('https://www.apiopen.top/weatherApi?city=广州');
const data2 = yield getJSON_TH('https://www.apiopen.top/weatherApi?city=番禺');
console.log(data1, data2);
}
co(gen);
yield
接收Promise
对象:
function* gen() {
const data1 = yield getJSON_PM('https://www.apiopen.top/weatherApi?city=广州');
const data2 = yield getJSON_PM('https://www.apiopen.top/weatherApi?city=番禺');
console.log(data1, data2);
}
co(gen);
async/await
async
函数是generator
函数的语法糖,它相对于一个自带执行器(如 co 模块)的generator
函数。
async
函数中的await
关键字预期接收一个Promise
对象,如果不是 Promise 对象则返回原值,这使得它的适用性比 co 执行器更广。
async
函数返回一个Promise
对象,这点与 co 执行器一样,这使得async
函数比返回generator
对象的generator
函数更实用。如果 async 函数顺利执行完,则返回的 Promise 对象状态变为 fulfilled,且 value 值为 async 函数中 return 关键字的返回值;如果 async 函数执行时遇到错误且没有在 async 内部捕获错误,则返回的 Promise 对象状态变为 rejected,且 reason 值为 async 函数中的错误。
await
只处理Promise
对象:
async function azc() {
const data1 = await getJSON_PM('https://www.apiopen.top/weatherApi?city=广州');
const data2 = await getJSON_PM('https://www.apiopen.top/weatherApi?city=番禺');
console.log(data1, data2);
}
azc();
async
函数将generator
函数的自动执行器,改在语言层面提供,不暴露给用户。
async function fn(args) {
// ...
}
相当于:
function fn(args) {
return exec(function* () {
// ...
});
}
优点:最简洁,最符合语义,最接近同步代码,最适合处理多个 Promise 异步操作。相比 generator 方式,async 方式省掉了自动执行器,减少了代码量。
缺点:js 语言自带的 async 执行器功能性可能没有 co 模块等执行器强。你可以根据自己的需求定义自己的 generator 函数执行器。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。