Тема 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 - прошло ли указаное в нём время. Если да, запускает указанную его аргументом функцию для выполнения, если нет, ждёт.

При этом возможны немного неожиданные ситуации
  1. В setTimeout мы указываем небольшую задержку, например, 1-2 мс, но оставшаяся часть кода выполняется намного дольше. Всё равно функция в setTimeout ждёт, пока не выполнится весь оставшийся код и только тогда выполняется. То есть время, указанное в setTimeout это минимальное время ожидания, но оно может быть и больше, если код после setTimeout выполняется дольше.
  2. Если в нескольких setTimeout после выполнения кода уже закончилось время ожидания, они выполняются в той последовательности, в какой были указаны в коде. Поэтому функция с setTimeout в 1 мс может выполниться после функции с setTimeout 10 мс, если она указана по коду ниже, а весь оставшийся код выполняется больше 10 мс.
  3. Даже если в 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-запросов
  • работают в режиме реального времени 
  • не требуют перезагрузки страницы
  • являются неблокирующими
Для того, чтобы отправлять AJAX-запросы на сервер, нам понадобится специальный объект (что не удивительно, для JavaScript, в котором практически всё - это объект). Называется такой объект XMLHttpRequest. Создаётся следующим образом:

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 для чайников