javascript與異步編程
為了避免資源管理等復雜性的問題,
javascript被設計為單線程的語言,即使有了html5 worker,也不能直接訪問dom.
javascript 設計之初是為瀏覽器設計的GUI編程語言,GUI編程的特性之一是保證UI線程一定不能阻塞,否則體驗不佳,甚至界面卡死。
一般安卓開發,會有一個界面線程,一個后台線程,保證界面的流暢。
由於javascript是單線程,所以采用異步非阻塞的編程模式,javascript的絕大多數api都是異步api.
本文是本人的一個總結:從Brendan Eich剛設計的初版javascript到現在的ES7,一步步總結javascript異步編程歷史。
說明:
本文所有源碼請訪問:https://github.com/etoah/note/tree/master/async
請安裝babel環境,用babel-node 執行對應例子
什么是異步編程
那么什么是異步編程,異步編程簡單來說就是:執行一個指令不會馬上返回結果而執行下一個任務,而是等到特定的事件觸發后,才能得到結果。
以下是當有ABC三個任務,同步或異步執行的流程圖:
示意圖來自stackoverflow
**同步 **
thread ->|----A-----||-----B-----------||-------C------|
異步
A-Start ---------------------------------------- A-End
| B-Start ----------------------------------------|--- B-End
| | C-Start -------------------- C-End | |
V V V V V V
thread-> |-A-|---B---|-C-|-A-|-C-|--A--|-B-|--C--|---A-----|--B--|
顯然,在宏觀上,同步程序是串行的執行各任務,執行單個任務時會阻塞純線程,異步可以“並行”的執行任務。
異步編程時就需要指定異步任務完成后需要執行的指令,總的來說有以下幾種“指定異步指令”的方式:
- 屬性
- 回調
- Promise
- Generator
- await,async
下面會一步一步展現各種方式。
屬性
每個編程語言對異步實現的方式不一樣,C#可以用委托,java可以用接口或基類傳入的方式,
早期的javascript的異步的實現也類似於這種類的屬性的方式:每個類實例的相關回調事件有相應的handler(onclick,onchange,onload等)。
在DOM0級事件處理程序,就是將一個函數賦值給一個元素的屬性。
element.onclick=function(){
alert("clicked");
}
window.onload=function(){
alert("loaded");
}
問題
這種寫法簡單明了,同時會有以下幾個問題
- 耦合度高
所有的事件處理都需要寫的一個函數中:
window.onload=function(){
handlerA();
handlerB();
handlerc();
}
如果這三個handler來自三個不同的模塊,那這個文件模塊耦合度就為3(華為的計算方法)。依賴高,不利於復用和維護。
- 不安全,容易被重寫
window.onload=function(){
console.log("handler 1");
}
//... 很多其它框架,庫,主題 的代碼
var handlerbak=window.onload
window.onload=function(){
handlerbak(); //這行注釋的話上面handler 1就會被覆蓋。
console.log("handler 2");
}
當代碼量大時,這種問題沒有warning也沒有error, 經驗不豐富的前端可能花費大量的時間查找問題。
事件handler容易被重寫,庫/框架的安全,寄托於使用者的對框架的熟練程度,極不安全。
回調(發布/訂閱)
由於javascript支持函數式編程,JavaScript語言對異步編程的實現可以用回調函數。
DOM2級事件解決了這個問題以上兩個問題
element.addEventListener("click",function(){
alert("clicked");
})
這里實際上是一個發布訂閱模式,addEventListener相當於subscribe, dispatchEvent相當於publish,
很好的解決了訂閱者之前的依賴,jquery,vue,flux,angularjs均實現了類似的模式。
發布訂閱模式雖解決了上面耦合和不安全的問題,但是在實現大型應用時,還會有以下問題。
問題
- 回調黑洞
多層回調嵌套,代碼可讀性差。
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// Do something with value4
});
});
});
});
- 異常無法捕捉
try{
setTimeout(function(){
JSON.parse("{'a':'1'}")
console.log("aaaa")
},0)
}
catch(ex){
console.log(ex); //不能catch到這個異常
}
- 流程控制(異步代碼,同步執行)
當C操作依賴於B操作和C操作,而B與A沒有依賴關系時,不用第三方庫(如async,eventproxy)的話,B與A本可以並行,卻串行了,性能有很大的提升空間。
流程圖如下:
graph LR
Start-->A
A-->B
B-->C
但用promise后,可以方便的用並行:
Promise:
graph LR
Start-->A
Start-->B
A-->C
B-->C
Promise(ECMAScript5)
如上流程圖,Promise很好的解決了“並行”的問題,我們看看用promise庫怎么發送get請求:
import fetch from 'node-fetch'
fetch('https://api.github.com/users/etoah')
.then((res)=>res.json())
.then((json)=>console.log("json:",json))
可以看到promise把原來嵌套的回調,改為級連的方式了,實際是一種代理(proxy)。
新建一個promise實例:
var promise = new Promise(function(resolve, reject) {
// 異步操作的代碼
if (/* 異步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
promise把成功和失敗分別代理到resolved 和 rejected .
同時還可以級連catch異常。
到這里異步的問題,有了一個比較優雅的解決方案了,如果要吹毛求疵,還有一些別扭的地方,需要改進。
問題
封裝,理解相對回調復雜,這是以下我公司項目的一段代碼(coffeescript),並發代碼,加上resolved,rejected的回調,
即使是用了coffee,混個業務和參數處理,第一眼看上去還是比較懵,代碼可讀性並沒有想象中的好。
#並發請求companyLevel
companyInfoP = companyinfoServicesP.companyLevel({hid: req.session.hid})
requestP(userOption).success((userInfo)->
roleOption =
uri: "#{config.server_host}/services/rights/userroles?userid=#{user.userId}"
method: 'GET'
#保證companyInfo 寫入
Q.all([companyInfoP, requestP(roleOption)]).spread(
(companyinfo, roles)->
Util.session.init req, user, roles.payload
Util.session.set(req, "companyInfo", companyinfo.payload)
Util.session.set(req, "roleids", roles.payload)
u = Util.session.getInfo req
return next {data: Util.message.success(u)}
, (err)->
return next err
)
在指明resolved 和 rejected的時,用的還是最原始的回調的方式。
能不能用同步的方式寫異步代碼?
在ES5前是這基本不可實現,但是,ES6的語法引入了Generator, yeild的關鍵字可以用同步的語法寫異步的程序。
Generator(ECMAScript6)
簡單來說generators可以理解為一個可遍歷的狀態機。
語法上generator,有兩個特征:
- function 關鍵字與函數名之前有一個星號。
- 函數體內部使用yield關鍵字,定義不同的內部狀態。
由於generator是一個狀態機,所以需要手動調用next 才能執行,但TJ大神開發了co模塊,可以自動執行generator。
import co from 'co';
co(function* (){
var now = Date.now();
yield sleep(150); //約等待150ms
console.log(Date.now() - now);
});
function sleep(ms){
return function(cb){
setTimeout(cb, ms);
};
}
import fetch from 'node-fetch'
co(function* (){
let result= yield [
(yield fetch('https://api.github.com/users/tj')).json(),
(yield fetch('https://api.github.com/users/etoah')).json(),
];
console.log("result:",result)
});
無論是延遲執行,還是並發的從兩個接口獲取數據,generator都可以用同步的方式編寫異步代碼。
注意:co模塊約定,yield命令后面只能是Thunk函數或Promise對象
問題
- 需要手動執行
即使用了TJ的CO模塊,不是標准的寫法,感覺用hack解決問題 - 不夠直觀,沒有語義化。
await,async(ECMAScript7)
ES7 引入了像C#語言中的 await,async關鍵字,而且babel已支持(引入plugins:transform-async-to-generator )
async函數完全可以看作多個異步操作,包裝成的一個Promise對象,而await命令就是內部then命令的語法糖。
import fetch from 'node-fetch';
(async function (){
let result= await fetch('https://api.github.com/users/etoah');
let json =await result.json();
console.log("result:",json);
})();
//exception
(async function (){
try{
let result= await fetch('https://api.3github.com/users/etoah');
let json =await result.json();
console.log("result:",json);
}
catch(ex){
console.warn("warn:",ex);
}
})()
簡單比較會發現,async函數就是將Generator函數的星號(*)替換成async,將yield替換成await,同時不需要co模塊,更加語義化。
但是與yeild又不完全相同,標准沒有接收await*的語法( 😦 查看詳情),
若需“並行”執行promise數組,推薦用Promise.All,所以需要並行請求時,需要這樣寫:
(async function (){
let result= await Promise.all([
(await fetch('https://api.github.com/users/tj')).json(),
(await fetch('https://api.github.com/users/etoah')).json()
]);
console.log("result:",result);
})();
雖說沒有不能用 await* , 總體來說結構還是簡單清晰的
沒有任何callback,流程和異常捕獲是完全同步的寫法。而且javascript語言級別支持這種寫法。可以說這是異步的終極解決方案了。
總結
到這里,jser結合promise,yield,await的寫法,可以和回調嵌套說拜拜了。
雖有這么多的不同的異步編程方式,但是異步編程的本質並沒有變,只有對coder更友好了而已,但對工程化可讀性和可維護性有很大的改進。
全文完,如有不嚴謹的地方,歡迎指正。