Тема 10. Асинхронность
Ссылка на вебинар https://youtu.be/Ih6Q7ka2eSQ
Таймеры
В первую очередь асинхронность рассмотрим на примере таймеров.
Таймеры широко используются в анимации, при создании механизма переподключения к серверу, вообще где угодно, где нам нужно отложить выполнение кода на определённое время.
Для создания таймера мы используем встроенную в среду разработки функцию setTimeout.
Первым аргументом мы передаём функцию, которую нужно выполнить, вторым - время, на которое нужно отложить выполнение данной функции.
setTimeout(() => {
console.log("timer");
}, 3000);
Так тоже можно
function fn() {
console.log("timer");
}
setTimeout(fn, 3000);
Посмотрим на последовательность выполнения функций. Для этого последовательно вызовем четыре функции.
console.log("1");
setTimeout(() => {
console.log("2");
}, 3000);
setTimeout(() => {
console.log("3");
}, 1000);
console.log("4");
В какой последовательности числа выведутся в консоль?
1 –– 4 –– 3 –– 2
Вначале выполняется функция перед setTimeout, сами setTimeout-ы откладываются в очередь и ожидают выполнения оставшейся части кода, когда код выполнен, интерпретатор проверяет отложенный в очередь setTimeout - прошло ли указаное в нём время. Если да, запускает указанную его аргументом функцию для выполнения, если нет, ждёт.
При этом возможны немного неожиданные ситуации
- В setTimeout мы указываем небольшую задержку, например, 1-2 мс, но оставшаяся часть кода выполняется намного дольше. Всё равно функция в setTimeout ждёт, пока не выполнится весь оставшийся код и только тогда выполняется. То есть время, указанное в setTimeout это минимальное время ожидания, но оно может быть и больше, если код после setTimeout выполняется дольше.
- Если в нескольких setTimeout после выполнения кода уже закончилось время ожидания, они выполняются в той последовательности, в какой были указаны в коде. Поэтому функция с setTimeout в 1 мс может выполниться после функции с setTimeout 10 мс, если она указана по коду ниже, а весь оставшийся код выполняется больше 10 мс.
- Даже если в setTimeout указать время ожидания равное нулю, он откладывается в очередь и выполняется только после выполнения оставшейся части кода.
Таким образом, JavaScript всего лишь эмулирует асинхронность, на самом деле setTimeout просто откладывает выполнение кода в очередь, но не выполняют его параллельно с основным потоком. У setTimeout есть некоторая погрешность, как правило, в пределах 4-6 мс, и об этом нужно помнить. setTimeout никоим образом не задерживает процесс выполнения основного потока кода. При этом указанные через setTimeout асинхронные операции выполняются когда-нибудь потом, точное время их выполнения мы точно спрогнозировать не можем, оно определяется временем выполнения основного потока кода.
Поэтапная загрузка картинок
Это могут быть не только картинки в галерее, но и данные с сервера, что угодно, что мы хотим загружать последовательно. Та же single page application (SPA) не должна грузиться вся одновременно, а первыми загрузить самые важные данные, а потом второстепенные.Да,так что касается картинок.
Пишем скрипт:
var url1 = "http://fanoboi.com/city/55/London-wallpaper-1366x768.jpg";
var url2 = "http://fanoboi.com/city/61/Catholic-Church-Vatican-wallpaper-1366x768.jpg";
var url3 = "http://fanoboi.com/city/136/Eilean-Donan-wallpaper-1366x768.jpg";
var image1 = document.createElement("img");
image1.height = 200;
image1.src = url1;
document.body.appendChild(image1);
var image2 = document.createElement("img");
image2.height = 200;
image2.src = url2;
document.body.appendChild(image2);
var image3 = document.createElement("img");
image3.height = 200;
image3.src = url3;
document.body.appendChild(image3);
Он загружает три изображения одновременно.
Кстати, чтобы увидеть процесс загрузки изображений при медленном интернете, в панели разработчика браузера Google Chrome (F12) нужно поставить галочку Disable Cashe и затем, нажав на стрелку справа, выбрать Presete "Fast 3G".
Чтобы загружать картинки одну вслед за другой, воспользуемся методом addEventListener. Для изображений он позволяет отлавливать событие загрузки - load.
image1.addEventListener("load", () => {
console.log("image1 is load");
});
Данный код вывел надпись в консоль. Но нам необходимо, чтобы после изображения 1 загружалось изображение 2, а потом 3.
Можно сделать примерно так:
var image1 = document.createElement("img");
image1.height = 200;
image1.src = url1;
document.body.appendChild(image1);
image1.addEventListener("load", () => {
var image2 = document.createElement("img");
image2.height = 200;
image2.src = url2;
document.body.appendChild(image2);
image2.addEventListener("load", () => {
var image3 = document.createElement("img");
image3.height = 200;
image3.src = url3;
document.body.appendChild(image3);
});
});
Код выглядит жутковато, но работает - после загрузки первой картинки загрузит вторую, после неё - третью.
Кстати, такой код называют пирамида, или лапша, или callback hell и выглядит примерно так
Такой код писать не нужно )
Разумеется, его можно немного улучшить, создав отдельную функцию для загрузки картинок loadImage и вызывая её, но это всё ещё лапша и по-прежнему плохая практика.
function loadImage(url, callback) {
var image = document.createElement("img");
image.height = 200;
image.src = url;
document.body.appendChild(image);
image.addEventListener("load", () => {
if(callback) {
callback();
}
});
}
loadImage(url1, () => {
loadImage(url2, () => {
loadImage(url3, () => {
console.log("1, 2, 3");
})
})
})
Код с большой вложенностью - пирамида, или лапша, или callback hell |
Рассмотрим как писать такой код правильно, но прежде поговорим чем на самом деле является поэтапная загрузка картинок.
Поэтапная загрузка картинок - это асинхронная операция. Мы надеемся, что она произойдёт когда-нибудь в будущем, но не знаем когда точно и не уверены, произойдёт ли вообще. Скорость загрузки картинки зависит от многих факторов - веса картинки, скорости интернета на локальном компьютере, скорости интернета на сервере, возможных ошибок при загрузке картинки, загруженности компьютера другими операциями и т.д.
Код, предназначенный для выполнения асинхронных операций, называется асинхронным кодом.
Организация асинхронного кода
Для организации асинхронного кода используются промисы – promise (обещание, англ).Для аналогии используем жизненную ситуацию - ожидание самолёта в аэропорту.
Вы сидите в аэропорту и находитесь в состоянии ожидания посадки на свой рейс.
Вы не знаете когда это произойдёт, как это произойдёт, произойдёт ли вообще, или самолёт приземлится на другом аэродроме. Следовательно, ожидание - асинхронная операция. Мы рассчитываем, что событие, которого ожидаем, произойдёт когда-нибудь в будущем, но точно не знаем время его наступления, да и само наступление данного события не гарантировано.
Состояние ожидания может быть разрешено двумя способами
0) - ожидание
1) - выполнено успешно - объявили посадку на самолёт
2) - выполнено неудачно - посадку отменили
Промис это объект (что не удивительно, в JavaScript объектом является практически всё).
У промиса есть три состояния, о которых говорили выше.
0) - ожидание - waiting / pending
1) - выполнено успешно - resolved / fulfilled
2) - выполнено неудачно - rejected
Таким образом, промис - это объект, который может находиться в одном из трёх состояний
pending / fulfilled / rejected
Состояниями промиса можно управлять
Создаём промис
var promise = new Promise (function(resolve, reject){
});
Аргументом промиса является функция, у неё два параметра resolve и reject.
Приходит аргумент resolve(), промис переходит в состояние fulfilled
Приходит аргумент reject(), промис переходит в состояние rejected
Не приходят аргументы, промис находится в состоянии pending - ждёт.
Предположим, приходят два аргумента по очереди
resolve();
reject();
После вызова resolve() промис переходит в состояние fulfilled и дальше состояние уже не меняет, reject() вызывается, но игнорируется.
То есть pending - промежуточное состояние промиса, fulfilled или rejected - конечные состояния.
Изначально промис создаётся с состоянием pending.
Переход промиса из промежуточного состояния pending в состояние fulfilled перехватываем при помощи метода then (когда).
promise.then()
Запустим небольшой код:
var promise = new Promise (function(resolve, reject){
setTimeout(function(){
resolve();
}, 2000);
});
promise.then(function(){
console.log("resolve");
})
Через 2 секунды промис переходит в состояние fulfilled и метод promise.then() позволяет это состояние отловить.
Подытожим
У промиса есть три состояния: pending, fulfilled и rejected.Промис создаётся вот таким образом:
var promise = new Promise (function(resolve, reject){
});
Промис создаётся с состоянием pending.
pending это промежуточное состояние промиса.
Из состояния pending промис может перейти в одно из двух состояний fulfilled или rejected
fulfilled и rejected это конечные состояния промиса, из них он уже никуда не переходит.
Переход промиса в состояние fulfilled может отловить метод promise.then();
Внутри этого метода записываем функцию, которая выполнится только в том случае, если промис перешёл в состояние fulfilled.
Метод promise.then() можно вызвать не один, а несколько раз. Функции, которые объявляются внутри этих методов, добавляются в очередь resolverQueue и будут выполнены только тогда, когда промис перейдёт в состояние fulfilled.
В отличие от setTimeout, функции, попавшие в resolverQueue гарантированно выполняются последовательно.
Благодаря способности промисов переходить из состояния в состояние, возможности отлавливать этот переход при помощи метода then() и способности промисов вызывать выполнение функций из очереди resolverQueue, при переходе промиса в состояние fulfilled, промисы используются для организации асинхронного кода.
Поэтапная загрузка картинок с использованием
promise
Перепишем функцию loadImage так, чтобы она возвращала новый промис:
function loadImage(url) {
return new Promise(function(resolve){
var image = document.createElement("img");
image.height = 200;
image.src = url;
document.body.appendChild(image);
image.addEventListener("load", () => {
resolve();
});
});
Когда картинка будет загружена, сработает событие load, мы вызываем resolve() и тем самым переводим промис в состояние fulfilled.
Вызываем функцию loadImage, она возвращает новый промис, у него вызываем метод then.
loadImage(url1).
then(function(){
console.log("картинка1 загружена");
})
Метод then() не только вызывает объявленную внутри него функцию, но и возвращает новый промис с состоянием pending.
Поэтому можно написать так
loadImage(url1).
then(function(){
console.log("картинка1 загружена");
}).
then(function(){
console.log("картинка2 загружена");
}).
then(function(){
console.log("картинка3 загружена");
})
Все эти then() сработают последовательно, как только загрузится первая картинка. Но это не совсем то, что нам нужно.
Добавим внутрь then() return
loadImage(url1).
then(function(){
console.log("картинка1 загружена");
return loadImage(url2);
}).
then(function(){
console.log("картинка2 загружена");
return loadImage(url3);
}).
then(function(){
console.log("картинка3 загружена");
})
Теперь then будут срабатывать только тогда, когда разрешится промис, возвращаемый функцией loadImage.
В новом коде промисы выстраиваются в цепочку, код организован хорошо и красиво. Такой код называется чейнинг (от англ. chain - цепь).
Этот код можно ещё улучшить с использованием стрелочных функций:
loadImage(url1).
then(() => loadImage(url2)).
then(() => loadImage(url3)).
then(() => console.log("картинка3 загружена"))
Кстати, вот этот участок кода функции loadImage тоже можно немного улучшить
image.addEventListener("load", () => {
resolve();
});
заменив его на
image.addEventListener("load", resolve);
В чём преимущество использования чейнинга, кроме красоты и читаемости кода?
Важно то, что функции, которые вызываются через then(), добавляются в очередь, то есть они не вызывают переполнение стека. Колбеки же, которые вызываются один из другого, при достаточно большом их количестве, могут вызвать переполнение стека вызовов.
AJAX
Когда мы ищем информацию в поисковике,по мере набора поискового запроса появляются всё новые и новые подсказки. При этом никакой перезагрузки странички не происходит, но при вводе каждой буквы отправляется AJAX-запрос на сервер
Особенности AJAX-запросов
- работают в режиме реального времени
- не требуют перезагрузки страницы
- являются неблокирующими
const xhr = new XMLHttpRequest();
Создали объект, теперь нам нужно его подготовить к отправке запросов на сервер. Для этого используем метод open()
У метода open() два параметра: протокол и адрес, по котором будет отправлен запрос.
xhr.open("GET", "file.txt");
Различие между GET и POST протоколами рассматривалось здесь https://studyjavascript.blogspot.com/2017/08/itbursa_21.html
Последнее, что нужно сделать, вызвать метод send(). Метод send() отправляет запрос.
xhr.send()
Ещё раз. Для отправки AJAX-запроса на сарвер, нужно сделать всего три шага:
1. Создать объект XMLHttpRequest:
const xhr = new XMLHttpRequest();
2. Подготовить запрос:
xhr.open("GET", url);
3. Отправить запрос:
xhr.send()
Отправка запроса и получение ответа - асинхронная операция.
Чтобы узнать, что ответ получен, воспользуемся обработчиком событий addEventListener() при помощи которого будем отслеживать событие load
xhr.addEventListener("load", () => {
console.log("ответ получен");
} );
Хорошо, о том, что ответ получен, узнали. Как получить доступ к содержимому ответа?
XMLHttpRequest - объект, у которого много полей.
Самые интересные на данный момент поля status и response.
status - статус ответа.
Возможные значения:
200 - ответ получен
404 - файл не найден
500 - ошибка сервера
response - содержание ответа.
После того, как ответ получен, в свойсте xhr.response будет храниться содержимое ответа.
Предположим, нам нужно отправить последовательно не один, а несколько AJAX-запросов. Отправлять их необходимо последовательно - один за другим.
Вспоминаем, как мы осуществляли поэтапную загрузку картинок и действуем аналогично.
Создаём универсальную функцию loadFile:
function loadFile(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(xhr.response);
}
});
});
}
Здесь нужно пояснить момент с передачей значения xhr.response внутрь resolve.
Это возможно благодаря тому, что resolve может не только перевести промис в состояние fulfilled, но и принять один какой-то аргумент, а потом передать его значение в then.
Создадим цепочку промисов:
loadFile(url1).
then((value1) => loadFile(url2)).
then((value2) => loadFile(url3)).
then((value3) => console.log("все файлы загружены"))
value1, value2, value3 - это и есть содержание загруженых файлов.
Обработка ошибок
Создадим новый промис и через две секунды вызовем reject:
var promise = new Promise ((resolve, reject) => {
setTimeout(() => {
reject();
}, 2000)
});
promise.then(
() => console.log("!!!")
);
Такой код вызывает ошибку - промис, находящийся в состоянии rejected, необходимо обработать.
Для этого достаточно внутри then вызвать ещё одну функцию
promise.then(
() => console.log("!!!"),
() => console.log("- - -"),
);
Первая функция срабатывает, если промис переходит в состояние resolve, вторая - в состояние reject.
JSON
JSON - JavaScript Object Notation. Объект в формате JavaScript. Пример
Как получить доступ?
const xhr = new XMLHttpRequest();
xhr.open("GET", "url");
xhr.send();
xhr.addEventListener("load", () => {
if(xhr.status === 200) {
let arr = JSON.parse(xhr.response);
console.log(arr);
document.write(xhr.response);
}
});
Результат: https://codepen.io/irinainina/pen/ROYzow
В консоли выводится вполне приличный объект, с которым можно работать.
Просмотреть результат просто запустив код в консоли не получится - политика безопасности YouTube API запрещает просмотр результатов с локальной страницы.
Использование Fetch
Fetch - экспериментальная технология с хорошей поддержкой в современных браузерах, которая предоставляет удобный метод fetch() для отправки асинхронных запросов и получения ответов. Ссылка на MDN
Вспомним, как выглядела функция loadFile для отправки асинхронных запросов:
function loadFile(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
xhr.addEventListener('load', () => {
if (xhr.status === 404) {
reject();
} else {
resolve(xhr.response);
}
});
});
}
Это базовая отправка AJAX-запроса.
Метод fetch() позволяет тот же самый код переписать более кратко.
function loadFile(url) {
return fetch(url).then(response => response.text())
}
Весь асинхронный запрос и ответ на него всего в одной строке )
Важно помнить, что fetch() уже возвращает нам новый промис. Через then мы можем получить объект ответа. В его полях содержится статус промиса, url, по которому отправляли запрос, но самого ответа пока нет, и, чтобы его получить, мы вызываем функцию
response => response.text()
Эта функция возвращает ещё один промис, в котором уже есть содержимое ответа.
Зачем два промиса?
Дело в том, что у нас две асинхронные операции: отправка AJAX-запроса, результат - объект ответа и чтение объекта ответа - результат содержимое ответа. Так вот, не только отправка запроса, но и чтение объекта ответа это асинхронные операции, для выполнения каждой из них нужен свой промис.
Чтобы получить не только объект, но и содержание ответа, функцию loadFile нам придётся немного дополнить:
function loadFile(url) {
return fetch(url)
.then(response => response.text())
.then(text);
}
Обработка ошибок
Ещё бувально пару строчек кода:
function loadFile(url) {
return fetch(url)
.then(response => {
if(response.status === 404){
throw new Error("file.not found");
} else {
response.text())
}
}
.then(text);
}
Вместо throw new Error("file.not found"); можем написать return Promise.reject();
Ещё материал по теме: Промисы в JavaScript для чайников