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 中进行了描述,它基本上在脚本加载并执行后执行一个函数。

现在,如果我们想从脚本中调用新函数,我们应该在回调中编写它

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 参数,我们可以在其中放置在操作完成后运行的函数。

我们在这里在 loadScript 中执行了此操作,但当然这是一个通用方法。

回调中的回调

我们如何按顺序加载两个脚本:第一个脚本,然后是第二个脚本?

自然的解决方案是将第二个 loadScript 调用放在回调中,如下所示

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 完成后,回调将启动内部 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 问题 或提交请求,而不是评论。
  • 如果您无法理解文章中的某些内容 – 请详细说明。
  • 要插入几行代码,请使用 <code> 标记,对于多行 - 将它们包装在 <pre> 标记中,对于 10 行以上 - 使用沙箱 (plnkrjsbincodepen…)