53

漫话JavaScript与异步·第三话——Generator:化异步为同步 - 大唐西域都护

 6 years ago
source link: https://www.cnblogs.com/leegent/p/8207246.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一、Promise并非完美

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

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

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

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

痴心妄想?

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

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

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

二、“暂停/继续”魔法

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

我们先来简单回顾一下,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观已经不足以解释其实现逻辑了,是时候掏出长年在书架上吃灰的计算机基础,重温那些考完试就忘掉的知识。

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

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

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

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

900937-20180107212418581-1346513923.png

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

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

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

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

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

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

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

而因为每次yield和iter.next都对应一次出栈和入栈,所以可以直接利用已有的栈机制,实现值的传出和传入

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

四、终极方案: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的来龙去脉想清楚,从而敢应用到实际工作中。所以说,基础是很重要的,这是永不过时的真理。前端发展非常迅速,框架、工具日新月异,只有基础扎实,才能从容应对,任他风起云涌,我自稳坐钓鱼台。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK