2018-03-20
Asynchronous
[Callback, Promise, Async]
JavaScript 到处都是异步, 没有异步根本没法编程, 对异步的处理,一直以来都是一条主线,那就是尽可能的以同步的方式来写异步,使得异步过程变得清晰可控。
本文将会从过去谈到现今,着重介绍回调,Promise,async三种方案的发展和思想。 超长文警告

# 回调

一个 setTimeout 接受一个函数,一个时间作为参数,将会在给定的时间后执行那个函数。
这是很多人第一次接触到的异步,也是最为直观的 ———— 异步过程即函数过程,异步执行即函数的异步执行,异步坏境即函数的执行环境(this 和 arguments)
00setTimeout(() => {
01    console.log('hello, world'); 
02}, 1000); 
03console.log('js'); 
04
05// 先打印 'js', 一秒后打印 'hello, world'
而很多 api,都有回调的身影,传入回调,是希望异步操作完成之后能 catch 到执行结果,比如读取文件,在操作系统完成了 IO 读取之后,把数据返回给 js,js 执行回调函数,并把参数传入回调中。
00const fs = require('fs'); 
01
02fs.readFile('a.md', function(err, data){
03    if (err) console.error(err); 
04    else console.log(data); 
05});
而且,你会发现,标准的异步回调 API,回调函数都是最后一个,而且回调函数的第一个参数是 err,第二个是异步过程蕴含的结果。
这不是巧合,而是为了把一个异步函数方便的 thunk 化。

# thunk 函数

参阅了阮一峰的 ES6,上面指出了,thunk 函数是用来实现传名调用的。
以下是例子修改自那本书。
00function f(m) {
01    return m * 2;
02}
03
04f(4 + 5);
05// 其实就是 f(9)
06// 最后返回 18 
07// 这种先求值的调用方式叫做传值调用 CBC (Call-By-Value)
08
09// 如果这样来写 ... 
10var thunk = () => 4 + 5; 
11
12thunk(); 
13// => 返回 9 
14
15function f(name) {
16    return name() * 2;
17}
18
19f(thunk); 
20// 其实就是传入  (4 + 5) 
21// 等到 f return 的时候才求值 thunk 从而达到传名调用的效果 (Call By Name)
我觉得,与其说实现了传名调用,倒不如说 thunk 更像是一种函数的柯里化形式,比如 thunk 版本的 readFile:
00const fs = require('fs'); 
01
02// 普通版本 
03fs.readFile('./a.md', (err, data) => {
04    console.log(err, data);
05}); 
06
07let Thunk = file_path => callback => {
08    fs.readFile(file_path, callback)
09}
10
11let read_a_Thunk = Thunk('./a.md'); 
12// 等同于 cb => fs.readFile('./a.md', cb); 
13
14read_a_Thunk((err, data) => {
15    console.log(err, data); 
16});
这样来看,readFile 的执行被分为两步,一步是文件路径的指定,一步是回调的喂入,只有把参数补齐,函数才会真正的运行,因此传名调用只是一个现象,最关键的是:异步被拖延了,异步的执行变成了一个函数,异步过程的完成需要一个回调函数来发动。
而前面我提到,标准的异步回调 API,回调函数都是最后一个,而且其参数的位置也总是第一个是err,后面是蕴含值,其原因是为了更好的将一个函数 Thunk 化。
00let Thunk = fn => (...args) => callback => {
01    fn(...args, callback); 
02}   // Thunk 是比较通用的 thunkify 函数 
03
04let readFileThunk = Thunk(fs.readFile); 
05
06let read_a_md = readFileThunk('./a.md'); 
07
08read_a_md((err, data) => {
09    console.log(err, data); 
10});
不过,一般来说,生产环境就不要自己写 Thunk 函数了,用别人写好的优质库是最好的, 如 thunkify。(更好的测试,更多人使用,更长的维护时间)
00$ npm i --save thunkify
安装好后,这样子使用:
00const thunkify = require('thunkify')
01    , fs = require('fs')
02 
03let read = thunkify(fs.readFile);
04 
05let readPackage = read('package.json', 'utf8'); 
06
07readPackage((err, str) => {
08    console.log(err, str); 
09});
thunk 函数的执行,似乎是把异步的函数执行了一半一样,只传入了部分参数,直到最后把 callback 喂进去,异步过程才真正开始,在使用 Generator 的时候可以利用这一点,完成异步过程的终极解决方案。
在此,还得说明一下其他的异步方案。

