【譯】JavaScript中的Callbacks

call_me_R發表於2019-05-02

你是否遇到過"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!

相關文章