案例解析:理解 JavaScript 中的嵌套回調與執行順序

更新日期: 2024 年 10 月 30 日

在 JavaScript 中,我們常常會遇到需要按順序執行一系列非同步任務的情況,比如依次執行三個任務 firstTasksecondTaskthirdTask

如果這些任務依次完成後還有後續操作,我們通常使用回調函數來控制這個順序。

本文將通過兩種不同的調用方式,來解釋如何控制執行順序,並展示每種方式的結果。

案例介紹

假設我們有三個函數:firstTasksecondTaskthirdTask

這三個函數模擬非同步操作,使用 setTimeout 進行 1 秒的延遲。

它們各自完成後會執行一個回調函數:

// 定義第一個任務,接收一個回調函數作為參數
function firstTask(callback) {
  setTimeout(() => {
    console.log("完成任務1"); // 顯示「完成任務1」
    callback(); // 執行回調函數
  }, 1000);
}

// 定義第二個任務,接收一個回調函數作為參數
function secondTask(callback) {
  setTimeout(() => {
    console.log("完成任務2"); // 顯示「完成任務2」
    callback(); // 執行回調函數
  }, 1000);
}

// 定義第三個任務,接收一個回調函數作為參數
function thirdTask(callback) {
  setTimeout(() => {
    console.log("完成任務3"); // 顯示「完成任務3」
    callback(); // 執行回調函數
  }, 1000);
}

目標是按順序執行這三個任務,並在所有任務完成後打印「所有任務完成」。

接下來,我們將比較兩種不同的調用方式。

方法一:嵌套回調

首先,我們可以使用嵌套回調來控制順序:

firstTask(() => {
  secondTask(() => {
    thirdTask(() => {
      console.log("所有任務完成");
    });
  });
});

在這種寫法中,secondTask 被放在 firstTask 的回調函數中,而 thirdTask 被放在 secondTask 的回調函數中。

這樣,只有當 firstTask 完成後,才會開始 secondTask,並且只有當 secondTask 完成後,才會開始 thirdTask

這段代碼的輸出結果是:

完成任務1
完成任務2
完成任務3
所有任務完成

這種方式使用嵌套的回調函數來確保執行順序,逐層傳遞回調函數,從而達到 順序控制 的效果。

概念解析:嵌套回調與 Call Stack

當我們調用 firstTask 時,JavaScript 引擎會先執行 firstTask,並設置一個 1 秒延遲的 setTimeout

在 1 秒之後,firstTask 的回調函數(即 secondTask)會被加入到 事件佇列,並等待主執行緒空閒時依次執行。

secondTaskthirdTask 同理,最終達到依次執行的效果。

方法二:直接傳遞嵌套函數 firstTask(secondTask(thirdTask))

另一種常見的嘗試是直接將 thirdTask 作為參數傳給 secondTask,然後再傳遞給 firstTask,即:

firstTask(secondTask(thirdTask));

乍看之下,這樣的寫法似乎也可以達到按順序執行的效果,但實際上並不是這樣。為什麼呢?讓我們一步步來分析。

  1. 首先求值 thirdTask():當 JavaScript 遇到 firstTask(secondTask(thirdTask)) 時,它會遵循「從內到外的求值順序」來處理。也就是說,thirdTask 會立即被執行,而不是等到 firstTask 完成後才執行。這稱為 求值順序(Order of Evaluation)
  2. 回傳結果作為參數傳遞thirdTask() 被執行後,由於沒有返回值,因此它的結果是 undefined。這個 undefined 被作為參數傳遞給 secondTask
  3. 執行 secondTask(undefined):現在,secondTask 立即執行,並接收 undefined 作為它的參數。執行過程中,secondTask 會設置一個 setTimeout,但它不會等待 thirdTask 完成。
  4. 執行 firstTask(undefined):同樣地,firstTask 最後會執行,並設置一個 setTimeout,等待 1 秒後輸出「完成任務1」。

這樣的寫法不會按照預期順序執行,而是所有任務的 setTimeout 幾乎同時開始計時。結果的輸出順序是無法保證的,可能是:

完成任務2
完成任務1
完成任務3

為什麼會產生這個問題?

關鍵在於 JavaScript 的求值順序與 Call Stack。當 firstTask(secondTask(thirdTask)) 被調用時:

  1. JavaScript 先執行最內層的 thirdTask() 並返回 undefined
  2. 接著,它將 undefined 作為參數傳給 secondTask,導致 secondTask 立即執行並設置計時器。
  3. 最後,firstTask 也接收 undefined 作為參數並立即執行,設置計時器。

因為這些 setTimeout 計時器是同時設置的,導致它們的回調無法按順序執行。

求值順序補充

「求值順序」(Order of Evaluation),有時也稱作 「運算子優先級」(Operator Precedence)「運算子結合性」(Operator Associativity)

在 JavaScript 中,當一個表達式包含多層嵌套的函數調用時,JavaScript 引擎會遵循一定的順序來對每個子表達式求值,從最內層到最外層。

求值順序與嵌套函數調用

當遇到嵌套函數調用(如 firstTask(secondTask(thirdTask)))時,JavaScript 會先求值最內層的表達式 thirdTask(),將其結果傳給外層的 secondTask,接著再傳遞給最外層的 firstTask

這種順序遵循 從內到外 的求值原則。

專有名詞

在編程語言的設計中,這種「由內到外」的求值順序主要是根據兩個概念來實現的:

  1. 「運算子優先級」(Operator Precedence)
    • 决定了在一個包含多個運算子的表達式中,哪些運算子先被處理。函數調用的運算子(())具有較高的優先級,因此 thirdTask() 會先被執行。
  1. 「運算子結合性」(Operator Associativity)
    • 決定了當運算子的優先級相同時,求值的方向。大多數函數調用都是「由內到外」,即從最內層開始依次求值。

如何應用這些概念

因為 JavaScript 的求值順序是由內到外,當寫 firstTask(secondTask(thirdTask)) 時,最內層的 thirdTask() 會先執行,然後再執行 secondTaskfirstTask

這種 先求內層表達式,再將結果向外傳遞 的方式是嵌套函數的一般執行模式。

結論:如何選擇控制順序的方法?

  • 嵌套回調:嵌套回調可以確保非同步任務依次執行,適合少量依次執行的操作。但過度嵌套會導致「回調地獄」,代碼難以維護。
  • 直接傳遞嵌套函數:直接傳遞嵌套函數無法控制順序,因為最內層函數會先求值並立即執行。這種寫法並不適合需要順序執行的情況。

理解這兩種方法的區別,有助於選擇合適的代碼結構控制任務的執行順序。

希望這篇文章能幫助你在 JavaScript 中順利掌握異步操作的控制!

Similar Posts