# 事件里的异步

事件的触发常常是异步的,在未来的某个点上执行的,如点击事件,如 onload 事件,关于它的描述,我在 《EventEmitter》 里已经有所解释,此处就不多说,不过,我自己做 Promise-pollyfill 的时候就用到了事件机制来写,可以用来传递消息。
不过说来,不论是事件,还是 thunk 还是 callback 他们均把函数看成是异步的一等公民,没有函数的异步执行就没有异步过程。
因而,它们都有回调地域的问题,例如要将文件内容全部大写化,再重新写入:
00const fs = require('fs'); 
01
02/**
03 * @description 读取文件转化成大写
04 * @params { String } file 文件路径 
05 * @params { Function } ok_cb 回调函数
06 */
07function fileUpperCase(file, ok_cb){
08    // 默认参数 
09    ok_cb = ok_cb || (() => {}); 
10
11    // 参数限制 
12    if (!file || typeof file !== 'string') {
13        return ok_cb('file should be string', null);
14    }
15
16    // 读取 
17    fs.readFile(file, (read_err, data) => {
18        if (read_err){
19            ok_cb(read_err, null); 
20        } else {
21            // 写入 
22            let toWrite = data.toString().toUpperCase(); 
23            fs.writeFile(file, toWrite, (write_err, data) => {
24                if (write_err) {
25                    ok_cb(write_err, null); 
26                } else {
27                    ok_cb(null, toWrite); 
28                }
29            });
30        }
31    });
32}
仅仅两层,对错误的 if 判断就很杂乱了,要是再深一点,恐怕就很难维护了,毕竟回调过于粗暴,将异步的先后用嵌套关系来表达,因此就会这样,越嵌越深。
对,这种异步方式的问题在于,其异步流的先继问题实际上是括号的嵌套问题,而不是我们期望中的 ———— 异步流的先继其实是代码的先继问题。
那么,如果,将先后关系,表达成代码的先后呢?
这是一个好的思路,Promise 就有这种影子了,很好地抽象了异步过程,使得以同步的方式写异步,成为了可能。

# Promise

以上的回调,thunk,还有事件其实都摆脱不了传递函数所造成的嵌套问题。
在我看来,Promise 抽象了异步过程,把异步的过程当成了数值来看待,异步说到底也总是某个数据的异步获取,在未来的时候取得,也就是说,一个 Promise 一般来说都会蕴含一个值。
00/**
01 * @description 返回一个 Promise 蕴含的值为 val,将会在 500 ms 后 resolved 
02 * @params { * } val 返回的 Promise 所蕴含的值
03 * @returns { Promise } 
04 */
05let wait = val => new Promise(res => {
06    setTimeout(() => {
07        console.log(val); 
08        res(val); 
09    }, 500);  
10}); 
11
12let p1 = wait(1); 
13// p1 蕴含着 1 
14// 500ms 后 console.log(1)
15
16let p2 = p1.then(e => e + 1); 
17// p2 蕴含着 2 
18// 5000ms 后 console.log(2)
而且,最为关键的是,then 会返回一个新 Promise,其蕴含的值是原先的值关于 then 所接收的函数的一个映射。
换言之,then 即 map,Promise 是一类 Container 是一类 functor。
这是 Promise 的函数式特性,使得异步流变得很清晰可见了,因为在 then 函数内,异步的先后关系,其实看起来更像是代码的先后关系了。
00
01let p3 = wait(3); 
02// 500ms 后 console.log(3)
03
04let p4 = p3.then(_3 => {
05    return wait(_3 + 1); 
06}).then(_4 => {
07    return wait(_4 + 1); 
08}).then(_5 => {
09    return wait(_5 + 1); 
10}).catch(err => {
11    console.log(err); 
12}); 
13// p3 先 resolved, 输出 3 
14// 紧接着执行 第一个 then 里面的,里面 return 一个 Promise,等待其 resolved 后继续执行之后的 then 
15// 然后 500 ms 后输出 4 
16// 再 500ms 后输出 5
17// 再 500ms 后输出 6
看起来就是以同步的方式写异步了,而且,报错将会被轻而易举的 catch 到。
看起来好像异步的问题已经解决的差不多了。
其实并没有,Promise 还缺点什么,你会发现,所谓的同步写异步必须 then 包着才能做到。
你想过为什么吗?
原因在于,只有被 then 包着了,才能监控到 then 里面那个函数的返回值,换言之,异步的过程中,有一段时间 CPU 控制权交给 Promise 了。
在这个时间里,Promise 判断返回值是不是 Promise,而且还找出了下一个 then 在哪,然后在时机成熟的时候执行它。
是 Promise 调度了异步的流程,才使得一切如此清晰。

