# 异步编程
# 为什么要用异步?
在编程的时候, 一个很重要的问题是: 如何表达和控制持续一段时间的程序行为。换句话说, 就是一段代码 "现在" 运行, 另一部分 "将来" 运行. "将来" 运行的代码无法在 "现在" 运行的代码执行完之后立刻执行. "现在" 和 "将来" 之间有一段间隙.
这个间隙可能是在等待用户输入信息、从数据库或文件系统中请求数据、或通过网络发送数据并等待响应.
处理好 "现在" 的代码和 "将来" 的代码之间的关系, 就是异步编程的核心.
# 异步的解决方案
# 回调函数
"回调" 的意思就是 "回头调用的函数". 函数中的代码是在 "将来" 的操作. 当相对应的 "事件" 被触发了, 函数会被执行.
回调是编写和处理 JavaScript 异步逻辑的最常用方式.
// 代码 A
setTimeout(function() {
// 代码 B
}, 1000)
// 代码 C
上面的代码中使用了回调函数. 如果用日常口语去描述这段代码的运行方式, 大概是 "代码 A 先执行; 然后设定一个 1000 毫秒的延时事件; 代码 C 执行; 延时事件被触发, 代码 B 得到执行."
# 回调地狱
setTimeout(function() {
console.log('1');
setTimeout(function() {
console.log('2');
setTimeout(function() {
console.log('3');
}, 1000)
}, 1000)
}, 1000)
这种多个回调函数嵌套在一起的代码, 被称作 "回调地狱".
被称为 "地狱" 的主要原因除了层层嵌套的代码难于阅读, 更主要的原因是: 回调函数的使用让代码执行顺序缺少 "顺序性". "非顺序(线性)" 的代码执行方式与我们大脑的顺序地思考方式不符.
doA(function(){
doC();
doD(function(){
doF();
});
doE();
});
doB();
如果 doA
和 doD
接收两个回调函数, 会被异步调用. 那么上面代码的执行顺序为 A -> B -> C -> D -> E -> F. 当我们在线性(顺序)地追踪这段代码的执行顺序时,我们不得不从一个函数跳到下一个,再跳到下一个. 可以想象当异步代码更复杂时, 这种追踪的难度会成倍增加。
这种 "非顺序的" 执行步骤并不符合我们的大脑思考方式. 我们的大脑类似于单线程运行的事件循环队列, 它喜欢以顺序地, 同步地方式去理解, 思考事情.
在回调函数上, 我们大脑的工作方式和代码的执行方式发生了 "分歧". 回调函数的滥用会让代码变得更加难以理解、追踪、调试和维护。这是回调函数的主要问题所在.
除此之外, 嵌套的回调函数把异步代码的执行顺序写死了. 上面代码中, doD
的回调函数必须等 doA
的回调函数得到执行之后才能被执行. 假如它们两个之间没有顺序关系, 这种写死的顺序会增加代码的脆弱性. 类比来说, 假如你计划五点钟去吃饭, 六点钟去看电影. 它们之间没有绝对的顺序关系, 哪怕你五点钟没有去吃饭, 也不会影响你六点去看电影.
# 信任问题
当我们在第三方提供的工具中使用回调函数的时候, 实际上我们就把代码一部分的操控权交给了第三方. 这被称为 "控制反转". 这加大了代码不确定性.
假如你使用了一个第三方提供的用来验证账号登录状态的函数. 并在传入的回调函数中进行支付操作.
checkLogin(accountInfo, function() {
payMoney();
})
看似一切都完美, 但是我们在这段代码中, 把支付操作的控制权交给了一个第三方. 这会导致很多可能出现的错误情况:
- 调用回调过早;
- 调用回调过晚(或没有调用);
- 调用回调的次数太少或太多;
- 没有把所需的环境 / 参数成功传给你的回调函数;
- 吞掉可能出现的错误或异常;
- 等等;
为了确保安全性, 常用的解决方案是对传入的参数, 回调函数的调用情况设定相应的安全机制. 但这也加大了代码的复杂度.
function addNumbers(x,y) {
// 确保输入为数字
if (typeof x != "number" || typeof y != "number") {
throw Error( "Bad parameters" );
}
// 如果到达这里,可以通过+安全的进行数字相加
return x + y;
}
addNumbers( 21, 21 ); // 42
addNumbers( 21, "21" ); // Error: "Bad parameters"
如果你还没有应用某种逻辑来解决所有这些控制反转导致的信任问题,那你的代码现在已经有了 bug,即使它们还没有给你造成损害。隐藏的 bug 也是 bug。
# Promise
Promise 是一种封装和组合未来值的易于复用的机制。这套机制可以用来处理异步操作. ES6 使用这套机制实现了 Promise 对象. 所谓 Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果. 从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。
# 三种状态: pending, fulfilled, rejected
先来简单解释一下 Promise 是个什么概念:
"想象中午你去麦当劳买汉堡, 你点餐的时候服务员是不会立刻给你汉堡的. 交完钱, 你会先得到一张带订单号的收据作为凭证. 这张收据就是 "Promise (承诺)". 服务员用它来向你承诺, 汉堡做好后会给你. 这个时候, 收据代表了你的汉堡. 当汉堡做好, 服务员叫你的订单号的时候, 这张收据 (承诺) 就可以用来换取你真正想要得到的汉堡. 当然还会有另一种结果, 是汉堡不能卖给你了, 可能因为卖光了, 可能因为质量有问题, 反正就是不能给你了."
在 JavaScript 中, Promise 对象代表一个异步操作.
有三种状态:pending
(进行中)、fulfilled
(已成功)和 rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
拿上面买汉堡类比, 拿着收据等待汉堡时, 状态为 "pending
"; 拿到汉堡了, 状态为 "fulfilled
"; 被通知汉堡卖光了, 状态为 "rejected
".
一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变,只有两种可能:从 pending
变为 fulfilled
和从 pending
变为 rejected
。
# 基本用法
ES6 规定,Promise 对象是一个构造函数,用来生成 Promise 实例。
const myPromise = new Promise(function(resolve, reject) {
// ... some code
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolve
和 reject
。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。Promise 构造函数调用后会立即执行。
resolve
函数的作用是,将 Promise 对象的状态从 “未完成” 变为 “成功”. 在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;
reject
函数的作用是,将 Promise 对象的状态从 “未完成” 变为 “失败”, 在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
myPromise.then(
function(value) {
// success
},
function(error) {
// failure
}
);
Promise 实例生成以后,可以用 then
方法分别指定 resolved
状态和 rejected
状态的回调函数。
then
函数是 Promise 状态改变时的回调函数.
then
方法可以接受两个回调函数作为参数。第一个回调函数是 Promise 对象的状态变为 resolved
时调用,第二个回调函数是 Promise 对象的状态变为 rejected
时调用。第二个函数是可选的.
再看另一个例子:
const p1 = new Promise(function (resolve, reject) {
setTimeout(() => reject('An error'), 500)
})
const p2 = new Promise(function (resolve, reject) {
setTimeout(() => resolve(p1), 100)
})
p2.then(
result => console.log(result),
err => console.log(err)
);
// 结果: An error
上面代码中,p1
是一个 Promise,500 毫秒之后变为 rejected
。p2
的状态在 100 毫秒之后改变,resolve
方法返回的是 p1
。由于 p2
返回的是另一个 Promise,导致 p2
自己的状态无效了,由 p1
的状态决定 p2
的状态。所以,后面的 then
语句都变成针对 p1
的状态。当 p1
变为 rejected
. then
函数接收的第二个回调函数参数被调用.
# 链式调用
then
方法返回的是一个新的 Promise 实例。因此可以采用链式写法,即 then
方法后面再调用另一个 then
方法. 前面 then
函数中回调函数参数返回的值, 会作为后一个 then
函数的回调函数参数的参数.
采用链式调用,可以指定一组按照次序调用的回调函数。
getJSON("/post/1.json")
.then(function(post) {
return getJSON(post.commentURL);
})
.then(function funcA(comments) {
console.log("resolved: ", comments);
}, function funcB(err) {
console.log("rejected: ", err);
}
);
# catch, finally
catch
函数用于指定发生错误时的回调函数.
getJSON('/posts.json').then(function(posts) {
// ...
}).catch(function(error) {
// 处理 getJSON 和 前一个回调函数运行时发生的错误
console.log('发生错误!', error);
});
如果异步操作抛出错误,状态就会变为 rejected
,就会调用 catch
方法指定的回调函数.
Promise 对象的错误具有 “冒泡” 性质,会一直向后传递,直到被捕获为止。也就是说,链式调用中, 无论多少个 Promise 对象, 任何一个出现错误, 都会被最后一个 catch
捕获.
一般来说,不要在 then
方法里面定义 reject
状态的回调函数(即 then
的第二个参数),总是使用 catch
方法。
const someAsyncThing = function() {
return new Promise(function(resolve, reject) {
// 下面一行会报错,因为x没有声明
resolve(x + 2);
});
};
someAsyncThing()
.catch(function(error) {
console.log('oh no', error);
})
.then(function() {
console.log('carry on');
});
// oh no [ReferenceError: x is not defined]
// carry on
catch
方法返回的还是一个 Promise 对象,因此后面还可以接着调用 then
方法。
finally
方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。
promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});
finally
方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是 fulfilled
还是 rejected
。这表明,finally
方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。
# all, race
Promise.all
方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。
接受一个数组作为参数,数组里的每一项必须都是 Promise 实例,如果不是,就会先调用 Promise.resolve
方法,将参数转为 Promise 实例,再进一步处理。(Promise.all
方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例)。
const p = Promise.all([p1, p2, p3]);
p
的状态由p1
、p2
、p3
决定,分成两种情况:
- 只有
p1
、p2
、p3
的状态都变成fulfilled
,p
的状态才会变成fulfilled
,此时p1
、p2
、p3
的返回值组成一个数组,传递给p
的回调函数。 - 只要
p1
、p2
、p3
之中有一个被rejected
,p
的状态就变成rejected
,此时第一个被reject
的实例的返回值,会传递给p
的回调函数。
Promise.race
方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。
const p = Promise.race([p1, p2, p3]);
p.then((result) => {console.log(result)});
上面代码中,只要 p1
、p2
、p3
之中有一个实例率先改变状态,p
的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p
的回调函数。
const p1 = new Promise((resolved, rejected) => {
setTimeout(() => {
resolved('1');
}, 1000);
})
const p2 = new Promise((resolved, rejected) => {
setTimeout(() => {
resolved('2');
}, 500);
})
const p3 = new Promise((resolved, rejected) => {
setTimeout(() => {
resolved('3');
}, 3000);
})
const p = Promise.race([p1, p2, p3]);
p.then((result) => {
console.log(result);
})
// 2
# 为什么用 Promise
现在再说说为什么用 Promise 机制.
首先回忆, 用回调函数处理异步操作, 缺乏顺序性和可信任性.
Promise 解决了我们因只用回调的代码而产生的 "控制反转" 问题. Promise 通过把回调的控制反转反转回来,我 们把控制权放在了一个可信任的系统(Promise)中,这种系统的设计目的就是为了使异步编码更清晰。
Promise 以顺序的方式表达异步流的一个更好的方法,这有助于我们的大脑更好地计划和维护异步 JavaScript 代码 .
# Generator
Generator 函数是 ES6 提供的一种异步编程解决方案.
语法上,首先可以把它理解成,Generator 函数是一个 "状态机",封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象(Iterator Object),可以依次遍历 Generator 函数内部的每一个状态。
# 基本用法
声明 Genrator 函数时, function
关键字与函数名之间有一个 "星号" (星号位置无所谓); 函数体内部使用 yield
表达式,定义不同的内部状态.
Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的遍历器对象(Iterator Object).
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}
var hw = helloWorldGenerator();
上面代码定义了一个 Generator 函数 helloWorldGenerator
,它内部有两个 yield
表达式(hello
和 world
),即该函数有三个状态:hello
,world
和 return
语句(结束执行)。
hw.next()
// { value: 'hello', done: false }
hw.next()
// { value: 'world', done: false }
hw.next()
// { value: 'ending', done: true }
hw.next()
// { value: undefined, done: true }
调用遍历器对象的 next
方法,使得指针移向下一个状态。每次调用 next
方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个 yield
表达式(或 return
语句)为止.
next
方法调用后, 返回一个有着 value
和 done
两个属性的对象。value
属性表示当前的内部状态的值,是 yield
表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
简单说, yield
表达式是暂停执行的标记,而 next
方法可以恢复执行。
正常函数只能返回一个值,而 Generator 函数通过 yield
和 next
可以返回一系列的值.
# yield 表达式
Generator 函数返回的遍历器对象,只有调用 next
方法才会遍历下一个内部状态,这提供了一种可以暂停执行的函数的方法。yield
表达式就是暂停标志。
next
方法的运行逻辑如下:
- 遇到
yield
表达式,就暂停执行后面的操作,并将紧跟在yield
后面的那个表达式的值,作为返回的对象的value
属性值。如果是遇到return
语句, 将return
语句后面的表达式的值,作为返回的对象的value
属性值。 - 下一次调用
next
方法时,再继续往下执行,直到遇到下一个yield
表达式, 或者return
语句. - 如果到最后, 该函数没有
return
语句,则返回的对象的value
属性值为undefined
function* demo() {
console.log('Hello' + (yield)); // OK
let input = yield; // OK
}
yield
表达式如果用在另一个表达式之中,必须放在 "圆括号" 里面。
但 yield
表达式用作 "函数参数" 或放在 "赋值表达式的右边",可以不加括号。
# next 方法的参数
yield
表达式本身总是返回 undefined
。nex
t 方法可以带一个参数,该参数就会被当作上一个 yield
表达式的返回值。
这个功能有很重要的语法意义。Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过 next
方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。
function* foo(x) {
var y = 2 * (yield (x + 1));
var z = yield (y / 3);
return (x + y + z);
}
var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}
var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }
上面代码, 当 next
方法的时候不带参数,导致 y
的值等于 2 * undefined
(即 NaN
)
由于 next
方法的参数表示上一个 yield
表达式的返回值,所以在第一次使用 next
方法时,传递参数是无效的。
# Genrator 与 异步编程
Generator 函数的暂停执行的效果,意味着可以把异步操作写在 yield
表达式里面,等到调用 next
方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在 yield
表达式下面
function* loadUI() {
showLoadingScreen();
yield loadUIDataAsync();
hideLoadingScreen();
}
var loader = loadUI();
// 加载UI
loader.next()
// 卸载UI
loader.next()
上面代码, 第一次调用 next
方法,则会显示 Loading
界面(showLoadingScreen
),并且异步加载数据(loadUIDataAsync
)。等到数据加载完成,再一次使用 next
方法,则会隐藏 Loading
界面。
下面是用 Generator 处理 Ajax 请求:
function* main() {
var result = yield request("http://some.url");
var resp = JSON.parse(result);
console.log(resp.value);
}
function request(url) {
makeAjaxCall(url, function(response){
it.next(response);
});
}
var it = main();
it.next();
# 错误处理
Generator 函数返回的遍历器对象,都有一个 throw
方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。
# Generator 与 Promise
# async/await
ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async 函数简单说就是 Generator 函数的语法糖。
const fs = require('fs');
const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
};
const gen = function* () {
const f1 = yield readFile('/etc/fstab');
const f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
上面代码的函数 gen
可以写成 async
函数,就是下面这样。async
函数就是将 Generator 函数的星号(*)替换成 async
,将 yield
替换成 await
,仅此而已。
const asyncReadFile = async function () {
const f1 = await readFile('/etc/fstab');
const f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
# 基本用法
async
函数返回一个 Promise 对象,可以使用 then
方法添加回调函数。await
命令后面一般是一个 Promise 对象. await
命令返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。
async
函数内部 return
语句返回的值,会成为 then
方法回调函数的参数。但只有 async
函数内部的异步操作执行完,才会执行 then
方法指定的回调函数。
const asyncFun = async function () {
let a = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Hello');
}, 1000);
});
let b = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve('World');
}, 1000);
});
return a + ' ' + b;
}
asyncFun().then((result) => {
console.log(result); // Hello World
});
# 错误处理
如果 await
后面的异步操作出错,那么等同于 async
函数返回的 Promise 对象被 reject
。防止出错的方法,也是将其放在 try...catch
代码块之中。
async function main() {
try {
const val1 = await firstStep();
const val2 = await secondStep(val1);
const val3 = await thirdStep(val1, val2);
console.log('Final: ', val3);
}
catch (err) {
console.error(err);
}
}