JavaScript中的Callbacks

4,652 阅读9分钟

你是否遇到过"callbacks"一词,但是不知道这意味着什么?别着急。你不是一个人。许多JavaScript的新手发现回调也很难理解。

尽管callbacks可能令人疑惑,但是你仍然需要彻底了解它们,因为它们是JavaScript中的一个重要的概念。如果你不知道callbacks,你不可能走得很远🙁。

这就是今天的文章(要讲的)!你将了解callbacks是什么,为什么它们很重要,以及如何使用它们。😄

备注:你会在这篇文章中看到ES6箭头函数。如果你不是很熟悉它们,我建议你在往下读之前复习一下ES6这篇文章(只了解箭头函数部分就可以了)。

callbacks是什么?

callback是作为稍后要执行的参数传递给另一个函数的函数。(开发人员说你在执行函数时“调用”一个函数,这就是被命名为回调函数的原因)。

它们在JavaScript中很常见,你可能自己潜意识的使用了它们而不知道它们被称为回调函数。

接受函数回调的一个示例是addEventLisnter:

const button = document.querySelector('button')
button.addEventListener('click', function(e) {
  // Adds clicked class to button
  this.classList.add('clicked')
})

看不出是回调函数吗?那么,这种写法怎样?

const button = document.querySelector('button')

// Function that adds 'clicked' class to the element
function clicked (e) {
  this.classList.add('clicked')
}

// Adds click function as a callback to the event listener
button.addEventListener('click', clicked)

在这里,我们告诉JavaScript监听按钮上的click事件。如果检测到点击,则JavaScript应触发clicked函数。因此,在这种情况下,clicked是回调函数,而addEventListener是一个接受回调的函数。

现在,你明白什么是回调函数了嘛?:)

我们来看另外一个例子。这一次,假设你希望通过过滤一组数据来获取小于5的列表。在这里,你将回调函数传递给filter函数:

const numbers = [3, 4, 10, 20]
const lesserThanFive = numbers.filter(num => num < 5)

现在,如果你想通过命名函数执行上面的代码,则过滤函数将如下所示:

const numbers = [3, 4, 10, 20]
const getLessThanFive = num => num < 5

// Passing getLessThanFive function into filter
const lesserThanFive = numbers.filter(getLessThanFive)

在这种情况下,getLessThanFive是回调函数。Array.filter是一个接受回调的函数。

现在明白为什么了吧?一旦你知道回调函数是什么,它们就无处不在!

下面的示例向你展示如何编写回调函数和接受回调的函数:

// Create a function that accepts another function as an argument
const callbackAcceptingFunction = (fn) => {
  // Calls the function with any required arguments
  return fn(1, 2, 3)
}

// Callback gets arguments from the above call
const callback = (arg1, arg2, arg3) => {
  return arg1 + arg2 + arg3
}

// Passing a callback into a callback accepting function
const result = callbackAcceptingFunction(callback)
console.log(result) // 6

请注意,当你将回调函数传递给另一个函数时,你只传递该函数的引用(并没有执行它,因此没有括号()

const result = callbackAcceptingFunction(callback)

你只能在callbackAcceptingFunction中唤醒(调用)回调函数。执行此操作时,你可以传递回调函数可能需要的任意数量的参数:

const callbackAcceptingFunction = (fn) => {
  // Calls the callback with three args
  fn(1, 2, 3)
}

这些由callbackAcceptingFunction传递给回调函数的参数,然后再通过回调函数(执行):

// Callback gets arguments from callbackAcceptingFunction
const callback = (arg1, arg2, arg3) => {
  return arg1 + arg2 + arg3
}

这是回调的解剖。现在,你应该知道addEventListener包含一个event参数:)

// Now you know where this event object comes from! :)
button.addEventListener('click', (event) => {
  event.preventDefault()
})

唷!这是callbacks的基本思路!只需要记住其关键:将一个函数传递给另一个函数,然后,你会想起我上面提到的机制。

旁注:这种传递函数的能力是一件很重要的事情。它是如此重要,以至于说JavaScript中的函数是高阶函数。高阶函数在编程范例中称为函数编程,是一件很重大的事情。

但这是另一天的话题。现在,我确信你已经开始明白callbacks是什么,以及它们是如何被使用的。但是为什么?你为什么需要callbacks呢?

为什么使用callbacks

回调函数以两种不同的方式使用 -- 在同步函数和异步函数中。

同步函数中的回调

如果你的代码从上到下,从左到右的方式顺序执行,等待上一个代码执行之后,再执行下一行代码,则你的代码是同步的

让我们看一个示例,以便更容易理解:

const addOne = (n) => n + 1
addOne(1) // 2
addOne(2) // 3
addOne(3) // 4
addOne(4) // 5

在上面的例子中,addOne(1)首先执行。一旦它执行完,addOne(2)开始执行。一旦addOne(2)执行完,addOne(3)执行。这个过程一直持续到最后一行代码执行完毕。

当你希望将部分代码与其它代码轻松交换时,回调将用于同步函数。

所以,回到上面的Array.filter示例中,尽管我们将数组过滤为包含小于5的数组,但你可以轻松地重用Array.filter来获取大于10的数字数组:

const numbers = [3, 4, 10, 20]
const getLessThanFive = num => num < 5
const getMoreThanTen = num => num > 10

// Passing getLessThanFive function into filter
const lesserThanFive = numbers.filter(getLessThanFive)

// Passing getMoreThanTen function into filter
const moreThanTen = numbers.filter(getMoreThanTen)

这就是为什么你在同步函数中使用回调函数的原因。现在,让我们继续看看为什么我们在异步函数中使用回调。

异步函数中的回调

这里的异步意味着,如果JavaScript需要等待某些事情完成,它将在等待时执行给予它的其余任务。

异步函数的一个示例是setTimeout。它接受一个回调函数以便稍后执行:

// Calls the callback after 1 second
setTimeout(callback, 1000)

如果你给JavaScript另外一个任务需要完成,让我们看看setTimeout是如何工作的:

const tenSecondsLater = _ = > console.log('10 seconds passed!')

setTimeout(tenSecondsLater, 10000)
console.log('Start!')

在上面的代码中,JavaScript会执行setTimeout。然后,它会等待10秒,之后打印出"10 seconds passed!"的消息。

同时,在等待setTimeout10秒内完成时,JavaScript执行console.log("Start!")

所以,如果你(在控制台上)打印上面的代码,这就是你会看到的:

// What happens:
// > Start! (almost immediately)
// > 10 seconds passed! (after ten seconds)

啊~异步操作听起来很复杂,不是吗?但为什么我们在JavaScript中频繁使用它呢?

要了解为什么异步操作很重要呢?想象一下JavaScript是你家中的机器人助手。这个助手非常愚蠢。它一次只能做一件事。(此行为被称为单线程)。

假设你告诉你的机器人助手为你订购一些披萨。但机器人是如此的愚蠢,在打电话给披萨店之后,机器人坐在你家门前,等待披萨送达。在此期间它无法做任何其它事情。

你不能叫它去熨衣服,拖地或在等待(披萨到来)的时候做任何事情。(可能)你需要等20分钟,直到披萨到来,它才愿意做其他事情...

此行为称为阻塞。当你等待某些内容完成时,其他操作将被阻止。

const orderPizza = flavour => {
  callPizzaShop(`I want a ${flavour} pizza`)
  waits20minsForPizzaToCome() // Nothing else can happen here
  bringPizzaToYou()
}

orderPizza('Hawaiian')

// These two only starts after orderPizza is completed
mopFloor()
ironClothes()

而阻止操作是一个无赖。🙁

为什么?

让我们把愚蠢的机器人助手放到浏览器的上下文中。想象一下,当单击按钮时,你告诉它更改按钮的颜色。

这个愚蠢的机器人会做什么?

它专注于按钮,忽略所有命令,直到按钮被点击。同时,用户无法选择任何其他内容。看看它都在干嘛了?这就是异步编程在JavaScript中如此重要的原因。

但是,要真正了解异步操作期间发生的事情,我们需要引入另外一个东西 -- 事件循环。

事件循环

为了设想事件循环,想象一下JavaScript是一个携带todo-list的管家。此列表包含你告诉它要做的所有事情。然后,JavaScript将按照你提供的顺序逐个遍历列表。

假设你给JavaScript下面五个命令:

const addOne = (n) => n + 1

addOne(1) // 2
addOne(2) // 3
addOne(3) // 4
addOne(4) // 5
addOne(5) // 6

这是JavaScript的待办事项列表中出现的内容。

todo-list

相关命令在JavaScript待办事项列表中同步出现。

除了todo-list之外,JavaScript还保留一个waiting-list来跟踪它需要等待的事情。如果你告诉JavaScript订购披萨,它会打电话给披萨店并在等候列表名单中添加“等待披萨到达”(的指令)。与此同时,它还会做了其他已经在todo-list上的事情。

所以,想象下你有下面代码:

const orderPizza (flavor, callback) {
  callPizzaShop(`I want a ${flavor} pizza`)

  // Note: these three lines is pseudo code, not actual JavaScript
  whenPizzaComesBack {
    callback()
  }
}

const layTheTable = _ => console.log('laying the table')

orderPizza('Hawaiian', layTheTable)
mopFloor()
ironClothes()

JavaScript的初始化todo-list如下:

initial todo-list

订披萨,拖地和熨衣服!😄

然后,在执行orderPizza时,JavaScript知道它需要等待披萨送达。因此,它会在执行其余任务时,将“等待披萨送达”(的指令)添加到waiting list上。

