iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 26
10
Modern Web

重新認識 JavaScript系列 第 26

重新認識 JavaScript: Day 26 同步與非同步

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


介紹過原型之後,原本想說可以往下個篇章繼續這樣,但我突然想起有個很重要的東西還沒講,就是初學者在學習 JavaScript 最容易搞混的特性之一:「同步 (Synchronous) 與 非同步 (Asynchronous)」的差別。

兩者容易搞混我覺得很大的原因出在 Synchronous 被翻譯成「同步」,光看字面上就可能把它想成是「所有動作同時進行」,但事實上剛好相反

怎麼說呢,今天過完就要放跨年假了,就讓我們用輕鬆一點的方式來介紹吧。


解釋「同步」與「非同步」

肥宅如我又要來介紹遊戲了,
最近有個很有趣的遊戲,叫「Overcooked」,中文翻譯叫「煮過頭」。 [註1]

https://ithelp.ithome.com.tw/upload/images/20171228/20065504SYwDOCydpj.png

遊戲的故事背景是洋蔥國被義大利麵肉丸怪入侵,要拯救世界的方式就是不斷地餵飽他,如果失敗世界就毀滅了。

https://ithelp.ithome.com.tw/upload/images/20171228/20065504WfBasoydCv.jpg

於是洋蔥國王打開一道傳送門,將主角們送回過去的世界好好磨練廚藝再回來拯救世界。

如果沒玩過的朋友可以點連結看看影片:老皮大廚的地獄廚房 | Overcooked 煮過頭
(ithelp 不能嵌入 youtube 影片,只好貼連結讓各位自己點了)

遊戲流程很簡單,要製作的餐點和配方會出現在畫面上方。
玩家遊戲步驟大概像這樣:「領食材 -> 切菜/煮菜 -> 合成 -> 裝盤 -> 上菜」

影片中可以看到在遊戲裡面,一群人手忙腳亂,每個人要負責做好各自的事情,好好合作才能順利過關。

.
.
.

謎之聲:....等等,這系列的組別應該不是「自我挑戰組」,說好的「同步」與「非同步」在哪裡?

別急,要開始進入正題了。


還記得嗎,我們在 重新認識 JavaScript: Day 18 Callback Function 與 IIFE 曾經提及:

JavaScript 是一個「非同步」的語言,所以當我們執行這段程式時, for 迴圈並不會等待 window.setTimeout 結束後才繼續,而是在執行階段就一口氣跑完。

就像在遊戲裡,客人點菜了之後,對應的食材原料就會送出來。

假設我們以這個番茄沙拉當例子:

https://ithelp.ithome.com.tw/upload/images/20171228/20065504TO9dkBMq9K.png

在領完食材原料之後,我們會有青菜、番茄需要處理。

但你不需要等到青菜切完才能處理番茄

而是在收到食材的同時,負責青菜的朋友就去處理青菜,負責番茄的朋友就去處理番茄

可能青菜先處理好,也可能番茄先處理好,但不要緊,等到青菜、番茄這些食材都弄好了,最後再一起裝盤、出餐。

像這樣處理事件的流程不會被「卡住」,就是非同步 (Asynchronous) 的概念。


那麼「同步」(Synchronous) 的概念又是什麼呢?

假設邊緣人如我,只能自己一人玩 Overcooked,在領完食材原料之後,一樣會有青菜、番茄需要處理。

因為只有一個廚師,所以要嘛先處理青菜、要嘛先處理番茄,必須先弄完一項之後再去處理另一項,整個流程會被前一個步驟卡住。

像這樣「先完成 A 才能做 B、C、D ...」的運作方式我們就會把它稱作「同步」(Synchronous) 。

https://ithelp.ithome.com.tw/upload/images/20171228/200655045cyd6aGPpC.png
然後手腳太慢洋蔥國世界就毀滅了


所以回到一開始所說的,「同步」光看字面上就可能把它想成是「所有動作同時進行」,但事實上比較像是「一步一步來處理」的意思。 而「非同步」則是,我不用等待 A 做完才做 B、C,而是這三個事情可以同時發送出去。 (當然回傳結果的順序也不一定就是)

在理解了同步與非同步的意義後,技術文不來寫點 code 我還是覺得哪裡怪怪的。 (抖M?)
所以接著來談談上回沒講到的「Callback Hell」的解法。


再見 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();

因為 funcAfuncB 分別加上了一個隨機的 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 就可能會出現了。

