ES6 系列之 Generator 的自動執行

冴羽發表於2018-10-18

單個非同步任務

var fetch = require('node-fetch');

function* gen(){
    var url = 'https://api.github.com/users/github';
    var result = yield fetch(url);
    console.log(result.bio);
}
複製程式碼

為了獲得最終的執行結果,你需要這樣做:

var g = gen();
var result = g.next();

result.value.then(function(data){
    return data.json();
}).then(function(data){
    g.next(data);
});
複製程式碼

首先執行 Generator 函式,獲取遍歷器物件。

然後使用 next 方法,執行非同步任務的第一階段,即 fetch(url)。

注意,由於 fetch(url) 會返回一個 Promise 物件,所以 result 的值為:

{ value: Promise { <pending> }, done: false }
複製程式碼

最後我們為這個 Promise 物件新增一個 then 方法,先將其返回的資料格式化(data.json()),再呼叫 g.next,將獲得的資料傳進去,由此可以執行非同步任務的第二階段,程式碼執行完畢。

多個非同步任務

上節我們只呼叫了一個介面,那如果我們呼叫了多個介面,使用了多個 yield,我們豈不是要在 then 函式中不斷的巢狀下去……

所以我們來看看執行多個非同步任務的情況:

var fetch = require('node-fetch');

function* gen() {
    var r1 = yield fetch('https://api.github.com/users/github');
    var r2 = yield fetch('https://api.github.com/users/github/followers');
    var r3 = yield fetch('https://api.github.com/users/github/repos');

    console.log([r1.bio, r2[0].login, r3[0].full_name].join('\n'));
}
複製程式碼

為了獲得最終的執行結果,你可能要寫成:

var g = gen();
var result1 = g.next();

result1.value.then(function(data){
    return data.json();
})
.then(function(data){
    return g.next(data).value;
})
.then(function(data){
    return data.json();
})
.then(function(data){
    return g.next(data).value
})
.then(function(data){
    return data.json();
})
.then(function(data){
    g.next(data)
});
複製程式碼

但我知道你肯定不想寫成這樣……

其實,利用遞迴,我們可以這樣寫:

function run(gen) {
    var g = gen();

    function next(data) {
        var result = g.next(data);

        if (result.done) return;

        result.value.then(function(data) {
            return data.json();
        }).then(function(data) {
            next(data);
        });

    }

    next();
}

run(gen);
複製程式碼

其中的關鍵就是 yield 的時候返回一個 Promise 物件,給這個 Promise 物件新增 then 方法,當非同步操作成功時執行 then 中的 onFullfilled 函式,onFullfilled 函式中又去執行 g.next,從而讓 Generator 繼續執行,然後再返回一個 Promise,再在成功時執行 g.next,然後再返回……

啟動器函式

在 run 這個啟動器函式中,我們在 then 函式中將資料格式化 data.json(),但在更廣泛的情況下,比如 yield 直接跟一個 Promise,而非一個 fetch 函式返回的 Promise,因為沒有 json 方法,程式碼就會報錯。所以為了更具備通用性,連同這個例子和啟動器,我們修改為:

var fetch = require('node-fetch');

function* gen() {
    var r1 = yield fetch('https://api.github.com/users/github');
    var json1 = yield r1.json();
    var r2 = yield fetch('https://api.github.com/users/github/followers');
    var json2 = yield r2.json();
    var r3 = yield fetch('https://api.github.com/users/github/repos');
    var json3 = yield r3.json();

    console.log([json1.bio, json2[0].login, json3[0].full_name].join('\n'));
}

function run(gen) {
    var g = gen();

    function next(data) {
        var result = g.next(data);

        if (result.done) return;

        result.value.then(function(data) {
            next(data);
        });

    }

    next();
}

run(gen);
複製程式碼

只要 yield 後跟著一個 Promise 物件,我們就可以利用這個 run 函式將 Generator 函式自動執行。

回撥函式

