JavaScript: Что такое функции обратного вызова/Callback

Источник: «What are Callbacks in JavaScript?»
Когда вы начинаете изучать JavaScript, вскоре услышите термин функции обратного вызова. Обратные вызовы — неотъемлемая часть модели выполнения JavaScript, и важно понимать, что они из себя представляют и как работают.

Что такое обратные вызовы JavaScript

В JavaScript Обратный Вызов (или Callback) — это функция передаваемая в качестве аргумента другой функции. Функция, получающая обратный вызов, решает, выполнять ли обратный вызов и когда:

function myFunction(callback) {
// 1. Что-то делает
// 2. Затем выполняет обратный вызов
callback()
}

function myCallback() {
// Делает что-то другое
}

myFunction(myCallback);

В приведённом выше примере есть две функции myFunction и myCallback. Как следует из названия, myCallback используется как функция обратного вызова, и мы передаём её в myFunction в качестве аргумента. Затем myFunction может выполнить обратный вызов, когда будет к этому готов.

Во многих статья пишут, что обратные вызовы называются так, потому что вы указываете некой функции сообщить, когда она будет готова с ответом. Менее запутанным названием было бы callafter, то есть вызов после того, как закончено со всем остальным.

Зачем нужны функции Обратного вызова

Вы часто будет слышать, что JavaScrip является однопоточным. Это означает, что он может делать одну вещь за раз. При выполнении медленной операции, такой как получение данных из удалённого API, это может быть проблематичным. Было бы не очень приятно, если бы ваша программа зависла до тех пор, пока данные не будут возвращены.

Один из способов, которым JavaScript позволяет избежать этого узкого места, — использование обратного вызова. Мы можем передать вторую функцию в качестве аргумента функции, отвечающей за выборку данных. Затем запускается запрос на получение данных, но вместо ожидания интерпретатор JavaScript продолжает выполнение остальной части программы. Когда приходит ответ от API, функция обратного вызова выполняется и может выполнять действия с результатом:

function fetchData(url, cb) {
// 1. Выполняет запрос к API по URL
// 2. Если ответ успешный, выполнить обратный вызов
cb(res);
}

function callback(res) {
// Сделать что-то с результатом
}

// Сделать что-то
fetchData('https://sitepoint.com', callback);
// Сделать что-то ещё

JavaScript — язык, управляемый событиями

Вы также услышите, как люди говорят, что JavaScript — язык управляемый событиями (event-driven language). Это означает, что он может прослушивать события и не реагировать на них, продолжая выполнять дальнейший код и не блокируя свой единственный поток.

И как он это делает? Вы уже догадались: обратные вызовы.

Представьте, что ваша программа привязывает слушатель событий к кнопке, а затем сидит и ждёт, пока кто-нибудь не нажмёт эту кнопку, отказываясь делать что-либо ещё. Это было бы не круто!

Используя обратные вызовы, мы можем указать, что определённый блок кода должен запускаться в ответ на определённое событие:

function handleClick() {
// Делаем что-нибудь (например, проверяем форму)
// в ответ на нажатием пользователем кнопки
}

document.querySelector('button').addEventListener('click', handleClick);

В приведённом выше примере функция handleClick представляет собой обратный вызов выполняющийся в ответ на действие происходящее на веб-странице (клик по кнопке).

Используя этот подход, мы можем реагировать на столько событий, сколько захотим, оставляя интерпретатору JavaScript свободу делать всё, что ему нужно.

Функции первого класса и высшего порядка

Ещё пара модных словечек с которыми вы можете столкнуться при изучении обратных вызовов, — это функции первого класса и функции высшего порядка. Звучит пугающе, но это не так.

Когда мы говорим, чт JavaScript поддерживает функции первого класса, это означает, что мы можем обращать с функциями как с обычными значениями. Мы можем хранить их в переменной. Мы можем возвращать их из другой функции. И как мы уже видели, мы можем передавать их как аргументы.

Что касается функций высшего порядка, то это просто функции, которые либо принимают функцию в качестве аргумента, либо возвращают функцию в качестве результата. Есть несколько нативных функций JavaScript являющихся функциями высшего порядка, например setTimeout. Давайте используем её для демонстрации создания и запуска обратного вызова.

Как создать функцию обратного вызова

Шаблон такой же как и выше: создайте функцию обратного вызова и передайте её функции высшего порядка как аргумент:

function greet() {
console.log('Hello, World!');
}

setTimeout(greet, 1000);

Функция setTimeout выполняет функцию greet() с задержкой в 1 секунду (1000 мс) и выводит в лог консоли Hello, World!.

Мы также можем сделать это немного сложнее и передать функции greet() имя того, кого нужно приветствовать:

function greet(name) {
console.log(`Hello, ${name}!`);
}

setTimeout(() => greet('Jim'), 1000);

Обратите внимание, мы использовали стрелочную функцию, чтобы обернуть исходный вызов приветствия. Если бы этого не сделали, то функция выполнялась бы сразу, а не с задержкой.

Как видите, существуют разные способы создания функций обратного вызова в JavaScript, что подводит нас к следующему разделу.

Различные виды функций обратного вызова

Отчасти благодаря поддержке JavaScript функций первого класса, существуют различные способы объявления функций в JavaScript и, следовательно, различные способы их использования в обратных вызовах.

Давайте рассмотрим их преимущества и недостатки.

Анонимные функции JavaScript

До сих пор мы использовали именованные функции. Обычно это считается хорошей практикой, но ни в коем случае не является обязательной. Рассмотрим следующий пример, в котором функция обратного вызова используется для проверки ввода нескольких форм:

