image Привет, Хаброжители! Как максимально прокачать свои навыки и стать топовым JS-программистом? Четвертое издание «JavaScript для профессиональных веб-разработчиков» идеально подойдет тем, кто уже имеет базовые знания и опыт разработки на JavaScript. Автор сразу переходит к техническим деталям, которые сделают ваш код чистым и переведут вас с уровня рядового кодера на высоту продвинутого разработчика.

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

В книге вы найдете:

  • Последнюю информацию о классах, промисах, async/await, прокси, итераторах, генераторах, символах, модулях и операторах spread/rest.
  • Фундаментальные концепции веб-разработки, такие как DOM, BOM, события, формы, JSON, обработка ошибок и веб-анимация.
  • Расширенные API-интерфейсы, такие как геолокация, service workers, fetch, атомизация, потоки, каналы сообщений и веб-криптография.
  • Сотни рабочих примеров кода, которые ясно и кратко иллюстрируют концепции.


Введение в асинхронное программирование


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

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

Синхронный и асинхронный JavaScript


Синхронное поведение аналогично последовательным инструкциям процессора в памяти. Каждая инструкция выполняется строго в том порядке, в котором она появляется, и каждая из них также способна немедленно извлекать информацию, которая хранится локально в системе (например, в регистре процессора или в системной памяти). В результате можно легко определить состояние программы (например, значение переменной) в любой заданной точке кода.

Тривиальным примером этого будет выполнение простой арифметической операции:

let x = 3;
x = x + 4;

На каждом шаге этой программы можно рассуждать о ее состоянии, потому что выполнение не будет продолжено, пока не будет выполнена предыдущая инструкция. Когда последняя инструкция завершается, вычисленное значение x сразу становится доступным для использования.

Этот фрагмент JS-кода легко разложить по полочкам, потому что нетрудно предвидеть, к каким низкоуровневым инструкциям он будет компилироваться (например, от JavaScript до x86). Предположительно, операционная система выделит некоторое количество памяти для числа с плавающей точкой в стеке, выполнит арифметическую операцию с этим значением и запишет результат в выделенную память. Все эти инструкции располагаются последовательно внутри одного потока выполнения. В каждой точке скомпилированной низкоуровневой программы можно с большой долей уверенности утверждать, что можно и что нельзя знать о состоянии системы.

И наоборот, асинхронное поведение аналогично прерываниям, когда объект, внешний по отношению к текущему процессу, может инициировать выполнение кода. Часто требуется асинхронная операция, потому что невозможно заставить процесс долго ждать завершения операции (как в случае синхронной операции). Это длительное ожидание может возникнуть из-за того, что код обращается к ресурсу с высокой задержкой, например, отправляет запрос на удаленный сервер и ожидает ответа.

Тривиальным примером JavaScript в этом случае будет выполнение арифметической операции за время ожидания:

let x = 3;
setTimeout(() => x = x + 4, 1000);

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

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

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

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

Устаревшие паттерны асинхронного программирования


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

Предположим, вы работали со следующей асинхронной функцией, которая использует setTimeout для выполнения некоторого поведения через одну секунду:

function double(value) {
    setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}

double(3);
// 6 (выводится примерно спустя 1000 мс)

Здесь не происходит ничего таинственного, но важно точно понять, почему эта функция асинхронна. setTimeout позволяет определить обратный вызов, который планируется выполнить по истечении заданного промежутка времени. Спустя 1000 мс во время выполнения JavaScript запланирует обратный вызов, поместив его в очередь сообщений JavaScript. Этот обратный вызов снимается и выполняется способом, который полностью невидим для кода JavaScript. Более того, функция double() завершается сразу после успешного выполнения операции планирования setTimeout.

Возврат асинхронных значений

Предположим, операция setTimeout вернула полезное значение. Как лучше всего вернуть значение туда, где оно необходимо? Широко используемая стратегия заключается в предоставлении обратного вызова для асинхронной операции, где обратный вызов содержит код, требующий доступ к вычисленному значению (предоставляется в качестве параметра). Это выглядит следующим образом:

function double(value, callback) {
     setTimeout(() => callback(value * 2), 1000);
}

double(3, (x) => console.log(`I was given: ${x}`));
// I was given: 6 (выводится примерно спустя 1000 мс)

Здесь при вызове setTimeout команда помещает функцию в очередь сообщений по истечении 1000 мс. Эта функция будет удалена и асинхронно вычислена средой выполнения. Функция обратного вызова и ее параметры все еще доступны в асинхронном исполнении через замыкание функции.

