ES6基础系列 —— 理解Generator及其应用场景

今天给团队做的一次关于Generator的分享,整理至此。这次分享的目的是:循序渐进地让大家对ES6的Generator有一个初步的了解、然后加深理解、再通过一个run函数知晓其应用场景,最后明晰Generator带来的意义。

一、开篇

我们比较常遇见异步的两个场景:顺序调用和并行调用。

我们平时的异步处理一般是这样写的:

1
var getList1 = (success) => {
    $.ajax({
        url: 'http://sapi.bbeeii.com/martgoods/category/fightgroup.html',
        type: 'GET',
        dataType: 'jsonp',
        jsonpCallback: 'bbeeiiMartgoodsCategoryGet',
        success
    });
};
var getList2 = (success) => {
    $.ajax({
        url: 'http://sapi.bbeeii.com/martshow/1-15----1.html',
        type: 'GET',
        dataType: 'jsonp',
        jsonpCallback: 'bbeeiiMartshowGet',
        success
    });
};
1
// 顺序调用
getList1((resp1) => {
    // do something
    getList2(resp2 => {
        // do something
        // ..... 回调地狱
    });
});

// 可能的并行调用
var getAllList = (getLists, callback) => {
    const listLen = getLists.length;
    const resultArr = Array.from({
        length: listLen
    });

    getLists.forEach((getList, index) => {
        getList((resp) => {
            resultArr[index] = resp;

            // 简单判断 中间要做各种兼容处理、错误判断,比较复杂
            if (resultArr.filter((result) => result).length === listLen) {
                callback(resultArr);
            }
        });
    });
};

上面两个例子相对还比较简单,但当我们把场景搬到Node上,我们毫无疑问会遇到两个问题:

  1. 错误处理
  2. 回调地狱

二、async.js / Promise

1
// 顺序调用
async.series([
        (callback) => callback(null, 'one'),
        (callback) => callback(null, 'two')
    ],
    // optional callback
    (err, results) => {
        // results is now equal to ['one', 'two']
    });

// 并行调用
async.parallel([
        (callback) => setTimeout(() => callback(null, 'one'), 200),
        (callback) => setTimeout(() => callback(null, 'two'), 100)
    ],
    // optional callback
    (err, results) => {
        // the results array will equal ['one','two'] even though
        // the second function had a shorter timeout.
    });

我们可以理解为,async.js提供了异步流程控制,相当于上述开篇的进一步封装,但其仍是各种callback。

同理,Promise尽管提供了链式的调用,看起来告别了回调地狱,提供了非常大的便利,但本质还是“异步的编写方式”,写太多then也比较繁琐(因为不在本文主题之内,不做发散)。

三、Generator

开始之前

Generator最初步的了解,需要大家至少从 阮一峰老师的ES6 里了解对应的function*、yield、next、value、done这几个概念。

先抛一个最简单的理解:就把他看成一种新的语法,Generator函数执行,返回一个对象,对象拥有next方法。next方法每一次执行,依次迭代一个yield,返回一个新的对象,该对象拥有value、done属性。

3.1 yield后面语句执行时机

1
// 2个log哪个先出来
const gen = function*() {
    yield setTimeout(() => {
        console.log('gen');
    }, 1000);
};
// Generator实例 拥有next方法
var genResult = gen();

// next调用执行后,返回一个对象,包含value与done属性
console.log(genResult.next());

知识点一:这里有一个误区,有可能会误认为Generator执行环境在另一个线程中,但实际上不是。yield后面的语句,还是按照正常的环境在执行,yield相当于把该行的语句推出来执行,并且从Generator执行环境中退出来。

再换一个方式,大家再试试

1
let test = 1;
const gen = function*() {
    yield test = 2;
    test = 3;
};
const result = gen();
console.log(test); // 1
console.log(result.next().value); // 2
// 补充注意:当迭代遇到return、发生错误、或最后一个yield执行完成再执行一遍next后,才会将done变更为true
console.log(result.next().value, test); // undefined 3

3.2 yield的返回值

1
var gen = function*() {
    var b = yield 1 + 2;
    console.log('b', b);
};
var result = gen();
console.log('第1次迭代', result.next());
console.log('第2次迭代', result.next());

// 依次出现:
// 第1次迭代 Object {value: 3, done: false}
// b undefined
// 第2次迭代 Object {value: undefined, done: true}

这里或许大家会好奇,为什么b是undefined,而第一次迭代出来的对象,value属性得到了 1+2 的值3。

知识点二:yield语句没有返回值,当 Generator实例调用next()方法后,返回对象value属性,得到了这一次yield 或return 后面语句的求值情况。

结合知识点一,可以把yield看成一种类return的语法。其返回值,只作用在next方法调用后返回的对象的value属性上。而其自身,是没有返回值的。

我们可以试着玩一点改造:把yield 语句换成 yieldFunc,并对后面语句进行一层function包裹

1
const yieldFunc = (() => {
    let yieldResult = undefined;
    return () => {
        yieldResult = 1 + 2;
    };
})();
yieldFunc();

如上,类似的,yieldFunc没有返回值,但是yieldResult得到了3。

再让我们把玩一番:将 function* 改造为 generatorFunc,进行一层function包裹。并将每一次yield、return拆分为不同的字符串,用于eval执行。