# Generator

上面提到过,Promise 调度了异步的流程,才使得一切如此清晰。
与操作系统调度进程一样,Promise 需要一个 CPU 控制权来调度异步。
如果语言层面上能提供这样的特性,即切换上下文的特性,也许我们可以在形式上做出一个飞跃。
Generator 应运而生,它允许函数暂停执行,交出上下文,在之后可以继续执行之。
这就为下一步的终极异步方案打下了语言基础。
00function* wait(){
01    console.log('wait 开始执行 ... yield 一个 5 出来'); 
02    
03    let a = yield 5; 
04
05    console.log('wait 继续执行 ... 外面给进来的 a = ', a);
06    
07    console.log('wait 返回 2'); 
08
09    return 2; 
10}
11
12let it = wait(); 
13// 得到一个遍历器 
14
15it.next();
16// 输出:wait 开始执行 ... yield 一个 5 出来
17// 返回:{value: 5, done: false} 
18
19it.next(123); 
20// 输出:wait 继续执行 ... 外面给进来的 a =  123
21// 返回:{value: 2, done: true}
22
23it.next(); 
24// 返回:{value: undefined, done: true}
25// 函数执行完毕
一开始我觉得这种方式执行函数很古怪,但细细想来,发现了这种函数执行的方式,似乎是将函数的执行,变成某种数据结构的遍历了,Generator 函数执行的结果是返回一个遍历器实例,可以调用 next 方法遍历它,其中 yield 关键字的右边是传递出去的值,而 yield 本身的值,是外部传进来的值。
如:
00let a = (yield 5); 
01// yield 表达式 ;
上面的内部的 5,也是一个表达式,会被传递出去。而整个括号,也会有返回值,也是一个 表达式,比如:在外部调用 next, 会得到 5, 但下一次调用 next(10) 继续执行 Generator 函数时,这个 a 就变成 10 了。

