非同步執行(例如DOM事件、AJAX等)無論在瀏覽器或是伺服器端,都很常見。使用他可以帶來一些效能上的優勢,也可以解決許多問題,但是同時也會引發一些問題。
比較大的問題是在使用非同步的功能時,基本上會割裂程式的流程,讓原本有順序的流程切分到一個個函數。另外,在需要使用較多非同步的功能時,會造成函數的嵌套,更影響到關於程式邏輯的可讀性。
今天先分享一些在Javascript中使用非同步功能的一些觀念,以及解決流程問題的一些原理。
先來看看一些基本的觀念...
Continuation-passing style
首先要提到一個觀念,叫做Continuation-passing style(以下簡稱做CPS)。他的觀念很簡單,就是在函數的最後不return,而是呼叫另一個函數,把執行的結果(狀態)傳給他繼續執行。例如:
function add(x,y) {
return x+y;
}
console.log(add(1,2));
改成CPS的形式,就變成:
function add(x, y, cb) {
cb(x+y);
}
add(x, y, function(m) {console.log(m);});
看起來很眼熟?這個方法基本上在Javascript到處都在用,所有的事件大概都是這樣處理的。例如:
<button id='button'>test</button>
<script>
document.getElementById('btn1').addEventListener('click', function(e) {alert('Hello');});
</script>
CPS既是解決問題的方法,但是也會造成問題...
Callback地獄
如果在大量使用非同步時,又需要讓程式依照固定的順序跑,就會造成函數的嵌套:
$.get('url1', {id: 1}, function(data) {
var rows = data.split('\r\n');
rows.forEach(function(row) {
var r = row.split(',');
var div = document.createElement('div');
var btn = document.createElement('button');
btn.innerHTML = r[1];
btn.addEventListener('click', function(e) {
$.get('url1', {sn: r[0]}, function(data) {
$('#panel').html(data);
});
});
div.appendChild(btn);
document.body.appendChild(div);
});
}
其實不複雜,但是類似這樣不斷嵌套下去,程式會很難閱讀,錯誤也不容易找。類似的情況在伺服器端的Javascript環境,例如node.js更常發生。
舉個抽象一點的例子,例如有step1~6個非同步函數,需要他按照步驟執行,就會變成這樣:
step1(s0, function(s1) {
step2(s1, function(s2) {
step3(s2, function(s3) {
step4(s3, function(s4) {
step5(s4, function(s5) {
step6(s5, function(s6) {
//處理s6...
});
});
});
});
});
});
有人把這個叫做pyramid of doom,看起來有點瘋狂,但是在處理伺服器端的Javascript其實還蠻常碰到,不過可能沒有長這麼深...
等待多個非同步執行?
在使用非同步的功能時,還常會碰到一個困難,就是...某個函數可能需要依賴數個同時執行的非同步函數執行結果來做運算。要解決這個問題,不靠一些外部狀態控制,我想很難直覺地做出來...
管理函數執行狀態
舉例來說,一個簡單的解決方式是管理函數的執行狀態,例如計算函數執行的次數。假設要等待三個非同步執行的函數結束,才執行下一個函數時,可以這樣做:
var count = 3;
var result = [];
function next() {
count--;
if(count===0) d();
}
function a() {
$.get('url1', function(data) {
result.push(data);
next();
});
}
function b() {
$.get('url2', function(data) {
result.push(data);
next();
});
}
function c() {
$.get('url3', function(data) {
result.push(data);
next();
});
}
function d() {
//處理result
}
a();
b();
c();
用類似的方式來維護函數執行的狀態,就可以解決非同步函數依賴的問題。而且,這其實還是用CPS的概念,在非同步執行的函數結束時,呼叫一個函數來通知函數執行狀態的改變。把上述程式稍微組織一下,就可以變成通用的方法:
function wait(fn, cb) {
var count = fn.length;
var result = [];
fn.forEach(function(f) {
f(next);
});
function next(r) {
result.push(r);
count--;
if(count === 0) cb(result);
}
}
然後就可以用這個函數來管理數個非同步函數的執行過程,當所有非同步動作結束時,才執行下一個動作,處理非同步執行的結果:
wait([
function (next) {
$.get('url1', function(data) {
next(data);
});
},
function (next) {
$.get('url2', function(data) {
next(data);
});
},
function (next) {
$.get('url3', function(data) {
next(data);
});
}
],
function(result) {
//處理result
});
類似的方法,稍微改一下就可以應用在需要循序執行的地方,避免callback地獄:
function serial(fn, r, cb) {
var count = 0;
next(r);
function next(r) {
if(count<fn.length) {
fn[count](r, next);
count++;
} else {
cb(r);
}
}
}
fn是一個要依序執行的函數陣列,r是要傳給第一個執行函數的參數,cb則是要處理執行結果的函數。然後就可以用這樣的規格來寫要循序執行的函數:
serial([
function (r, next) {
setTimeout(function() {next(r+r)}, 1000);
},
function (r, next) {
setTimeout(function() {next(r*r*r)}, 1);
}
], 1, function(r){console.log(r)});
傳給循序執行的函數的第一個參數是要實際處理的資料,第二個則是用來在執行結束時,通知狀態改變,回傳執行結果,並且執行下一個函數。上例的執行結果會顯示8。第一個函數會等一秒才執行,而且執行完畢之後才會跑下一個。
Flow Control = CPS + 函數執行狀態維護
基本上,結合CPS以及管理函數執行的狀態,就可以解決非同步執行的一些問題,這種解決方法就叫做flow control。上面舉的例子是最簡單的,目前已經有許多非常成熟的Library以及有趣的想法來解決類似的問題,明天再慢慢介紹吧。
這太厲害了! 不熟JS的狀況下很難寫出這種東西,以其他語言的概念來看這入門的時候真的增加滿多門檻的,非同步真的很**