https://ithelp.ithome.com.tw/upload/images/20171221/20065504RIRldpL7Fs.jpg
*「波動拳」(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);
  });
}

那麼改寫一下 funcAfuncBfuncC:

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


Promise 物件

最後,稍微提一下,為了解決同步/非同步的問題,自從 ES6 開始新增了一個叫做 Promise 的特殊物件。

https://ithelp.ithome.com.tw/upload/images/20171228/20065504ynp2CYyeHW.png
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 物件會有這幾種狀態:

  • pending: 初始狀態,不是 fulfilled 或 rejected。
  • fulfilled: 表示操作成功地完成。
  • rejected: 表示操作失敗。

https://ithelp.ithome.com.tw/upload/images/20171228/2006550492O1gEFJio.png

整個 Promise 流程可以用這張圖表示:

https://ithelp.ithome.com.tw/upload/images/20171228/20065504wOffsDTOqr.png
圖片來源: 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('上菜'); });

  • [註1] : Overcooked 在 PS4 / switch 平台上都有,是個極歡樂的友情破壞遊戲,但只有一個人玩的話極度不推薦 QQ。
  • [註2] : 鄉民說的其實是「不是被打一巴掌,就是被關一輩子」切勿以身試法

上一篇
重新認識 JavaScript: Day 25 原型與繼承
下一篇
重新認識 JavaScript: Day 27 從 Page 到 Application,談談前端框架與工具庫 (上)
系列文
重新認識 JavaScript37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
小不釘
iT邦新手 2 級 ‧ 2018-07-24 15:20:08

雖然前面遊戲介紹的地方我覺得冗就直接end了,但關於callback的部分是我看過最好懂的一篇說明

1
Amigo
iT邦新手 5 級 ‧ 2019-05-22 09:22:08

受益良多,謝謝!
想請問一個問題:

funcA().then(funcB).then(funcC);
的寫法,
如果改成
funcA().then(funcB()).then(funcC());
順序是亂掉的
那如果要傳參數進去,該如何寫呢?

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2019-05-23 13:55:05 檢舉

你想要傳什麼參數呢? 到 funcA ? funcB 還是 funcC ?

Amigo iT邦新手 5 級 ‧ 2019-05-23 17:44:48 檢舉

比如說這樣:
function funcX(x){
return new Promise(function(resolve, reject){
window.setTimeout(function(){
console.log(x);
resolve(x);
}, (Math.random() + 1) * 1000);
});
}

Kuro Hsu iT邦新手 1 級 ‧ 2019-05-23 18:24:43 檢舉
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。

Amigo iT邦新手 5 級 ‧ 2019-05-24 11:12:16 檢舉

了解。謝謝您
我還找到這個解法:
funcX(3).then(()=>funcX(1)).then(()=>funcX(2))

0
RocMark
iT邦新手 5 級 ‧ 2019-05-29 22:45:39

想請教一下為何最後一行的 funcA(funcB) 改成 funcA(funcB)
會造成順序相反,而沒有callBack效果呢?

  • 更改 a執行時間設為較長、b較短
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() )
Kuro Hsu iT邦新手 1 級 ‧ 2019-05-30 10:08:29 檢舉

你好,因為加了 ()funcB 實際上是將 funcB 「呼叫之後的結果」作為參數傳入 funcAfuncA( funcB() )

所以在這種情況下,無論如何都會先呼叫 funcB 喔。

RocMark iT邦新手 5 級 ‧ 2019-05-30 16:36:47 檢舉

原來如此,感謝大大~

0
noway
iT邦研究生 4 級 ‧ 2019-09-11 09:51:49

您好:
請問 您的 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);
0
Amigo
iT邦新手 5 級 ‧ 2019-09-20 09:49:07

老師你好,最近又重新懷疑了自己對JS非同步的概念是否正確...用一些code去測試,得到一個心得:
在JS無法創造出同時在update一個參數的例子
例如無法寫出"同時"對一個global number ++,想問老師是這樣嗎?

Kuro Hsu iT邦新手 1 級 ‧ 2019-09-20 10:20:52 檢舉

應該這樣說,不管是「同步」或是「非同步」,在執行時一定都有順序之分。

只是「同步」任務的順序我們可以掌握,而「非同步」的任務會因執行時的各種狀況導致執行的順序不同。

不管是哪一種都不會出現「同時」修改某個變數的情況喔。

Amigo iT邦新手 5 級 ‧ 2019-09-20 11:08:02 檢舉

果然,謝謝老師!/images/emoticon/emoticon42.gif

我要留言

立即登入留言