Здесь мы используем методы браузера в примерах
Чтобы продемонстрировать использование обратных вызовов, обещаний и других абстрактных концепций, мы будем использовать некоторые методы браузера: в частности, загрузку скриптов и выполнение простых манипуляций с документами.
Если вы не знакомы с этими методами и их использование в примерах сбивает с толку, возможно, вы захотите прочитать несколько глав из следующей части руководства.
Хотя, мы все равно постараемся внести ясность. Ничего сложного с точки зрения браузера не будет.
Многие функции предоставляются хост-средами JavaScript, которые позволяют планировать асинхронные действия. Другими словами, действия, которые мы инициируем сейчас, но завершаем позже.
Например, одной из таких функций является функция setTimeout
.
Существуют и другие реальные примеры асинхронных действий, например, загрузка скриптов и модулей (мы рассмотрим их в последующих главах).
Взгляните на функцию loadScript(src)
, которая загружает скрипт с заданным src
:
функция loadScript(src) { // создаем тег <script> и добавляем его на страницу // это приводит к тому, что скрипт с заданным источником начинает загрузку и запускается после завершения пусть скрипт = document.createElement('script'); script.src = источник; document.head.append(скрипт); }
Он вставляет в документ новый, динамически созданный тег <script src="…">
с заданным src
. Браузер автоматически начинает его загрузку и выполняет ее по завершении.
Мы можем использовать эту функцию следующим образом:
// загружаем и выполняем скрипт по заданному пути loadScript('/my/script.js');
Скрипт выполняется «асинхронно», так как он начинает загружаться сейчас, но запускается позже, когда функция уже завершится.
Если под loadScript(…)
есть какой-либо код, он не будет ждать завершения загрузки скрипта.
loadScript('/my/script.js'); // код ниже loadScript // не ждет завершения загрузки скрипта // ...
Допустим, нам нужно использовать новый скрипт, как только он загрузится. Он объявляет новые функции, и мы хотим их запустить.
Но если мы сделаем это сразу после вызова loadScript(…)
, это не сработает:
loadScript('/my/script.js'); // в скрипте есть "функция newFunction() {…}" новаяФункция(); // нет такой функции!
Естественно, браузер, вероятно, не успел загрузить скрипт. На данный момент функция loadScript
не позволяет отслеживать завершение загрузки. Скрипт загружается и в конечном итоге запускается, вот и все. Но нам хотелось бы знать, когда это произойдет, чтобы использовать новые функции и переменные из этого скрипта.
Давайте добавим функцию callback
в качестве второго аргумента в loadScript
, которая должна выполняться при загрузке скрипта:
функция loadScript(src, обратный вызов) { пусть скрипт = document.createElement('script'); script.src = источник; script.onload = () => обратный вызов (скрипт); document.head.append(скрипт); }
Событие onload
описано в статье Загрузка ресурсов: onload и onerror. По сути, оно выполняет функцию после загрузки и выполнения скрипта.
Теперь, если мы хотим вызвать новые функции из скрипта, нам нужно написать это в обратном вызове:
loadScript('/my/script.js', function() { // обратный вызов запускается после загрузки скрипта новаяФункция(); // и теперь это работает ... });
В этом и идея: второй аргумент — это функция (обычно анонимная), которая запускается после завершения действия.
Вот работоспособный пример с реальным скриптом:
функция loadScript(src, обратный вызов) { пусть скрипт = document.createElement('script'); script.src = источник; script.onload = () => обратный вызов (скрипт); document.head.append(скрипт); } loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', скрипт => { alert(`Отлично, скрипт ${script.src} загружен`); тревога( _ ); // _ — функция, объявленная в загруженном скрипте });
Это называется стилем асинхронного программирования, основанным на обратном вызове. Функция, которая делает что-то асинхронно, должна предоставлять аргумент callback
, в котором мы запускаем функцию после ее завершения.
Здесь мы сделали это в loadScript
, но, конечно, это общий подход.
Как мы можем загрузить два скрипта последовательно: первый, а затем второй?
Естественным решением было бы поместить второй вызов loadScript
внутри обратного вызова, например:
loadScript('/my/script.js', function(script) { alert(`Отлично, ${script.src} загружен, давайте загрузим ещё один`); loadScript('/my/script2.js', function(script) { alert(`Отлично, второй скрипт загружен`); }); });
После завершения внешнего loadScript
обратный вызов инициирует внутренний.
Что, если нам понадобится еще один сценарий…?
loadScript('/my/script.js', function(script) { loadScript('/my/script2.js', function(script) { loadScript('/my/script3.js', function(script) { // ...продолжаем после загрузки всех скриптов }); }); });
Итак, каждое новое действие находится внутри обратного вызова. Это хорошо для некоторых действий, но не для многих, поэтому скоро мы увидим и другие варианты.
В приведенных выше примерах мы не учитывали ошибки. Что делать, если загрузка скрипта не удалась? Наш обратный вызов должен иметь возможность отреагировать на это.
Вот улучшенная версия loadScript
, которая отслеживает ошибки загрузки:
функция loadScript(src, обратный вызов) { пусть скрипт = document.createElement('script'); script.src = источник; script.onload = () => обратный вызов (null, скрипт); script.onerror = () => callback(new Error(`Ошибка загрузки скрипта для ${src}`)); document.head.append(скрипт); }
Он вызывает callback(null, script)
для успешной загрузки и callback(error)
в противном случае.
Использование:
loadScript('/my/script.js', function(error, script) { если (ошибка) { // обрабатываем ошибку } еще { // скрипт успешно загружен } });
Опять же, рецепт, который мы использовали для loadScript
на самом деле довольно распространен. Это называется стилем «обратного вызова по ошибке».
Конвенция – это:
Первый аргумент callback
зарезервирован на случай возникновения ошибки. Затем вызывается callback(err)
.
Второй аргумент (и последующие, если необходимо) предназначены для успешного результата. Затем вызывается callback(null, result1, result2…)
.
Таким образом, одна функция callback
используется как для сообщения об ошибках, так и для возврата результатов.
На первый взгляд это выглядит как жизнеспособный подход к асинхронному кодированию. И это действительно так. Для одного или двух вложенных вызовов все выглядит нормально.
Но для нескольких асинхронных действий, следующих одно за другим, у нас будет такой код:
loadScript('1.js', функция(ошибка, скрипт) { если (ошибка) { handleError (ошибка); } еще { // ... loadScript('2.js', функция(ошибка, скрипт) { если (ошибка) { handleError (ошибка); } еще { // ... loadScript('3.js', функция(ошибка, скрипт) { если (ошибка) { handleError (ошибка); } еще { // ...продолжаем после загрузки всех скриптов (*) } }); } }); } });
В приведенном выше коде:
Загружаем 1.js
, потом если ошибки нет…
Загружаем 2.js
, потом если ошибки нет…
Загружаем 3.js
, затем если ошибки нет — делаем что-нибудь еще (*)
.
По мере того, как вызовы становятся более вложенными, код становится глубже, и им становится все труднее управлять, особенно если у нас есть реальный код, а не ...
который может включать больше циклов, условных операторов и так далее.
Иногда это называют «адом обратного вызова» или «пирамидой гибели».
«Пирамида» вложенных вызовов растет вправо с каждым асинхронным действием. Вскоре ситуация выходит из-под контроля.
Так что этот способ кодирования не очень хорош.
Мы можем попытаться облегчить проблему, сделав каждое действие отдельной функцией, например:
loadScript('1.js', шаг1); функция шаг1 (ошибка, сценарий) { если (ошибка) { handleError (ошибка); } еще { // ... loadScript('2.js', шаг2); } } функция шаг2 (ошибка, сценарий) { если (ошибка) { handleError (ошибка); } еще { // ... loadScript('3.js', шаг3); } } функция шаг3 (ошибка, сценарий) { если (ошибка) { handleError (ошибка); } еще { // ...продолжаем после загрузки всех скриптов (*) } }
Видеть? Он делает то же самое, и теперь нет глубокой вложенности, поскольку мы сделали каждое действие отдельной функцией верхнего уровня.
Это работает, но код выглядит как разорванная электронная таблица. Читать сложно, и вы, наверное, заметили, что во время чтения приходится прыгать глазами между отрывками. Это неудобно, особенно если читатель не знаком с кодом и не знает, куда прыгнуть взглядом.
Кроме того, все функции с именем step*
являются одноразовыми, они созданы только для того, чтобы избежать «пирамиды гибели». Никто не собирается повторно использовать их за пределами цепочки действий. Так что здесь немного загромождено пространство имен.
Нам бы хотелось чего-то лучшего.
К счастью, есть и другие способы избежать подобных пирамид. Один из лучших способов — использовать «обещания», описанные в следующей главе.