原文链接:http://javascript.info/callbacks,translate with ❤️ by zhangbao.
在 JavaScript 中许多操作都是异步的。
例如,看一下函数 loadScript(src)
:
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
这个函数的目的用于加载新脚本。当文档中添加了 <script src="…">
之后,浏览器就会加载和执行它。
使用方式如下:
// 加载并执行一个脚本
loadScript('/my/script.js');
这个函数称为“异步的”,因为(加载脚本)操作不是当下就能完成的,而要稍后片刻。
调用函数初始化了脚本的加载,紧接着就会执行。脚本加载时,后来的代码可能已经执行结束,如果异步加载相对长一些,其他脚本可能也会先执行。
loadScript('/my/script.js');
// 后来的代码不会等待 loadScript 加载完成后再执行
// ...
现在,我们要使用新加载脚本里的定义的东西——可能是一个新的函数,我们要执行它。
但是如果我们在 loadScript(...)
之后立即调用的话,发现并不行:
loadScript('/my/script.js'); // 脚本中包含 "function newFunction() {…}" 的代码
newFunction(); // 没有这个函数!
很自然的,我们没有给浏览器充足的时间去加载脚本,就开始立马调用脚本中声明的新函数,导致了失败。现在,loadScript
函数还不能给我们暴露脚本已加载完毕的接口。现在能做的,就是加载脚本并运行,仅此而已。但我们想知道加载完成的那一刻,以便在第一时间使用新脚本的声明的函数和变量。
我们为 loadScript
函数添加第二个参数 callack
,作为脚本加载完成后执行的回调函数:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
现在,如果我们想调用脚本中的函数,就应该在回调中写了:
loadScript('/my/script.js', function() { // // 脚本加载完毕后,执行这个回调函
newFunction(); // 现在就没问题了
...
});
这是原理:第二个参数是在操作完成时执行的函数(通常是匿名的)。
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.src} is loaded`);
alert( _ ); // 使用脚本中声明的函数
});
这称为“基于回调”的异步编程形式。一个异步执行函数需要提供一个 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('/my/script.js', function(script) {
loadScript('/my/script2.js', function(script) {
loadScript('/my/script3.js', function(script) {
// ...继续在所有脚本加载完成后加载
});
})
});
这样,每个回调中都有一个新的操作。对于只有少量几个的连续操作——还可以,没有问题,但是一旦多起来,可就炸了,我们之后会看到其他变体。
错误处理
在上例中,我们没有考虑发生错误的情况。但是如果脚本加载失败怎么办?我们的回调应该能够对此做出反应。
下面是改进版的 loadSript
函数,添加了跟踪加载错误的逻辑:
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) {
// 处理错误
} else {
// 脚本加载成功
}
});
再一次说明,在 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 {
// ...在所有脚本加载完成后继续 (*)
}
});
}
})
}
});
上面代码里:
我们加载
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*
的函数用途单一,它们的创建只是为了避免“末日金字塔”。没有人会在其它地方重用它们。
所以这里有一些名称空间混乱。
我们需要一个更好的替代方案。
幸运的是,还有其他方法可以避免这样的金字塔。最好的方法之一是使用“Promise”,在下一章中介绍。
练习题
问题
一、使用回调使圆动起来
在 Animated circle 任务中,我们实现了一个逐步增大的动圆效果。
现在我们不只是要显示一个圆,还要在完成显示后,在其中显示一条消息。
在任务的解决方案中,函数 showCircle(cx, cy, radius)
用来画圆,但是没有暴露给我们圆已画好的接口。
添加一个回调参数:showCircle(cx, cy, radius, callback)
,在动画完成后调用。callback
接收圆 <div>
作为参数。
这是例子:
showCircle(150, 150, 100, div => {
div.classList.add('message-ball');
div.append("Hello, world!");
});
答案
一、使用回调使圆动起来
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
.message-ball {
font-size: 20px;
line-height: 200px;
text-align: center;
}
.circle {
transition-property: width, height, margin-left, margin-top;
transition-duration: 2s;
position: fixed;
transform: translateX(-50%) translateY(-50%);
background-color: red;
border-radius: 50%;
}
</style>
</head>
<body>
<button onclick="go()">Click me</button>
<script>
function go() {
showCircle(150, 150, 100, div => {
div.classList.add('message-ball');
div.append("Hello, world!");
});
}
function showCircle(cx, cy, radius, callback) {
let div = document.createElement('div');
div.style.width = 0;
div.style.height = 0;
div.style.left = cx + 'px';
div.style.top = cy + 'px';
div.className = 'circle';
document.body.append(div);
setTimeout(() => {
div.style.width = radius * 2 + 'px';
div.style.height = radius * 2 + 'px';
div.addEventListener('transitionend', function handler() {
div.removeEventListener('transitionend', handler);
callback(div);
});
}, 0);
}
</script>
</body>
</html>
在线查看。
(完)