JS异步编程的浅思

1,312 阅读13分钟

最近使用egg写一个node项目时,被它的异步流控制震惊的泪流满面。话不多说,先上代码体验一下。

async function pay() {
    try {
        let user = await getUserByDB();
        if (!user) {
            user = await createUserByDB();
        }
        let order = await getOrderByDB();
        if (!order) {
            order = await createOrderByDB();
        }
        const newOrder = await toPayByDB();
        return newOrder;
    } catch (error) {
        console.error(new Error('支付失败'));
    }
}
pay().then(order => console.log(order));

以上代码是付款的简易流程,先找人,再找订单,最后支付。其中找人、找订单和支付都是异步逻辑。写出这段代码的时候,回忆把我带到了callback的时代。

回调函数

callback是我们最熟悉的方式了。很容易就能写出一个熟悉又简单异步回调

setTimeout(function () {
    console.log(1);
}, 1000);
console.log(2);

这个栗子的结果还是很容易让人接受的:先打印出2,延迟1000ms之后,再打印出1。下面👇这个栗子就让人抓狂了,体现出异步是如何的反人类!

setTimeout(function () {
    console.log(1);
}, 0);
console.log(2);

你可能会觉得,定时0ms,就是没有延迟,应该是先打印出1,接着打印出2。然而结果却和第一个回调栗子的结果是一样,唯一区别就是,前者延迟1000ms之后打印1,后者延迟0ms之后打印1。

开篇提到的支付栗子,用callback的方式实现如下

function pay() {
    getUserByDB(function (err, user) {
        if (err) {
            console.error('出错了');
            return false;
        }
        if (user) {
            getOrderByDB(function (err, order) {
                if (err) {
                    console.error('出错了');
                    return false;
                }
                if (order) {
                    toPayByDB(function (err) {
                        if (err) {
                            console.error('出错了');
                            return false;
                        }
                        console.log('支付成功');
                    });
                } else {
                    createOrderByDB(function (err, order) {
                        if (err) {
                            console.error('出错了');
                            return false;
                        }
                        toPayByDB(function (err) {
                            if (err) {
                                console.error('出错了');
                                return false;
                            }
                            console.log('支付成功');
                        });
                    });
                }
            });
        } else {
            createUserByDB(function (err, user) {
                if (err) {
                    console.error('出错了');
                    return false;
                }
                getOrderByDB(function (err, order) {
                    if (err) {
                        console.error('出错了');
                        return false;
                    }
                    if (order) {
                        toPayByDB(function (err) {
                            if (err) {
                                console.error('出错了');
                                return false;
                            }
                            console.log('支付成功');
                        });
                    } else {
                        createOrderByDB(function (err, order) {
                            if (err) {
                                console.error('出错了');
                                return false;
                            }
                            toPayByDB(function (err) {
                                if (err) {
                                    console.error('出错了');
                                    return false;
                                }
                                console.log('支付成功');
                            });
                        });
                    }
                });
            });
        }
    });
}
pay();

没看懂?没看懂就对了😂。我写的时候,都是怀揣着崩溃的心情,并且检查了N遍。后期维护的时候,可能还要看N遍,才能明白这坨代码到底是什么意思。

👇引用一下颜海镜为回调函数列举了N大罪状:

  • 违反直觉
  • 错误追踪
  • 模拟同步
  • 并发执行
  • 信任问题

违反直觉:直觉就是顺序执行(将来要发生的事,在当前的步骤完成之后),从上自然的看到下面。而回调却让我们跳来跳去,跳着跳着,就不知道跳到哪去了~

错误追踪:异步的世界里,可以丢掉try catch了。但异步的错误也要处理的啊,一般会有两种方案,分离回调和first error。

jquery的ajax就是典型的分离回调

function success(data) {
    console.log(data);
};
function error(err) {
    console.error(err);
};
$.ajax({}, success, error);

Node采用的是first error,它的异步接口第一个参数都是error对象,这个参数的值如果为null,就认为没有错误。

function callback(err, data) {
    if (err) {
        // 出错
        return;
    }
    // 成功
    console.log(data);
}
async("url", callback);

