JavaScript 的同步與非同步 - 從 Callback function 到 Promise


Posted by Nicolakacha on 2020-09-11

本篇主要會釐清同步和非同步的觀念,並介紹 JavaScript 從 Callback function 進化到 Promise 的非同步方法,及用非同步 AJAX 發出 Request 的方法等。

內容涵蓋:

  1. JavaScript 在執行堆疊( call stack)是怎麼被執行的?
  2. 什麼是同步與非同步,為什麼需要非同步?
  3. 什麼方法可以達到非同步執行 JavaScript?
  4. Callback function 怎麼使用?有什麼缺點
  5. 使用 XMLHttpRequest 和 JQuery 發出 Request,怎麼搭配 callback function
  6. Promise 怎麼使用,解決了什麼問題?
  7. 使用 Fetch 發出 Request,怎麼處理回傳的 Promise 物件?
  8. async 與 await 怎麼使用?

同步和非同步?

我們先來釐清一下同步和非同步的觀念,到底是什麼是非同步呢,為什麼我們需要非同步?首先我們要看一下 JavaScript 這個程式語言的特性:

單線程 single treaded

JavaScript 所有的程式碼都會在執行堆疊( call stack)中被執行,一行一行程式碼往下執行的,且一次只執行一個程式碼片段。

堆疊 stack

stack 會記錄目前執行到程式的哪個部分,如果進入某一個 function,便把這個 function 放到 stack 的最上方。如果執行了 return,就把這個 function 從 stack 最上方抽離。

阻塞 blocking

因為是一行一行往下執行,如果 stack 中有一個程式碼是要花很多時間處理,例如在某網站中,stack 中最上面的運行的程式碼是儲存資料到後端,下一個程式碼是單純渲染畫面,跟上面的存資料無關,卻要等存完資料才能執行,網站畫面就會因為資料存太久,導致畫面看起來停滯,而對使用者體驗扣分,非同步就是為了要解決這樣的問題而誕生的。

JavaScript is a synchronous, blocking, single-threaded language.

你可能聽說過我們可以透過 AJAX 做到 JavaScript 的非同步。是的,但這不代表 JavaScript 本身是非同步的,而是在說,我們可以透過一些執行環境(如瀏覽器、node.js 等)提供的方法來操控 JavaScript,使其變成以非同步的方式執行,例如 setTimeout

setTimeout(function () {
  console.log('there')
}, 5000)
console.log('hi')
// hi
// 五秒後出現 there

但要注意的是 setTimeout 本身並不是 JavaScript 的一部分,而是執行環境提供給我們的一個 WebAPI。
以下我們就來介紹一些可以達到非同步的方法吧:

callback function

callback function 應該是最直觀的非同步了,這裡我們可以用 setTimeout 搭配 callback function 來說明:

console.log('hi')

setTimeout(function () {
  console.log('there')
}, 5000)

console.log('my friend')

call stack 會先執行 console.log('hi'),接著執行 setTimeout,但在 setTimeout 中的這個 function 不會立即被執行,而是繼續往下一行執行 console.log('my friend')

setTimeout 第二個參數數秒數到 5 秒的時候,才回去 call 剛才的 function,並執行 console.log('there')

用 stack 來理解的話,就是當我們在 call stack 執行 setTimeout 時,setTimeout 會被放到 WebAPIs 中,這時 setTimeout 這個 function 就已經執行結束並從 stack 中脫離,當計時器的時間到了之後,會把要執行的 callback function 放到叫做 task quere 的地方,事件循環(event loop)機制會不斷偵測 call stack,如果 call stack 是空的,就會把 task quere 最上面的任務放到 stack 裡面來執行。

可以使用 loupe 工具來看看自己的程式碼是怎麼被放進 call stack 執行的~

我們在做事件監聽時,也是利用 callback function 的概念,例如下例中就是當 .btn_alert 被點擊的時候,執行 handleClick 這個 callback function:

const btn = document.querySelector('.btn_alert')
btn.addEventListener('click', handleClick)

function handleClick() {
  alert('click!')
}

callback error first

在 callback function 中,我們想要幹嘛?

我們想要拿到的東西有兩個,第一個是回傳值,另一個則是有沒有發生錯誤。callback function 的第一個參數通常會帶錯誤處理,是因為錯誤只會有一個,而回傳值可能會有很多個。

為什麼 setTimeout 和 event listener 都不用做錯誤處理?

因為應用場合不需要,倒數幾秒和事件本身是不會發生錯誤的,如果像是用 ajax 去串 API,則過程中有可能會發生錯誤,這時才需要做錯誤處理。

AJAX 裡的 callback function

Ajax 就是指利用非同步的方法,向 server 發送 request 時或收到 response 時,由 JavaScript 決定要傳送及存取哪些資料,而不需要重新載入整個頁面而呈現資料。

XMLHttpRequest

我們可以利用 XMLHttpRequest 達到透過非同步發送 Request,request.onload 就是加載完成時會觸發的 callback function:

const request = new XMLHttpRequest()
// 非同步接受響應
// load 是加載完成時觸發
request.onload = function() {
    if (request.status >= 200 && request.status < 400) {
        console.log(request.responseText)
    } else {
        console.log("err")
    }
}

// error 是加載失敗時觸發
request.onerror = function() {
    console.log("error")
}

// 3個參數
// 傳送的請求的型別、請求的URL、是否非同步傳送請求的布林值
request.open("GET", "http://google.com", true)
// send()方法接收一個引數,即要作為請求主體傳送的資料
// 呼叫send()方法後,請求被分派到伺服器
request.send()

JQuery

也可以透過 JQuery 的 ajax 來達到同樣的功能:

$.ajax({
    url: 'URL',
    type: 'POST',
    data: yourData,
    datatype: 'json'
})
.done(function (data) { successFunction(data); })
.fail(function (jqXHR, textStatus, errorThrown) { serrorFunction(); });

Callback Function 的問題

信任問題

使用 callback 我們把控制權從某函式轉到另一個 callback 的函式,但在控制權轉移後,我們就無法再信任轉移前的那個函式,例如有可能會發生重複呼叫、過早呼叫等。

Callback Hell 回呼地獄

但是如果我們今天要做的 ajax 有很多,而且每次都一定要有前一次的結果才能做下一個 ajax,就會產生 callback hell 波動拳 XDD

doSomeAjax(url1, function(res1) {
  doSomeAjax(url2, function(res2) {
    doSomeAjax(url3, function(res3) {
      doSomeAjax(url5, function(res4) {
        doSomeAjax(url6, function(res5) {
        });
      });
    });
    });
});

而接下來要介紹的 Promise ,就是要來解決這些問題的!

Promise 物件

Promise 是一個保證會在未來某段時間內回傳一個唯一結果的物件,如果成功(resolved),它會回傳一個值,失敗(rejected)的話它則會回傳錯誤(也就是失敗的原因)。

建立 Promise 物件:

把一個 promise 物件 new 出來:

const promise = new Promise((resolve, reject) => {
    if (true) {
        resolve('Stuff worked')
    } else {
        reject('Error, it broke')
    }
})

https://i.imgur.com/AmGc3NO.png

使用 Promise 物件

用 then 來接收回傳的結果,catch 來接收回傳的錯誤:

promise
    .then(result => result + '!');
    .then(result2 => {
        throw Error
        console.log(result2);
})
    .catch(() => console.log('error!'))

如果中間遇到錯誤,.catch 底下的 .then 就不會被執行:

promise
    .then(result => result + '!');
    .then(result2 => result2 + '?'
    .catch(()=>console.log('error!'))
    .then(result3 => {
        console.log(result3 + '!')
    })

簡化再簡化的練習

function sleep() {
  const myPromise = new Promise(resolve => {
    setTimeout(resolve, 3000)
  })
  return myPromise;
}

sleep().then((data) => {
  console.log('myPromise Data', data);
})
.catch(err => {
  console.log('err', err);
})

上面 function sleep() 中我們宣告一個叫做 myPromise 的 Promise 再回傳回去,其實也就等於直接回傳 Promise 物件,可以改成如下程式碼,我們還可以把秒數抽離出來變成參數,以便之後隨時修改秒數:

function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms)
  })
}

還可以再簡化成 arrow function:

const sleep = ms => {
  return new Promise(resolve => {
    setTimeout(resolve, ms)
  })
}

中括號和 return 也可以拿掉了:

const sleep = ms => new Promise(resolve => {
    setTimeout(resolve, ms)
})

resolve 裡面的 arrow function,也可以把中括號拿掉,這樣就完成終極簡化了:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

新的 AJAX 方法:fetch

利用 fetch() 這個 ajax 方法來發出 request,可以回傳是一個 promise 物件。

// 用 fetch 發 GET request
const result = fetch('https://www.example_api.php')
    console.log(result);

then() 來接收 promise 物件回傳的的結果,then 裡面就是接完 API 之後會執行的 callback function,我們這邊先假設 callback function 是要把 response 印出來:

function printResult(response) {
      console.log(response);
  }
const result = fetch('https://www.example_api.php')
result.then(printResult);

語法簡化:

const result = fetch('https://www.example_api.php')
    .then((response)=> {
      console.log(response);
    })

印出來的 respsone 長這樣:

我們就可以拿到 response 的 body, status, header 的回傳值,但我們真正關心的是 response 的內容,也就是 body 的部分,但是如果直接印出 repsonse.body,會是一個稱為 ReadableStream 的實體:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    console.log(res.body);
  })

在這個階段,我們還不能讀取 response 真正的內容,但 ReadableStream 這個實體可以利用一些對應的方法來取得資料:

  • arrayBuffer()
  • blob()
  • formData()
  • json()
  • text()

這邊以 text() 來做示範,這樣應該可以拿到資料了吧:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    console.log(res.text());
  })

才怪,res.text 回傳的,還是一個 Promise 物件

可...可惡,那我們只好再用一個 .then 來拿資料,這樣終於拿到資料惹:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    res.text().then (text => {
      console.log(text);
    });
  })

/*
    {
        "text": "yoyoyo"
    }
*/

