原文链接:http://javascript.info/callbacks,translate with ❤️ by zhangbao.

在 JavaScript 中许多操作都是异步的。

例如,看一下函数 loadScript(src)

  1. function loadScript(src) {
  2. let script = document.createElement('script');
  3. script.src = src;
  4. document.head.append(script);
  5. }

这个函数的目的用于加载新脚本。当文档中添加了 <script src="…"> 之后,浏览器就会加载和执行它。

使用方式如下:

  1. // 加载并执行一个脚本
  2. loadScript('/my/script.js');

这个函数称为“异步的”,因为(加载脚本)操作不是当下就能完成的,而要稍后片刻。

调用函数初始化了脚本的加载,紧接着就会执行。脚本加载时,后来的代码可能已经执行结束,如果异步加载相对长一些,其他脚本可能也会先执行。

  1. loadScript('/my/script.js');
  2. // 后来的代码不会等待 loadScript 加载完成后再执行
  3. // ...

现在,我们要使用新加载脚本里的定义的东西——可能是一个新的函数,我们要执行它。

但是如果我们在 loadScript(...) 之后立即调用的话,发现并不行:

  1. loadScript('/my/script.js'); // 脚本中包含 "function newFunction() {…}" 的代码
  2. newFunction(); // 没有这个函数!

很自然的,我们没有给浏览器充足的时间去加载脚本,就开始立马调用脚本中声明的新函数,导致了失败。现在,loadScript 函数还不能给我们暴露脚本已加载完毕的接口。现在能做的,就是加载脚本并运行,仅此而已。但我们想知道加载完成的那一刻,以便在第一时间使用新脚本的声明的函数和变量。

我们为 loadScript 函数添加第二个参数 callack,作为脚本加载完成后执行的回调函数:

  1. function loadScript(src, callback) {
  2. let script = document.createElement('script');
  3. script.src = src;
  4. script.onload = () => callback(script);
  5. document.head.append(script);
  6. }

现在,如果我们想调用脚本中的函数,就应该在回调中写了:

  1. loadScript('/my/script.js', function() { // // 脚本加载完毕后,执行这个回调函
  2. newFunction(); // 现在就没问题了
  3. ...
  4. });

这是原理:第二个参数是在操作完成时执行的函数(通常是匿名的)。

  1. function loadScript(src, callback) {
  2. let script = document.createElement('script');
  3. script.src = src;
  4. script.onload = () => callback(script);
  5. document.head.append(script);
  6. }
  7. loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  8. alert(`Cool, the ${script.src} is loaded`);
  9. alert( _ ); // 使用脚本中声明的函数
  10. });

这称为“基于回调”的异步编程形式。一个异步执行函数需要提供一个 callback 参数,以便在异步任务完成后,执行基于这个脚本的后续操作。

这种编程思想在 loadScript 函数中得到了体现,它是一个通用方法。

回调中的回调

怎样按序加载两个脚本:先加载第一个,然后加载第二个。

自然想到的方法,是让第二个 loadScript 函数放在第一个的回调函数中:

  1. loadScript('/my/script.js', function(script) {
  2. alert(`Cool, the ${script.src} is loaded, let's load one more`);
  3. loadScript('/my/script2.js', function(script) {
  4. alert(`Cool, the second script is loaded`);
  5. });
  6. });

外部的脚本加载完成后,回调中开始加载第二个脚本了。

那么我们如果又要加载第三个脚本呢?

  1. loadScript('/my/script.js', function(script) {
  2. loadScript('/my/script2.js', function(script) {
  3. loadScript('/my/script3.js', function(script) {
  4. // ...继续在所有脚本加载完成后加载
  5. });
  6. })
  7. });

这样,每个回调中都有一个新的操作。对于只有少量几个的连续操作——还可以,没有问题,但是一旦多起来,可就炸了,我们之后会看到其他变体。

错误处理

在上例中,我们没有考虑发生错误的情况。但是如果脚本加载失败怎么办?我们的回调应该能够对此做出反应。

下面是改进版的 loadSript 函数,添加了跟踪加载错误的逻辑:

  1. function loadScript(src, callback) {
  2. let script = document.createElement('script');
  3. script.src = src;
  4. script.onload = () => callback(null, script);
  5. script.onerror = () => callback(new Error(`Script load error for ${src}`));
  6. document.head.append(script);
  7. }

加载成功时,调用 callback(null, script);否则(即失败)调用 callback(error)

使用:

  1. loadScript('/my/script.js', function(error, script) {
  2. if (error) {
  3. // 处理错误
  4. } else {
  5. // 脚本加载成功
  6. }
  7. });

再一次说明,在 loadScript 中使用的这段代码比较通用,称为“错误优先的回调”形式。

便捷的地方是:

  1. callback 的第一个参数是为发生错误时保留的,错误发生时,调用 callback(err)

  2. 第二个参数(如果需要的话,后面的参数也是)保留着成功的结果,调用形式如 callback(null, result1, result2)

