本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。
購書連結 https://www.tenlong.com.tw/products/9789864344130
讓我們再次重新認識 JavaScript!
介紹過原型之後,原本想說可以往下個篇章繼續這樣,但我突然想起有個很重要的東西還沒講,就是初學者在學習 JavaScript 最容易搞混的特性之一:「同步 (Synchronous) 與 非同步 (Asynchronous)」的差別。
兩者容易搞混我覺得很大的原因出在 Synchronous 被翻譯成「同步」,光看字面上就可能把它想成是「所有動作同時進行」,但事實上剛好相反。
怎麼說呢,今天過完就要放跨年假了,就讓我們用輕鬆一點的方式來介紹吧。
肥宅如我又要來介紹遊戲了,
最近有個很有趣的遊戲,叫「Overcooked」,中文翻譯叫「煮過頭」。 [註1]
遊戲的故事背景是洋蔥國被義大利麵肉丸怪入侵,要拯救世界的方式就是不斷地餵飽他,如果失敗世界就毀滅了。
於是洋蔥國王打開一道傳送門,將主角們送回過去的世界好好磨練廚藝再回來拯救世界。
如果沒玩過的朋友可以點連結看看影片:老皮大廚的地獄廚房 | Overcooked 煮過頭
(ithelp 不能嵌入 youtube 影片,只好貼連結讓各位自己點了)
遊戲流程很簡單,要製作的餐點和配方會出現在畫面上方。
玩家遊戲步驟大概像這樣:「領食材 -> 切菜/煮菜 -> 合成 -> 裝盤 -> 上菜」
影片中可以看到在遊戲裡面,一群人手忙腳亂,每個人要負責做好各自的事情,好好合作才能順利過關。
.
.
.
謎之聲:....等等,這系列的組別應該不是「自我挑戰組」,說好的「同步」與「非同步」在哪裡?
別急,要開始進入正題了。
還記得嗎,我們在 重新認識 JavaScript: Day 18 Callback Function 與 IIFE 曾經提及:
JavaScript 是一個「非同步」的語言,所以當我們執行這段程式時,
for
迴圈並不會等待window.setTimeout
結束後才繼續,而是在執行階段就一口氣跑完。
就像在遊戲裡,客人點菜了之後,對應的食材原料就會送出來。
假設我們以這個番茄沙拉當例子:
在領完食材原料之後,我們會有青菜、番茄需要處理。
但你不需要等到青菜切完才能處理番茄。
而是在收到食材的同時,負責青菜的朋友就去處理青菜,負責番茄的朋友就去處理番茄。
可能青菜先處理好,也可能番茄先處理好,但不要緊,等到青菜、番茄這些食材都弄好了,最後再一起裝盤、出餐。
像這樣處理事件的流程不會被「卡住」,就是非同步 (Asynchronous) 的概念。
那麼「同步」(Synchronous) 的概念又是什麼呢?
假設邊緣人如我,只能自己一人玩 Overcooked,在領完食材原料之後,一樣會有青菜、番茄需要處理。
因為只有一個廚師,所以要嘛先處理青菜、要嘛先處理番茄,必須先弄完一項之後再去處理另一項,整個流程會被前一個步驟卡住。
像這樣「先完成 A 才能做 B、C、D ...」的運作方式我們就會把它稱作「同步」(Synchronous) 。
然後手腳太慢洋蔥國世界就毀滅了
所以回到一開始所說的,「同步」光看字面上就可能把它想成是「所有動作同時進行」,但事實上比較像是「一步一步來處理」的意思。 而「非同步」則是,我不用等待 A 做完才做 B、C,而是這三個事情可以同時發送出去。 (當然回傳結果的順序也不一定就是)
在理解了同步與非同步的意義後,技術文不來寫點 code 我還是覺得哪裡怪怪的。 (抖M?)
所以接著來談談上回沒講到的「Callback Hell」的解法。
先複習一下,在 DAY 18 曾經提過的範例:
var funcA = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function A');
}, i * 1000);
};
var funcB = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function B');
}, i * 1000);
};
funcA();
funcB();
因為 funcA
與 funcB
分別加上了一個隨機的 setTimeout
,而且由於 JavaScript 的非同步特性,所以分別呼叫兩個函式的時候,其實我們無法預期 'function A'
與 'function B'
誰會先出現。
過去常見的做法,會將「後續要做的事情」透過參數的方式,帶給原本的函式,以確保在原本的函式執行後才去呼叫:
var funcA = function(callback){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function A');
// 如果 callback 是個函式就呼叫它
if( typeof callback === 'function' ){
callback();
}
}, i * 1000);
};
var funcB = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function B');
}, i * 1000);
};
// 為了確保先執行 funcA 再執行 funcB, 呼叫 funcA() 的時候,將 funcB 作為參數帶入
funcA( funcB );
然而,在大量使用非同步且又想要依照固定的順序來執行時,Callback Hell 就可能會出現了。
*「波動拳」(a.k.a. "Callback Hell") *
執行順序的問題是一個,還有另一個常見的狀況是這樣,再回到 「Overcooked」 的場景。
當我要確保「切青菜、切番茄、擺盤」三個動作都完成之後,我才能繼續「上菜」這個動作。 在面臨這種問題的時候,我要怎麼確保三個動作都完成之後,才繼續執行後面的程式呢?
最直覺的方式是新增一個變數來管理狀態:
var result = [];
var step = 3;
// 假設 funcA、funcB、funcC 分別代表「切青菜、切番茄、擺盤」
function funcA(){
window.setTimeout(function(){
result.push('A');
console.log('A');
if( result.length === step ){
funcD();
}
}, (Math.random() + 1) * 1000);
}
function funcB(){
window.setTimeout(function(){
result.push('B');
console.log('B');
if( result.length === step ){
funcD();
}
}, (Math.random() + 1) * 1000);
}
function funcC(){
window.setTimeout(function(){
result.push('C');
console.log('C');
if( result.length === step ){
funcD();
}
}, (Math.random() + 1) * 1000);
}
function funcD(){
console.log('上菜!');
result = [];
}
funcA();
funcB();
funcC();
像上面這樣,當我們依序執行了 funcA()
、funcB()
、funcC()
,由於內部 setTimeout
會等待亂數時間的關係,我們無法得知誰先誰後。 但可以確定的是,當這三個函式執行的時候就會去檢查 result.length === step
,如果成立,就表示三個任務都已經完成,那麼就可以再去呼叫 funcD
執行後續的事情。
如果不希望使用全域變數來污染執行環境的話,甚至可以包裝成一個通用的函式:
function serials(tasks, callback) {
var step = tasks.length;
var result = [];
// 檢查的邏輯寫在這裡
function check(r) {
result.push(r);
if( result.length === step ){
callback();
}
}
tasks.forEach(function(f) {
f(check);
});
}
那麼改寫一下 funcA
、funcB
、funcC
:
function funcA(check){
window.setTimeout(function(){
console.log('A');
check('A');
}, (Math.random() + 1) * 1000);
}
function funcB(check){
window.setTimeout(function(){
console.log('B');
check('B');
}, (Math.random() + 1) * 1000);
}
function funcC(check){
window.setTimeout(function(){
console.log('C');
check('C');
}, (Math.random() + 1) * 1000);
}
function funcD(){
console.log('上菜!');
}
最後呼叫的時候,我們就可以透過這樣呼叫 serials()
:
serials([funcA, funcB, funcC], funcD);
把想要提前執行的函式以陣列的方式傳進 serials()
作為第一個參數,當陣列中的函式都執行完畢後,才會呼叫第二個參數的 funcD
。
最後,稍微提一下,為了解決同步/非同步的問題,自從 ES6 開始新增了一個叫做 Promise
的特殊物件。
https://caniuse.com/#feat=promises
可以看到截至目前為止,各家瀏覽器對 Promises
的實作還不算全面支援。
好在我們可以透過 es6-promise 這個 polyfill 來進行擴充。
簡單來說,Promise
按字面上的翻譯就是「承諾、約定」之意,回傳的結果要嘛是「完成」,要嘛是「拒絕」。 就好像鄉民說的,「遇上喜歡的女生就衝了阿,反正結果『不是一巴掌,就是一輩子』」。 [註2]
實際寫成 Promise
的程式碼大概像這樣:
const myFirstPromise = new Promise((resolve, reject) => {
resolve(someValue); // 完成
reject("failure reason"); // 拒絕
});
要提供一個函式 promise
功能,讓它回傳一個 promise
物件即可:
function myAsyncFunction(url) {
return new Promise((resolve, reject) => {
// resolve() or reject()
});
};
當 Promise
被完成的時候,我們就可以呼叫 resolve()
,然後將取得的資料傳遞出去。 或是說想要拒絕 Promise
,當個完全沒有信用的人, 那麼就呼叫 reject()
來拒絕。
一般來說, Promise
物件會有這幾種狀態:
整個 Promise
流程可以用這張圖表示:
圖片來源: MDN: Promise
如果我們需要依序串連執行多個 promise
功能的話,可以透過 .then()
來做到。
以剛剛的 funcA, funcB, funcC
來當範例,我們將這三個函式分別透過 Promise
包裝:
function funcA(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('A');
resolve('A');
}, (Math.random() + 1) * 1000);
});
}
function funcB(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('B');
resolve('B');
}, (Math.random() + 1) * 1000);
});
}
function funcC(){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('C');
resolve('C');
}, (Math.random() + 1) * 1000);
});
}
最後透過呼叫
funcA().then(funcB).then(funcC);
就可以做到等 funcA()
被 「resolve」之後再執行 funcB()
,然後 resolve 再執行 funcC()
的順序了。
如果我們不在乎 funcA()
funcB()
funcC()
誰先誰後,只關心這三個是否已經完成呢?
那就可以透過 Promise.all()
來做到:
// funcA, funcB, funcC 的先後順序不重要
// 直到這三個函式都回覆 resolve 或是「其中一個」 reject 才會繼續後續的行為
Promise.all([funcA(), funcB(), funcC()])
.then(function(){ console.log('上菜'); });
受益良多,謝謝!
想請問一個問題:
在
funcA().then(funcB).then(funcC);
的寫法,
如果改成
funcA().then(funcB()).then(funcC());
順序是亂掉的
那如果要傳參數進去,該如何寫呢?
你想要傳什麼參數呢? 到 funcA ? funcB 還是 funcC ?
比如說這樣:
function funcX(x){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log(x);
resolve(x);
}, (Math.random() + 1) * 1000);
});
}
function funcA(val){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('A');
resolve(val);
}, (Math.random() + 1) * 1000);
});
}
function funcB(val){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('B', val);
resolve(val);
}, (Math.random() + 1) * 1000);
});
}
function funcC(val){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log('C', val);
resolve(val);
}, (Math.random() + 1) * 1000);
});
}
funcA(123).then(funcB).then(funcC);
像這樣就可以把 123 從 A 帶到 B 再帶到 C。
了解。謝謝您
我還找到這個解法:
funcX(3).then(()=>funcX(1)).then(()=>funcX(2))
想請教一下為何最後一行的 funcA(funcB) 改成 funcA(funcB)
會造成順序相反,而沒有callBack效果呢?
var funcA = function(callback){
window.setTimeout(function(){
console.log('function A');
if( typeof callback === 'function' ){ callback(); }
}, 3000);
};
var funcB = function(){
window.setTimeout(function(){
console.log('function B');
}, 1000);
};
// 為了確保先執行 funcA 再執行 funcB, 呼叫 funcA() 的時候,將 funcB 作為參數帶入
funcA( funcB );
// B先執行 才執行A ????
funcA( funcB() )
你好,因為加了 ()
的 funcB
實際上是將 funcB
「呼叫之後的結果」作為參數傳入 funcA
: funcA( funcB() )
所以在這種情況下,無論如何都會先呼叫 funcB
喔。
原來如此,感謝大大~
您好:
請問 您的 callback範例中
請問
1.serials([funcA, funcB, funcC], funcD); 傳給function serials()
2.function serials() 執行
tasks.forEach(function(f) {
f(check); //---Q1
});
其中 f,是 'funcA' 名稱,還是 function ? 我看console.log他列出整段涵式
而他 f(check); //---Q1
==>funcA( check ) 嗎?
3.接下來 他去執行function funcA(check){ //----Q5 嗎?
順便把 function check(r) { //---Q3 丟給 funcA?
到了 check('A'); ////---Q4
就去執行 function check(r) { //---Q3 ??
if( result.length === step ){
callback(); //---Q2
}
這一段callbACK() 是指?
謝謝!
function serials(tasks, callback) {
var step = tasks.length;
var result = [];
// 檢查的邏輯寫在這裡
function check(r) { //---Q3
result.push(r);
if( result.length === step ){
callback(); //---Q2
}
}
tasks.forEach(function(f) {
f(check); //---Q1
});
}
function funcA(check){ //----Q5
window.setTimeout(function(){
console.log('A');
check('A'); ////---Q4
}, (Math.random() + 1) * 1000);
}
function funcB(check){
window.setTimeout(function(){
console.log('B');
check('B');
}, (Math.random() + 1) * 1000);
}
function funcC(check){
window.setTimeout(function(){
console.log('C');
check('C');
}, (Math.random() + 1) * 1000);
}
function funcD(){
console.log('上菜!');
}
serials([funcA, funcB, funcC], funcD);
老師你好,最近又重新懷疑了自己對JS非同步的概念是否正確...用一些code去測試,得到一個心得:
在JS無法創造出同時在update一個參數的例子
例如無法寫出"同時"對一個global number ++,想問老師是這樣嗎?
應該這樣說,不管是「同步」或是「非同步」,在執行時一定都有順序之分。
只是「同步」任務的順序我們可以掌握,而「非同步」的任務會因執行時的各種狀況導致執行的順序不同。
不管是哪一種都不會出現「同時」修改某個變數的情況喔。
果然,謝謝老師!