Цикъл на събития, обратно извикване, опашка за съобщения, опашка за задания, обещания, асинхронно/изчакване

JavaScript — цикъл на събития, обратно извикване

Някои основни термини в JavaScript

Цикълът на събитията

Цикълът на събитията е един от най-важните аспекти за разбиране на JavaScript.

JavaScript кодът се изпълнява в една нишка. Има само едно нещо, което се случва в даден момент. Това помага на вашата програма да не се тревожи за проблеми с паралелността. Просто трябва да обърнете внимание как да напишете своя код и да избягвате всичко, което може да блокира нишката, като синхронни мрежови повиквания или безкрайни цикли.

По принцип в повечето браузъри има цикъл на събитие за всеки раздел на браузъра, за да се изолира всеки процес и да се избегне уеб страница с безкрайни цикли или тежка обработка, която да блокира целия ви браузър. Средата управлява множество едновременни цикли на събития, за да обработва например извиквания на API. „Уеб работниците“ също работят в свой собствен цикъл на събития.

Web Workers са просто средство за изпълнение на скриптове във фонови нишки за уеб съдържание. Работната нишка може да изпълнява задачи, без да се намесва в потребителския интерфейс. В допълнение, те могат да извършват I/O, използвайки XMLHttpRequest (въпреки че атрибутите responseXML и channel винаги са нулеви). Веднъж създаден, работникът може да изпраща съобщения до JavaScript кода, който го е създал, като публикува съобщения до манипулатор на събития, указан от този код (и обратно).— MDN

1. Блокиране на цикъла на събитията

Ако кодът на JavaScript отнеме твърде много време, за да върне обратно управлението на цикъла на събитията, той ще блокира изпълнението на всеки друг JavaScript код в страницата, дори ще блокира нишката на потребителския интерфейс, последствието е, че потребителят не може да щрака наоколо, да превърта страницата и т.н. На. Почти I/O примитивите в JS са неблокиращи. Мрежови заявки, операции на файловата система Node.js и т.н. Блокирането е изключение и това е причината JS да се основава толкова много на обратни извиквания, а наскоро и на обещания и async/await.

2. Стекът на повикванията

call stack е LIFO опашка (последен влязъл, първи излязъл). event loop непрекъснато проверява call stack, за да види дали има някаква функция, която трябва да се изпълни. Докато прави това, той добавя всяко извикване на функция, което намери, към стека за повиквания и изпълнява всяко по ред. Проследяването на стека за грешка, което виждате в програмата за отстраняване на грешки или в конзолата на браузъра, се разглежда в стека на повикванията.

3. Просто обяснение на цикъла на събитията

const bar = () => console.log('bar')const baz = () => console.log('baz')const foo = () => {
  console.log('foo')
  bar()
  baz()
}foo()

изход

foo
bar
baz

В този момент call stack изглежда така:

event loop на всяка итерация проверява дали има нещо в стека за повиквания и го изпълнява, докато стекът за повиквания се изпразни.

4. Изпълнение на функцията за опашка

Нека видим как да отложим функция, докато стекът се изчисти. Можете да използвате setTimeout(() => {}, 0) за извикване на функция но да я изпълните след като всяка друга функция в кода е изпълнена.

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}
foo()

изход

foo
baz
bar

call stack изглежда така

итерациите на цикъла на събитията

Защо се случва това?

5. Опашката за съобщения

Когато се извика setTimeout(), браузърът или Node.js стартират таймера. След като таймерът изтече, в този случай веднага след като поставим 0 като таймаут, функцията за обратно извикване се поставя в Message Queue.

Message Queue също е мястото, където инициираните от потребителя събития като щракване или събития от клавиатурата или извличане на отговори се поставят на опашка, преди вашият код да има възможност да реагира на тях. Или също DOM събития като onLoad.

Цикълът дава приоритет на стека за повиквания и първо обработва всичко, което намери в стека за повиквания, и след като там няма нищо, отива да вземе нещата в опашката за събития.