所以只用一个回调函数 callback,既能用来处理错误,又能用来传递成功时的返回结果。

末日金字塔

第一眼看上去,回调是一种可行的异步编码形式,也确实如此。对于一个或两个嵌套调用,还不错。

但是对于一个接一个的多个异步操作,我们就会产生这样的代码:

  1. loadScript('1.js', function(error, script) {
  2. if (error) {
  3. handleError(error);
  4. } else {
  5. // ...
  6. loadScript('2.js', function(error, script) {
  7. if (error) {
  8. handleError(error);
  9. } else {
  10. // ...
  11. loadScript('3.js', function(error, script) {
  12. if (error) {
  13. handleError(error);
  14. } else {
  15. // ...在所有脚本加载完成后继续 (*)
  16. }
  17. });
  18. }
  19. })
  20. }
  21. });

上面代码里:

  1. 我们加载 1.js,如果没有错误的话,

  2. 我们加载 2.js,如果没有错误的话,

  3. 我们加载 3.js,如果没有错误的话,再做其他操作 (*)。

随着回调得一层层嵌套,代码变得越来越深,越来越难以管理。上面只是用 ... 省略了项目中的真实代码,可以想象其中包含了很多的循环和条件语句等业务代码,这就是为什么说代码越来越难以管理的原因。

这种情况被称为“回调地狱”或“末日金字塔”。

回调函数 - 图1

每次的异步调用都会导致代码整体向右偏离。不就这个“金字塔”就失去控制了。

所以这种编码形式不是很好。

我们可以通过让每个动作都成为一个独立的功能来解决这个问题,如下所述:

  1. loadScript('1.js', step1);
  2. function step1(error, script) {
  3. if (error) {
  4. handleError(error);
  5. } else {
  6. // ...
  7. loadScript('2.js', step2);
  8. }
  9. }
  10. function step2(error, script) {
  11. if (error) {
  12. handleError(error);
  13. } else {
  14. // ...
  15. loadScript('3.js', step3);
  16. }
  17. }
  18. function step3(error, script) {
  19. if (error) {
  20. handleError(error);
  21. } else {
  22. // ...continue after all scripts are loaded (*)
  23. }
  24. };

看到了吗?做的事情一样的,现在也没有了深层嵌套,因为每个操作都是一个单独的顶级函数。

它能正常工作,但代码看起来像一个被撕裂的电子表格。你可能已经注意到了,它很难阅读。当你读它的时候,你需要在不同的部分之间跳跃。这很不方便,尤其当阅读者不熟悉代码时,也不知道眼睛该往哪里看。

此外,名为 step* 的函数用途单一,它们的创建只是为了避免“末日金字塔”。没有人会在其它地方重用它们。
所以这里有一些名称空间混乱。

我们需要一个更好的替代方案。

幸运的是,还有其他方法可以避免这样的金字塔。最好的方法之一是使用“Promise”,在下一章中介绍。

练习题

问题

一、使用回调使圆动起来

Animated circle 任务中,我们实现了一个逐步增大的动圆效果。

现在我们不只是要显示一个圆,还要在完成显示后,在其中显示一条消息。

在任务的解决方案中,函数 showCircle(cx, cy, radius) 用来画圆,但是没有暴露给我们圆已画好的接口。

添加一个回调参数:showCircle(cx, cy, radius, callback),在动画完成后调用。callback 接收圆 <div> 作为参数。

这是例子:

  1. showCircle(150, 150, 100, div => {
  2. div.classList.add('message-ball');
  3. div.append("Hello, world!");
  4. });

答案

一、使用回调使圆动起来

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <style>
  6. .message-ball {
  7. font-size: 20px;
  8. line-height: 200px;
  9. text-align: center;
  10. }
  11. .circle {
  12. transition-property: width, height, margin-left, margin-top;
  13. transition-duration: 2s;
  14. position: fixed;
  15. transform: translateX(-50%) translateY(-50%);
  16. background-color: red;
  17. border-radius: 50%;
  18. }
  19. </style>
  20. </head>
  21. <body>
  22. <button onclick="go()">Click me</button>
  23. <script>
  24. function go() {
  25. showCircle(150, 150, 100, div => {
  26. div.classList.add('message-ball');
  27. div.append("Hello, world!");
  28. });
  29. }
  30. function showCircle(cx, cy, radius, callback) {
  31. let div = document.createElement('div');
  32. div.style.width = 0;
  33. div.style.height = 0;
  34. div.style.left = cx + 'px';
  35. div.style.top = cy + 'px';
  36. div.className = 'circle';
  37. document.body.append(div);
  38. setTimeout(() => {
  39. div.style.width = radius * 2 + 'px';
  40. div.style.height = radius * 2 + 'px';
  41. div.addEventListener('transitionend', function handler() {
  42. div.removeEventListener('transitionend', handler);
  43. callback(div);
  44. });
  45. }, 0);
  46. }
  47. </script>
  48. </body>
  49. </html>

在线查看

(完)