Дорога к async/await

Олег Кислицын

Итоги

✅ Прошли увлекательный путь от базовых функций до генераторов
✅ Посмотрели на коллбэки, промисы и замыкания с позиции спецификации
🤔
function giveATalk () {
introduceYourself()
shareMainPoints()
summarize()
}
giveATalk()
async function giveATalk () {
await introduceYourself()
await shareMainPoints()
summarize()
}
giveATalk()

Дорога к async/await

Олег Кислицын

@olegafx

⚠️

Некоторые подробности реализации упущены для упрощения понимания

ECMAScript

Однопоточный объектно-ориентированный язык программирования
const num = 3
function multiplyBy2 (inputNumber) {
const result = inputNumber * 2
return result
}
const name = 'Oleg'
Как только данный код начинает выполняться, создаётся global execution context, который состоит из:
Thread of execution (парсим и выполняем код строчку за строчкой)
Global Variable Environment (память с переменными и данными)
const num = 3
function multiplyBy2 (inputNumber) {
const result = inputNumber * 2
return result
}
const output = multiplyBy2(4)
const newOutput = multiplyBy2(10)
Когда вы вызываем функцию, мы создаём новый execution context, который также состоит из:
Thread of execution (выполняем код функции строчка за строчкой)
Variable Environment (место, где будут храниться данные, определенные в функции)

Мы можем отследить, какие функции вызываются в JavaScript с помощью call stack

Call stack позволяет узнать, в каком execution context мы сейчас находимся, какая функция сейчас вызвана, и куда мы попадём, когда текущий контекст будет удалён из стека

Есть всего один глобальный execution context, но на каждый вызов функции создаётся свой контекст

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

Но что, если мы хотим выполнить какой-то код не прямо сейчас, а спустя какое-то время? Например, после того, как получим данные от API?

# Решение 1 – простой блокирующий вызов:

function display(data) {
console.log(data)
}
const dataFromAPI = fetchAndWait('https://twitter.com/olegafx/tweets/1')
// пользователь ничего не может сделать в это время!
// а ведь это может длиться долго
display(dataFromAPI)
console.log('Me later!')

😎 Удобно и понятно для разработчика

🤦🏻‍ Пользователю приходится долго ждать
🤦🏻‍ Пользователь не понимает, происходит что-то или нет

# Решение 2 – используем Web Browser API:

function printHello() {
console.log('Hello')
}
setTimeout(printHello, 1000)
console.log('Me first!')

setTimeout не является частью JavaScript

Мы вышли за пределы JavaScript,
поэтому нам нужны правила:

function printHello() {
console.log('Hello')
}
function blockFor1Sec() {
// блокируется поток в JavaScript на 1 секунду
}
setTimeout(printHello, 0)
blockFor1Sec()
console.log('Me first!')

😎 Довольно легко понимать, разобравшись в таком коде лишь однажды

🤦🏻‍ Данные доступны только в callback. Прямая дорога к callback hell

Callback hell

function one() {
setTimeout(function two() {
console.log('1. First thing setting up second thing')
setTimeout(function three() {
console.log('2. Second thing setting up third thing')
setTimeout(function four() {
console.log('3. Third thing setting up fourth thing')
setTimeout(function five() {
console.log('4. Fourth thing')
}, 2000)
}, 2000)
}, 2000)
}, 2000)
}

Улучшаем читабельность – используем промисы

– Специальные объекты в JavaScript, которые возвращаются при использовании некоторых фич браузера (например, fetch)

– Промисы действуют как плейсхолдеры для данных, которые мы надеемся получить в будущем

– Мы также можем прикрепить какой-то функционал, который будет вызван по окончанию

– Этот функционал будет автоматически вызван объектом промиса

Решение 3 – используем промис
и передаём в него функцию для обработки данных:

function display(data) {
console.log(data)
}
const futureData = fetch('https://twitter.com/olegafx/tweets/1')
futureData.then(display) // прикрепляем дополнительный функционал
console.log('Me first!')

Промисы работают в JavaScript (объект промиса) и в среде выполнения (получение value)

.then() просто добавляет указанную функцию в onFulfilled

В каком порядке JavaScript
вызывает разный вид отложенного функционала?

function display(data) { console.log(data) }
function printHello() { console.log('Hello') }
function blockFor300ms() {/* как-то блокируем thread на 300мс */}
setTimeout(printHello, 0);
const futureData = fetch('https://twitter.com/olegafx/tweets/1')
futureData.then(display)
blockFor300ms()
// что будет вызвано раньше?
console.log('Me first!')

😎 Довольно читабельный код, очень похожий на код с синхронными вызовами
😎 Легко обрабатывать ошибки (например, с помощью catch)

🤦🏻‍ Многие разработчики не понимают, как это работает “под капотом”
🤦🏻‍ Такой код довольно непросто отлаживать

Какие есть правила у выполнения отложенного кода?

– Каждый отложенный промис-вызов помещается в очередь микротасков (microtask queue, job queue в спецификации), а если это не промис, то в очередь задач (task queue)

– Вызываем функцию (добавляем в call stack) только тогда, когда call stack пуст (event loop проверяет это условие)

Микротаски имеют приоритет над обычными тасками (вызываются раньше)

Сейчас нет нормального способа добавить вручную микротаск. Это расстраивает Леонида Аркадьевича разработчиков фреймворков. Поэтому появилось предложение ввести queueMicrotask

Promise, Web API, Task Queue, Microtask Queue (Job Queue) и Event Loop позволяют нам отложить выполнение некоторых действий, пока нужные задачи не будут выполнены, при этом продолжая выполнять наш код строчка за строчкой

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

