跳轉到內容

JavaScript/非同步

來自華夏公益教科書



在沒有特定關鍵字和技術的情況下,JavaScript 引擎會按照書寫程式碼的順序,依次執行語句。在大多數情況下,這是必要的,因為一行程式碼的結果會用在下一行程式碼中。

"use strict";

/* Line 1 */ const firstName = "Mahatma";
/* Line 2 */ const familyName = "Gandhi";
/* Line 3 */ const completeName = firstName + " " + familyName;

第 1 行和第 2 行必須完全完成,才能執行第 3 行。這是通常的順序行為。

但有些情況,後面的語句不需要等待當前語句執行結束。或者,你預期某個活動會執行很長時間,你想要在這期間做些別的事情。這種並行執行有可能顯著地減少整體響應時間。這是可能的,因為現代計算機擁有多個 CPU,能夠同時執行多個任務。在上面的例子中,第 1 行和第 2 行可以並行執行。此外,客戶端/伺服器架構在多個伺服器之間分配任務。

典型的情況是長時間執行的資料庫更新、處理大型檔案或 CPU 密集型計算。但即使對於渲染 HTML 頁面這樣看起來很簡單的事情,瀏覽器通常也執行多個任務。

單執行緒

[編輯 | 編輯原始碼]

原生,“JavaScript 是單執行緒的,所有 JavaScript 程式碼都在單個執行緒中執行。這包括你的程式原始碼和你包含在你程式中的第三方庫。當程式進行 I/O 操作來讀取檔案或網路請求時,這會阻塞主執行緒”[1]

為了無論如何都實現同時活動的目標,瀏覽器、服務工作者、庫和伺服器提供了額外的適當介面。它們的初衷是解除 HTTP 和資料庫請求的阻塞執行;一小部分集中在 CPU 密集型計算上。

在 JavaScript 語言級別,有三種技術可以實現非同步 - 這感覺起來像同時性。

  • 回撥函式
  • Promise
  • 關鍵字 asyncawait

基本技術是使用回撥函式。這個術語用於作為引數傳遞給另一個函式的函式,特別是 - 但不僅僅是 - 用於實現非同步行為的函式。

一個Promise 代表非同步操作的最終完成或失敗,包括其結果。它在這些非同步執行操作結束後,引導進一步處理。

因為使用 .then.catchPromise 進行評估可能會導致程式碼難以閱讀 - 特別是如果它們巢狀 -,JavaScript 提供了關鍵字 asyncawait。它們的用法生成與傳統 JavaScript 中的 try .. catch .. finally 類似的井井有條的程式碼。但它們並沒有實現額外的功能。相反,在底層,它們是基於Promise

嚴格順序?不。

[編輯 | 編輯原始碼]

為了證明程式碼並不總是按照嚴格的順序執行,我們使用一個指令碼,其中包含一個 CPU 密集型計算,它在一個或多或少很大的迴圈中執行。根據你的計算機,它會執行幾秒鐘。

"use strict";

// function with CPU-intensive computation
async function func_async(upper) {
  await null;                                           // (1)
  return new Promise(function(resolve, reject) {        // (2)
    console.log("Starting loop with upper limit: " + upper);
    if (upper < 0) {
      // an arbitrary test to generate a failure
      reject(upper + " is negative. Abort.");
    }
    for (let i = 0; i < upper; i++) {
       // an arbitrary math function for test purpose
       const s = Math.sin(i);                           // (3)
    }
    console.log("Finished loop for: " + upper);
    resolve("Computed: " + upper);
  })
}

const doTask = function(arr) {
  for (let i = 0; i < arr.length; i++) {
    console.log("Function invocation with number: " + arr[i]);
    func_async(array1[i])                              // (4)
      .then((msg) => console.log("Ok. " + msg))
      .catch((msg) => console.log("Error. " + msg));
    console.log("Behind invocation for number: " + arr[i]);
  }
}

const array1 = [3234567890, 10, -30];
doTask(array1);
console.log("End of program. Really?");

