Menu

JavaScript 回调函数:函数作参数与回调地狱

JavaScript 中回调函数的工作方式:把函数作为参数传递、错误优先模式,以及为什么层层嵌套的回调促使社区转向了 Promise。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

回调就是交给另一个函数的函数

JavaScript 中的函数是值。你可以把它们存进变量、放进数组,而且对这一页最重要的是,可以把它们作为参数传递。当你把一个函数传给另一个函数,让它之后去调用时,你传进去的那个函数就是回调函数。

greet 不知道也不关心 formatter 做了什么。它只是用一个名字调用它,然后使用返回的结果。你通过传入不同的回调来选择不同的行为。这种灵活性正是回调存在的全部意义。

同步回调立即执行

并不是所有回调都是异步的。你已经在用的许多数组方法都基于回调,而且它们会同步执行回调,也就是在外层调用返回之前执行:

map、filter 和 reduce 都接收一个回调,并当场对每个元素调用一次。等 map 返回时,对回调的每次调用都已经完成了。没有任何东西被排队留到以后执行。

这是一种普通的高阶函数模式:“这是一些工作,这是做法,把结果给我。”不涉及事件循环。

异步回调稍后执行

人们说“回调”时,通常指的是异步回调。你把一个函数交给某个需要花时间的 API,比如定时器、网络请求或文件读取,工作完成后,这个 API 再回过头来调用你的函数。

输出顺序是:before、after,一秒后才是 timer fired。setTimeout 不会暂停你的程序。它把回调交给运行时,立即返回,脚本的其余部分继续执行。一秒后,事件循环取出这个回调并运行它。

这种“现在返回,稍后回调”的结构,就是理解 JavaScript 中所有异步回调 API 的思维模型,从 addEventListener 到较老的 Node.js 文件 API 都是如此。

错误优先约定(Node.js)

在 Promise 出现之前,Node.js 统一采用了一种特定的回调形式:第一个参数是错误(或 null),其余参数才是真正的结果。你在旧代码和一些库中仍然会碰到它。

调用方先检查 err,如果它是真值就提前返回。只有这样,它才信任结果。这只是一种约定,语言本身并不强制,但只要你见过 (err, result) => ... 这种签名,以后到处都能认出它来。

回调地狱

当一个异步步骤依赖另一个步骤的结果时,麻烦就来了。每个回调都要嵌套在前一个回调里面,代码就会像台阶一样一路向右缩进:

这就是著名的“末日金字塔”,也叫回调地狱。有几点让它非常痛苦:

  • 控制流来回曲折,而不是从上往下读。
  • 每一层都要重复同样的 if (err) return ... 样板代码。
  • 一个回调抛出的异常不会传播到外层回调,你必须在每一层都处理错误。
  • 重构意味着要重新缩进整个代码块。

把回调提取成具名函数可以在一定程度上把它压平,但核心问题依然存在:用原始回调组合异步操作很笨拙。而这正是 Promise 被设计出来要解决的问题。

两个值得知道的坑

不要不小心调用了回调。 传递回调时,你传的是函数本身,而不是调用它得到的结果。

小心 this。 如果你的回调是一个使用了 this 的普通函数,this 的值取决于回调被调用的方式,而不是它被定义的位置。箭头函数从外层作用域继承 this,从而绕开了这个问题:

正是因为这个原因,箭头函数成了内联回调的默认选择。

回调与 Promise

回调依然出现在同步 API(map、forEach、sort)、事件监听器(element.addEventListener("click", ...))和底层运行时钩子中。对于只产生一个结果的异步工作,整个生态几乎已经全面转向了 Promise。

快速对比一下:

  • 回调:直接、简单,但不容易组合。每一步都要手动处理错误。
  • Promise:一个代表未来结果的值。用 .then() 链式调用,用 .catch() 统一处理错误,金字塔就被压平了。

你仍然需要理解回调:Promise 是建立在回调之上的,而且回调在事件驱动的代码中无处不在。但如今已经很少有人用原始回调来编写新的异步 API 了。

下一页:Promise

Promise 把“等那个准备好了就做这个”的思路包装成一个对象,你可以传递它、链式调用它、组合它。这就是下一页的内容,它也是通往 async/await 的桥梁,而大多数现代 JavaScript 正是用 async/await 来处理异步工作的。

常见问题

JavaScript 中的回调函数是什么?

回调函数是你作为参数传给另一个函数的函数,这样那个函数就可以在之后调用它。setTimeout(() => console.log('hi'), 1000) 把一个箭头函数作为回调传了进去:setTimeout 把它保存起来,在定时器触发时调用它。回调是 JavaScript 处理“等那个准备好了就做这个”的最初方式。

同步回调和异步回调有什么区别?

同步回调会立即执行,就在接收它的那次调用期间:[1, 2, 3].map(x => x * 2) 会在 map 返回之前调用回调三次。异步回调则会被保存起来,等某个事件发生之后再调用:setTimeout、fs.readFile 和 DOM 事件监听器都是这样工作的。异步回调不会阻塞你其余的代码。

什么是回调地狱?如何避免?

回调地狱是指异步回调相互依赖、最终嵌套好几层时形成的金字塔形状。它让控制流和错误处理都很难理清。解决办法是使用带 .then() 链的 Promise,更好的是使用 async/await,两者都能把金字塔重新压平成可读的代码。

Coddy 编程语言插图

和 Coddy 一起学编程

开始