Итераторы

У нас часто есть какие-то данные (например, списки), в которых мы хотим сделать что-то с каждым из элементов

const numbers = [4, 5, 6]
for (let i = 0; i < numbers.length; i++) {
console.log(numbers[i])
}
Недавно в JavaScript появился новый удобный способ для выполнения этой задачи

Как мы обычно работаем с коллекциями данных:

– Получаем данные
– Делаем что-то с полученными данными

Итераторы позволяют нам не заботиться о том, как именно мы получаем данные, а сконцентрироваться на том, что с этими данными делать

Итераторы каким-то образом запоминают, какой элемент нужно вернуть следующим

В JavaScript можно возвращать функции из других функций

function createNewFunction() {
function add2 (num) {
return num + 2
}
return add2;
}
const newFunction = createNewFunction()
const result = newFunction(3)

А если мы сделаем функцию, которая хранит наши данные, запоминает текущую позицию и умеет возвращать следующий элемент?

function createFunction(array) {
let i = 0
function inner() {
const element = array[i]
i++
return element
}
return inner
}
const returnNextElement = createFunction([4,5,6])

Как нам теперь получить следующий элемент?
Вызвать returnNextElement

function createFunction(array) {
let i = 0
function inner() {
const element = array[i]
i++
return element
}
return inner
}
const returnNextElement = createFunction([4,5,6])
const element1 = returnNextElement()
const element2 = returnNextElement()

Привязка

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

– Когда мы возвращаем inner, окружающие её данные возвращаются вместе с ней. В данном случае под глобальной меткой returnNextElement

– Когда мы вызываем returnNextElement и не находим там данные или текущую позицию, мы обращаемся к этим "привязанным" данным

– Эти "привязанные" данные называются C.O.V.E. или closure (замыкание)

Среда выполнения (env) может захватывать не всё окружение, а только используемые переменные

У returnNextElement есть всё, что нужно, для работы:

– наши данные
– текущая позиция
– возможность вернуть следующий элемент

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

Итераторы в JavaScript – это на самом деле объекты с методом next,
вызывая который, можно получить следующий элемент

function createFlow(array) {
let i = 0
const inner = {
next: function() {
const element = array[i]
i++
return element
}
}
return inner
}
const returnNextElement = createFlow([4,5,6])
const element1 = returnNextElement.next()
const element2 = returnNextElement.next()
И возвращается результат в формате { value: 4 }

Итераторы часто вызываются автоматически. Например:

for (let value of ['a', 'b', 'c']) {
console.log(value)
}
// или
[...'abc']

Итераторы у встроенных типов
можно получить с помощью Symbol.iterator

const str = 'test'
const iterator = str[Symbol.iterator]()
const firstChar = iterator.next()

Теперь, когда мы представляем наши данные как некий "поток", мы можем переосмыслить то, как мы генерируем эти данные

В JavaScript мы теперь можем
использовать функции для генерации "потоков"

function* createFlow() {
yield 4
yield 5
yield 6
}
const returnNextElement = createFlow()
const element1 = returnNextElement.next()
const element2 = returnNextElement.next()

Это позволяет нам не только брать данные из этого "потока",
но и контролировать то, что мы хотим оттуда получить

function* createFlow() {
const num = 10
const newNum = yield num
yield 5 + newNum
yield 6
}
const returnNextElement = createFlow()
const element1 = returnNextElement.next() // 10
const element2 = returnNextElement.next(2) // 7

returnNextElement – это специальный объект (объект генератора),
который при вызове метода next возвращает то,
что встречается вместе с yield (своеобразный аналог return)

function* createFlow() {
const num = 10
const newNum = yield num
yield 5 + newNum
yield 6
}
const returnNextElement = createFlow()
const element1 = returnNextElement.next() // 10
const element2 = returnNextElement.next(2) // 7
Мы можем получить любой элемент один за другим, просто вызывая next

И, что самое главное, мы можем приостановить выполнение этой функции и вернуться к её выполнению потом, вызвав метод next

В асинхронном JavaScript мы хотим:

Запустить какую-то задачу, которая занимает продолжительное время (например, запрос данных с сервера)

Вернуться к выполнению остального кода в синхронном стиле

– Запустить некий функционал тогда, когда данные станут доступны

Что, если мы выйдем из функции через yield, запустив в ней долгую задачу, а потом вернёмся в эту функцию, когда выполнение долгой задачи закончится?

1. function doWhenDataReceived (value) {
2. returnNextElement.next(value)
3. }
4.
5. function* createFlow() {
6. const data = yield fetch('http://twitter.com/olegafx/tweets/1')
7. console.log(data)
8. }
9.
10. const returnNextElement = createFlow()
11. const futureData = returnNextElement.next()
12.
13. futureData.then(doWhenDataReceived)

 

🤯

Async/await упрощает весь этот процесс,
а также позволяет нам не писать кучу коллбэков

async function createFlow() {
console.log("Me first")
const data = await fetch('https://twitter.com/olegafx/tweets/1')
console.log(data)
}
createFlow()
console.log('Me second')
Здесь не нужно запускать функцию после resolve промиса,
выполнение createFlow будет автоматически восстановлено
(но оно всё равно будет использовать микротаски)

Итоги

✅ Прошли увлекательный путь от базовых функций до генераторов
✅ Посмотрели на коллбэки, промисы и замыкания с позиции спецификации
✅ Узнали как связаны call stack и event loop с асинхронным JavaScript
✅ Определили что относится к JavaScript, а что к API браузера
✅ Попытались понять, на самом ли деле асинхронный Javascript является асинхронным
Async JavaScript is a lie
@olegafx