預期輸出

Function invocation with number: 3234567890
Behind invocation for number: 3234567890
Function invocation with number: 10
Behind invocation for number: 10
Function invocation with number: -30
Behind invocation for number: -30
End of program. Really?
Starting loop with upper limit: 3234567890
Finished loop for: 3234567890
Starting loop with upper limit: 10
Finished loop for: 10
Starting loop with upper limit: -30
Finished loop for: -30
Ok. Computed: 3234567890
Ok. Computed: 10
Error. -30 is negative. Abort.
  • 非同步函式 func_async 的核心是一個迴圈,其中進行了一次數學計算 [(3) 第 14 行]。迴圈需要多少時間取決於給定的引數。
  • func_async 的返回值不是一個簡單值,而是一個Promise。[(2) 第 6 行]
  • func_async 針對給定陣列中的每個元素,被 doTask 呼叫一次。[(4) 第 24 行]
  • 由於 asyn_func 的非同步性質,函式 doTaskfunc_async 執行之前就完全執行了!這可以透過程式輸出觀察到。
  • await null [(1) 第 5 行] 是一個虛擬呼叫。它會掛起 func_async 的執行,讓 doTask 繼續執行。如果你刪除這個語句,輸出結果將不同。結論:要讓函式真正非同步,你需要兩個關鍵字,函式簽名中的 async 和函式體中的 await
  • 如果你有一個工具可以詳細觀察你的計算機,你會發現 func_async 的三個呼叫不是在不同的 CPU 上同時執行,而是在一個接一個地執行(在同一或不同的 CPU 上)。

將函式作為引數傳遞給(非同步)函式是 JavaScript 中的最初技術。我們將使用預定義的 setTimeout 函式來演示它的目的和優勢。它接受兩個引數。第一個是我們所說的回撥函式。第二個是指定回撥函式被呼叫後經過多少毫秒的一個數字。

"use strict";

function showMessageLater() {
  setTimeout(showMessage, 3000);  // in ms
}

function showMessage() {
  alert("Good morning.");
}

showMessage();       // immediate invocation
showMessageLater();  // invocation of 'showMessage' after 3 seconds

如果 showMessage 被呼叫,它會立即執行。如果 showMessageLater 被呼叫,它會將 showMessage 作為回撥函式傳遞給 setTimeoutsetTimeout 會在延遲 3 秒後執行它。

一個Promise 會跟蹤(非同步)函式是否已成功執行或已因錯誤終止,並確定接下來會發生什麼,呼叫 .then.catch

Promise 處於三種狀態之一

  • 掛起:初始狀態,在“已解決”或“已拒絕”之前
  • 已解決:函式成功完成之後:呼叫了 resolve
  • 已拒絕:函式因錯誤而完成之後:呼叫了 reject
"use strict";

// here, we have only the definition!
function demoPromise() {

  // The 'return' statement is executed immediately, and the calling program
  // continues its execution. But the value of 'return' keeps undefined until
  // either 'resolve' or 'reject' is executed here.
  return new Promise(function(resolve, reject) {
    //
    // perform some time-consuming actions like an 
    // access to a database
    //
    const result = true;  // for example

    if (result === true) {
      resolve("Demo worked.");
    } else {
      reject("Demo failed.");
    }
  })
}

demoPromise()  // invoke 'demoPromise'
  .then((msg) => console.log("Ok: " + msg))
  .catch((msg) => console.log("Error: " + msg));

console.log("End of script reached. But the program is still running.");

預期輸出

End of script reached. But the program is still running.
Ok: Demo worked.

demoPromise 被呼叫時,它會建立一個新的Promise 並返回它。然後,它會執行耗時的操作,並根據結果呼叫 resolvereject