回调地狱:我用回调的方式实现开篇的付款流程就已经是回调地狱了

模拟同步:比较常见的就是在循环里调用异步,这个坑曾经让我怀疑过世界。

for(var i = 0; i < 10; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        })
    })(i)
}

并发执行、信任问题:当把程序的一部分拿出来并把它执行的控制权移交给另一个第三方时,这种情况称为控制倒转。这时候就存在了信任问题,只能假装第三方是可靠的,当然也不知道会不会被并发执行,被并发执行多少次。也就是说交给第三方执行我们的回调后,需要默默的祈祷🙏...

// 第三方支付API
function weChatAPI(cb) {
    // weChatAPI做了某些我们无法掌控的事
    cb(null, 'success'); // 执行我们传来的回调
    // weChatAPI做了某些我们无法掌控的事
}

function toPay() {
    weChatAPI(function (err, data) {
        if (err) {
            console.log(err);
            return false;
        }
        console.log(data);
    });
}

toPay();

看到cb(),有股莫名的亲切感。

既然回调如此的让人头疼和不安全,那么有没有方案去尝试拯救回调呢?CommonJS工作组提出的Promise应运而生了,一出场就解决了回调的控制倒转问题,让我们与第三方API合作的时候,不再依靠祈祷了!

Promise

一开始遇到Promise的时候,我是拒绝的。看过很多Promise的博客、文章,基本都说Promise是能解决回调地狱的异步解决方案,内部具备三种状态(pending,fulfilled,rejected)。也会举一些小栗子

new Promise(function (resolve, reject) {
    doSomething(function (err, data) {
        if (err) {
            reject(err);
        }
        resolve(data);
    });
}).then(function (data) {
    console.log(data);
}, function (err) {
    console.error(err);
});

那时候的我见到这样栗子,并没有看出有什么了不起的地方,觉得这还是回调,而且增加了很多概念(原谅当年那个才疏学浅的我,虽然现在依旧才疏学浅)。

现在回过头来,再看这段简单的demo,有种惊为天人的感觉。

首先new一个Promise,将doSomething(..)包装成Promise对象,并将结果交给后续的then方法处理。神奇的解决了回调的控制倒转问题。

假设weChatAPI(..)返回的是一个Promoise对象,我们就可以在后面接上then(..)方法接收并处理它返给我们的数据了,怎么处理,什么时候处理,处理成什么样,处理几次,都是我们说的算。

weChatAPI(function (err, data) {
    // 完全交给weChatAPI去执行
    if (err) {
        console.log(err);
        return false;
    }
    console.log(data);
});
    
weChatAPI().then(function (data) {
    // 我们自己去执行并处理
    console.log(data);
}, function (err) {
    console.log(err);
})
    

后面还可以继续.then(..),以jQuery的链式风格,来处理多个异步逻辑,解决回调地狱的问题。

下面用Promise实现开篇的付款流程

// 这里假设所有异步操作的返回都是符合Promise规范的。
// 实际场景中,比如mongoose是可以配置的,异步回调也可以自己去封装
function pay() {
    return getUserByDB()
        .then(function (user) {
            if (user) return user;
            return createUserByDB();
        })
        .then(getOrderByDB)
        .then(function (order) {
            if (order) return order;
            return createOrderByDB();
        })
        .then(toPayByDB)
        .catch(function (err) {
            console.error(err);
        });
}
pay().then(function (order) {
    console.log('付款成功了');
});

现在看起来就很清晰了吧,而且与开篇的demo也比较相近了。当我将Promise运用到实际场景中后,就再也离不开他了,ajax全部包装成Promise,项目里到处充斥着Promise链,一条链横跨好几个文件。

随着Promise的各种“滥用”,最终暴露出了它的缺陷——Promise的错误处理。《你不知道的JS》甚至用了绝望的深渊来形容这种缺陷。

默认情况下,它会假定所有的错误都交给Promise处理,状态会变成rejected。如果忘记去接收和处理错误,那错误就会在Promise链中默默地消失了——这时候绝望是必然的,甚至会怀疑人生。