yield 後一定要跟著一個 Promise 物件才能保證 Generator 的自動執行嗎?如果只是一個回撥函式呢?我們來看個例子:

首先我們來模擬一個普通的非同步請求:

function fetchData(url, cb) {
    setTimeout(function(){
        cb({status: 200, data: url})
    }, 1000)
}
複製程式碼

我們將這種函式改造成:

function fetchData(url) {
    return function(cb){
        setTimeout(function(){
            cb({status: 200, data: url})
        }, 1000)
    }
}
複製程式碼

對於這樣的 Generator 函式:

function* gen() {
    var r1 = yield fetchData('https://api.github.com/users/github');
    var r2 = yield fetchData('https://api.github.com/users/github/followers');

    console.log([r1.data, r2.data].join('\n'));
}
複製程式碼

如果要獲得最終的結果:

var g = gen();

var r1 = g.next();

r1.value(function(data) {
    var r2 = g.next(data);
    r2.value(function(data) {
        g.next(data);
    });
});
複製程式碼

如果寫成這樣的話,我們會面臨跟第一節同樣的問題,那就是當使用多個 yield 時,程式碼會迴圈巢狀起來……

同樣利用遞迴,所以我們可以將其改造為:

function run(gen) {
    var g = gen();

    function next(data) {
        var result = g.next(data);

        if (result.done) return;

        result.value(next);
    }

    next();
}

run(gen);
複製程式碼

run

由此可以看到 Generator 函式的自動執行需要一種機制,即當非同步操作有了結果,能夠自動交回執行權。

而兩種方法可以做到這一點。

(1)回撥函式。將非同步操作進行包裝,暴露出回撥函式,在回撥函式裡面交回執行權。

(2)Promise 物件。將非同步操作包裝成 Promise 物件,用 then 方法交回執行權。

在兩種方法中,我們各寫了一個 run 啟動器函式,那我們能不能將這兩種方式結合在一些,寫一個通用的 run 函式呢?我們嘗試一下:

// 第一版
function run(gen) {
    var gen = gen();

    function next(data) {
        var result = gen.next(data);
        if (result.done) return;

        if (isPromise(result.value)) {
            result.value.then(function(data) {
                next(data);
            });
        } else {
            result.value(next)
        }
    }

    next()
}

function isPromise(obj) {
    return 'function' == typeof obj.then;
}

module.exports = run;
複製程式碼

其實實現的很簡單,判斷 result.value 是否是 Promise,是就新增 then 函式,不是就直接執行。

return Promise

我們已經寫了一個不錯的啟動器函式,支援 yield 後跟回撥函式或者 Promise 物件。

現在有一個問題需要思考,就是我們如何獲得 Generator 函式的返回值呢?又如果 Generator 函式中出現了錯誤,就比如 fetch 了一個不存在的介面,這個錯誤該如何捕獲呢?

這很容易讓人想到 Promise,如果這個啟動器函式返回一個 Promise,我們就可以給這個 Promise 物件新增 then 函式,當所有的非同步操作執行成功後,我們執行 onFullfilled 函式,如果有任何失敗,就執行 onRejected 函式。

我們寫一版:

// 第二版
function run(gen) {
    var gen = gen();

    return new Promise(function(resolve, reject) {

        function next(data) {
            try {
                var result = gen.next(data);
            } catch (e) {
                return reject(e);
            }

            if (result.done) {
                return resolve(result.value)
            };

            var value = toPromise(result.value);

            value.then(function(data) {
                next(data);
            }, function(e) {
                reject(e)
            });
        }

        next()
    })

}

function isPromise(obj) {
    return 'function' == typeof obj.then;
}

function toPromise(obj) {
    if (isPromise(obj)) return obj;
    if ('function' == typeof obj) return thunkToPromise(obj);
    return obj;
}

function thunkToPromise(fn) {
    return new Promise(function(resolve, reject) {
        fn(function(err, res) {
            if (err) return reject(err);
            resolve(res);
        });
    });
}

module.exports = run;
複製程式碼