Обработка ошибок

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

function double(value, success, failure) {
       setTimeout(() => {
          try {
             if (typeof value !== 'number') {
                 throw 'Must provide number as first argument';
                }
                success(2 * value);
           } catch (e) {
              failure(e);
           }
      }, 1000);
 }

const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);

double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);

// Success: 6 (выводится примерно спустя 1000 мс)
// Failure: Must provide number as first argument
// (выводится примерно спустя 1000 мс)

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

Вложенные асинхронные обратные вызовы

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

function double(value, success, failure) {
     setTimeout(() => {
         try {
               if (typeof value !== 'number') {
                   throw 'Must provide number as first argument';
               }
               success(2 * value);
       } catch (e) {
          failure(e);
       }
    }, 1000);
}

const successCallback = (x) => {
      double(x, (y) => console.log(`Success: ${y}`));
};
const failureCallback = (e) => console.log(`Failure: ${e}`);

double(3, successCallback, failureCallback);

// Success: 12 (выводится примерно спустя 1000 мс)

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

Промисы


Промис — это суррогатная сущность, которая выступает в качестве замены для результата, который еще не существует. Термин «промис» был впервые предложен Дэниелом Фридманом и Дэвидом Уайзом в их статье 1976 г. «Влияние прикладного программирования на многопроцессорность (The Impact of Applicative Programming on Multiprocessing)», но концептуальное поведение промиса было формализовано лишь десятилетие спустя Барбарой Лисков и Любой Шрира в их статье 1988 г. «Промисы: лингвистическая поддержка эффективных асинхронных процедурных вызовов в распределенных системах (Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems)». Современные компьютерные ученые описали похожие понятия, такие как «возможное», «будущее», «задержка» или «отсроченное»; все они описаны в той или иной форме программным инструментом для синхронизации выполнения программы.

Спецификация Promises/А+