1
const generatorFunc = (arr) => {
    let i = 0;
    let yieldResult = undefined;
    const len = arr.length;

    const yieldFunc = (callback) => {
        yieldResult = callback();
    };

    // 用于保留作用域
    let evalCall = (i) => {
        eval(arr[i]);
        evalCall = () => eval(arr[i + 1]);
    };

    return () => {
        return {
            next() {
                evalCall(i);
                i += 1;
                return {
                    value: yieldResult,
                    done: i === len
                }
            }
        }
    };
};

const gen = generatorFunc([`var a = 1; var b = yieldFunc(function () {
        return 1 + 2;
    })`,
    'yieldFunc(function(){console.log("a=" + a + ";b=" + b)});'
]);
const result = gen();

console.log('第1次迭代', result.next());
console.log('第2次迭代', result.next());

// 依次出现
// 第1次迭代 Object {value: 3, done: false}
// a=1;b=undefined
// 第2次迭代 Object {value: undefined, done: true}

是不是发现还挺好玩的?

但是上面的处理,只是最基本的模拟实现,不支持next传递参数(好像说了什么不得了的东西)。

3.3 next传递参数

1
const gen = function*() {
    const b = yield 1 + 2;
    const c = yield b + '测试';
    console.log('c', c);
};
const result = gen();
console.log('第1次迭代', result.next());
console.log('第2次迭代', result.next('第2次迭代'));
console.log('第3次迭代', result.next('第3次迭代'));

// 依次出现:
// 第1次迭代 Object {value: 3, done: false}
// 第2次迭代 Object {value: "第2次迭代测试", done: false}
// c 第3次迭代
// 第3次迭代 Object {value: undefined, done: true}

我们发现,第一次迭代,得到的value 3毫无疑问,是第一次yield出来的求值情况。而yield是没有返回值的,为什么第二次迭代中,b获得了第2次迭代这个字符串,而在第三次迭代中,为什么c获得了第3次迭代这个字符串。

知识点三:next方法可以接受参数,参数替代了上一次yield语句的位置,被作为“返回值”。

Generator总结

记住上面三个知识点,非常重要。

巩固一下最简单的理解(表现层面):把他看成一种新的语法,Generator函数执行,返回一个对象,对象拥有next方法。

  1. next方法每一次执行,依次迭代一个yield(这时候才推出后面的语句,进行执行),返回一个新的对象
  2. yield语句类似于return 本身没有返回值,但其后面语句的求值结果会被作为返回对象的value属性
  3. next方法可以接受参数,参数被当作上一次yield 语句的返回值

下面开始明晰其应用场景

Generator应用场景

至关重要的run函数

1
const run = (fn) => {
    const gen = fn();
    const next = (data) => {
        // 第一次传递的data undefined
        const result = gen.next(data);
        if (result.done) return;
        // 只要保证genrator 内部 yield 出来的东西是函数即可
        // yield 出来的是一个函数,接受另一个函数作为参数
        // 也就达到了顺序执行的目的
        result.value(next);
    }
    next();
};
1
var getList1 = (success) => {
    $.ajax({
        url: 'http://sapi.bbeeii.com/martgoods/category/fightgroup.html',
        type: 'GET',
        dataType: 'jsonp',
        jsonpCallback: 'bbeeiiMartgoodsCategoryGet',
        success
    });
};
var getList2 = (success) => {
    $.ajax({
        url: 'http://sapi.bbeeii.com/martshow/1-15----1.html',
        type: 'GET',
        dataType: 'jsonp',
        jsonpCallback: 'bbeeiiMartshowGet',
        success
    });
};
var genFunc = function*() {
    // 注意a/b,是通过 上面的data传进来的东西(而不是函数的返回值)
    // 写惯了异步,还是非常不习惯的
    var a = yield getList1;
    var b = yield getList2;
    console.log(a, b);
};
run(genFunc);

记住上面提到的三个知识点,能理解a、b值的情况,相信对Generator理解的问题,便不大了。

再让我们加深下理解。

1
run(function * () {
    var a = yield (callback) => {
        setTimeout(() => {
            alert(1);
            callback(2);
        }, 1000);
    }

    var b = yield (callback) => {
        setTimeout(() => {
            console.log(a, b);
            var c = a + b;
            callback(c);
        }, 3000);
    }
    console.log(a, b);
});

其他

  1. 话糙的最关键点一:yield没有返回值,通过.next传递进来的值,代替上一个yield语句执行的地方;
  2. 话糙的最关键点二:yield出来的东西,作为.next迭代出来的对象的value属性
  3. 概念:不是两个线程,是协程(具体名词还未深究)。但会发现,我们完全用了同步的写法,而底层机制仍然是异步,这是比较精髓的地方。
  4. 当我们把上面的data参数,换成error, data,结合generator.throw,进行try catch等操作,包括yield语句本身可以推出任何东西,这里面的可玩空间,就非常大了。
  5. TJ的co库,应该也是基于上面类似的方式,并且与Promise、thunkify结合,了解run函数,也是从thunkify了解到。
  6. 理解了Generator的概念/机制/应用场景,对ES7 async/await的理解,KOA的使用,相信后续也会方便很多。

参考来源: