2022 年 6 月 18 日

簡介:回呼函式

我們在範例中使用瀏覽器方法

為了示範回呼函式、Promise 和其他抽象概念的使用方式,我們將使用一些瀏覽器方法:特別是載入腳本和執行簡單的文件操作。

如果你不熟悉這些方法,而且範例中的用法令人困惑,你可以閱讀教學課程下一部分的幾個章節。

儘管如此,我們還是會盡量把事情說清楚。在瀏覽器方面,不會有任何真正複雜的事情。

JavaScript 主機環境提供了許多函式,可讓你排程非同步動作。換句話說,我們現在啟動的動作,但它們會在稍後完成。

例如,其中一個這樣的函式是 setTimeout 函式。

還有其他非同步動作的真實世界範例,例如載入腳本和模組(我們將在後面的章節中介紹它們)。

看看函式 loadScript(src),它會載入具有給定 src 的腳本

function loadScript(src) {
  // creates a <script> tag and append it to the page
  // this causes the script with given src to start loading and run when complete
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}

它會將一個新的動態建立的標籤 <script src="…"> 插入文件,其中具有給定的 src。瀏覽器會自動開始載入它,並在完成時執行。

我們可以像這樣使用這個函式

// load and execute the script at the given path
loadScript('/my/script.js');

腳本會「非同步」執行,因為它現在開始載入,但會在函式已完成後才執行。

如果 loadScript(…) 下方有任何程式碼,它不會等到腳本載入完成。

loadScript('/my/script.js');
// the code below loadScript
// doesn't wait for the script loading to finish
// ...

假設我們需要在腳本載入後立即使用它。它會宣告新的函式,而我們想要執行它們。

但如果我們在 loadScript(…) 呼叫後立即執行,那將無法運作

loadScript('/my/script.js'); // the script has "function newFunction() {…}"

newFunction(); // no such function!

當然,瀏覽器可能沒有時間載入腳本。目前,loadScript 函式不提供追蹤載入完成的方式。腳本會載入並最終執行,就這樣。但我們希望知道何時發生這種情況,以使用該腳本中的新函式和變數。

讓我們新增一個 callback 函式作為 loadScript 的第二個引數,它應該在腳本載入時執行

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(script);

  document.head.append(script);
}

onload 事件在文章 資源載入:onload 和 onerror 中有說明,它基本上會在腳本載入並執行後執行一個函式。

現在,如果我們想要呼叫腳本中的新函式,我們應該在 callback 中撰寫它

loadScript('/my/script.js', function() {
  // the callback runs after the script is loaded
  newFunction(); // so now it works
  ...
});

這就是這個概念:第二個引數是一個函式(通常是匿名的),它會在動作完成時執行。

以下是使用實際腳本的可執行範例

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  alert(`Cool, the script ${script.src} is loaded`);
  alert( _ ); // _ is a function declared in the loaded script
});

這稱為非同步程式設計的「基於 callback」樣式。非同步執行某項作業的函式應該提供一個 callback 引數,我們可以在其中放置在它完成後執行的函式。

我們在 loadScript 中執行這項作業,但這當然是一種一般性的方法。

callback 中的 callback

我們如何依序載入兩個腳本:第一個,然後是第二個腳本?

自然而然的解決方法是將第二個 loadScript 呼叫放入 callback 中,如下所示

loadScript('/my/script.js', function(script) {

  alert(`Cool, the ${script.src} is loaded, let's load one more`);

  loadScript('/my/script2.js', function(script) {
    alert(`Cool, the second script is loaded`);
  });

});

在外層 loadScript 完成後,callback 會啟動內層 loadScript

如果我們想要多一個腳本…?

loadScript('/my/script.js', function(script) {

  loadScript('/my/script2.js', function(script) {

    loadScript('/my/script3.js', function(script) {
      // ...continue after all scripts are loaded
    });

  });

});

因此,每個新動作都在回呼函式中。對於少數動作來說沒問題,但對於許多動作來說並不好,所以我們很快就會看到其他變體。

處理錯誤

在上述範例中,我們沒有考慮錯誤。如果腳本載入失敗怎麼辦?我們的回呼函式應該能夠對此做出反應。

以下是追蹤載入錯誤的 loadScript 改良版本

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

它會在載入成功時呼叫 callback(null, script),否則呼叫 callback(error)

用法

loadScript('/my/script.js', function(error, script) {
  if (error) {
    // handle error
  } else {
    // script loaded successfully
  }
});

再次說明,我們用於 loadScript 的食譜實際上很常見。它稱為「錯誤優先回呼函式」樣式。

慣例是

  1. callback 的第一個引數保留給錯誤(如果發生錯誤)。然後呼叫 callback(err)
  2. 第二個引數(以及後續引數,如果需要的話)是成功結果。然後呼叫 callback(null, result1, result2…)

因此,單一 callback 函式用於回報錯誤和傳回結果。

厄運金字塔

乍看之下,這看起來像是可行的非同步編碼方法。事實上也的確如此。對於一個或兩個巢狀呼叫來說,看起來沒問題。

但是對於多個連續發生的非同步動作,我們的程式碼會像這樣

loadScript('1.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript('3.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // ...continue after all scripts are loaded (*)
          }
        });

      }
    });
  }
});

在上述程式碼中

  1. 我們載入 1.js,然後如果沒有錯誤…
  2. 我們載入 2.js,然後如果沒有錯誤…
  3. 我們載入 3.js,然後如果沒有錯誤 – 執行其他動作 (*)

隨著呼叫越來越巢狀,程式碼變得越來越深,也越來越難管理,特別是如果我們有實際程式碼而不是 ...,其中可能包含更多迴圈、條件式陳述式等。

這有時稱為「回呼函式地獄」或「厄運金字塔」。

巢狀呼叫的「金字塔」會隨著每個非同步動作向右方擴展。很快就會失控。

因此,這種編碼方式並不好。

我們可以透過讓每個動作成為獨立函式來嘗試緩解這個問題,如下所示

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...continue after all scripts are loaded (*)
  }
}

看到了嗎?它執行相同的動作,而且現在沒有深層巢狀,因為我們讓每個動作成為獨立的頂層函式。

它可以運作,但程式碼看起來像被撕開的試算表。它很難閱讀,而且你可能注意到在閱讀時需要在各個部分之間跳動。這很不方便,特別是如果讀者不熟悉程式碼,也不知道該跳到哪裡。

此外,名為 step* 的函式都是單一用途,它們只建立來避免「厄運金字塔」。沒有人會在動作鏈之外重複使用它們。因此,這裡有點名稱空間混亂。

我們想要有更好的方法。

幸運的是,還有其他方法可以避免這種金字塔。其中最好的方法之一是使用「承諾」,這將在下一章中說明。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有改進建議 - 請 提交 GitHub 議題 或提交 pull request,而不是留言。
  • 如果你看不懂文章中的某些內容 - 請詳細說明。
  • 要插入少量的程式碼,請使用 <code> 標籤,要插入多行程式碼 - 請將它們包覆在 <pre> 標籤中,要插入 10 行以上的程式碼 - 請使用沙盒 (plnkrjsbincodepen…)