为了回避这个缺陷,一些开发者宣称Promise链的“最佳实践”是,总是将你的链条以catch(..)终结,就像这样:

var p = Promise.resolve( 42 );

p.then(function fulfilled(msg){
	// 数字没有字符串方法,
	// 所以这里抛出一个错误
	console.log( msg.toLowerCase() );
})
.catch( handleErrors );

因为我们没有给then(..)传递错误处理器,默认的处理器会顶替上来,它仅仅简单地将错误传播到链条的下一个promise中。如此,在p中发生的错误,与在p之后的解析中(比如msg.toLowerCase())发生的错误都将会过滤到最后的handleErrors(..)中。

似乎问题解决了,一开始,我天真的以为是的,严格按照这个规则去处理Promise链。

然而,catch(..)方法实际上是基于then(..)实现的,同样会返回一个Promise,它里面发生的异常,同样会被Promise捕获到,并将状态改为rejected。但如果没有在catch(..)后面追加错误处理器,这个错误将会永远的丢失了,变成了绝望的深渊。

幸运的是,浏览器和V8引擎可以追踪Promise对象,当它们进行垃圾回收的时候,如果检测到Promise的状态是rejected,就可以抛出未捕获的错误,将开发者从绝望的深渊中拯救出来,但却没有彻底拉出这个深渊。因为浏览器抛出的错误栈,一点也不友好(实在没法看)。

Promise虽然有着一些缺陷,但只要谨慎运用,它还是会给我们带来很多难以想象的好处的。

Promise虽然没有彻底摆脱回调,但它对回调进行了重新组织,解决了臭名昭著的回调地狱,同时也解决了肆虐在回调代码中的控制倒转问题。

Promise链还开始以顺序的风格定义了一种更好的(当然,还不完美)表达异步流程的方式,它帮我们的大脑更好的规划和维护异步JS代码。

Generator

在阮一峰的博客里看到Generator 函数的含义与用法,虽然阮大神讲的很浅显易懂(现在的看法),但当时我是一脸懵逼。

重读阮大神这篇文章,我注意到里面用了很小篇幅介绍的一个概念——协程(coroutine),意思是多个线程互相协作,完成异步任务。理解了它的流程,我觉得也就理解了generator。

以下是协程的简化流程。

第一步,协程A开始执行。

第二步,协程A执行到一半,进入暂停,执行权转移到协程B。

第三步,(一段时间后)协程B交还执行权。

第四步,协程A恢复执行。

对于generator,关键字yield则负责第二步和第三步,暂停和转移执行权。换句话说,将执行权交给协程B(协程B开始运行),并等待协程B交还执行权(协程B运行结束)。

与协程不同的是第四步。generator暂停,就是停止了,不会自动走第四步。因为协程B交还的执行权,被yield转让出去了,由外部去控制协程A是否继续恢复执行。

还是举个例子吧

function B() {
    // 协程B可以是字符串、同步函数、异步函数、对象、数组
    // 这里用函数更能说明问题
    console.log('协程B拿到了执行权');
    return '协程B交还了执行权';
}

function * A() {
    console.log('协程A第一部分逻辑');
    let A2 = yield B();
    console.log('协程A第二部分逻辑');
    return A2;
}

let it = A();
// it 就是generator A返回的一个指针。或者A就是个倔强的骏马,而it则是它的主人。
console.log(it.next()); // next是主人手里的鞭子。这时候,鞭子抽了一下,骏马开始跑起来了。
// 打印出:协程A第一部分逻辑。
// 打印出:协程B拿到了执行权。
// 打印出:{value: '协程B交还了执行权', done: false}
// 此时骏马停住了,确实倔强。抽了一鞭子,就走了这么点路
console.log(it.next()); // 于是又抽了一鞭子
// 打印出:协程A第二部分逻辑
// 打印出:{value: undefined, done: true}
// 看到done的值是true了,表示骏马跑完了赛道。

惯例,用generator实现以下开篇的支付流程吧。

