跳到主要內容
黯羽輕揚每天積累一點點

向 WindJS 致敬_Node 非同步流程控制 4

免費2016-07-07#Node#WindJS#Jscex#Wind#WindJS原理

學習技術思想本身,而不是單純的程式碼應用,這才是程式設計

###寫在前面

老趙(Jeffrey Zhao) 是筆者敬佩的能做 20 個引體向上的前輩

當時還混部落格的時候有看過幾篇老趙的.Net 文章,後來因為選擇前端學習 JS 仔細看了 yield 系列文章,頓時高山仰止(人家那才叫程式設計呢,筆者這只能算是程式碼應用

再後來 github 追隨了老趙(目前筆者唯二 follow 的人),多半因為技術敬佩,另一小半因為老趙的 學生資助計劃(雖然現在已經像 WindJS 一樣被歷史塵封,但應該向有擔當且做實事的人致敬)

##一.WindJS 的基本原理

Wind.js 的確是個「輪子」,但絕對不是「重新發明」的輪子。我不會掩飾對 Wind.js 的「自誇」:Wind.js 在 JavaScript 非同步程式設計領域絕對是一個創新,可謂前無來者。有朋友就評價說「在看到 Wind.js 之前,真以為這是不可能實現的」,因為 Wind.js 事實上是用類庫的形式「修補」了 JavaScript 語言,也正是這個原因,才能讓 JavaScript 非同步程式設計體驗獲得質的飛躍。

我們現在想要解決的是「流程控制」問題,那麼說起在 JavaScript 中進行流程控制,有什麼能比 JavaScript 語言本身更為現成的解決方案呢?在我看來,流程控制並非是一個理應使用類庫來解決的問題,它應該是語言的職責。只不過在某些時候,我們無法使用語言來表達邏輯或是控制流程,於是退而求其次地使用「類庫」來解決這方面的問題。

以上引自 專訪 Wind.js 作者老趙(上):緣由、思路及發展

基本原理從一個例子說起,如果我們希望給 O(n) 去重添上動畫效果,要怎麼做?

###0. 問題重述

輸入:一個陣列,元素型別為 Number|String(清晰起見,簡化問題)

輸出:一個不含重複元素的陣列

去重方法如下:

// O(n) 去重
var unique = function(arr) {
    var dir = {};
    var _arr = arr.slice();
    var res = [];

    _arr.forEach(function(item) {
        // id = type + item,避免 String 鍵名衝突
        var id = typeof item + item;
        if (!dir[id]) {
            dir[id] = true;
            res.push(item);
        }
    });

    return res;
};
// test
var arr = [1, 2, '1', 2, 3, 23, 1, '5'];
console.log(unique(arr));
// log print:
// [ 1, 2, '1', 3, 23, '5' ]

那麼如果現在要給去重的過程添上動畫,要怎麼做?

  • 先去重並記錄過程細節,去重結束後執行動畫序列,動畫結束後繼續其它業務

似乎只有這一種穩妥的選擇,但如果問題不是簡單的去重,而是其它更複雜的(比如快排甚至退火演算法)呢?是不是要設計一個複雜的資料結構,儲存過程細節?考慮快排動畫,我們希望表達每趟操作的細節(2 個指標如何移動,如何比較,如何賦值...),就必須要用一個巨大的物件陣列把這些過程保留下來嗎?

純屬浪費,因為排序只想得到排序結果,而不是過程細節,我們辛苦記錄的過程細節事實上只用了一次就被丟棄了

換個角度,在排序過程中執行動畫才是最合理的,在排序過程中可以拿到所有細節,執行動畫,然後下一步排序

但問題是動畫在做,排序也在做,Step1 的動畫完成後,排序已經進行到 Step5 了。嗯,我們需要讓 JS 停下來

###1. 讓 JS 停下來

使用 yield 再簡單不過了,F12 輸入 function* + yield 語法,想怎麼暫停就怎麼暫停,關於 ES6 yield 的更多資訊,請檢視 [generator(生成器)_ES6 筆記 2](/articles/generator(生成器)-es6 筆記 2/)

注意,現在時間是6 年前(2010-6),JS 才剛剛迎來 ES5 時代,沒有 yield 可以用,當時大多數人和筆者一樣覺得 JS 是停不下來的(比如迴圈如何暫停)

確實,迴圈停不下來,但是遞迴完全可以暫停

模擬一個非同步動畫(CSS 動畫等等),如下:

var asyncAnim = function(str, callback) {
    console.log(str);
    setTimeout(function() {
        // 耗時動畫
        //...
        callback();
    }, 100);
};

然後迴圈改遞迴:

var uniqueWithAnim = function(arr, callback) {
    var dir = {};
    var _arr = arr.slice();
    var res = [];
    var animLock = false;

    // 用遞迴代替迴圈
    var i = 0, len = _arr.length;
    var find = function() {
        if (i === len) {
            return callback(res);
        }

        var item = _arr[i];
        var id = typeof item + item;

        if (!dir[id]) {
            dir[id] = true;
            res.push(item);
            // 動畫
            animLock = true;
            asyncAnim('push ' + item, function() {
                // 動畫完成回撥
                animLock = false;
                find();
            });
        }
        i++;

        if (!animLock) {
            find();
        }
    };

    // 開始遞迴
    find();
};

測試一下:

// test
uniqueWithAnim(arr, function() {
    console.log('all done');
});
// log print:
// push 1
// (100ms later)push 2
// (100ms later)push 1
// (100ms later)push 3
// (100ms later)push 23
// (100ms later)push 5
// (100ms later)all done

效果是 JS 被停下來了,我們通過回撥把非同步邏輯與同步邏輯串起來了,但是需要改寫程式碼,一點都不美

###2. 模擬 yield

我們想要的是一種通用的能讓 JS 停下來的工具模式,所以需要模擬實現 yield

var w = function(fn) {
    // $yield
    var $yield = function(result, next) {
        var res = {};
        res.value = result;

        if (typeof next === 'function') {
            res.next = next;
        }

        return res;
    };

    return fn.bind(null, $yield);
};

w 是一個 wrapper,給 fn 注入引數,$yield 用來建立 step 鏈

試用一下:

var oneTwoThree = w(function($yield) {
    return $yield(1, function() {
    return $yield(2, function() {
    return $yield(3);

    });
    });
});
console.log(oneTwoThree());
console.log(oneTwoThree().next());
console.log(oneTwoThree().next().next());
// log print:
// { value: 1, next: [Function] }
// { value: 2, next: [Function] }
// { value: 3 }

這就是一種簡單的 yield 實現方式,看起來也不美

###3. WindJS 原理

  • 遞迴代替迴圈。把邏輯塊拆分為 step 鏈,延時呼叫下一個函式,就是所謂的暫停(Sleep

  • 「編譯」隱藏改寫程式碼細節。Wind 幫我們改寫原始碼,所以效果是我們可以以同步形式編寫非同步程式碼

Wind 不改變使用者的思維方式和編碼習慣,也不強加一堆 API,而是以「隱式」重寫原始碼的方式來實現,所以看起來像是修改了 JS 語言本身,以不變應萬變,不用像 async 模組一樣提供大而全的方案

P.S. 現在 ES7 已經吸納了這種方案(提供了 async&await),這樣看來,Wind 確實「修改」了 JS 語言本身

P.S. 如果框架都按這種思路來設計,FEers 就沒有那麼多東西需要學了...如果語言本身吸納各種特性趨於完美,就不需要什麼框架了,頂多還需要工程化工具(比如構建工具等等)

##二。「編譯」原始碼

「編譯」原始碼是 JS終極黑魔法,像一扇黑氣繚繞的大門,代表著無限可能

###1. Wind 編譯示例

讀取檔案觸發異常,示例如下:

var fs = require('fs');
var Wind = require('Wind');

// 任務模型捕獲非同步異常
var Task = Wind.Async.Task;
var Binding = Wind.Async.Binding;

var readFileAsync = Binding.fromStandard(fs.readFile);
var readFile = eval(Wind.compile('async', function() {
    try {
        var file = $await(readFileAsync('./nosuch.file', 'utf-8'));
    } catch (err) {
        console.log('catch error: ' + err);
    }
}));
// 獲取任務物件
var task = readFile();
// 啟動任務
task.start();

編譯結果如下:

(function () {
    var _builder_$0 = Wind.builders["async"];
    return _builder_$0.Start(this,
        _builder_$0.Try(
            _builder_$0.Delay(
            function () {
                return _builder_$0.Bind(readFileAsync("./nosuch.file", "utf-8"), function (file) {
                     return _builder_$0.Normal();
                });
            }),
            function (err) {
                console.log("catch error: " + err);
                return _builder_$0.Normal();
            },
            null
        )
    );
})

Wind 幫我們完成了改寫,把同步形式的程式碼改寫為非同步回撥形式

###2. 模擬捕獲非同步回撥中的異常

Wind 的 try...catch 能夠捕獲非同步回撥中的異常,看起來很神秘,其實原理很簡單

// 嘗試捕獲非同步回撥中的異常
var ex = {};
ex.Try = function(asyncFn, errHandler) {
    asyncFn.call(null, errHandler);
};
// test
var _readFileSync = function(callback) {
    fs.readFile('./nosuch.file', 'utf-8', callback);
};
ex.Try(_readFileSync, function(err) {
    console.log('catch error: ' + err);
});
// log print:
// catch error: Error: ENOENT: no such file or directory, open 'E:\node\learn\async\wind\nosuch.file'

Wind 內部與上面的示例類似,只是我們被 compile 矇住了雙眼,才看不透這層神祕

###3.「編譯」

我們模擬的 ex.Try 看起來一點也不像 Wind 優雅的原生 try...catch,沒關係,我們也來試試「編譯」美容法:

ex.compile = function(fn) {
    var rTry = /\s+ try\s*{([^}]+)}/m;
    var rCatch = /\s+ catch[^)]+\)\s*{([^}]+)}/m;
    var source = fn.toString();
    // console.log(source);

    // parse try block
    var sourceTry = rTry.exec(source)[1].trim();
    // console.log(sourceTry);
    var sourceTry = sourceTry.replace(/^[^$]*\$await\s*\((.+)\)\s*\)/m, function(match, p1) {
        // console.log(p1);
        return '(function(callback) {\n' + p1 + ', callback);\n});';
    });
    // console.log(sourceTry);
    var asyncFn = eval(sourceTry);
    
    // parse catch block
    var sourceCatch = rCatch.exec(source)[1].trim();
    // console.log(sourceCatch);
    sourceCatch = '(function(err) {\n' + sourceCatch + '\n});';
    var errHandler = eval(sourceCatch);

    return {
        start: function() {
            ex.Try(asyncFn, errHandler);
        }
    };
};