waiting

JavaScript等待披萨到达

当披萨到达时,门铃会通知JavaScript,当它完成其余杂务时。它会做个**心理记录(mental note)**去执行layTheTable

mental-note

JavaScript知道它需要通过在其 mental note 中添加命令来执行layTheTable

然后,一旦完成其他杂务,JavaScript就会执行回调函数layTheTable

lay-table

其他所有内容完成后,JavaScript就会去布置桌面(layTheTable)

我的朋友,这个就被称为事件循环。你可以使用事件循环中的实际关键字替换我们的管家,类比来理解所有的内容:

  • Todo-list -> Call stack
  • Waiting-list -> Web apis
  • Mental note -> Event queue

event-loop

JavaScript的事件循环

如果你有20分钟的空余时间,我强烈建议你观看Philip Roberts 在JSconf中谈论的事件循环。它将帮助你理解事件循环的细节。

厄...那么,为什么callbacks那么重要呢?

哦~我们在事件循环绕了一大圈。我们回正题吧😂。

之前,我们提到如果JavaScript专注于按钮并忽略所有其他命令,那将是不好的。是吧?

通过异步回调,我们可以提前提供JavaScript指令而无需停止整个操作

现在,当你要求JavaScript查看点击按钮时,它会将“监听按钮”(指令)放入waiting list中并继续进行杂务。当按钮最终获得点击时,JavaScript会激活回调,然后继续执行。

以下是回调中的一些常见用法,用于告诉JavaScript要做什么...

  1. 当事件触发时(比如addEventListener
  2. 在AJAX调用后(比如jQuery.ajax
  3. 在读/写文件之后(比如fs.readFile
// Callbacks in event listeners
document.addEventListener(button, highlightTheButton)
document.removeEventListener(button, highlightTheButton)

// Callbacks in jQuery's ajax method
$.ajax('some-url', {
  success (data) { /* success callback */ },
  error (err) { /* error callback */}
});

// Callbacks in Node
fs.readFile('pathToDirectory', (err, data) => {
  if (err) throw err
  console.log(data)
})

// Callbacks in ExpressJS
app.get('/', (req, res) => res.sendFile(index.html))

这就是它(异步)的回调!😄

希望你清楚callbacks是什么以及现在如何使用它们。在开始的时候,你不会创建很多回调,所以要专注于学习如何使用可用的回调函数。

现在,在我们结束(本文)之前,让我们看一下开发人员(使用)回调的第一个问题 -- 回调地狱。

回调地狱

回调地狱是一种多次回调相互嵌套的现象。当你执行依赖于先前异步活动的异步活动时,可能会发生这种情况。这些嵌套的回调使代码更难阅读。

根据我的经验,你只会在Node中看到回调地狱。在使用前端JavaScript时,你几乎从不会遇到回调地狱。

下面是一个回调地狱的例子:

// Look at three layers of callback in this code!
app.get('/', function (req, res) {
  Users.findOne({ _id:req.body.id }, function (err, user) {
    if (user) {
      user.update({/* params to update */}, function (err, document) {
        res.json({user: document})
      })
    } else {
      user.create(req.body, function(err, document) {
        res.json({user: document})
      })
    }
  })
})

而现在,你有个挑战 -- 尝试一目了然地破译上面的代码。很难,不是吗?难怪开发者在看到嵌套回调时会不寒而栗。

克服回调地狱的一个解决方案是将回调函数分解为更小的部分以减少嵌套代码的数量:

const updateUser = (req, res) => {
  user.update({/* params to update */}, function () {
    if (err) throw err;
    return res.json(user)
  })
}

const createUser = (req, res, err, user) => {
  user.create(req.body, function(err, user) {
    res.json(user)
  })
}

app.get('/', function (req, res) {
  Users.findOne({ _id:req.body.id }, (err, user) => {
    if (err) throw err
    if (user) {
      updateUser(req, res)
    } else {
      createUser(req, res)
    }
  })
})

更容易阅读了,是吧?

还有其他解决方案来对抗新版JavaScript中的回调地狱 -- 比如promisesasync / await。但是,解释它们是我们另一天的话题。

结语

今天,你了解到了回调是什么,为什么它们在JavaScript中如此重要以及如何使用它们。你还学会了回调地狱和对抗它的方法。现在,希望callbakcs不再吓到你了😉。

你对回调还有任何疑问吗?如果你有,请随时在下面发表评论,我会尽快回复你的。【PS:本文译文,若需作者解答疑问,请移步原作者文章下评论】

感谢阅读。这篇文章是否帮助到你?如果有,我希望你考虑分享它。你可能会帮助到其他人。非常感谢!

后话

原文:zellwk.com/blog/callba…

文章首发:github.com/reng99/blog…

下一篇文章关于 promises

更多内容:github.com/reng99/blog…

by the way, Happy International Workers' Day!