與第一版有很大的不同:

首先,我們返回了一個 Promise,當 result.done 為 true 的時候,我們將該值 resolve(result.value),如果執行的過程中出現錯誤,被 catch 住,我們會將原因 reject(e)

其次,我們會使用 thunkToPromise 將回撥函式包裝成一個 Promise,然後統一的新增 then 函式。在這裡值得注意的是,在 thunkToPromise 函式中,我們遵循了 error first 的原則,這意味著當我們處理回撥函式的情況時:

// 模擬資料請求
function fetchData(url) {
    return function(cb) {
        setTimeout(function() {
            cb(null, { status: 200, data: url })
        }, 1000)
    }
}
複製程式碼

在成功時,第一個引數應該返回 null,表示沒有錯誤原因。

優化

我們在第二版的基礎上將程式碼寫的更加簡潔優雅一點,最終的程式碼如下:

// 第三版
function run(gen) {

    return new Promise(function(resolve, reject) {
        if (typeof gen == 'function') gen = gen();

        // 如果 gen 不是一個迭代器
        if (!gen || typeof gen.next !== 'function') return resolve(gen)

        onFulfilled();

        function onFulfilled(res) {
            var ret;
            try {
                ret = gen.next(res);
            } catch (e) {
                return reject(e);
            }
            next(ret);
        }

        function onRejected(err) {
            var ret;
            try {
                ret = gen.throw(err);
            } catch (e) {
                return reject(e);
            }
            next(ret);
        }

        function next(ret) {
            if (ret.done) return resolve(ret.value);
            var value = toPromise(ret.value);
            if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
            return onRejected(new TypeError('You may only yield a function, promise ' +
                'but the following object was passed: "' + String(ret.value) + '"'));
        }
    })
}

function isPromise(obj) {
    return 'function' == typeof obj.then;
}

function toPromise(obj) {
    if (isPromise(obj)) return obj;
    if ('function' == typeof obj) return thunkToPromise(obj);
    return obj;
}

function thunkToPromise(fn) {
    return new Promise(function(resolve, reject) {
        fn(function(err, res) {
            if (err) return reject(err);
            resolve(res);
        });
    });
}

module.exports = run;
複製程式碼

co

如果我們再將這個啟動器函式寫的完善一些,我們就相當於寫了一個 co,實際上,上面的程式碼確實是來自於 co……

而 co 是什麼? co 是大神 TJ Holowaychuk 於 2013 年 6 月釋出的一個小模組,用於 Generator 函式的自動執行。

如果直接使用 co 模組,這兩種不同的例子可以簡寫為:

// yield 後是一個 Promise
var fetch = require('node-fetch');
var co = require('co');

function* gen() {
    var r1 = yield fetch('https://api.github.com/users/github');
    var json1 = yield r1.json();
    var r2 = yield fetch('https://api.github.com/users/github/followers');
    var json2 = yield r2.json();
    var r3 = yield fetch('https://api.github.com/users/github/repos');
    var json3 = yield r3.json();

    console.log([json1.bio, json2[0].login, json3[0].full_name].join('\n'));
}

co(gen);
複製程式碼
// yield 後是一個回撥函式
var co = require('co');

function fetchData(url) {
    return function(cb) {
        setTimeout(function() {
            cb(null, { status: 200, data: url })
        }, 1000)
    }
}

function* gen() {
    var r1 = yield fetchData('https://api.github.com/users/github');
    var r2 = yield fetchData('https://api.github.com/users/github/followers');

    console.log([r1.data, r2.data].join('\n'));
}

co(gen);
複製程式碼

是不是特別的好用?

ES6 系列

ES6 系列目錄地址:github.com/mqyqingfeng…

ES6 系列預計寫二十篇左右,旨在加深 ES6 部分知識點的理解,重點講解塊級作用域、標籤模板、箭頭函式、Symbol、Set、Map 以及 Promise 的模擬實現、模組載入方案、非同步處理等內容。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章