为了演示回调、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
的方法实际上非常常见。它被称为“错误优先回调”样式。
惯例是
- 如果发生错误,则
callback
的第一个参数保留给错误。然后调用callback(err)
。 - 第二个参数(以及需要的话,后续参数)用于成功的结果。然后调用
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.js
,然后如果没有错误... - 我们加载
2.js
,然后如果没有错误... - 我们加载
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*
的函数都是单用途的,它们仅创建是为了避免“厄运金字塔”。除了操作链之外,没有人会重新使用它们。因此,这里有一些命名空间混乱。
我们希望有更好的东西。
幸运的是,还有其他方法可以避免这样的金字塔。最好的方法之一是使用“承诺”,将在下一章中进行描述。
评论
<code>
标记,对于多行 - 将它们包装在<pre>
标记中,对于 10 行以上 - 使用沙箱 (plnkr、jsbin、codepen…)