漫话JavaScript与异步·第三话——Generator:化异步为同步

1、Promise并不是完美

我在上一话中介绍了Promise,这种模式加强了事件订阅机制,很好地解决了控制反转带来的信任问题、硬编码回调执行顺序形成的“回调金字塔”问题,无疑大大提升了前端开发体验。但有了Promise就能完美地解决异步问题了吗?并无。html

首先,Promise仍然须要经过then方法注册回调,虽然只有一层,但沿着Promise链一长串写下来,仍是有些让人头晕。前端

更大的问题在于Promise的错误处理比较麻烦,由于Promise链中抛出的错误会一直传到链尾,但在链尾捕获的错误却不必定清楚来源。并且,链中抛出的错误会fail掉后面的整个Promise链,若是要在链中及时捕获并处理错误,就须要给每一个Promise注册一个错误处理回调。噢,又是一堆回调!程序员

那么最理想的异步写法是怎样的呢?像同步语句那样直观地按顺序执行,却又不会阻塞主线程,最好还能用try-catch直接捕捉抛出的错误。也就是说,“化异步为同步”!es6

痴心妄想?ajax

我在第一话里提到,异步和同步之间的鸿沟在于:同步语句的执行时机是“如今”,而异步语句的执行时机在“将来”。为了填平鸿沟,若是一个异步操做要写成同步的形式,那么同步代码就必须有“等待”的能力,等到“将来”变成“如今”的那一刻,再继续执行后面的语句。算法

在不阻塞主线程的前提下,这可能吗?框架

听起来不太可能。幸亏,Generator(生成器)为JS带来了这种超能力!异步

 

2、“暂停/继续”魔法

ES6引入的新特性中,Generator多是其中最强大也最难理解的之一,即便看了阮一峰老师列举的大量示例代码,知道了它的所有API,也还是不得要领,这是由于Generator的行为方式突破了咱们所熟知的JS运行规则。可一旦掌握了它,它就能赋予咱们巨大的能量,极大地提高代码质量、开发效率,以及FEer的幸福指数。async

咱们先来简单回顾一下,ES6以前的JS运行规则是怎样的呢?函数

1. JS是单线程执行,只有一个主线程

2. 宿主环境提供了一个事件队列,随着事件被触发,相应的回调函数被放入队列,排队等待执行 

3. 函数内的代码从上到下顺序执行;若是遇到函数调用,就先进入被调用的函数执行,待其返回后,用返回值替代函数调用语句,而后继续顺序执行

对于一个FEer来讲,平常开发中理解到这个程度已经够用了,直到他尝试使用Generator……

function* gen() {
    let count = 0;
    while(true) {
        let msg = yield ++count;
        console.log(msg);
    }
}

let iter = gen();
console.log(iter.next().value);
// 1
console.log(iter.next('magic').value);
// 'magic'
// 2

等等,gen明明是个function,执行它时却不执行里面的代码,而是返回一个Iterator对象?代码执行到yield处居然能够暂停?暂停之后,居然能够恢复继续执行?说好的单线程呢?另外,暂停/恢复执行时,还能够传出/传入数据?怎么肥四?难道ES6对JS作了什么魔改?

其实Generator并无改变JS运行的基本规则,不过套用上面的naive JS观已经不足以解释其实现逻辑了,是时候掏出终年在书架上吃灰的计算机基础,重温那些考完试就忘掉的知识。

  

3、法力的秘密——栈与堆

(注:这个部分包含了大量的我的理解,未必准确,欢迎指教)

理解Generator的关键点在于理解函数执行时,内存里发生了什么

一个JS程序的内存分为代码区、栈区、堆区和队列区,从MDN借图一张以说明(图中没有画出代码区):

队列(Queue)就是FEer所熟知的事件循环队列。

代码区保存着所有JS源代码被引擎编译成的机器码(以V8为例)。

栈(stack)保存着每一个函数执行所需的上下文,一个栈元素被称为一个栈帧,一个栈帧对应一个函数。

对于引用类型的数据,在栈帧里只保存引用,而真正的数据存放在堆(Heap)里。堆与栈不一样的是,栈内存由JS引擎自动管理,入栈时分配空间,出栈时回收,很是清楚明了;而堆是程序员经过new操做符手动向操做系统申请的内存空间(固然,用字面量语法建立对象也算),什么时候该回收没那么明晰,因此须要一套垃圾收集(GC)算法来专门作这件事。

扯了一堆预备知识,终于能够回到Generator的正题了:

普通函数在被调用时,JS引擎会建立一个栈帧,在里面准备好局部变量函数参数临时值代码执行的位置(也就是说这个函数的第一行对应到代码区里的第几行机器码),在当前栈帧里设置好返回位置,而后将新帧压入栈顶。待函数执行结束后,这个栈帧将被弹出栈而后销毁,返回值会被传给上一个栈帧。