Не е нужно да чакаме функции като setTimeout(), fetch или други неща да си свършат работата, защото те се предоставят от браузъра и живеят в собствени нишки. Например, ако зададете времето за изчакване на setTimeout на 2 секунди, не е нужно да чакате 2 секунди - изчакването се случва другаде.

6. Опашка за задания на ES6

ECMAScript 2015 (ES6) въведе концепцията за опашка за задания, която се използва от Promises (също въведена в ES6). Това е начин да изпълните резултата от асинхронна функция ВЪЗМОЖНО ПО-СКОРО, ВМЕСТО да бъдете поставени в края на стека за извикване.

Обещанията, които са разрешени преди края на текущата функция, ще бъдат изпълнени веднага след текущата функция.

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}
foo()

Това се отпечатва

foo
baz
should be right after baz, before bar
bar

Това е голяма разлика между Promises (и Async/Await, който е изграден върху Promises) и обикновените стари асинхронни функции чрез setTimeout() или други платформени API. Например:
- резултатите от метода setTimeout() се поставят в опашката за съобщения и се изпълняват след изчистване на стека на повикванията.
- резултатите от метода Promises/Async се поставят в опашката за задания и се изпълняват веднага след текущата изпълняваща се функция на стека за повиквания, не е необходимо да чакате да изпразните стека за повиквания.

Асинхронно програмиране и обратни извиквания

По подразбиране JavaScript е синхронен и е еднонишков. Това означава, че кодът не може да създава нови нишки и да работи паралелно. Нека научим какво означава асинхронен код и как изглежда.

1. Асинхронност в езиците за програмиране

Програмите вътрешно използват прекъсвания, сигнал, който се излъчва към процесора, за да привлече вниманието на системата. Обикновено програмата е асинхронна, тя спира изпълнението им, докато не се нуждаят от внимание, а компютърът може да изпълнява други неща междувременно. Когато дадена програма чака отговор от мрежата, тя не може да спре процесора, докато заявката не приключи.

C, Java, C#, PHP, Go, Ruby, Swift, Python всички те са синхронни по подразбиране. Някои от тях обработват async чрез използване на нишки, създавайки нов процес.

2. JavaScript

JavaScript е синхронен по подразбиране и е еднопоточен. Но JavaScript е роден вътре в браузъра, основната му работа в началото беше да отговаря на действията на потребителя, като onClick, onMouseOver, onChange, onSubmit и т.н. Как може да направи това с модел на синхронно програмиране?

Отговорът беше в неговата среда. Браузърът предоставя начин да го направите, като предоставя набор от API, които могат да се справят с този вид функционалност.

Съвсем наскоро Node.js въведе неблокираща I/O среда, за да разшири тази концепция за достъп до файлове, мрежови повиквания и т.н.

3. Обратни повиквания

Не можете да знаете кога потребителят ще щракне върху бутон, така че това, което правите, е да дефинирате манипулатор на събитие за събитието с щракване. Този манипулатор на събития приема функция, която ще бъде извикана, когато събитието бъде задействано.

document.getElementById('button').addEventListener('click', () => {
  // item clicked
})

Това е така нареченото обратно извикване.

Обратното извикване е проста функция, която се предава като стойност на друга функция и ще бъде изпълнена само когато се случи събитието. Можем да направим това, защото JavaScript има функции от първи клас, които могат да бъдат присвоени на променливи и предадени на други функции (наречени функции от по-висок ред).

Обичайно е да обвиете целия си клиентски код в слушател на събития за зареждане на обекта прозорец, който изпълнява функцията за обратно извикване само когато страницата е готова.

window.addEventListener('load', () => {
  // Window loaded
  // do what you want
})

Обратните повиквания се използват навсякъде, не само в DOM събития.

Един често срещан пример е чрез използване на времена:

setTimeout(() => {
  // run after 2 seconds
})

XHR заявките също приемат обратно повикване. В този пример чрез присвояване на функция на свойство, което ще бъде извикано при настъпване на определено събитие (в този случай състоянието на заявката се променя).