Ранние формы промисов появились в jQuery и Dojo Deferred API, а в 2010 г. растущая популярность привела к появлению спецификации Promises/A внутри проекта CommonJS. Сторонние библиотеки промисов JavaScript, такие как Q и Bluebird, продолжали завоевывать популярность, но каждая реализация немного отличалась от предыдущей. Чтобы устранить разногласия в пространстве промисов, в 2012 г. организация Promises/A + разветвила предложение CommonJS Promises/A и создала одноименную спецификацию промисов Promises/A+ (https://promisesaplus.com/). Эта спецификация в конечном итоге определит, как промисы будут реализованы в спецификации ECMAScript 6.

ECMAScript 6 представил первоклассную реализацию совместимого с Promise/A+ типа Promise. За время, прошедшее с момента его введения, промисы пользовались невероятно высоким уровнем поддержки. Все современные браузеры полностью поддерживают тип промисов ES6, и несколько API-интерфейсов браузера, таких как fetch() и Battery API, используют исключительно его.

Основы промисов


Начиная с ECMAScript 6, Promise является поддерживаемым ссылочным типом и может быть создан с помощью оператора new. Для этого требуется передать параметр функции исполнителя (описанный в следующем разделе), который здесь является пустым объектом функции, чтобы угодить интерпретатору:

let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>

Если функция исполнителя не предусмотрена, будет сгенерирована ошибка SyntaxError.

Машина состояний промисов

При передаче экземпляра промиса в console.log выводы консоли (которые могут различаться в разных браузерах) указывают, что этот экземпляр промиса находится в состоянии ожидания. Как упоминалось ранее, промис — это объект с состоянием, который может существовать в одном из трех состояний:

  • в ожидании (Pending);
  • выполнен (иногда также называется решенным) (Fulfilled);
  • отклонен (Rejected).

Состояние ожидания — это начальное состояние, с которого начинается промис. Из состояния ожидания промис может быть установлен путем перехода в выполненное состояние, указывающее на успех, или отклоненное, указывающее на отказ. Этот переход к установленному состоянию необратим; как только происходит переход к выполненному или отклоненному состоянию, состояние промиса уже не сможет измениться. Кроме того, не гарантируется, что промис когда-либо покинет состояние ожидания. Следовательно, хорошо структурированный код должен вести себя правильно, если промис успешно разрешается, если он отклоняется или никогда не выходит из состояния ожидания.

Важно отметить, что состояние промиса является частным и не может быть напрямую проверено в JavaScript. Причина этого заключается прежде всего в том, чтобы предотвратить синхронную программную обработку объекта промиса на основе его состояния при чтении. Кроме того, состояние промиса не может быть изменено внешним JS-кодом по той же причине, по которой состояние не может быть прочитано: промис намеренно инкапсулирует блок асинхронного поведения, а внешний код, выполняющий синхронное определение его состояния, противоречит его цели.

Разрешенные значения, причины отказа и полезность промисов

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

В некоторых случаях внутренней машиной состояний является вся полезность, которую промис должен предоставить: одного лишь знания о том, что кусок асинхронного кода завершен, достаточно для информирования о ходе выполнения программы. Например, предположим, что промис отправляет HTTP-запрос на сервер. Запрос, возвращающийся со статусом не 200–299, может быть достаточным для перехода состояния обещания в выполненное. Точно так же запрос, возвращающийся со статусом, который не является 200–299, перевел бы состояние промиса в отклоненное.

В других случаях асинхронное выполнение, которое оборачивает промис, фактически генерирует значение, и поток программы будет ожидать, что это значение будет доступно, когда промис изменит состояние. С другой стороны, если промис отклоняется, поток программы ожидает причину отклонения после изменения состояния промиса. Например, предположим, что промис отправляет HTTP-запрос на сервер и ожидает его возврата в формате JSON. Запрос, возвращающийся со статусом 200–299, может быть достаточным для перевода промиса в выполненное состояние, и JSON-строка будет доступна внутри промиса. Точно так же запрос, возвращаемый со статусом, который не является 200–299, перевел бы состояние промиса в отклоненное, и причиной отклонения может быть объект Error, содержащий текст, сопровождающий HTTP-код статуса.

Для поддержки этих двух вариантов использования каждый промис, который переходит в выполненное состояние, имеет закрытое внутреннее значение. Точно так же каждый промис, который переходит в отклоненное состояние, имеет закрытую внутреннюю причину. И значение, и причина являются неизменной ссылкой на примитив или объект. Оба являются необязательными и по умолчанию будут иметь значение undefined. Асинхронный код, который планируется выполнить после того, как промис достигает определенного установленного состояния, всегда снабжается значениемили причиной.

Контроль состояния промиса с помощью исполнителя

Поскольку состояние промиса является закрытым, им можно манипулировать только изнутри. Эта внутренняя манипуляция выполняется внутри функции-исполнителя промиса. Функция-исполнитель выполняет две основные обязанности: инициализирует асинхронное поведение промиса и контролирует любой возможный переход состояния. Управление переходом между состояниями осуществляется путем вызова одного из двух параметров функции, которые обычно называются resolve и reject. Вызов resolve изменит состояние на выполненное; вызов reject изменит состояние на отклоненное. Вызов rejected() также сгенерирует ошибку (это поведение ошибки будет рассмотрено позже).

let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise <resolved>

let p2 = new Promise((resolve, reject) => reject());
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught error (in promise)

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

new Promise(() => setTimeout(console.log, 0, 'executor'));
setTimeout(console.log, 0, 'promise initialized');

// executor
// promise initialized

Можно отложить переход состояния, добавив setTimeout:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000));

// При выполнении console.log обратный вызов тайм-аута еще не будет выполнен:
setTimeout(console.log, 0, p); // Promise <pending>

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

let p = new Promise((resolve, reject) => {
      resolve();
      reject(); // Безрезультатно
});

setTimeout(console.log, 0, p); // Promise <resolved>

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

let p = new Promise((resolve, reject) => {
       setTimeout(reject, 10000);               // Вызов reject() спустя 10 секунд
      // Код исполнителя
});

setTimeout(console.log, 0, p);                 // Promise <pending>
setTimeout(console.log, 11000, p);           // Проверка состояния спустя 11 секунд

// (Спустя 10 секунд) Uncaught error
// (Спустя 11 секунд) Promise <rejected>

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

Преобразование промисов с помощью Promise.resolve()

Промис не обязательно должен начинаться с состояния ожидания и использовать функцию-исполнитель для достижения установленного состояния. Можно создать экземпляр промиса в состоянии «разрешено», вызвав статический метод Promise.resolve(). Следующие два экземпляра промисов фактически эквивалентны:

let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();

Значение этого разрешенного обещания станет первым аргументом, переданным Promise.resolve(). Это позволяет эффективно «преобразовать» любое значение в промис:

setTimeout(console.log, 0, Promise.resolve());
// Promise <resolved>: undefined

setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3

// Дополнительные аргументы игнорируются
setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
// Promise <resolved>: 4

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

let p = Promise.resolve(7);

setTimeout(console.log, 0, p === Promise.resolve(p));
// true

setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));
// true

Эта идемпотентность будет учитывать состояние промиса, переданного ему:

let p = new Promise(() => {});

setTimeout(console.log, 0, p);                      // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p));    // Promise <pending>
setTimeout(console.log, 0, p === Promise.resolve(p)); // true

Помните, что этот статический метод с радостью обернет любой не-промис, включая объект ошибки, как разрешенный промис, что может привести к непреднамеренному поведению:

let p = Promise.resolve(new Error('foo'));

setTimeout(console.log, 0, p);
// Promise <resolved>: Error: foo

Отклонение промисов с помощью Promise.reject()
В принципе, аналогично Promise.resolve(), Promise.reject() создает отклоненный промис и генерирует асинхронную ошибку (которая не будет перехвачена try/ catch и может быть перехвачена только обработчиком отклонения). Следующие два экземпляра промисов фактически эквивалентны:

let p1 = new Promise((resolve, reject) => reject());
let p2 = Promise.reject();

Поле «причины» этого разрешенного промиса будет первым аргументом, переданным Promise.reject(). Оно также будет ошибкой, переданной обработчику отклонения:

let p = Promise.reject(3);
setTimeout(console.log, 0, p); // Promise <rejected>: 3

p.then(null, (e) => setTimeout(console.log, 0, e)); // 3

Важно отметить, что Promise.reject() не отражает поведение Promise.resolve() в отношении идемпотентности. Если объект промиса передан, он с радостью использует этот промис в качестве поля «причины» отклоненного промиса:

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>

Двойственность синхронного/асинхронного выполнения

Большая часть конструкции Promise заключается в создании совершенно отдельного режима вычислений в JavaScript. Это аккуратно инкапсулировано в следующем примере, который выдает ошибки двумя различными способами:

try {
       throw new Error('foo');
} catch(e) {
       console.log(e); // Error: foo
}

try {
      Promise.reject(new Error('bar'));
} catch(e) {
      console.log(e);
}

// Uncaught (in promise) Error: bar

Первый блок try/catch генерирует ошибку и затем перехватывает ее, но второй блок try/catch генерирует ошибку, которая не перехватывается. Это может показаться нелогичным, поскольку кажется, что код синхронно создает отклоненный экземпляр промиса, который затем выдает ошибку при отклонении. Однако причина, по которой второй промис не был перехвачен, заключается в том, что код не пытается перехватить ошибку в соответствующем «асинхронном режиме». Такое поведение подчеркивает, как на самом деле ведут себя промисы: это синхронные объекты, используемые в синхронном режиме выполнения, выступающие в качестве моста к асинхронному режиму выполнения.

В предыдущем примере ошибка от отклоненного промиса выдается не в потоке синхронного выполнения, а в результате асинхронного выполнения очереди сообщений в браузере. Следовательно, инкапсулирующего блока try/catch будет недостаточно, чтобы перехватить эту ошибку. Как только код начинает выполняться в этом асинхронном режиме, единственный способ взаимодействовать с ним — использование конструкций асинхронного режима, а именно методы промисов.

Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — JavaScript

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.

Комментарии (1)


  1. Stac
    15.10.2021 17:36

    Важно отметить, что состояние промиса является частным и не может быть напрямую проверено в JavaScript. Причина этого заключается прежде всего в том, чтобы предотвратить синхронную программную обработку объекта промиса на основе его состояния при чтении. Кроме того, состояние промиса не может быть изменено внешним JS-кодом по той же причине, по которой состояние не может быть прочитано: промис намеренно инкапсулирует блок асинхронного поведения, а внешний код, выполняющий синхронное определение его состояния, противоречит его цели.

    О нет, за что? Где-то на этом абзаце мой промис купить эту книгу перешел в состояние rejected.

    Даже разбирая в промисах трудно понять, что хотел сказать переводчик в цитируемом абзаце.

    Свою рекомендацию издательству Питер привлекать технических редакторов (выраженную где-то в обратной связна на книгу про Laravel) я оставляю в силе.

    Помните, что этот статический метод с радостью обернет любой не-промис, включая объект ошибки, как разрешенный промис, что может привести к непреднамеренному поведению

    С радостью? С какой такой радости статические методы могут испытать радость? Он же статические :)