如果我们不同步的调用 next,而是异步的调用 next,Generator 内部的异步过程,是不是可以变成同步了?
好像是!试想想:
00const fs = require('fs'); 
01
02function* double_read(file){
03    let fileA   = yield file; 
04    let fileA_2 = yield file; 
05
06    console.log(fileA + fileA_2); 
07}
08
09// 来,现在让 double_read 变成 `同步` 的
10
11let it = double_read('./po.js'); 
12
13let fileA_name = it.next().value; 
14
15// 为了讨论方便,在此不做错误处理 
16fs.readFile(fileA_name, (err, data) => {
17    let fileB_name = it.next(data.toString()).value; 
18
19    fs.readFile(fileB_name, (err, data) => {
20        it.next(data.toString());
21    }); 
22});
可以看见,如果要执行 Generator 函数,得要有相应的代码去遍历返回的遍历器,这个代码叫做遍历器,也就是说,Generator 函数的执行离不开遍历器。
遍历器,或者说是执行器,实际上,可以用来做调度,这就为同步的写异步代码做足了基础。上述的例子里,我们已经完成了用同步的方法写异步的形式,不过这需要一个复杂的执行器才能进行。
因此 double_read 的执行,看起来是同步的,但是其内部执行是完全异步的。yield 把函数卡住了,这时候就可以跳出来执行调度代码了,之后再继续执行函数,这样函数的执行看起来就好像是同步的了,形式上也跟同步代码保持一致了。
而如果能写一个通用的执行器,那么异步问题将会有一个较好的解决方案:以同步的方式写异步,以下是 po 模块
00const po = require('./po'); 
01// 假想中的通用执行器 
02// 下文会实现它 
03
04// 前文的 wait 函数 会返回一个 Promise 
05let wait = val => new Promise(res => {
06    setTimeout(() => {
07        console.log(val); 
08        res(val); 
09    }, 500);  
10}); 
11
12po(function *looooong(){
13    let val = [0, 0, 0, 0]; 
14
15    for (let i = 0; i < val.length; i++){
16        val[i] = yield wait(i); 
17        // 假想中的执行器在遇到 yield 的时候会跳出
18        // 接着执行器调用 wait(idx).then 来取出 Promise 的蕴含值 
19        // 取得蕴含值后继续执行函数
20    }
21
22    // 理想中的执行情况: 
23    // 500ms 0
24    // 1000ms 1 
25    // 1500ms 2 
26    // 2000ms 3
27});
以下,是 po 模块的实现, 为了谈论方便,我没有考虑错误处理。
00// po 
01module.exports = po; 
02
03function po(generator_func){
04    let iter = generator_func(); 
05
06    return _po(iter); 
07}
08
09function _po(iter, para = null){
10    let p = iter.next(para); 
11
12    // 如果 Generator 函数执行完毕,return 结束
13    if (p.done) return; 
14
15    // p.value 是 yield 右边的表达式的值 
16    // 这里是 Promise,利用 then 方法取出其蕴含值
17    // 取得值后继续执行 generator 函数 (递归的执行)
18    p.value.then(async_val => {
19        _po(iter, async_val); 
20    }); 
21}
当然,早有人实现了上述所说的执行器,那就是 co 模块,它不仅仅支持 Promise 的 yield 还支持 thunk 函数。
00$ npm i --save co
安装好之后如下使用:
00const co = require('co')
01    , fs = require('fs')
02    , thunkify = require('thunkify')
03    , readFile = thunkify(fs.readFile)
04
05let read_po = readFile('./po.js', 'utf-8'); 
06
07co(function *(){
08    let _1 = yield wait(1); 
09    let pojs = yield read_po
10
11    console.log(_1); 
12    console.log(pojs); 
13})
可以看到,只 yield 了一个 read_po 函数出去,co 模块就可以完成读取 po.js 的异步过程。仔细想想,读取 po.js 实际上就差一个 callback 了而已,此处由 co 模块提供这个 callback 去执行这个异步过程,进而将结果返回到 Generator 函数里继续执行。
这就是为什么要 thunk 的原因,补齐异步所需的参数,只差最终的一个调度。

额,前文所述的都是一个接着一个的执行,那可不可以并发呢? 当然可以:
00co(function *(){
01    let _1_p = wait(1); 
02    let _2_p = wait(2); 
03
04    let _1_2 = yield Promise.all([_1_p, _2_p]); 
05    // let _1_2 = yield ([_1_p, _2_p]); 
06    // 这样写也是可以的。 co 模块也支持处理 Promises
07
08    let [_1, _2] = _1_2; 
09
10    console.log('函数结束:', _1, _2);
11});
yield 意味着等待,函数暂停执行,切换上下文到 co 模块,co 模块做完处理后重启函数的执行,因此函数的执行看起来就像是同步的方式执行异步代码了。