const xhr = new XMLHttpRequest() 
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) : console.error('error')
  }
}
xhr.open('GET', 'https://yoursite.com')
xhr.send()

4. Обработка на грешки при обратни извиквания

Как се справяте с грешки с обратно извикване? Една много често срещана стратегия е да се използва това, което Node.js възприе: първият параметър във всяка функция за обратно извикване е обектът за грешка: error-first обратни извиквания.

Ако няма грешка, обектът е нула. Ако има грешка, тя съдържа описание на грешката и друга информация.

fs.readFile('/file.json', (err, data) => {
  if (err !== null) {
    // handle error
    console.log(err)
    return
  }
// no errors, process data
  console.log(data)
})

5. Проблемът с обратните повиквания

Обратните повиквания са чудесни за прости случаи!

Всяко обратно извикване обаче добавя ниво на влагане и когато имате много обратни извиквания, кодът започва да се усложнява много бързо

window.addEventListener('load', () => {
  document.getElementById('button'.addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        // your code here
      })
    }, 2000)
  })
})

Това е просто прост код от 4 нива, но съм виждал много повече нива на влагане и не е забавно.

Как да разрешим това?

6. Алтернативи на обратните повиквания

Започвайки с ES6, JavaScript въведе няколко функции, които ни помагат с асинхронен код, който не включва използване на обратни извиквания:

  • Обещания (ES6)
  • Async/Await (ES8)

6.1 Обещания

Обещанията са един от начините да се справите с асинхронния код в JavaScript, без да пишете твърде много обратни извиквания във вашия код.

Обещанието обикновено се дефинира като прокси за стойност, която в крайна сметка ще стане достъпна.

Въпреки че съществуват от години, те са стандартизирани и въведени в ES6, а сега са заменени в ES8 от функцията Async.

Асинхронните функции използват API за обещания като техен градивен елемент, така че разбирането им е фундаментално, дори ако в по-нов код вероятно ще използвате асинхронни функции вместо обещания.

  • Как работят Promises, накратко

След като дадено обещание бъде извикано, то ще започне в чакащо състояние. Това означава, че функцията за повикване продължава изпълнението, докато чака обещанието да извърши собствена обработка и да даде обратна връзка на функцията за повикване.

В този момент извикващата функция изчаква или да върне обещанието в разрешено състояние, или в отхвърлено състояние, но функцията продължава своето изпълнение, докато обещанието го прави това е работа.

  • Кои JS API използват обещания?

В допълнение към вашия собствен код и код на библиотеки, обещанията се използват от стандартни съвременни уеб API, като например: Battery API, Fetch API, Service Workers.

Малко вероятно е в съвременния JavaScript да откриете, че не използвате обещания, така че нека започнем да ги навлизаме направо

  • Създаване на обещание

API на Promise разкрива конструктор на Promise, който инициализирате с помощта на new Promise()

let done = true
const isItDoneYet = new Promise(
  (resolve, reject) => {
    if (done) { 
      const workDone = 'Here is the thing I built'
      resolve(workDone)
    } else {
      const why = 'Still working on something else'
      reject(why)
    }
  }
)

Както можете да видите обещанието проверява изпълнената глобална константа и ако това е вярно, връщаме разрешено обещание, в противен случай отхвърлено обещание, в противен случай отхвърлено обещание.

Използвайки resolve и reject, можем да комуникираме обратно стойност, а в горния случай просто връщаме низ, но може да бъде и обект.

  • Използване на обещание

В последния раздел представихме как се създава обещание.

Сега нека видим как обещанието може да бъде консумирано или използвано.

const isItDoneYet = new Promise(
  // ...
)
const checkIfItsDone = () => {
  isItDoneYet
    .then((ok) => {
      console.log(ok)
    })
    .catch((err) => {
      console.error(err)
    })
  }

Изпълнението на checkIfItsDone() ще изпълни обещанието isItDoneYet() и ще изчака то да се разреши, като използва обратното извикване then, и ако има грешка, ще я обработи в обратното извикване на catch.

  • Верижни обещания

