如何優雅地寫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、 匿名調試
取消上面代碼中拋出異常的注釋再執行
wtf,這個unexpected
錯誤從哪個方法拋出來的?
神馬?你覺的這個代碼寫得很好,優雅得無可挑剔?那么你現在可以忽略下文直接去最后的評論寫:樓主敏感詞
怎樣寫才能讓js回調看上去優雅?
- 消除回調嵌套
- 命名方法
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');
}
}
我們現在可以快速定位拋出異常的方法
他山之石 可以攻玉
借助第三方庫,優化異步代碼
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
優點:
- 簡單、易于理解
- 函數豐富,幾乎可以滿足任何回調需求
- 流行
缺點:
- 額外引入第三方庫
- 雖然簡單,但還是難以掌握所有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);
});
這么簡單?
看看readPackageFile
、checkBackupDir
和backupPackageFile
的定義:
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
語句,把整個函數拆分成許多小塊,每次調用generator
的next
方法就按順序執行一小塊,執行到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();
}
readPackageFile
、checkBackupDir
和backupPackageFile
直接使用上面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還能這樣寫”,那么我的目的就達到了。本文的寫作初衷不是介紹Async
、Deferred
、Promise
、Generator
的用法,如果對于這幾個概念不是很熟悉的話,建議查閱其他資料學習。寫js就像說英語,不是write in js,而是think in js。不管使用那種方式,都是為了增強代碼的可讀性和可維護性;如果是在已有的項目中修改,還要考慮對現有代碼的侵略性。
參考地址
- 回調地獄
- JavaScript Promise啟示錄
- Promises/A+
- ECMAScript 6入門
- JavaScript Promises
- 使用 (Generator) 生成器解決 JavaScript 回調嵌套問題
- 擁抱Generator,告別回調
題圖引自:http://forwardjs.com/img/workshops/advancedjs-async.jpg