document.querySelector('form').addEventListener('submit', function(e)  {
e.preventDefault();
// Выполняем некую проверку данных
// Если всё выглядит нормально, то...
this.submit();
});

Как видите, у функции обратного вызова нет имени. Определение функции без имени называется анонимной функцией. Анонимные функции хорошо работают в коротких сценариях, где функция всегда вызывается только в одном месте. И, поскольку они объявлены встроенными, они также имеют доступ к области действия своего родителя.

Стрелочные функции

Стрелочные функции были представлены в 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 не является функцией `this`
// указывает на объект window, а не на form.
this.submit();
});

Стрелочные функции — одно из моих любимых дополнений к JavaScript за последние годы, и разработчики определённо должны с ним ознакомиться.

Именованные функции

Есть два способа создания именованных функций в 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

Варианты использования функций обратного вызова JavaScript широки и разнообразны. Как мы видели, они полезны при работе с асинхронным кодом (например, Ajax запрос) и при реагировании на события (например, отправка формы). Теперь давайте посмотрим ещё на пару мест, где мы находим обратные вызовы.

Методы массива

Ещё одно место, где вы столкнётесь с обратными вызовами, — это работа с методами массива в 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

Также следует отметить, что Node.js и вся его экосистема в значительной степени зависят от кода, основанного на обратных вызовах. Например, вот 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 или нет, надеюсь, этот код будет простым для понимания. По сути, нам требуется модуль http Node и вызывается его метод 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 в этом руководстве, но надеемся, что теперь такой код будет намного легче читать.

Синхронные и Асинхронные Обратные вызовы

Выполняется ли обратный вызов синхронно или асинхронно, зависит от функции, которая его вызывает. Давайте рассмотрим пару примеров.

Синхронные функции обратного вызова

Когда код является синхронным, он выполняется сверху вниз, строка за строкой. Операции выполняются одна за другой, при этом каждая операция ожидает завершения предыдущей. Мы уже видели пример выше синхронного обратного вызова в функции Array.reduce.

Чтобы ещё больше проиллюстрировать это, вот демонстрация, в которой используется как Array.map, так и Array.reduce для вычисления максимального чиста в списке чисел, разделённых запятыми:

See the Pen

Основное действие происходит здесь:

const highest = input.value
.replace(/\s+/, '')
.split(',')
.map((el) => Number(el))
.reduce((acc,val) => (acc > val) ? acc : val);

Двигаясь сверху вниз, делаем следующее:

Почему бы вам не поиграть с кодом на CodePen? Попробуйте изменить обратный вызов для получения другого результата (например, для поиска наименьшего числа или всех нечётных числе и т.д.).

Асинхронные функции обратного вызова

В отличие от синхронного кода, асинхронный код JavaScript не будет выполняться сверху вниз, строка за строкой. Вместо этого асинхронная операция зарегистрирует функцию обратного вызова, которая будет выполнена после её завершения. Это означает, что интерпретатору JavaScript не нужно завершения асинхронной операции, а вместо этого он может выполнять другие задачи во время её выполнения.

Одним из основных примеров асинхронного кода является получение данных из удалённого API. Давайте посмотрим на пример и разберём как он использует обратные вызовы.

See the Pen

Основное действие происходит здесь:

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, вы найдёте множество других ресурсов, которые вы можете получить и использовать.

Что нужно знать при использовании обратных вызовов

Обратные вызовы существуют в JavaScript давно, и они не всегда могут подходить для того, что вы пытаетесь сделать. Рассмотрим пару вещей, о которых нужно знать.

Избегайте ада обратных вызовов (Callback Hell)

В примере кода приведённом выше мы видели, что обратные вызовы можно вкладывать друг в друга. Это часто встречается при работе с асинхронными функциями зависящими друг от друга. Например, вы можете получить список фильмов одним запросом, а затем использовать этот список для получения постера для каждого отдельного фильма.

И это нормально, пока у вас один или два уровня вложенности, но вы должны знать, что эта стратегия обратного вызова плохо масштабируется. Вскоре вы получите беспорядочный и трудный для понимания код:

fetch('...')
.then(response => response.json())
.then(json => {
// Делаем некую обработку
fetch('...')
.then(response => response.json())
.then(json => {
// Делаем ещё обработку
fetch('...')
.then(response => response.json())
.then(json => {
// Делаем другую обработку
fetch('...')
.then(response => response.json())
.then(json => {
// Делаем ещё какую-то обработку
});
});
});
});

Это ласково называют адом обратных вызовов. Но этого можно избежать.

Предпочитайте более современные методы управления потоком

Хотя обратные вызовы являются неотъемлемой частью JavaScript, в более поздних версиях языка были добавлены улучшенные методы управления потоком.

Например, Promise и async...await обеспечивают более чистый синтаксис для работы с вышеуказанным кодом. Но это выходит за рамки этой статьи.

Заключение

В этой статье мы рассмотрели, что такое обратные вызовы. Мы рассмотрели основы модели выполнения JavaScript, как обратные вызовы вписываются в эту модель и почему они необходимы. Мы также рассмотрели, как создавать и использовать обратные вызовы, различные виды обратных вызовов и когда их использовать. Теперь у вас должно быть чёткое представление о работе с обратными вызовами в JavaScript и возможность использовать эти методы в своём собственном коде.

Дополнительные материалы

Предыдущая Статья

Что такое CORS (Cross-origin resource sharing)

Следующая Статья

JavaScript: Спасение из ада обратных вызовов