.json 也是一樣的用法,只是回傳的結果會幫我們轉成 json 格式:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    res.json().then (text => {
      console.log(text);
    });
  })

// {text: "yoyoyo"}

Chaining 特性

fetch() ⇒ Promise
response.json() ⇒ Promise
respose.json.then() ⇒ Promise

因為 .then 裡面回傳的還是一個 promise 物件,我們可以一直使用 .then 來對回傳的 promise 做處理。

.then().then().then().then().then() (誤

.then return 的值會是下一個 .then 的值:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    res.json().then (text => {
      return text;
    }).then( text => {
      console.log(text);
    })
  })

// {text: "yoyoyo"}

如果 .then 裡面回傳的是另一個 promise,.then 拿到的值就會是回傳的 promise 解析過的值,而不是 promise 物件:

const result = fetch('https://www.example_api.php')
  .then(res => {
    return res.json()
    // 回傳 promise
  }).then ( value => {
    // 拿到的就已經是被解析過的值了,不是 promise 物件
    console.log(value)
  })

比較一下兩種寫法,可以發現用 chaining 的寫法能有效減少層數:

const result = fetch('https://www.example_api.php')
  .then((res)=> {
    res.json().then (text => {
      console.log(text);
    });
  })

// ----------------------------------

const result = fetch('https://www.example_api.php')
  .then(res => {
    return res.json()
  }).then ( value => {
    console.log(value)
  })

欸?難道....,這不就是我們要找的 callback hell 的解法嗎?沒錯,這就是 promise 成為新標準的原因之一:

getGames(()=>{
  getStreams(()=>{
    getChannel(()=>{
      getTitle(()=>{

      })
    })
  })
})
getGames(() => {
  return getStreams();
}).then((streams) => {
  return getChannel();
}).then((channel) => {
  return getTitle();
});

Error 錯誤處理

這裡再回憶一下我們之前在介紹 Promise 提過的用 .catch 來接錯誤的部分。因為 fetch 回傳的是一個 Promise 物件,所以一樣只要用 .catch 就可以接住 fetch 的 error 了:

const result = fetch('https://www.example_api.php')
  .then(res => {
    return res.json()
  }).then ( value => {
    console.log(value)
  }).catch ( err => {
    console.log(err);
  })

// TypeError: Failed to fetch

上面介紹的都以用 fetch 發 GET request 當例子,如果是 POST 呢?可以看看下面的例子

POST

const data = {
  hello: 'world'
}

const result = fetch(api400, {
  method: 'POST',
  body: JSON.stringify(data),
  headers: new Headers({
    'Content-Type': 'application/json'
  })
})
  .then()
    .then()
    .catch()

Fetch 使用時的注意事項

content type

要注意 content type 是什麼,看 API 要接收的是什麼格式的資料

credential

發 request 給不是同個來源 domain 的 API 時,不會自動把 cookie 帶上去,要加上 credentials: 'includes'

const data = {
  hello: 'world'
}

const result = fetch(api400, {
  method: 'POST',
  body: JSON.stringify(data),
  credentials: 'includes',
  headers: new Headers({
    'Content-Type': 'application/json'
  })
})
  .then()
    .then()
    .catch()

流言終結者 mode: 'no-cors' 不能搞定 CORS

mode: 'no-cors' 不能突破 CORS 限制,而是會回傳一個空的 response,只是跟瀏覽器說,我本來就沒有要拿到 response,不要噴錯誤給我。要在瀏覽器解決 CORS,還是要 Server 開 CORS 的 header 給你

Async / Await

可以用看起來像同步的語法,做到非同步的事情。
用 async 宣告一個非同步的 function,await 接一個 Promise 物件,會等到執行完 await 裡面的 promise 才往下執行。

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

async function() {
    console.log('hello');
    await sleep(1000); // 接一個 Promise
    console.log('world');
}
function getData() {
  const api200 =
  'https://run.mocky.io/v3/d9d4ec56-b7de-41e6-b737-31bae9a30d2b';
  return fetch(api200)
  .then(res => {
    return res.json()
  })
}

getData().then(data => {
  console.log(data);
})

async function main() {
  console.log('enter main');
  const result = await getData();
  console.log(result);
}

參考資料:

JavaScript 中的同步與非同步(上):先成為 callback 大師吧!
所以說event loop到底是什麼玩意兒?| Philip Roberts | JSConf EU
[筆記] 理解 JavaScript 中的事件循環、堆疊、佇列和併發模式(Learn event loop, stack, queue, and concurrency mode of JavaScript in depth)
Is JavaScript Synchronous or Asynchronous? What the Hell is a Promise?
JavaScript ES6 Promise
你懂 JavaScript 嗎?#23 Callback
你懂 JavaScript 嗎?#24 Promise


#非同步 #Promise #Callback #程式導師計畫







Related Posts

Vue、MVVM、MVC、雙向綁定

Vue、MVVM、MVC、雙向綁定

nmapAutomator - OSCP考試好用的工具

nmapAutomator - OSCP考試好用的工具

CSS 效能優化

CSS 效能優化


Comments