# Async / Await

即上文的 Generator / Co 的一个语法糖。
00co(function *(){
01    let _1_p = wait(1); 
02    let _2_p = wait(2); 
03
04    let _1_2 = yield Promise.all([_1_p, _2_p]); 
05    // let _1_2 = yield ([_1_p, _2_p]); 
06    // 这样写也是可以的。 co 模块也支持处理 Promises
07
08    let [_1, _2] = _1_2; 
09
10    console.log('函数结束:', _1, _2);
11}); 
12
13// 等于 ==> 
14
15(async function(){
16    let _1_p = wait(1); 
17    let _2_p = wait(2); 
18
19    let _1_2 = await Promise.all([_1_p, _2_p]); 
20    // 注意, 此处就不支持下面这种省略的写法了 
21    // let _1_2 = await ([_1_p, _2_p]); 
22
23    let [_1, _2] = _1_2; 
24
25    console.log('函数结束:', _1, _2);
26})();
async / await 是语言层面上的实现,性能比 co / generaotr 要好,因此建议直接使用 async 函数,而不是利用 co 模块。 (koa1 本来是 co / generator 实现的,但出了 asnyc 函数之后就全套使用 async 函数了,变成 koa2 了)
async 也不用像 generator 一样需要执行器,其执行器是语言内部实现的,无需自己导入 co 模块。

# 各方案错误处理

异步有个问题,那就是错误处理的问题。

# 回调函数 / thunk 的错误处理

标准的异步 API 里,回调函数的第一个参数总是错误 err,这是约定。如果无错误,则 err 是 null,如果有则是错误对象。
00const fs = require('fs'); 
01
02fs.readFile('test.md', 'utf-8', (err, data) => {
03    if (err){
04        console.log(err); 
05    } else {
06        console.log(data); 
07    }
08});
在回调地狱的情况下,这样的处理方式实在是难以接受,代码复杂度会一下子上去,会写很多的 if 分支去判断情况,很难追踪,嵌套愈深,愈难理清关系。

# Promise 的错误处理

相比前者,Promise 的方案就清晰多了。
前文说过,Promise 把异步过程抽象成数值 (容器) 了,整个容器蕴含着数值,将会在 resolved 的时候变得可用,其错误也是一样,将会在 rejected 的时候可用。
00// 前文的 wait 函数 
01wait(1).then(_1 => {
02    console.log(_1); 
03    // => 1 
04
05    // 如果此处抛出错误 
06    throw 'error !'; 
07}).then(_2 => {
08    // 那么此处不会执行 
09    console.log(_2); 
10}).catch(err => {
11    console.log(err); 
12    // `error !`
13
14    // 如果此处返回了一个非 rejected 的 promise
15    return wait(2); 
16}).then(_2 => {
17    // promise 执行流将会回归正常
18    console.log(_2); 
19
20    // 否则 如果返回一个 rejected 的 Promise 
21    return Promise.reject('i am error!'); 
22}).catch(err => {
23    // 那么又会跳回错误流的执行
24    console.log('err');
25    // `i am error!`; 
26});

# co / async

co 和 async 的本质上是一样的,以同步的方式写异步,因此错误处理,也一样是以同步的方式去处理,使得可以使用同步的 try / catch 去捕获错误。
00async function(){
01    try {
02        let _1 = wait(1); 
03    } catch (err){
04        console.log(err); 
05    }
06}
这也带来了,以同步的方式处理错误的回归。

# TL;DR

JavaScript 近十几年的发展,终于摆脱了回调地狱,拥抱 Promise。
直到看到了切换上下文的 Generator,意识到了异步不过是跳出和返回,才有了 co 模块,才有了现今 async / await 的终极解决方案。
对异步的探索,其实一直都是一条主线,那就是同步的方式写异步,使得异步流尽可能的清晰可见。
应该配张图的
应该配张图的




回到顶部