function * Pay() {
    // 这四个变量是为了更好的说明这个过程
    // 其实只需user 和  order 两个变量就能解决问题
    let oldUser = null;
    let newUser = null;
    let oldOrder = null;
    let newOrder = null;
    try {
        let oldUser = yield getUserByDB();
        if (!oldUser) {
            newUser = yield createUserByDB();
        }
        let oldOrder = yield getOrderByDB();
        if (!oldOrder) {
            newOrder = yield createOrderByDB();
        }
        const result = yield toPayByDB();
        return result;
    } catch (error) {
        console.error('支付失败');
    }
}

const pay = Pay();
pay.next().value.then(function (user) { // 执行getUserByDB(),得到user,并停止
    // user不存在,next()不传值,则oldUser被赋值为undefined,然后执行createUserByDB(),得到user,并停止
    if (!user) return pay.next().value;
    return user; // 如果user存在,直接返回
}).then(function (user) {
    // 这个next(user)就有点复杂了。
    // 如果代码在执行了getUserByDB()后停止的,则next(user)就是把user赋值给oldUser
    // 如果代码在执行了createUserByDB()后停止的,则next(user)就是user赋值给newUser
    // 然后执行getOrderByDB(),得到order,并停止
    return pay.next(user).value.then(function (order) {
        // order不存在,next()不传值,则oldOrder被赋值为undefined,然后执行createOrderByDB(),得到order,并停止
        if (!order) return pay.next().value;
        return order; // 如果order存在,直接返回
    });
}).then(function (order) {
    // 这个next(order)同样。
    // 如果代码在执行了getOrderByDB()后停止的,则next(order)就是把order赋值给oldOrder
    // 如果代码在执行了createOrderByDB()后停止的,则next(order)就是order赋值给newOrder
    // 然后执行toPayByDB(),并停止。
    return pay.next(order).value; //  done的值为false
}).then(function () {
    // next(),将undefined赋值给result,并返回result
    pay.next(); // 此时done的值为true
});

不看下面的抽鞭子逻辑,只看*Pay(..)逻辑,是不是感觉无限接近开篇的demo了,只是关键字不同而已。至于抽鞭子逻辑,我是疯了。

跟纯Promise实现的demo相比,虽然前面的逻辑更加接近顺序执行,同时还能找回丢失已久的try catch来处理错误。但是后面的抽鞭子逻辑,恕我不敢苟同。

幸运是的tj大神出品的CO库则帮我们接过了鞭子,自动去抽打这匹倔强的骏马。下面用CO库实现上面的逻辑。

// Pay依然是上面的generator
co(Pay()).then(function () {
    console.log('支付完成了');
});

一下感觉整个世界都清净了不少,可以愉快的享受generator带给我们的快感了。

虽然CO封装的generator用起来感觉很爽,但(看到这个字,我想到了辩证法,凡是都有两面性)CO约定,yield后面只能跟 Thunk 函数或 Promise 对象。而且抛出的错误栈也极其的不友好,可参考egg团队的分析

此时我依然不明白,yield为什么要把执行权转让出去。《你不知道的JS》中关于这个的解释大致就是,为了打破“运行至完成”这个常规行为,希望外部可以控制generator的内部运行。恕我才疏学浅,我更愿意相信这是给async/await的出场做铺垫。

async/await

async/await就像自然界遵循着进化论一样,从最初的回调一步一步的演化而来,达到异步编程的最高境界,就是根本不用关心它是不是异步

async function demo() {
    try {
        const a = await 1;
        console.log(a); // 1
        const b = await [2];
        console.log(b); // [2]
        const c = await { c: 3 };
        console.log(c); // {c: 3}
        const d = await (function () {
            return 4;
        })();
        console.log(d); // 4
        const e = await Promise.resolve(5);
        console.log(e); // 5
        throw new Error(6);
        // 不执行
        console.log(7);
    } catch (error) {
        console.log(error); // 6
    }
}
demo();

篇首的例子加上面的例子,足可说明,async/await已经达到异步编程的最高境界了。

简单就是美。

参考:

1、《你不懂的JS:异步与性能》

2、异步编程那些事

3、Generator 函数的含义与用法

4、async 函数的含义和用法