Debugowanie wyjątków w aplikacjach internetowych wydaje się proste: gdy coś pójdzie nie tak, wstrzymaj wykonywanie i zbadaj problem. Jednak asynchroniczny charakter JavaScriptu sprawia, że jest to zaskakująco skomplikowane. Jak Narzędzie programistyczne Chrome może wiedzieć, kiedy i gdzie wstrzymać działanie, gdy wyjątki przelatują przez obietnice i funkcje asynchroniczne?
W tym wpisie omawiamy wyzwania związane z prognozowaniem wyjątków – możliwością przewidywania przez DevTools, czy wyjątek zostanie przechwycony w późniejszych fragmentach kodu. Wyjaśnimy, dlaczego jest to tak trudne i jak ostatnie ulepszenia w V8 (mechanizm JavaScriptu używany w Chrome) zwiększają dokładność i ułatwiają debugowanie.
Dlaczego przewidywanie złapań ma znaczenie
W Narzędziach deweloperskich Chrome możesz wstrzymać wykonywanie kodu tylko w przypadku nieprzechwyconych wyjątków, pomijając te przechwycone.
Aby zachować kontekst, debuger zatrzymuje się natychmiast po wystąpieniu wyjątku. Jest to przewidywanie, ponieważ obecnie nie można z pewnością stwierdzić, czy wyjątek zostanie przechwycony w późniejszej części kodu, zwłaszcza w sytuacjach asynchronicznych. Ta niepewność wynika z niezbędnej trudności w przewidywaniu zachowania programu, podobnej do problemu zatrzymania.
Rozważmy ten przykład: gdzie powinien się zatrzymać debuger? (odpowiedź znajdziesz w następnej sekcji).
async function inner() {
throw new Error(); // Should the debugger pause here?
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ... or should the debugger pause here?
}
}
Wstrzymywanie wyjątków w debugerze może być uciążliwe i prowadzić do częstych przerw oraz przeskakiwania do nieznanego kodu. Aby temu zapobiec, możesz debugować tylko nieprzechwycone wyjątki, które są bardziej prawdopodobnym sygnałem rzeczywistych błędów. Zależne jest to jednak od dokładności prognozy dotyczącej złowionych ryb.
Nieprawidłowe prognozy powodują frustrację:
- Wyniki fałszywie negatywne (prognozowanie „niezłapanego”, gdy zostanie złapany). niepotrzebne zatrzymania w debugerze,
- Wyniki fałszywie pozytywne (przewidywanie „złapania”, gdy nie dojdzie do złapania). Brak możliwości wykrycia błędów krytycznych, co może zmusić Cię do debugowania wszystkich wyjątków, w tym tych oczekiwanych.
Inną metodą na ograniczenie przerw w debugowaniu jest użycie listy ignorowania, która zapobiega przerwom w wyjątkach w określonym kodzie zewnętrznym. W tym przypadku nadal kluczowe znaczenie ma jednak trafne przewidywanie złowionych ryb. Jeśli wyjątek pochodzący z kodu innej firmy ucieknie i wpłynie na Twój kod, zechcesz go debugować.
Jak działa kod asynchroniczny
Obietnice, async
i await
oraz inne wzorce asynchroniczne mogą prowadzić do sytuacji, w której wyjątek lub odrzucenie może przed obsłużeniem przejść ścieżkę wykonania, którą trudno jest określić w momencie zgłaszania wyjątku. Dzieje się tak, ponieważ obietnice mogą być oczekujące lub mieć dodane uchwyty wyjątków dopiero po wystąpieniu wyjątku. Przyjrzyjmy się naszemu poprzedniemu przykładowi:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
W tym przykładzie funkcja outer()
najpierw wywołuje funkcję inner()
, która natychmiast zgłasza wyjątek. Na tej podstawie debuger może stwierdzić, że inner()
zwróci odrzuconą obietnicę, ale obecnie nic nie oczekuje ani nie obsługuje tej obietnicy. Debuger może przypuszczać, że outer()
będzie na to czekać i że zrobi to w bieżącym bloku try
, ale nie może być tego pewien, dopóki nie zwróci odrzuconego obietnienia i nie dojdzie do instrukcji await
.
Debuger nie może zagwarantować, że prognozy będą dokładne, ale używa różnych heurystycznych metod w przypadku typowych wzorców kodowania, aby prognozy były prawidłowe. Aby zrozumieć te wzorce, warto dowiedzieć się, jak działają obietnice.
W V8 obiekt JavaScript Promise
jest reprezentowany jako obiekt, który może mieć jeden z 3 stanów: spełniony, odrzucony lub oczekujący. Jeśli obietnica jest w stanie spełnionej i wywołujesz metodę .then()
, tworzona jest nowa obietnica oczekująca na spełnienie i planowane jest nowe zadanie reakcji na obietnicę, które uruchomi moduł obsługi, a następnie ustawi obietnicę jako spełnioną z wynikiem modułu obsługi lub jako odrzuconą, jeśli moduł obsługi wyrzuci wyjątek. To samo dzieje się, gdy wywołujesz metodę .catch()
w przypadku odrzuconego obietnienia. Wręcz przeciwnie, wywołanie .then()
w przypadku odrzuconej obietnicy lub .catch()
w przypadku obietnicy spełnionej zwróci obietnicę w tym samym stanie i nie uruchomi metody obsługi.
Obietnica oczekująca na spełnienie zawiera listę reakcji, w której każdy obiekt reakcji zawiera element obsługi realizacji lub element obsługi odrzucenia (lub oba) oraz obietnicę reakcji. Wywołanie .then()
w przypadku obietnicy oczekującej na spełnienie spowoduje dodanie reakcji z obsługą spełnioną oraz nowej obietnicy oczekującej na spełnienie dla obietnicy reakcji, którą zwróci .then()
. Wywołanie .catch()
spowoduje dodanie podobnej reakcji, ale z obsługą odrzucenia. Wywołanie funkcji .then()
z 2 argumentami powoduje reakcję z użyciem obu obsługiwanych funkcji. Wywołanie funkcji .finally()
lub oczekiwanie na obietnicę spowoduje reakcję z użyciem 2 obsługiwanych funkcji, które są wbudowanymi funkcjami specyficznymi dla implementacji tych funkcji.
Gdy obietnica oczekująca na spełnienie zostanie spełniona lub odrzucona, zadania reakcji zostaną zaplanowane dla wszystkich obsługiwanych spełnionych obietnic lub wszystkich obsługiwanych odrzuconych obietnic. Zaktualizujemy odpowiednie obietnice reakcji, co może spowodować uruchomienie własnych zadań reakcji.
Przykłady
Rozważ ten kod:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Może nie być oczywiste, że ten kod zawiera 3 różne obiekty Promise
. Powyższy kod jest równoważny temu:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
W tym przykładzie występują te kroki:
- Wywołuje się konstruktor
Promise
. - Utworzono nową oczekującą
Promise
. - Funkcja anonimowa jest wykonywana.
- Wyjątek jest zgłaszany. W tym momencie debuger musi zdecydować, czy się zatrzymać.
- Konstruktor obietnicy przechwytuje to wyjątek, a następnie zmienia stan obietnicy na
rejected
, przypisując mu wartość błędu, który został wygenerowany. Zwraca obietnicę, która jest przechowywana wpromise1
. .then()
nie planuje zadania reakcji, ponieważpromise1
jest w stanierejected
. Zamiast tego zwracana jest nowa obietnica (promise2
), która również ma stan odrzucona z tym samym błędem..catch()
planuje zadanie reakcji z podanym modułem obsługi i nową oczekującą obietnicą reakcji, która jest zwracana jakopromise3
. W tym momencie debuger wie, że błąd zostanie obsłużony.- Gdy zadanie reakcji zostanie wykonane, uchwytnik zwraca normalnie i stan
promise3
zmienia się nafulfilled
.
Następujący przykład ma podobną strukturę, ale jego wykonanie jest zupełnie inne:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Jest to równoważne:
const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;
W tym przykładzie występują te kroki:
- Element
Promise
zostanie utworzony w staniefulfilled
i zapisany wpromise1
. - Zaplanowano zadanie reakcji obietnicy z pierwszą funkcją anonimową, a obietnica reakcji
(pending)
jest zwracana jakopromise2
. - Do
promise2
dodano reakcję z metodą obsługi i obietnicą reakcji, która jest zwracana jakopromise3
. - Do
promise3
dodana jest reakcja z odrzuconym modułem obsługi i inną obietnicą reakcji, która jest zwracana jakopromise4
. - Zadanie reakcji zaplanowane w kroku 2 jest wykonywane.
- Obsługa zwraca wyjątek. W tym momencie debuger musi zdecydować, czy się zatrzymać. Obecnie obciążnik jest jedynym uruchamianym kodem JavaScript.
- Ponieważ zadanie kończy się wyjątkiem, powiązana obietnica reakcji (
promise2
) jest ustawiana w stanie odrzucenia z wartością błędu, który został wygenerowany. - Ponieważ
promise2
miała jedną reakcję, a ta reakcja nie miała odrzuconego modułu obsługi, jej obietnica reakcji (promise3
) jest też ustawiona narejected
z tym samym błędem. - Ponieważ
promise3
miało jedną reakcję, a ta reakcja miała odrzucony moduł obsługi, zaplanowano zadanie reakcji obietnicy z tym modułem obsługi i obietnicą reakcji (promise4
). - Gdy to zadanie reakcji zostanie wykonane, obsługa zwraca normalnie i stan
promise4
zmienia się na „spełniony”.
Metody prognozowania złowionych ryb
Prognozy dotyczące połowów można tworzyć na podstawie 2 źródeł informacji. Jednym z nich jest stos wywołań. Jest to przydatne w przypadku wyjątków synchronicznych: debuger może przejść przez stos wywołań w taki sam sposób jak kod odwijania wyjątku i zatrzyma się, jeśli znajdzie ramkę, w której znajduje się blok try...catch
. W przypadku odrzuconych obietnic lub wyjątków w konstruktorach obietnic albo w funkcjach asynchronicznych, które nigdy nie zostały zawieszone, debugger korzysta też ze stosu wywołań, ale w tym przypadku jego przewidywania nie zawsze są wiarygodne. Dzieje się tak, ponieważ zamiast rzucać wyjątkiem do najbliższego modułu obsługi kod asynchroniczny zwraca odrzucony wyjątek, a debuger musi założyć, co wywołujący zrobi z tym wyjątkiem.
Po pierwsze, debuger zakłada, że funkcja, która otrzymuje zwróconą obietnicę, zwróci tę obietnicę lub pochodną obietnicę, aby funkcje asynchroniczne znajdujące się wyżej w steku miały możliwość jej oczekiwania. Po drugie, debuger zakłada, że jeśli funkcja asynchroniczna zwróci obietnicę, to wkrótce ją odczeka, nie wchodząc najpierw do bloku try...catch
ani z niego nie wychodząc. Żadne z tych założeń nie musi być prawidłowe, ale wystarczają do tworzenia prawidłowych prognoz w przypadku najpopularniejszych wzorców kodowania z funkcjami asynchronicznymi. W wersji Chrome 125 dodaliśmy kolejną heurystyczną metodę: debuger sprawdza, czy wywoływana funkcja ma zamiar wywołać .catch()
na wartości, która zostanie zwrócona (lub .then()
z 2 argumentami albo łańcuch wywołań funkcji .then()
lub .finally()
, po którym następuje wywołanie funkcji .catch()
z 2 argumentami lub .then()
z 2 argumentami). W tym przypadku debuger zakłada, że są to metody obietnicy, którą śledzimy, lub metody powiązane z tą obietnicą, więc odrzucenie zostanie wychwycone.
Drugim źródłem informacji jest drzewo reakcji na obietnicę. Debuger zaczyna się od obietnicy głównej. Czasami jest to obietnica, której właśnie wywołano metodę reject()
. Częściej, gdy podczas wykonywania zadania związanego z reakcją na obietnicę wystąpi wyjątek lub odrzucenie, a nic w zbiorze wywołań nie wydaje się go przechwytywać, debuger tworzy ścieżki od obietnicy powiązanej z reakcją. Debuger sprawdza wszystkie reakcje na oczekujące obietnice i sprawdza, czy mają one procedury obsługi odrzucenia. Jeśli nie, sprawdza obietnicę reakcji i rekursywnie ją śledzi. Jeśli wszystkie reakcje prowadzą ostatecznie do odbiornika odrzucenia, debuger uzna odrzucenie obietnicy za złapanie. Istnieją pewne szczególne przypadki, które należy uwzględnić, na przykład pominięcie wbudowanego modułu obsługi odrzucenia w przypadku wywołania .finally()
.
Drzewo reakcji na obietnicę to zwykle wiarygodne źródło informacji, jeśli zawiera ono takie informacje. W niektórych przypadkach, np. w wywołaniu funkcji Promise.reject()
lub w konstruktorze Promise
albo w funkcji asynchronicznej, która jeszcze niczego nie oczekuje, nie będzie reakcji do śledzenia, a debuger musi polegać tylko na stosie wywołań. W innych przypadkach drzewo reakcji obietnicy zwykle zawiera elementy niezbędne do wywnioskowania prognozy złapania, ale zawsze istnieje możliwość dodania później kolejnych elementów, które zmienią wyjątek z złapanego na niezłapany lub odwrotnie. Są też obietnice, takie jak te tworzone przez Promise.all/any/race
, gdzie inne obietnice w grupie mogą wpływać na sposób traktowania odrzucenia. W przypadku tych metod debuger zakłada, że odrzucenie obietnicy zostanie przekazane, jeśli obietnica jest nadal oczekująca na rozpatrzenie.
Zapoznaj się z tymi 2 przykładami:
Chociaż te 2 przykłady wykrytych wyjątków wyglądają podobnie, wymagają one zupełnie innych heurystyk przewidywania. W pierwszym przykładzie tworzymy obietnicę rozwiązania, a następnie planujemy zadanie reakcji dla .then()
, które wyrzuci wyjątek. Następnie wywołujemy funkcję .catch()
, aby dołączyć do obietnicy reakcji moduł obsługi odrzucenia. Gdy zostanie uruchomione zadanie reakcji, zostanie rzucone wyjątek, a drzewo reakcji obietnicy będzie zawierać moduł obsługi błędów, więc zostanie wykryte jako złapany. W drugim przykładzie obietnica jest od razu odrzucana, zanim zostanie uruchomiony kod dodawania obsługi wyjątków, więc w drzewie reakcji obietnicy nie ma żadnych elementów obsługi odrzucania. Debuger musi sprawdzić stos wywołań, ale nie ma też bloków try...catch
. Aby poprawnie przewidzieć to, debuger skanuje kod przed bieżącą lokalizacją, aby znaleźć wywołanie funkcji .catch()
, i na tej podstawie zakłada, że odrzucenie zostanie ostatecznie obsłużone.
Podsumowanie
Mamy nadzieję, że to wyjaśnienie przybliżyło Ci działanie przewidywania błędów w Narzędziach deweloperskich w Chrome, jego zalety i ograniczenia. Jeśli napotkasz problemy z debugowaniem z powodu nieprawidłowych prognoz, rozważ te opcje:
- Zmień wzór kodowania na taki, który jest łatwiejszy do przewidzenia, np. używając funkcji asynchronicznych.
- Wybierz, aby przerwać działanie przy wszystkich wyjątkach, jeśli narzędzia deweloperskie nie zatrzymają się w odpowiednim momencie.
- Jeśli debuger zatrzymuje się w nieodpowiednim miejscu, użyj punktu przerwania „Nigdy nie wstrzymywać” lub warunku, aby to zmienić.
Podziękowania
Dziękujemy Sofii Emelianova i Jecelyn Yeen za nieocenioną pomoc w edytowaniu tego posta.