JS异步编程的浅思

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

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的时代。html

回调函数

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

setTimeout(function () {
    console.log(1);
}, 1000);
console.log(2);
复制代码

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

setTimeout(function () {
    console.log(1);
}, 0);
console.log(2);
复制代码

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

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

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遍,才能明白这坨代码究竟是什么意思。github

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

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

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

错误追踪:异步的世界里,能够丢掉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已经达到异步编程的最高境界了。

简单就是美。

参考:

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

二、异步编程那些事

三、Generator 函数的含义与用法

四、async 函数的含义和用法

相关文章
相关标签/搜索