如何優雅地寫js異步代碼

hubuke 8年前發布 | 37K 次閱讀 JavaScript開發 JavaScript

如何優雅地寫js異步代碼 本文通過一個簡單的需求:讀取文件并備份到指定目錄(詳見第一段代碼的注釋),以不同的js代碼實現,來演示代碼是如何變優雅的。對比才能分清好壞,想知道什么是優雅的代碼,先看看糟糕的代碼。

不優雅的代碼是什么樣的?

1、 回調地獄

/**
 * 讀取當前目錄的package.json,并將其備份到backup目錄
 * 
 * 1. 讀取當前目錄的package.json
 * 2. 檢查backup目錄是否存在,如果不存在就創建backup目錄
 * 3. 將文件內容寫到備份文件
 */
fs.readFile('./package.json', function(err, data) {  
    if (err) {
        console.error(err);
    } else {
        fs.exists('./backup', function(exists) {
            if (!exists) {
                fs.mkdir('./backup', function(err) {
                    if (err) {
                        console.error(err);
                    } else {
                        // throw new Error('unexpected');
                        fs.writeFile('./backup/package.json', data, function(err) {
                            if (err) {
                                console.error(err);
                            } else {
                                console.log('backup successed');
                            }
                        });
                    }
                });
            } else {
                fs.writeFile('./backup/package.json', data, function(err) {
                    if (err) {
                        console.error(err);
                    } else {
                        console.log('backup successed');
                    }
                });
            }
        });
    }
});

2、 匿名調試

取消上面代碼中拋出異常的注釋再執行

如何優雅地寫js異步代碼

wtf,這個unexpected錯誤從哪個方法拋出來的?

神馬?你覺的這個代碼寫得很好,優雅得無可挑剔?那么你現在可以忽略下文直接去最后的評論寫:樓主敏感詞

怎樣寫才能讓js回調看上去優雅?

  1. 消除回調嵌套
  2. 命名方法
fs.readFile('./package.json', function(err, data) {  
    if (err) {
        console.error(err);
    } else {
        writeFileContentToBackup(data);
    }
});


function writeFileContentToBackup(fileContent) {  
    checkBackupDir(function(err) {
        if (err) {
            console.error(err);
        } else {
            backup(fileContent, log);
        }
    });
}

function checkBackupDir(cb) {  
    fs.exists('./backup', function(exists) {
        if (!exists) {
            mkBackupDir(cb);
        } else {
            cb(null);
        }
    });
}

function mkBackupDir(cb) {  
    // throw new Error('unexpected');
    fs.mkdir('./backup', cb);
}

function backup(data, cb) {  
    fs.writeFile('./backup/package.json', data, cb);
}

function log(err) {  
    if (err) {
        console.error(err);
    } else {
        console.log('backup successed');
    }
}

我們現在可以快速定位拋出異常的方法

如何優雅地寫js異步代碼

他山之石 可以攻玉

借助第三方庫,優化異步代碼

browser js

  • jQuery Deferred
    • ajax
    • animate

NodeJs

jQuery Deferred

在jQuery-1.5中引進,被應用在ajax、animate等異步方法上

一個簡單的例子:

function sleep(timeout) {  
    var dtd = $.Deferred();
    setTimeout(dtd.resolve, timeout);
    return dtd;
}

// 等同于上面的寫法
function sleep(timeout) {  
    return $.Deferred(function(dtd) {
        setTimeout(dtd.resolve, timeout);
    });
}

console.time('sleep');  
sleep(2000).done(function() {  
    console.timeEnd('sleep');
});

一個復雜的例子:

function loadImg(src) {  
    var dtd = $.Deferred(),
        img = new Image;

    img.onload = function() {
        dtd.resolve(img);
    }

    img.onerror = function(e) {
        dtd.reject(e);
    }

    img.src = src;

    return dtd;
}

loadImg('http://www.baidu.com/favicon.ico').then(  
    function(img) {
        $('body').prepend(img);
    }, function() {
        alert('load error');
    }
)

那么問題來了,我想要過5s后把百度Logo顯示出來?

普通寫法:

sleep(5000).done(function() {  
     loadImg('http://www.baidu.com/favicon.ico').done(function(img) {
        $('body').prepend(img);
    });
});

二逼寫法:

setTimeout(function() {  
    loadImg('http://www.baidu.com/favicon.ico').done(function(img) {
        $('body').prepend(img);
    });
}, 5000);

文藝寫法(睡5s和加載圖片同步執行):

$.when(sleep(5000), loadImg('http://www.baidu.com/favicon.ico')).done(function(ignore, img) {
    $('body').prepend(img);
});

Async

使用方法參考:https://github.com/caolan/async

優點:

  1. 簡單、易于理解
  2. 函數豐富,幾乎可以滿足任何回調需求
  3. 流行

缺點:

  1. 額外引入第三方庫
  2. 雖然簡單,但還是難以掌握所有api

ECMAScript 6

ES6的目標,是使得JavaScript語言可以用來編寫大型的復雜的應用程序,成為企業級開發語言。

接下來介紹ES6的新特性:Promise對象和Generator函數,是如何讓代碼看起來更優雅。

更多ES6的特性參考:ECMAScript 6 入門

Promise

Promise對象的初始化以及使用:

var promise = new Promise(function(resolve, reject) {  
    setTimeout(function() {
        if (true) {
            resolve('ok');
        } else {
            reject(new Error('unexpected error'));
        }
    }, 2000);
});

