当你开始学习 JavaScript 时,不久你就会听到"回调函数"这个词。回调是 JavaScript 执行模型不可或缺的一部分,了解它们是什么以及它们如何工作很重要。
什么是 JavaScript 回调? {#whatarejavascriptcallbacks}
在 JavaScript 中,回调是作为参数传递给第二个函数的函数。接收回调的函数决定是否以及何时执行回调:
function myFunction(callback) {
// 1. Do something
// 2. Then execute the callback
callback()
}
function myCallback() {
// Do something else
}
myFunction(myCallback);
在上面的例子中,我们有两个函数:myFunction
和myCallback
。顾名思义,myCallback
用作回调函数,我们将它myFunction
作为参数传递给。myFunction
然后可以在准备好时执行回调。
许多博客文章会说回调之所以称为回调,是因为您告诉某些函数在准备好回答时给您回电。一个不太容易混淆的名字是"callafter":也就是说,在你完成所有其他事情之后调用这个函数。
为什么我们需要回调函数? {#whydoweneedcallbackfunctions}
你会经常听到人们说 JavaScript 是单线程的。这意味着它一次只能做一件事。当执行缓慢的操作时------例如从远程 API 获取数据------这可能会出现问题。如果您的程序在返回数据之前冻结,那将不会是很好的用户体验。
JavaScript 避免这种瓶颈的方法之一是使用回调。我们可以将第二个函数作为参数传递给负责数据获取的函数。然后启动数据获取请求,但 JavaScript 解释器不会等待响应,而是继续执行程序的其余部分。当从 API 收到响应时,将执行回调函数并对结果执行某些操作:
function fetchData(url, cb) {
// 1. Make API request to url
// 2. If response successful, execute callback
cb(res);
}
function callback(res) {
// Do something with results
}
// Do something
fetchData('https://sitepoint.com', callback);
// Do something else
JavaScript 是一种事件驱动的语言 {#javascriptisaneventdrivenlanguage}
您还会听到人们说 JavaScript 是一种事件驱动的语言。这意味着它可以侦听和响应事件,同时继续执行更多代码并且不会阻塞其单个线程。
它是如何做到的?你猜对了:回调。
想象一下,如果您的程序将一个事件侦听器附加到一个按钮,然后坐在那里等待某人单击该按钮,同时拒绝执行任何其他操作。那可不好!
使用回调,我们可以指定应运行特定代码块以响应特定事件:
function handleClick() {
// Do something (e.g. validate a form)
// in response to the user clicking a button
}
document.querySelector('button').addEventListener('click', handleClick);
在上面的示例中,该handleClick
函数是一个回调,它是为响应网页上发生的操作(单击按钮)而执行的。
使用这种方法,我们可以根据需要对任意数量的事件做出反应,同时让 JavaScript 解释器自由地继续执行它需要做的任何其他事情。
一等函数和高阶函数 {#firstclassandhigherorderfunctions}
在学习回调时,您可能会遇到更多的流行语是"一等函数"和"高阶函数"。这些听起来很可怕,但实际上并非如此。
当我们说 JavaScript 支持一等函数时,这意味着我们可以像对待常规值一样对待函数。我们可以将它们存储在一个变量中,我们可以从另一个函数返回它们,而且,正如我们已经看到的,我们可以将它们作为参数传递。
至于高阶函数,这些只是将函数作为参数或返回函数作为结果的函数。有几个本机 JavaScript 函数也是高阶函数,例如setTimeout
. 让我们用它来演示如何创建和运行回调。
如何创建回调函数 {#howtocreateacallbackfunction}
模式与上面相同:创建一个回调函数并将其作为参数传递给高阶函数:
function greet() {
console.log('Hello, World!');
}
setTimeout(greet, 1000);
该函数延迟一秒setTimeout
执行函数并记录"Hello, World!" greet
到控制台。
我们还可以让它稍微复杂一点,并向greet
函数传递一个需要问候的人的名字:
function greet(name) {
console.log(`Hello, ${name}!`);
}
setTimeout(() => greet('Jim'), 1000);
请注意,我们使用了箭头函数来包装对greet
. 如果我们没有这样做,该函数将立即执行,而不是在延迟之后执行。
如您所见,在 JavaScript 中有多种创建回调的方法,这让我们很好地进入下一节。
不同种类的回调函数 {#differentkindsofcallbackfunctions}
部分归功于 JavaScript 对一等函数的支持,在 JavaScript 中有多种声明函数的方式,因此也有多种在回调中使用它们的方式。
现在让我们看看这些并考虑它们的优点和缺点。
匿名函数 {#anonymousfunctions}
到目前为止,我们一直在命名我们的函数。这通常被认为是好的做法,但绝不是强制性的。考虑以下使用回调函数验证某些表单输入的示例:
document.querySelector('form').addEventListener('submit', function(e) {
e.preventDefault();
// Do some data validation
// If everything looks ok, then...
this.submit();
});
如您所见,回调函数未命名。没有名称的函数定义称为匿名函数。匿名函数在只在一个地方被调用的短脚本中使用得很好。而且,由于它们被声明为内联的,因此它们也可以访问其父级的范围。
箭头函数 {#arrowfunctions}
ES6 引入了箭头函数。由于它们简洁的语法,并且因为它们具有隐式返回值,所以它们通常用于执行简单的单行代码,例如在以下示例中,它从数组中过滤重复值:
const arr = [1, 2, 2, 3, 4, 5, 5];
const unique = arr.filter((el, i) => arr.indexOf(el) === i);
// [1, 2, 3, 4, 5]
但是请注意,它们不绑定自己的this
值,而是从它们的父范围继承它。这意味着,在前面的示例中,我们将无法使用箭头函数来提交表单:
document.querySelector('form').addEventListener('submit', (e) => {
...
// Uncaught TypeError: this.submit is not a function
// `this` points to the window object, not to the form
this.submit();
});
箭头函数是近年来我最喜欢的 JavaScript 新增功能之一,它们绝对是开发人员应该熟悉的东西。如果您想了解有关箭头函数的更多信息,请查看我们的 JavaScript 中的箭头函数:如何使用 Fat & Concise 语法教程。
命名函数 {#namedfunctions}
在 JavaScript 中创建命名函数主要有两种方式:函数表达式和函数声明。两者都可以与回调一起使用。
函数声明涉及使用关键字创建函数function
并为其命名:
function myCallback() {... }
setTimeout(myCallback, 1000);
函数表达式涉及创建一个函数并将其分配给一个变量:
const myCallback = function() { ... };
setTimeout(myCallback, 1000);
或者:
const myCallback = () => { ... };
setTimeout(myCallback, 1000);
我们还可以标记使用function
关键字声明的匿名函数:
setTimeout(function myCallback() { ... }, 1000);
以这种方式命名或标记回调函数的优点是它有助于调试。让我们的函数抛出一个错误:
setTimeout(function myCallback() { throw new Error('Boom!'); }, 1000);
// Uncaught Error: Boom!
// myCallback file:///home/jim/Desktop/index.js:18
// setTimeout handler* file:///home/jim/Desktop/index.js:18
使用命名函数,我们可以准确地看到错误发生的位置。但是,看看当我们删除名称时会发生什么:
setTimeout(function() { throw new Error('Boom!'); }, 1000);
// Uncaught Error: Boom!
// <anonymous> file:///home/jim/Desktop/index.js:18
// setTimeout handler* file:///home/jim/Desktop/index.js:18
在这个独立的小示例中,这没什么大不了的,但是随着代码库的增长,这是需要注意的事情。甚至有一个ESLint 规则来强制执行此行为。
JavaScript 回调函数的常见用例 {#commonusecasesforjavascriptcallbackfunctions}
JavaScript 回调函数的用例广泛多样。正如我们所见,它们在处理异步代码(如 Ajax 请求)和响应事件(如表单提交)时非常有用。现在让我们再看几个可以找到回调的地方。
数组方法 {#arraymethods}
遇到回调的另一个地方是在 JavaScript 中使用数组方法时。随着您在编程之旅中的进步,您将越来越多地这样做。例如,假设你想对一个数组中的所有数字求和,考虑这个简单的实现:
const arr = [1, 2, 3, 4, 5];
let tot = 0;
for(let i=0; i<arr.length; i++) {
tot += arr[i];
}
console.log(tot); //15
虽然这可行,但更简洁的实现可能会使用Array.reduce
它,您猜对了,它使用回调对数组中的所有元素执行操作:
const arr = [1, 2, 3, 4, 5];
const tot = arr.reduce((acc, el) => acc + el);
console.log(tot);
// 15
Node.js {#nodejs}
还应该注意的是,Node.js及其整个生态系统严重依赖基于回调的代码。例如,这里是规范的 Hello, World! 的节点版本!例子:
const http = require('http');
http.createServer((request, response) => {
response.writeHead(200);
response.end('Hello, World!');
}).listen(3000);
console.log('Server running on http://localhost:3000');
无论您是否曾经使用过 Node,这段代码现在应该很容易理解。本质上,我们需要 Node 的http
模块并调用它的createServer
方法,我们将匿名箭头函数传递给它。任何时候 Node 在端口 3000 上收到请求时都会调用此函数,它将以 200 状态和文本"Hello, World!"作为响应。
Node 还实现了一种称为错误优先回调的模式。这意味着回调的第一个参数是为错误对象保留的,而回调的第二个参数是为任何成功的响应数据保留的。
下面是 Node 文档中的一个示例,展示了如何读取文件:
const fs = require('fs');
fs.readFile('/etc/hosts', 'utf8', function (err, data) {
if (err) {
return console.log(err);
}
console.log(data);
});
我们不想在本教程中深入探讨 Node,但希望这种代码现在应该更容易阅读。
同步与异步回调 {#synchronousvsasynchronouscallbacks}
回调是同步执行还是异步执行取决于调用它的函数。让我们看几个例子。
同步回调函数 {#synchronouscallbackfunctions}
当代码是同步的时,它从上到下逐行运行。操作一个接一个地发生,每个操作都等待前一个操作完成。我们已经在Array.reduce
上面的函数中看到了一个同步回调的例子。
为了进一步说明这一点,这里有一个演示,它同时使用Array.map
和Array.reduce
来计算逗号分隔数字列表中的最高数字:
<!DOCTYPE html>
<html>
<head>
<title>Numbers</title>
<meta charset="utf-8">
<style>
form{
max-width: 600px;
padding: 15px;
}
</style>
</head>
<body>
<form>
<div class="mb-3">
<p id="result"></p>
<label for="numbers" class="form-label">Enter a list of comma separated numbers</label>
<input type="text" required class="form-control" id="numbers" placeholder="1,2,3,4,5">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<script>
const form = document.querySelector('form');
const input = document.querySelector('input');
const res = document.querySelector('p');
form.addEventListener('submit', (e) => {
e.preventDefault();
const highest = input.value
.replace(/\s+/, '')
.split(',')
.map((el) => Number(el))
.reduce((acc,val) => (acc > val) ? acc : val);
res.innerText = `The highest number is ${highest}`
});
</script>
</body>
</html>
主要动作发生在这里:
const highest = input.value
.replace(/\s+/, '')
.split(',')
.map((el) => Number(el))
.reduce((acc,val) => (acc > val) ? acc : val);
从上到下,我们执行以下操作:
-
获取用户的输入
-
删除任何空格
-
在逗号处拆分输入,从而创建一个字符串数组
-
使用回调将字符串转换为数字来映射数组的每个元素
-
用于
reduce
迭代数字数组以确定最大的
为什么不尝试一下 CodePen 上的代码,并尝试更改回调以产生不同的结果(例如找到最小的数字,或所有奇数,等等)。
异步回调函数 {#asynchronouscallbackfunctions}
与同步代码相比,异步JavaScript 代码不会从上到下逐行运行。相反,异步操作将注册一个回调函数,一旦完成就执行。这意味着 JavaScript 解释器不必等待异步操作完成,而是可以在运行时继续执行其他任务。
异步函数的主要示例之一是从远程 API 获取数据。现在让我们看一个例子,了解它是如何使用回调的。
<!DOCTYPE html>
<html>
<head>
<title>Numbers</title>
<meta charset="utf-8">
<style>
.users {
padding: 15px;
}
ul {
margin-top: 15px;
}
</style>
</head>
<body>
<div class="mb-3 users">
<button type="submit" class="btn btn-primary">Fetch Users</button>
<ul id="result"></ul>
</div>
<script>
const button = document.querySelector('button');
const ul = document.querySelector('ul');
button.addEventListener('click', (e) => {
e.preventDefault();
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then(json => {
const names = json.map(user => user.name);
names.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
ul.appendChild(li);
});
})
});
</script>
</body>
</html>
主要动作发生在这里:
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then(json => {
const names = json.map(user => user.name);
names.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
ul.appendChild(li);
});
});
上例中的代码使用FetchAPI 将对虚拟用户列表的请求发送到伪造的 JSON API。服务器返回响应后,我们将运行第一个回调函数,它会尝试将该响应解析为 JSON。之后,运行我们的第二个回调函数,它构造一个用户名列表并将它们附加到列表中。请注意,在第二个回调中,我们使用另外两个嵌套回调来完成检索名称和创建列表元素的工作。
我再次鼓励您尝试一下代码。如果您查看API 文档,您可以获取和操作大量其他资源。
使用回调时的注意事项 {#thingstobeawareofwhenusingcallbacks}
回调在 JavaScript 中已经存在很长时间了,它们可能并不总是最适合你想要做的事情。让我们看一下需要注意的几件事。
谨防 JavaScript 回调地狱 {#bewareofjavascriptcallbackhell}
我们在上面的代码中看到可以嵌套回调。这在处理相互依赖的异步函数时尤其常见。例如,您可能会在一个请求中获取电影列表,然后使用该电影列表获取每部电影的海报。
虽然这对于一层或两层嵌套来说没有问题,但您应该意识到这种回调策略的扩展性不佳。不久之后,您将得到凌乱且难以理解的代码:
fetch('...')
.then(response => response.json())
.then(json => {
// Do some processing
fetch('...')
.then(response => response.json())
.then(json => {
// Do some more processing
fetch('...')
.then(response => response.json())
.then(json => {
// Do even processing
fetch('...')
.then(response => response.json())
.then(json => {
// Do yet more processing
});
});
});
});
更喜欢更现代的流量控制方法 {#prefermoremodernmethodsofflowcontrol}
虽然回调是 JavaScript 工作方式不可或缺的一部分,但该语言的最新版本添加了改进的流程控制方法。
例如,promises 并async...await
提供更简洁的语法来处理上述代码。虽然超出了本文的范围,但您可以在现代 JS 中的 JavaScript Promises和流程控制概述:回调到 Async/Await 的 Promises中阅读所有相关内容。
结论 {#conclusion}
在本文中,我们研究了回调到底是什么。我们了解了 JavaScript 执行模型的基础知识、回调如何适应该模型以及为什么需要回调。我们还研究了如何创建和使用回调、不同类型的回调以及何时使用它们。您现在应该牢牢掌握在 JavaScript 中使用回调,并能够在您自己的代码中使用这些技术。