Едно обещание може да бъде върнато към друго обещание, създавайки верига от обещания.

Чудесен пример за верижни обещания е даден от Fetch API, слой върху XMLHttpRequest API, който можем да използваме, за да получим ресурс и да поставим на опашка верига от обещания, които да бъдат изпълнени, когато ресурсът бъде извлечен.

Fetch API е механизъм, базиран на обещание, и извикването на fetch() е еквивалентно на дефиниране на нашето собствено обещание с помощта на new Promise().

  • Пример за верижни обещания
const status = (response) => {
  if (response.status >= 200 && response.status < 300) { 
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}
const json = (response) => response.json()
fetch('/todos.json')
  .then(status)
  .then(json)
  .then((data) => { console.log('Request succeeded with JSON response', data) })
  .catch((error) => { console.log('Request failed', error) })

В този пример извикваме fetch(), за да получим списък със TODO елементи от файла todos.json, намиращ се в корена на домейна, и създаваме верига от обещания.

Изпълнението на fetch() връща отговор, който има много свойства и в тези, които препращаме:

~ статус: цифрова стойност, представляваща HTTP кода на състоянието
~ statusText: съобщение за статус, което е ОК, ако заявката е успешна.

response също има метод json(), който връща обещание, което ще се разреши със съдържанието на тялото, обработено и трансформирано в JSON.

И така, предвид тези предпоставки, ето какво се случва: първото обещание във веригата е функция, която дефинирахме, наречена status(), която проверява състоянието на отговора и ако не е успешен отговор (между 200 и 299), отхвърля обещание.

Тази операция (отхвърляне) ще накара веригата обещания да пропусне всички изброени верижни обещания и ще прескочи директно до оператора catch() в долната част, записвайки заедно текста Request failed със съобщението за грешка.

Ако вместо това успее, извиква функцията json(), която сме дефинирали. Тъй като предишното обещание, когато е успешно, връща обекта за отговор, ние го получаваме като вход към второто обещание.

В този случай ние връщаме обработените JSON данни, така че третото обещание получава JSON директно:

.then((data) => {
  console.log('Request succeeded with JSON response', data)
})

и ние просто го регистрираме в конзолата.

  • Грешки при обработка

В примера, в предишния раздел, имахме уловка, която беше добавена към веригата от обещания.

Когато нещо във веригата от обещания се провали и предизвика грешка или отхвърли обещанието, контролата отива към най-близкия оператор catch() надолу по веригата.

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch((err) => { console.error(err) })
// or
new Promise((resolve, reject) => {
  reject('Error')
})
  .catch((err) => { console.error(err) })
  • Каскадни грешки

Ако вътре в catch() повдигнете грешка, можете да добавите втори catch(), за да я обработите и т.н.

new Promise((resolve, reject) => {
  throw new Error('Error 1')
})
  .catch((err) => { throw new Error('Error 2') })
  .catch((err) => { console.error(err) })
  • Оркестриране на обещания

Promise.all() и Promise.race()

Ако трябва да синхронизирате различни обещания, Promise.all() ви помага да дефинирате списък с обещания и да изпълните нещо, когато всички са разрешени.

Например:

const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')
Promise.all([f1, f2]).then((res) => {
  console.log('Array of results', res)
})
  .catch((err) => {
    console.error(err)
  })

Синтаксисът за деструктуриране на присвояването на ES6 ви позволява също да го направите

Promise.all([f1, f2]).then(([res1, res2]) => {
  console.log('Results', res1, res2)
})

Разбира се, не сте ограничени до използването на fetch, всяко обещание е готово.

Promise.race() се изпълнява, когато първото от обещанията, които му подадете, се разреши и изпълнява прикаченото обратно извикване само веднъж, като резултатът от първото обещание е разрешен.

Например:

const first = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'first')
})
const second = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'second')
})
Promise.race([first, second]).then((result) => {
  console.log(result) // second
})

6.2 Async и Await

