Pisanie zadań asynchronicznych we współczesnym JavaScript
Opublikowany: 2022-03-10JavaScript ma dwie główne cechy jako język programowania, obie ważne dla zrozumienia, jak będzie działał nasz kod. Po pierwsze, jest to synchroniczna natura, co oznacza, że kod będzie wykonywany wiersz po wierszu, prawie tak, jak go czytasz, a po drugie, że jest jednowątkowy , w danym momencie wykonywane jest tylko jedno polecenie.
Wraz z rozwojem języka na scenie pojawiły się nowe artefakty, umożliwiające asynchroniczne wykonanie; programiści próbowali różnych podejść, rozwiązując bardziej skomplikowane algorytmy i przepływy danych, co doprowadziło do pojawienia się wokół nich nowych interfejsów i wzorców.
Wykonanie synchroniczne i wzorzec obserwatora
Jak wspomniano we wstępie, przez większość czasu JavaScript uruchamia kod, który piszesz linia po linii. Już w pierwszych latach język miał wyjątki od tej reguły, choć było ich kilka i możesz je już znać: żądania HTTP, zdarzenia DOM i interwały czasowe.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Jeśli dodamy detektor zdarzeń, na przykład kliknięcie elementu, a użytkownik wyzwoli tę interakcję, silnik JavaScript umieści zadanie w kolejce dla wywołania zwrotnego detektora zdarzeń, ale będzie kontynuował wykonywanie tego, co znajduje się na jego bieżącym stosie. Gdy zakończy się z obecnymi tam połączeniami, uruchomi teraz oddzwonienie słuchacza.
To zachowanie jest podobne do tego, co dzieje się z żądaniami sieciowymi i licznikami czasu, które były pierwszymi artefaktami, które miały dostęp do asynchronicznego wykonywania dla programistów sieci Web.
Chociaż były to wyjątki wspólnego wykonywania synchronicznego w JavaScript, ważne jest, aby zrozumieć, że język jest nadal jednowątkowy i chociaż może kolejkować takty, uruchamiać je asynchronicznie, a następnie wracać do głównego wątku, może wykonać tylko jeden fragment kodu na czas.
Na przykład sprawdźmy żądanie sieciowe.
var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();
Po powrocie serwera zadanie dla metody przypisanej do onreadystatechange
jest umieszczane w kolejce (wykonanie kodu jest kontynuowane w głównym wątku).
Uwaga : Wyjaśnienie, w jaki sposób silniki JavaScript kolejkują zadania i obsługują wątki wykonania, jest złożonym tematem do omówienia i prawdopodobnie zasługuje na osobny artykuł. Mimo to polecam obejrzeć „Czym do cholery jest pętla zdarzeń mimo wszystko?” autorstwa Phillipa Robertsa, aby pomóc Ci lepiej zrozumieć.
W każdym wymienionym przypadku odpowiadamy na zdarzenie zewnętrzne. Osiągnięty określony przedział czasu, działanie użytkownika lub odpowiedź serwera. Nie byliśmy w stanie stworzyć zadania asynchronicznego per se, zawsze obserwowaliśmy zdarzenia dziejące się poza naszym zasięgiem.
Właśnie dlatego kod ukształtowany w ten sposób nazywa się wzorem Observer , który w tym przypadku lepiej reprezentuje interfejs addEventListener
. Wkrótce rozkwitły biblioteki emiterów zdarzeń lub frameworki ujawniające ten wzorzec.
Node.js i emitery zdarzeń
Dobrym przykładem jest Node.js, którego strona opisuje siebie jako „asynchroniczne środowisko uruchomieniowe JavaScript sterowane zdarzeniami”, więc emitery zdarzeń i wywołania zwrotne były pierwszorzędnymi obywatelami. Miał nawet zaimplementowany konstruktor EventEmitter
.
const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');
Było to nie tylko gotowe podejście do wykonywania asynchronicznego, ale także podstawowy wzorzec i konwencja jego ekosystemu. Node.js otworzył nową erę pisania JavaScript w innym środowisku — nawet poza siecią. W konsekwencji możliwe były inne sytuacje asynchroniczne, takie jak tworzenie nowych katalogów lub zapisywanie plików.
const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })
Możesz zauważyć, że wywołania zwrotne otrzymują error
jako pierwszy argument, jeśli oczekiwane są dane odpowiedzi, są one traktowane jako drugi argument. Nazywało się to wzorcem wywołania zwrotnego Error-first , co stało się konwencją, którą autorzy i współtwórcy przyjęli dla swoich własnych pakietów i bibliotek.
Obietnice i niekończący się łańcuch oddzwaniania
Ponieważ tworzenie stron internetowych napotykało na bardziej złożone problemy do rozwiązania, pojawiła się potrzeba lepszych asynchronicznych artefaktów. Jeśli spojrzymy na ostatni fragment kodu, zobaczymy powtarzające się łańcuchy wywołań zwrotnych, które nie skalują się dobrze wraz ze wzrostem liczby zadań.
Na przykład dodajmy jeszcze tylko dwa kroki, odczytywanie plików i wstępne przetwarzanie stylów.
const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })
Możemy zobaczyć, jak program, który piszemy, staje się bardziej złożony, kod staje się trudniejszy do naśladowania dla ludzkiego oka z powodu wielu łańcuchów wywołań zwrotnych i powtarzającej się obsługi błędów.
Obietnice, opakowania i wzory łańcuchów
Promises
nie cieszyły się zbytnią uwagą, kiedy zostały po raz pierwszy ogłoszone jako nowy dodatek do języka JavaScript, nie są nową koncepcją, ponieważ inne języki miały podobne implementacje dziesiątki lat wcześniej. Prawda jest taka, że okazało się, że bardzo zmieniły semantykę i strukturę większości projektów, nad którymi pracowałem od momentu ich pojawienia się.
Promises
nie tylko wprowadziły wbudowane rozwiązanie dla programistów do pisania kodu asynchronicznego, ale także otworzyły nowy etap w tworzeniu stron internetowych, służąc jako baza konstrukcyjna późniejszych nowych funkcji specyfikacji sieciowej, takich jak fetch
.
Migracja metody z podejścia zwrotnego do opartego na obietnicach stała się coraz bardziej powszechna w projektach (takich jak biblioteki i przeglądarki), a nawet Node.js zaczął powoli migrować do nich.
Zawińmy na przykład metodę readFile
:
const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }
Tutaj ukrywamy wywołanie zwrotne, wykonując wewnątrz konstruktora Promise, wywołując resolve
, gdy wynik metody się powiedzie, i reject
, gdy zdefiniowany jest obiekt błędu.
Gdy metoda zwraca obiekt Promise
, możemy śledzić jego pomyślne rozwiązanie, przekazując funkcję do then
, jej argumentem jest wartość, z którą obietnica została rozwiązana, w tym przypadku data
.
Jeśli podczas metody zostanie zgłoszony błąd, zostanie wywołana funkcja catch
, jeśli jest obecna.
Uwaga : Jeśli chcesz lepiej zrozumieć, jak działają Promises, polecam artykuł Jake'a Archibalda „JavaScript Promises: An Introduction”, który napisał na blogu Google poświęconym tworzeniu stron internetowych.
Teraz możemy użyć tych nowych metod i uniknąć łańcuchów wywołań zwrotnych.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Posiadanie natywnego sposobu tworzenia zadań asynchronicznych i przejrzystego interfejsu do śledzenia możliwych wyników umożliwiło branży wyjście z wzorca obserwatora. Te oparte na obietnicach wydawały się rozwiązywać nieczytelny i podatny na błędy kod.
Ponieważ lepsze wyróżnianie składni lub jaśniejsze komunikaty o błędach pomagają podczas kodowania, kod, który jest łatwiejszy do uzasadnienia, staje się bardziej przewidywalny dla programisty, który go czyta, z lepszym obrazem ścieżki wykonania, tym łatwiej wychwycić potencjalną pułapkę.
Przyjęcie Promises
było tak globalne w społeczności, że Node.js szybko wypuszcza wbudowane wersje swoich metod I/O, aby zwracać obiekty Promise, takie jak importowanie ich operacji na plikach z fs.promises
.
Zapewnił nawet narzędzie promisify
do zawijania dowolnej funkcji, która jest zgodna ze wzorcem wywołania zwrotnego pierwszego błędu i przekształcenia go w funkcję opartą na obietnicy.
Ale czy Obietnice pomagają we wszystkich przypadkach?
Ponownie wyobraźmy sobie nasze zadanie wstępnego przetwarzania stylu, które zostało napisane za pomocą Promises.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))
Istnieje wyraźna redukcja nadmiarowości w kodzie, szczególnie w zakresie obsługi błędów, ponieważ teraz polegamy na catch
, ale Promises jakoś nie dostarczyło wyraźnego wcięcia kodu, które bezpośrednio odnosi się do konkatenacji działań.
W rzeczywistości jest to osiągane w pierwszej instrukcji then
po readFile
. To, co dzieje się po tych wierszach, to konieczność stworzenia nowego zakresu, w którym możemy najpierw utworzyć katalog, aby później zapisać wynik w pliku. Powoduje to przerwanie rytmu wcięć, nie ułatwiając na pierwszy rzut oka określenia kolejności instrukcji.
Sposobem na rozwiązanie tego problemu jest wstępne upieczenie niestandardowej metody, która obsługuje to i pozwala na prawidłowe połączenie metody, ale wprowadzilibyśmy jeszcze jedną głębię złożoności do kodu, który wydaje się już mieć to, czego potrzebuje do wykonania zadania chcemy.
Uwaga : Weź pod uwagę, że jest to przykładowy program, a my kontrolujemy niektóre metody i wszystkie są zgodne z konwencją branżową, ale nie zawsze tak jest. Przy bardziej złożonych konkatenacjach lub wprowadzeniu biblioteki o innym kształcie, nasz styl kodu może się łatwo zepsuć.
Na szczęście społeczność JavaScript nauczyła się ponownie od składni innych języków i dodała notację, która bardzo pomaga w tych przypadkach, w których konkatenacja zadań asynchronicznych nie jest tak przyjemna ani łatwa do odczytania jak kod synchroniczny.
Asynchronizuj i czekaj
Promise
jest zdefiniowana jako nierozwiązana wartość w czasie wykonywania, a utworzenie instancji Promise
jest wyraźnym wywołaniem tego artefaktu.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))
Wewnątrz metody asynchronicznej możemy użyć zarezerwowanego słowa await
, aby określić rozwiązanie Promise
przed kontynuowaniem jej realizacji.
Wróćmy do tego fragmentu kodu, używając tej składni.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()
Uwaga : Zwróć uwagę, że musieliśmy przenieść cały nasz kod do metody, ponieważ nie możemy dziś użyć await
poza zakresem funkcji asynchronicznej.
Za każdym razem, gdy metoda asynchroniczna znajdzie instrukcję await
, przestanie wykonywać do momentu rozwiązania wartości lub obietnicy postępowania.
Istnieje wyraźna konsekwencja używania notacji async/await, pomimo wykonywania asynchronicznego kodu, który wygląda tak, jakby był synchroniczny , do czego my, programiści, jesteśmy bardziej przyzwyczajeni.
A co z obsługą błędów? Do tego używamy stwierdzeń, które od dawna są obecne w języku, try
i catch
.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()
Zapewniamy, że każdy błąd zgłoszony w procesie zostanie obsłużony przez kod wewnątrz instrukcji catch
. Mamy skoncentrowane miejsce, które zajmuje się obsługą błędów, ale teraz mamy kod, który jest łatwiejszy do odczytania i naśladowania.
Posiadanie kolejnych akcji, które zwróciły wartość, nie musi być przechowywane w zmiennych, takich jak mkdir
, które nie zakłócają rytmu kodu; nie ma również potrzeby tworzenia nowego zakresu, aby uzyskać dostęp do wartości result
w późniejszym kroku.
Można śmiało powiedzieć, że Promises były podstawowym artefaktem wprowadzonym do języka, niezbędnym do włączenia notacji async/await w JavaScript, której można używać zarówno w nowoczesnych przeglądarkach, jak i w najnowszych wersjach Node.js.
Uwaga : Ostatnio w JSConf Ryan Dahl, twórca i pierwszy współtwórca Node, żałował, że nie trzymał się Promises we wczesnym rozwoju, głównie dlatego, że celem Node było stworzenie serwerów sterowanych zdarzeniami i zarządzanie plikami, do których lepiej służył wzorzec Observer.
Wniosek
Wprowadzenie Promises do świata tworzenia stron internetowych zmieniło sposób, w jaki kolejkujemy akcje w naszym kodzie i zmieniło sposób, w jaki myślimy o wykonywaniu naszego kodu oraz o tym, jak tworzymy biblioteki i pakiety.
Ale odejście od łańcuchów wywołań zwrotnych jest trudniejsze do rozwiązania, myślę, że konieczność przekazania metody then
pomogła nam odejść od toku myślenia po latach przyzwyczajenia się do wzorca obserwatora i podejść przyjętych przez głównych dostawców w społeczności, takiej jak Node.js.
Jak mówi Nolan Lawson w swoim znakomitym artykule o niewłaściwych zastosowaniach w konkatenacjach Promise, stare nawyki oddzwaniania ciężko umierają ! Później wyjaśnia, jak uniknąć niektórych z tych pułapek.
Wierzę, że Promises były potrzebne jako środkowy krok, aby umożliwić naturalny sposób generowania zadań asynchronicznych, ale nie pomogły nam zbytnio w przejściu do lepszych wzorców kodu, czasami faktycznie potrzebna jest bardziej elastyczna i ulepszona składnia języka.
Gdy próbujemy rozwiązywać bardziej złożone łamigłówki za pomocą JavaScript, widzimy potrzebę stworzenia bardziej dojrzałego języka i eksperymentujemy z architekturami i wzorcami, do których wcześniej nie byliśmy przyzwyczajeni w sieci.
“
Nadal nie wiemy, jak specyfikacja ECMAScript będzie wyglądać za lata, ponieważ zawsze rozszerzamy zarządzanie JavaScript poza sieć i próbujemy rozwiązywać bardziej skomplikowane zagadki.
Trudno teraz powiedzieć, czego dokładnie będziemy potrzebować od języka, aby niektóre z tych łamigłówek przekształciły się w prostsze programy, ale jestem zadowolony z tego, jak sieć i sam JavaScript poruszają rzeczy, próbując dostosować się do wyzwań i nowych środowisk. Teraz czuję, że JavaScript jest bardziej asynchronicznym, przyjaznym miejscem niż wtedy, gdy ponad dekadę temu zacząłem pisać kod w przeglądarce.
Dalsza lektura
- „Obietnice w języku JavaScript: wprowadzenie”, Jake Archibald
- „Promise Anti-Patterns”, dokumentacja biblioteki Bluebird
- „Mamy problem z obietnicami” — Nolan Lawson