異步處理(Asynchronous)
我們知道程式的執行順序是從由上到下的,所以當程式碼中如果有使用到需要耗時較久的操作時,就會造成程式的等待阻塞,如果是以前端畫面的想法來看,就會造成畫面的凍結,而這樣的情況對使用者的體驗來說是非常不好的。
因此就需要一個可以讓程式在執行到需要耗時的操作時,可以先觸發這個操作,然後忽視它,繼續往下執行,等到這個操作完成之後再回頭來處理這個操作的結果。這樣的操作就稱為異步處理(Asynchronous)。
在很多程式語言中都有提供異步處理的機制:
| 語言 | 異步處理機制 |
|---|---|
| JavaScript | Promise、Async/Await |
| Python | Async/Await |
| Java | Future、CompletableFuture |
| C# | Task、Async/Await |
| Flutter | Future、Async/Await |
| ... | ... |
所以異步處理是一個非常重要的概念,不管是前端、後端、手機端、桌面端,都會有異步處理的需求。
setTimeout/setInterval
setTimeout 和 setInterval 是 JS 中最早的異步處理機制,它們可以讓程式在指定的時間後執行某個函式。
setTimeout(() => {
console.log('Hello')
}, 1000) // 1 秒後印出 Hello
setInterval(() => {
console.log('World')
}, 1000) // 每 1 秒印出 WorldsetTimeout 是只執行一次,setInterval 是每隔一段時間就執行一次。 如果使用 setInterval 時,如果不再需要執行,可以使用 clearInterval 來清除。
const interval = setInterval(() => {
console.log('World')
}, 1000) // 每 1 秒印出 World
// ... setInterval 運行一段時間後
clearInterval(interval) // 停止執行Promise
Promise 是 ES6 中新增的語法,需要傳入一個函式, Promise 會提供 resolve 和 reject 兩個函式傳入進來,可以對這兩個函式傳入參數,當 resolve 被呼叫時,代表這個操作成功,reject 被呼叫時,代表這個操作失敗;然後再用 then 來接收 resolve 的結果,用 catch 來接收 reject 的結果,最後可以用 finally 來做一些收尾的工作。
const p = new Promise((resolve, reject) => {
// 一秒後執行 resolve
setTimeout(() => {
resolve('Promise is done!') // 把 'Promise is done!' 傳給 then
}, 1000)
// 一秒後執行 reject
setTimeout(() => {
reject('Oh no... Promise is failed.') // 把 'Oh no... Promise is failed.' 傳給 catch
}, 500)
})
p.then((result) => {
console.log(result) // Done
})
.catch((error) => {
console.log(error) // Error
})
.finally(() => {
console.log('Finally!') // Finally!
})上面這個範例我們建立了一個 Promise: p ,在 Promise 的傳入函式中,我們設定了兩個 setTimeout,一個是在 1 秒後執行 resolve,一個是在 0.5 秒後執行 reject,所以在 then 中會印出 Promise is done!,在 catch 中會印出 Oh no... Promise is failed.
但是因為 reject 被呼叫的時間會早於 resolve,所以呼叫完 reject 之後,就會直接進入 catch,然後 finally 會在 catch 之後執行。這個 Promise 就算結束了,所以在 then 中的結果不會被印出。
利用 Promise 來實現資料的非同步載入
在實務上,前端把畫面渲染出來之外,還需要跟伺服器溝通取得或是傳送資料,但透過網路傳輸是需要時間的,也有可能會因為網路環境的不穩定而導致資料無法正確取得,所以取得資料的過程會有延遲、甚至是失敗的可能,所以就必須使用異步處理的機制來處理這個問題。
使用 fetch 來取得資料
fetch 是 JS ES6 中新增的 API,建立一個 Promise 來發送網路請求,並且會自動的根據網路狀況來判斷是否成功取得資料。
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => {
if (!response.ok) { // 如果狀態碼不是 200 OK 就拋出錯誤
throw new Error('Network response was not ok')
}
return response.json() // 將回應轉換成 JSON,但要注意的是這裡也是一個 Promise
})
.then((data) => { // 這裡的 data 就是 response.json Promise.then 的結果
console.log(data) // 印出資料
})
.catch((error) => {
// 處理錯誤
console.error('There has been a problem with your fetch operation:', error)
})
.finally(() => {
// 無論成功或失敗都會執行
console.log('Fetch done!')
})async/await
我們先設想一個情境:有一個網站系統,它提供了登入功能,並且在登入成功之後會去取得使用者的個人資料,然後才會被導入到首頁。大概流程會是這樣:
- 使用者輸入帳號密碼,執行登入的動作
- 前端向後端發送登入請求
- 後端檢驗帳號密碼成功,回傳使用者的登入金鑰
- 前端儲存登入金鑰
- 前端持登入金鑰,向後端發送取得使用者資料的請求
- 後端檢驗登入金鑰成功,回傳使用者的個人資料
- 前端取得使用者的個人資料,導入首頁
有沒有發現一個狀況,就是在取得使用者的個人資料之前,必須要先取得登入金鑰。也就是說,取得使用者個人資料這個動作是依賴於取得登入金鑰這個動作的,所以這兩個動作是有先後順序的。如果用 Promise 來實現的話,會是這樣:
fetch('https://fakeapi.com/login', { // 登入
method: 'POST',
body: JSON.stringify({
username: 'admin',
password: 'admin'
})
})
.then((response) => {
// 如果登入API返回的狀態碼不是 OK 就拋出錯誤
if (!response.ok) {
throw new Error('Network response was not ok')
}
// 這裡也是一個 Promise,將回應轉換成 JSON
return response.json()
})
.then((data) => {
// 登入成功,取得登入金鑰,並再次向後端發送請求取得使用者資料
return fetch('https://fakeapi.com/user', {
headers: {
// 將前面登入成功後取得的金鑰附帶在請求的 Header 中
'Authorization': `Bearer ${data.token}`
}
})
})
.then((response) => {
// 如果登入API返回的狀態碼不是 OK 就拋出錯誤
if (!response.ok) {
throw new Error('Network response was not ok')
}
// 這裡也是一個 Promise,將回應轉換成 JSON
return response.json()
})
.then((data) => {
// 取得使用者資料成功,導入首頁
console.log(data)
// 這裡可以導入首頁
redirectToHomepage()
})
.catch((error) => {
console.error('There has been a problem with your fetch operation:', error)
})
.finally(() => {
console.log('Fetch done!')
})這樣的程式碼執行上是沒有問題的,但是程式碼的可讀性就變得很差,讓程式碼變得很難閱讀和維護。 所以在 ES8 中新增了 async/await 語法,讓程式碼更像同步程式碼一樣撰寫。
async function login(){
try{
const loginResponse = await fetch('https://fakeapi.com/login', {
method: 'POST',
body: JSON.stringify({
username: 'admin',
password: 'admin'
})
})
const data = await loginResponse.json()
const userResponse = await fetch('https://fakeapi.com/user', {
headers: {
'Authorization': `Bearer ${data.token}`
}
})
const userData = await userResponse.json()
redirectToHomepage()
}
catch(e){
console.error('There has been a problem with your fetch operation:', error)
}
finally{
console.log('Fetch done!')
}
}解釋一下 async/await 的語法:
| 語法 | 說明 |
|---|---|
| async | 宣告這個函式是一個異步函式,這個函式會回傳一個 Promise |
| await | 等待 Promise 的結果,預期會收到 resolve 的結果,可以被存到左邊的變數裡 |
另外 async/await 可以搭配來 try/catch 來捕捉錯誤,也可以使用 finally 來做一些收尾的工作。 如果有多個 await 的話,會依序執行,也就是說第一個 await 會等到 resolve 之後才會執行第二個 await。 但是若其中有一個 await 出現 reject 的話,就會直接跳到 catch 中,後面的 await 就不會執行了。
所以 async/await 很適合用於一些有先後順序的非同步操作。
迸發/平行處理(Parallel)
有些時候,我們會需要同時執行多個非同步操作,比如說當我們有一個100G的檔案,如果一次上傳全部可能會造成傳輸超時、程序崩潰的問題,必須要將檔案切成比較小塊來傳輸,這時候就可以使用 Promise.all 來實現。
async function uploadFile(file){
try{
const chunkSize = 1024 * 1024 * 10 // 每塊10MB
const chunks = []
for(let i = 0; i < file.size; i += chunkSize){
// 每10MB切成一塊,並加入到儲存切塊的陣列裡,直到檔案大小跟切塊大小相等
chunks.push(file.slice(i, i + chunkSize))
}
// 對切塊陣列用 map 取出每一塊,並用 fetch 來上傳,這時候會得到一個 Promise 陣列
const uploadPromises = chunks.map(chunk => {
return fetch('https://fakeapi.com/upload', {
method: 'POST',
body: chunk
})
})
// 觸發所有的 Promise,等待所有的 Promise 都 resolve 之後才會執行下一步
const responses = await Promise.all(uploadPromises)
console.log('All chunks uploaded!')
}catch(err){
console.error('There has been a problem with your fetch operation:', err)
}finally{
console.log('Fetch done!')
}
}總結
異步處理是一個很重要的概念,它的應用並不限於前端,後端、手機端、桌面端,也不限於程式語言的種類。只要有非同步操作的需求,就會需要異步處理的機制。畢竟我們都不希望程式因為等待而停止執行,這樣會讓使用者感到程式很慢,體驗很差。