接下來(在此期間,呼叫指令碼已執行了其他操作),demoPromise 呼叫後的 .then().catch() 函式中的一個會被執行。這兩個函式都接受一個(匿名)函式作為引數。傳遞給匿名函式的引數是Promise 的值。

 .then(   (     msg       )  =>  console.log("Ok: " + msg)   )
  |   | | |               |      |                       | | | |
  |   | | └── parameter ──┘      └────── funct. body ────┘ | | |
  |   | |                                                  | | |
  |   | └─────────────  anonymous function  ───────────────┘ | |
  |   |                                                      | |
  |   └───────  parameter of then() function  ───────────────┘ |
  |                                                            |
  └───────────────────  then() function  ──────────────────────┘

請注意,指令碼的最後一條語句已之前執行了。

許多庫、API 和伺服器函式的介面都是定義和實現為返回一個Promise 的函式,類似於上面的 demoPromise。只要你不必建立自己的非同步函式,你就沒有必要建立Promise。通常,呼叫外部介面並只使用 .then.catch(或下一章的 asyncawait)就足夠了。

async / await

[編輯 | 編輯原始碼]

關鍵字 await 會強制 JavaScript 引擎在執行下一條語句之前,完全執行 await 後面的指令碼 - 包括 new Promise 部分。因此 - 從呼叫指令碼的角度來看 - 非同步行為被去除了。帶有 await 的函式必須在它們的簽名中用關鍵字 async 標記。

"use strict";

// same as above
function demoPromise() {
  return new Promise(function(resolve, reject) {
    const result = true;  // for example
    if (result === true) {
      resolve("Demo worked.");
    } else {
      reject("Demo failed.");
    }
  })
}

// a function with the call to 'demoPromise'
// the keyword 'async' is necessary to allow 'await' inside
async function start() {
  try {
    // use 'await' to wait for the end of 'demoPromise'
    // before executing the following statement
    const msg =  await demoPromise();
    // without 'await', 'msg' contains the Promise, but without
    // the success- or error-message
    console.log("Ok: " + msg);
  } catch (msg) {
    console.log("Error: " + msg);
  }
}

start();
console.log("End of script reached. End of program?");

使用 async .. await 允許你使用傳統的 try .. catch 語句,而不是 .then.catch

一個現實的例子

[編輯 | 編輯原始碼]

我們使用免費提供的演示 API https://jsonplaceholder.typicode.com/。它以 JSON 格式提供少量測試資料。

"use strict";

async function getUserData() {

  // fetch() is an asynchronous function of the Web API. It returns a Promise
  // see: https://mdn.club.tw/en-US/docs/Web/API/Fetch_API/Using_Fetch
  await fetch('https://jsonplaceholder.typicode.com/users')

    // for the use of 'response', see: https://mdn.club.tw/en-US/docs/Web/API/Response/json
    // '.json()' reads the response stream
    .then((response) => { return response.json() })

    // in this case, 'users' is an array of objects (JSON format)
    .then((users) => {
      console.log(users); // total data: array with 10 elements

      // loop over the ten array elements
      for (const user of users) {
        console.log(user.name + " / " + user.email);
      }        
    })
    .catch((err) => console.log('Some error occurred: ' + err.message));
}

// same with 'try / catch'
async function getUserData_tc() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    const users = await response.json();
    console.log(users);
    console.log(users[0].name);
    for (const user of users) {
      console.log(user.name + " / " + user.email);
    }        
  } catch (err) {
    console.log('Some error occurred: ' + err.message);
  }
}

getUserData();
getUserData_tc();

此例子的步驟為

  • await fetch():獲取 URL 後面的資料。await 部分保證指令碼在 fetch 傳遞所有資料之前不會繼續。
  • json() 讀取包含結果資料的流。
  • 結果資料是一個包含 10 個元素的陣列。每個元素都以 JSON 格式,例如
{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}
  • 作為示例,該指令碼在控制檯中顯示了完整資料及其部分內容。

注意:當你使用任意 URL 時,你可能會遇到一個CORS 錯誤

... 可在另一個頁面上找到(點選這裡)。

參考文獻

[編輯 | 編輯原始碼]
華夏公益教科書