Асинхронните функции са комбинация от обещания и генератори и по същество те са абстракция от по-високо ниво спрямо обещанията. Отново Async/Await е изграден върху Promises

  • Защо бяха въведени Async/Await?

Те намаляват шаблона около обещанията и ограничението „не прекъсвайте веригата“ на верижните обещания.

Когато Promises бяха въведени в EF6, те трябваше да решат проблем с асинхронен код и го направиха, но в продължение на 2 години от EF20015(ES6) до ES2017(ES8) беше ясно, че Promises не може да бъде окончателният решението.

Бяха въведени обещания за решаване на известния проблем с обратно извикване, но те сами въведоха сложност и сложност на синтаксиса.

Те бяха добри примитиви, около които можеше да бъде изложен по-добър синтаксис на разработчиците, така че когато дойде подходящият момент, получихме Async функции.

Те карат кода да изглежда като синхронен, но е асинхронен и неблокиращ зад кулисите.

  • Как работи

Асинхронна функция връща обещание, както в този пример:

const doSomethingAsync = () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve('I did something'), 3000
  )}
)}

Когато искате да извикате тази функция, добавяте пред await и извикващият код ще спре, докато обещанието не бъде разрешено или отхвърлено. Едно предупреждение: клиентската функция трябва да бъде дефинирана като async. Ето един пример:

const doSomething = async () => {
  console.log(await doSomethingAsync())
}
  • Бърз пример
const doSomethingAsync = () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve('I did something'), 3000)
  })
}
const doSomething = async () => {
  console.log(await doSomethingAsync())
}
console.log('Before')
doSomething()
console.log('After')

  • Обещай всичко

Добавянето на ключовата дума async към която и да е функция означава, че функцията ще върне обещание. Дори и да не го прави изрично, вътрешно ще го накара да върне обещание. Ето защо този код е валиден:

const foo = async () = {
  return 'test'
}
foo().then(alert) // this will alert 'test'

и е същото като:

const baz = async () = {
  return Promise.resolve('test')
}
baz().then(alert) // this will alert 'test'
  • Кодът е много по-лесен за четене
const getFirstUserData = () => {
  return fetch('/users.json') // get users list
    .then(response => response.json()) // parse JSON
    .then(users => users[0]) // pick first user
    .then(user => fetch(`/users/${user.name}`)) // get user data
    .then(userResponse => response.json()) // parse JSON
}
getFirstUserData()

и използване на функция Async

const getFirstUserData = async () => {
  const response = await fetch('/users.json') // get users list
  const users = await response.json() // parse JSON
  const user = users[0] // pick first user
  const userResponse = await fetch(`/users/${user.name}`) // get user data
  const userData = await user.json() // parse JSON
  return userData
}
getFirstUserData()
  • Множество асинхронни функции в серия

Асинхронните функции могат да бъдат верижни много лесно и синтаксисът е много по-четлив, отколкото при обикновените обещания

const foo = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000)
  })
}
const watchLevelOne = async () => {
  const baz = await foo()
  return baz + ' and Level One'
}
const watchLevelTwo = async () => {
  const bar = await watchLevelOne()
  return bar + ' and Level Two'
}
watchLevelTwo().then((res) => {
  console.log(res)
})

  • По-лесно отстраняване на грешки

Обещанията за отстраняване на грешки са трудни, защото дебъгерът няма да прекрачи асинхронния код.

Async/Await прави това много лесно, защото компилаторът е точно като синхронен код.

Заключение

Досега бях накратко за The Event Loop с някои членове: Call Stack, Queuing (Message Queue за стар тип асинхронна функция: setTimeout, Job Queue за нов тип асинхронна функция: Promise/Async). Опашката със съобщения се проверява, след като стекът за повиквания е празен, опашката за задания се проверява веднага след завършване на текущата изпълнявана функция (в стека за повиквания). И втората тема за функцията за обратно извикване с традиционен стил (setTimeout), и Promises стил, и накрая е Async/Await стил.

Препратки





http://latentflip.com/loupe