最後,測試效果:

// test
var t = ex.compile(function() {
    try {
        var file = $await(fs.readFile('./nosuch.file', 'utf-8'));
    } catch (err) {
        console.log('catch error: ' + err);
    }
});
t.start();
// log print:...

形式和效果都完全一樣,這就是 Wind 的 try...catch 能夠捕獲非同步回撥異常的祕密

「編譯」完成的工��如下:

var file = $await(fs.readFile('./nosuch.file', 'utf-8'));
-->
var _readFileSync = function(callback) {
    fs.readFile('./nosuch.file', 'utf-8', callback);
};

console.log('catch error: ' + err);
-->
function(err) {
    console.log('catch error: ' + err);
}

說白了就是字串拼接,僅此而已

同樣的,Wind 能處理無限序列就沒什麼好奇怪的了,示例如下:

// infinite fib series
var fib = eval(Wind.compile("async", function () {

    $await(Wind.Async.sleep(1000));
    console.log(0);
    
    $await(Wind.Async.sleep(1000));
    console.log(1);

    var a = 0, current = 1;
    while (true) {
        var b = a;
        a = current;
        current = a + b;

        $await(Wind.Async.sleep(1000));
        console.log(current);
    }
}));

fib().start();

while(true) 死迴圈被替換成了含斷點的死遞迴,就像 ES6 的 yield

// infinite fib series in es6 yield
var fib = (function* () {
    yield 0;
    yield 1;

    var a = 0, current = 1;
    while (true) {
        var b = a;
        a = current;
        current = a + b;
        yield current;
    }
})();

fib.next(); // 0
fib.next(); // 1
fib.next(); // 1

##三。總結

Wind 的特點如下:

  • 編譯原始碼

  • 任務模型

  • 以同步形式編寫非同步程式碼

適用場景:適用於從已有的以同步方式編寫的程式碼向 Node 遷移,能夠省去重寫程式碼的開銷

那麼,ES7 的 async&await 能否取代 Wind?

可以,因為實現原理與目標都是一致的

  • 實現原理:yield 暫停

  • 目標:以同步形式編寫非同步程式碼

ES7 的 async&await 從 promise,generator 一路輾轉走來,而 Wind 早在 5 年前就看到了這一天,並提前實現了願景

學習技術思想本身,而不是單純的程式碼應用,這才是程式設計

###參考資料

  • 《深入淺出 NodeJS》

評論

暫無評論,快來發表你的看法吧

提交評論