当执行到yield语句时,Generator的栈帧一样会被弹出栈外,但Generator在这里耍了个花招——它在堆里保存了栈帧的引用(或拷贝)!这样当iter.next方法被调用时,JS引擎便不会从新建立一个栈帧,而是把堆里的栈帧直接入栈。由于栈帧里保存了函数执行所需的所有上下文以及当前执行的位置,因此当这一切都被恢复如初之时,就好像程序从本来暂停的地方继续向前执行了。

而由于每次yield和iter.next都对应一次出栈和入栈,因此能够直接利用已有的栈机制,实现值的传出和传入

这就是Generator魔法背后的秘密!

 

4、终极方案:Promise+Generator

Generator的这种特性对于异步来讲,意味着什么呢?

意味着,咱们终于得到了一种在不阻塞主线程的前提下实现“同步等待”的方法!

为便于说明,先上一段直接使用回调的代码:

let it = gen();  // 得到迭代器 function request() {
    ajax({
        url: 'www.someurl.com',
        onSuccess(res){
            it.next(res);  // 恢复Generator运行,同时向其中塞入异步返回的结果
        }
    });
}

function* gen() {
    let response = yield request();
    console.log(response.text);
}

it.next();  // 启动Generator

注意let response = yield request()这行代码,是否是颇有同步的感受?就是这个Feel!

咱们来仔细分析下这段代码是如何运行的。首先,最后一行it.next()使得Generator内部的代码从头开始执行,执行到yield语句时,暂停,此时能够把yield想象成return,Generator的栈帧须要被弹出,会先计算yield右边的表达式,即执行request函数调用,以得到用于返回给上一级栈帧的值。固然request函数没有返回值,但它发送了一个异步ajax请求,并注册了一个onSuccess回调,表示在请求返回结果时,恢复Generator的栈帧并继续运行代码,并把结果做为参数塞给Generator,准确地说是塞到yield所在的地方,这样response变量就得到了ajax的返回值。

能够看出,这里yield的功能设计得很是巧妙,好像它能够“赋值”给response。

更妙的是,迭代器不但能够.next,还能够.throw,即把错误也抛入Generator,让后者来处理。也就是说,在Generator里使用try-catch语句捕获异步错误,再也不是梦!

先别急着激动,上面的代码仍是too young too simple,要真正发挥Generator处理异步的威力,还得结合他的好兄弟——Promise一块儿上阵。代码以下:

function request() {  // 此处的request返回的是一个Promise
    return new Promise((resolve, reject) => {
        ajax({
            url: 'www.someurl.com',
            onSuccess(res) {
                resolve(res);
            },
            onFail(err) {
                reject(err);
            }
         });
    });
}

let it = gen();
let p = it.next().value;  // p是yield返回的Promise
p.then(res => it.next(res),
    err => it.throw(err)  // 发生错误时,将错误抛入生成器
);

function* gen() {
    try {
        let response = yield request();
        console.log(response.text);
    } catch (error) {
        console.log('Ooops, ', error.message);  // 能够捕获Promise抛进来的错误!
    }
}

这种写法完美结合了Promise和Generator的优势,能够说是FEer们求之不得的超级武器。

但聪明的你必定看得出来,这种写法套路很是固定,当Promise对象一多时,就须要写许多相似于p.then(res => ...., err => ...)这样的重复语句,因此人们为了偷懒,就把这种套路给提炼成了一个更加精简的语法,那就是传说中的async/await

async funtion fetch() {
    try {
        let response = await request();  // request定义同上一端段示例代码
        console.log(response.text);
    } catch (error) {
        console.log('Ooops, ', error.message);
    }
}

fetch();

这这这。。。就靠拢同步风格的程度而言,我以为async/await已经到了登峰造极的地步~

顺便说一句,著名Node.js框架Koa2正是要求中间件使用这种写法,足见其强大和可爱。

前端们,擦亮手中的新锐武器,准备迎接来自异步的高难度挑战吧!

 

写在最后

距离发表第二话(Promise)已通过去大半年了,本来设想的终章——第三话(Generator),却迟迟未能动笔,由于笔者一直没能弄懂Generator这个行为怪异的家伙到底是如何存在于JS世界的,又如何成为“回调地狱”的终极解决方案?直到回头弥补了一些计算机基础知识,才最终突破了理解上的障碍,把Generator的前因后果想清楚,从而敢应用到实际工做中。因此说,基础是很重要的,这是永不过期的真理。前端发展很是迅速,框架、工具突飞猛进,只有基础扎实,才能从容应对,任他风起云涌,我自稳坐钓鱼台。

相关文章
相关标签/搜索