promise.then(function(msg) {  
    // throw new Error('unexpected resolve error');
    console.log(msg);
}).catch(function(err) {
    console.error(err);
});

JavaScript Promise 的 API 會把任何包含有 then 方法的對象當作“類 Promise”(或者用術語來說就是 thenable)

與上面介紹的jQuery Deferred對象類似,但api方法和錯誤捕捉等不完全一樣。
可以使用以下方法轉換:

var promise = Promise.resolve($.Deferred());  

那怎么使用Promise改寫回調地獄那個例子?

// 1. 讀取當前目錄的package.json
readPackageFile.then(function(data) {  
    // 2. 檢查backup目錄是否存在,如果不存在就創建backup目錄
    return checkBackupDir.then(function() {
        // 3. 將文件內容寫到備份文件
        return backupPackageFile(data);
    });
}).then(function() {
    console.log('backup successed');
}).catch(function(err) {
    console.error(err);
});

這么簡單?

看看readPackageFilecheckBackupDirbackupPackageFile的定義:

var readPackageFile = new Promise(function(resolve, reject) {  
    fs.readFile('./package.json', function(err, data) {
        if (err) {
            reject(err);
        }

        resolve(data);
    });
});

var checkBackupDir = new Promise(function(resolve, reject) {  
    fs.exists('./backup', function(exists) {
        if (!exists) {
            resolve(mkBackupDir);
        } else {
            resolve();
        }
    });
});

var mkBackupDir = new Promise(function(resolve, reject) {  
    // throw new Error('unexpected error');
    fs.mkdir('./backup', function(err) {
        if (err) {
            return reject(err);
        }

        resolve();
    });
});

function backupPackageFile(data) {  
    return new Promise(function(resolve, reject) {
        fs.writeFile('./backup/package.json', data, function(err) {
            if (err) {
                return reject(err);
            }

            resolve();
        });
    });
};

是不是感覺到滿滿的欺騙,說好的簡單呢,先別打,至少調用起來還是很簡單的XD。個人覺得使用Promise最大的好處就是讓調用方爽。

流程優化,使用js的無阻塞特性,我們發現第一步和第二步可以同步執行:

Promise.all([readPackageFile, checkBackupDir]).then(function(res) {  
    return backupPackageFile(res[0]);
}).then(function() {
    console.log('backup successed');
}).catch(function(err) {
    console.error(err);
});

在ES5環境下可以使用的庫:

Generator

NodeJs默認不支持Generator的寫法,但在v0.12后可以添加--harmony參數使其支持:

> node --harmony generator.js

允許函數在特定地方像return一樣退出,但是稍后又能恢復到這個位置和狀態上繼續執行

function * foo(input) {  
    console.log('這里會在第一次調用next方法時執行');
    yield input;
    console.log('這里不會被執行,除非再調一次next方法');
}

var g = foo(10);

console.log(Object.prototype.toString.call(g)); // [object Generator]  
console.log(g.next()); // { value: 10, done: false }  
console.log(g.next()); // { value: undefined, done: true }  

如果覺得比較難理解,就把yield看成return語句,把整個函數拆分成許多小塊,每次調用generatornext方法就按順序執行一小塊,執行到yield就退出。

告訴你一個驚人的秘密,我們現在可以“同步”寫js的sleep了:

var sleepGenerator;

function sleep(time) {  
    setTimeout(function() {
        sleepGenerator.next(); // step 5
    }, time);
}

var sleepGenerator = (function * () {  
    console.log('wait...'); // step 2
    console.time('how long did I sleep'); // step 3
    yield sleep(2000); // step 4
    console.log('weakup'); // step 6
    console.timeEnd('how long did I sleep'); // step 7
}());

sleepGenerator.next(); // step 1  

合體,使用Promise和Generator重寫回調地獄的例子

合體前的準備工作,參考Q.async

function run(makeGenerator) {  
    function continuer(verb, arg) {
        var result;
        try {
            result = generator[verb](arg);
        } catch (err) {
            return Promise.reject(err);
        }
        if (result.done) {
            return result.value;
        } else {
            return Promise.resolve(result.value).then(callback, errback);
        }
    }
    var generator = makeGenerator.apply(this, arguments);
    var callback = continuer.bind(continuer, "next");
    var errback = continuer.bind(continuer, "throw");
    return callback();
}

readPackageFilecheckBackupDirbackupPackageFile直接使用上面Promise中的定義,是不是很爽。

合體后的執行:

run(function *() {  
    try {
        // 1. 讀取當前目錄的package.json
        var data = yield readPackageFile;

        // 2. 檢查backup目錄是否存在,如果不存在就創建backup目錄
        yield checkBackupDir;

        // 3. 將文件內容寫到備份文件
        yield backupPackageFile(data);

        console.log('backup successed');
    } catch (err) {
        console.error(err);
    }
});

是不是感覺跟寫同步代碼一樣了。

總結

看完本文,如果你感慨:“靠,js還能這樣寫”,那么我的目的就達到了。本文的寫作初衷不是介紹AsyncDeferredPromiseGenerator的用法,如果對于這幾個概念不是很熟悉的話,建議查閱其他資料學習。寫js就像說英語,不是write in js,而是think in js。不管使用那種方式,都是為了增強代碼的可讀性和可維護性;如果是在已有的項目中修改,還要考慮對現有代碼的侵略性。

續集:如何優雅地寫js異步代碼(2)

參考地址

題圖引自:http://forwardjs.com/img/workshops/advancedjs-async.jpg

來自:http://iammapping.com/write-js-async-gracefully/

 本文由用戶 hubuke 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!