Het debuggen van uitzonderingen in webapplicaties lijkt eenvoudig: pauzeer de uitvoering als er iets misgaat en onderzoek het. Maar het asynchrone karakter van JavaScript maakt dit verrassend complex. Hoe kan Chrome DevTools weten wanneer en waar ze moeten pauzeren wanneer uitzonderingen door beloften en asynchrone functies heen vliegen?
Dit bericht duikt in de uitdagingen van catch-voorspelling : het vermogen van DevTools om te anticiperen of er later in uw code een uitzondering wordt opgevangen. We zullen onderzoeken waarom het zo lastig is en hoe recente verbeteringen in V8 (de JavaScript-engine die Chrome aandrijft) het nauwkeuriger maken, wat leidt tot een soepelere foutopsporingservaring.
Waarom vangstvoorspelling belangrijk is
In Chrome DevTools heeft u de mogelijkheid om de uitvoering van code alleen te onderbreken voor niet-afgevangen uitzonderingen, waarbij u de wel opgevangen uitzonderingen overslaat.
Achter de schermen stopt de debugger onmiddellijk wanneer er een uitzondering optreedt om de context te behouden. Het is een voorspelling omdat het op dit moment onmogelijk is om zeker te weten of de uitzondering later in de code zal worden opgemerkt of niet, vooral in asynchrone scenario's. Deze onzekerheid komt voort uit de inherente moeilijkheid om programmagedrag te voorspellen, vergelijkbaar met het Halting-probleem .
Neem het volgende voorbeeld: waar moet de debugger pauzeren? (Zoek een antwoord in het volgende gedeelte.)
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?
}
}
Pauzeren bij uitzonderingen in een debugger kan storend zijn en leiden tot frequente onderbrekingen en sprongen naar onbekende code. Om dit te beperken, kunt u ervoor kiezen om alleen fouten op te sporen die niet zijn onderschept, omdat de kans groter is dat deze daadwerkelijke bugs signaleren. Dit is echter afhankelijk van de nauwkeurigheid van de vangstvoorspelling.
Verkeerde voorspellingen leiden tot frustratie:
- Fout-negatieven (voorspellen dat het niet wordt opgepakt wanneer het wel wordt opgepakt) . Onnodige stops in de debugger.
- Valse positieven (voorspellen dat 'gepakt' wordt terwijl het niet wordt opgepakt) . Gemiste kansen om kritieke fouten op te sporen, waardoor u mogelijk alle uitzonderingen moet debuggen, inclusief de verwachte uitzonderingen.
Een andere methode om onderbrekingen bij het opsporen van fouten te verminderen, is door gebruik te maken van de negeerlijst , die pauzes bij uitzonderingen binnen gespecificeerde code van derden voorkomt. Een nauwkeurige vangstvoorspelling is hier echter nog steeds van cruciaal belang. Als een uitzondering die voortkomt uit code van derden ontsnapt en uw eigen code beïnvloedt, wilt u fouten kunnen opsporen.
Hoe asynchrone code werkt
Beloften, async
en await
en andere asynchrone patronen kunnen leiden tot scenario's waarin een uitzondering of afwijzing, voordat deze wordt afgehandeld, een uitvoeringspad kan volgen dat moeilijk te bepalen is op het moment dat er een uitzondering wordt gegenereerd. Dit komt omdat er mogelijk pas op beloften wordt gewacht of er catch handlers worden toegevoegd nadat de uitzondering al heeft plaatsgevonden. Laten we eens kijken naar ons vorige voorbeeld:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
In dit voorbeeld roept outer()
eerst inner()
aan, wat onmiddellijk een uitzondering genereert. Hieruit kan de debugger concluderen dat inner()
een afgewezen belofte zal retourneren, maar momenteel wacht er niets op of handelt deze belofte anderszins af. De debugger kan raden dat outer()
er waarschijnlijk op zal wachten en vermoedt dat hij dit in zijn huidige try
-blok zal doen en het daarom zal afhandelen, maar de debugger kan hier pas zeker van zijn nadat de afgewezen belofte is geretourneerd en de await
instructie is uiteindelijk bereikt.
De debugger kan geen enkele garantie bieden dat vangstvoorspellingen accuraat zullen zijn, maar gebruikt een verscheidenheid aan heuristieken om algemene coderingspatronen correct te voorspellen. Om deze patronen te begrijpen, helpt het om te leren hoe beloften werken.
In V8 wordt een JavaScript- Promise
weergegeven als een object dat zich in een van de volgende drie statussen kan bevinden: vervuld, afgewezen of in behandeling. Als een belofte de status 'vervuld' heeft en u de methode .then()
aanroept, wordt er een nieuwe belofte in behandeling gemaakt en wordt er een nieuwe reactietaak voor de belofte gepland, die de handler uitvoert en vervolgens de belofte op 'vervuld' zet met het resultaat van de handler of stel het in op afgewezen als de handler een uitzondering genereert. Hetzelfde gebeurt als u de methode .catch()
aanroept voor een afgewezen belofte. Integendeel, het aanroepen van .then()
op een afgewezen belofte of .catch()
op een vervulde belofte zal een belofte in dezelfde staat retourneren en de handler niet uitvoeren.
Een openstaande belofte bevat een reactielijst waarbij elk reactieobject een afhandelingshandler of afwijzingshandler (of beide) en een reactiebelofte bevat. Dus het aanroepen van .then()
op een in behandeling zijnde belofte zal een reactie toevoegen met een vervulde handler, evenals een nieuwe in behandeling zijnde belofte voor de reactiebelofte, die .then()
zal retourneren. Het aanroepen van .catch()
zal een soortgelijke reactie toevoegen, maar met een afwijzingshandler. Het aanroepen van .then()
met twee argumenten creëert een reactie met beide handlers, en het aanroepen van .finally()
of het wachten op de belofte zal een reactie toevoegen met twee handlers die ingebouwde functies zijn die specifiek zijn voor het implementeren van deze functies.
Wanneer de openstaande belofte uiteindelijk wordt vervuld of afgewezen, worden reactietaken gepland voor alle vervulde afhandelaars of voor alle afgewezen afhandelaars. De overeenkomstige reactiebeloften zullen dan worden bijgewerkt, waardoor mogelijk hun eigen reactietaken kunnen worden geactiveerd.
Voorbeelden
Beschouw de volgende code:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Het is misschien niet voor de hand liggend dat deze code drie verschillende Promise
objecten omvat. De bovenstaande code is gelijk aan de volgende code:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
In dit voorbeeld gebeuren de volgende stappen:
- De
Promise
constructor wordt aangeroepen. - Er wordt een nieuwe lopende
Promise
gemaakt. - De anonieme functie wordt uitgevoerd.
- Er wordt een uitzondering gegenereerd. Op dit punt moet de debugger beslissen of hij wil stoppen of niet.
- De belofteconstructor vangt deze uitzondering op en wijzigt vervolgens de status van zijn belofte in
rejected
waarbij de waarde is ingesteld op de fout die is gegenereerd. Het retourneert deze belofte, die is opgeslagen inpromise1
. -
.then()
plant geen reactietaak omdatpromise1
de statusrejected
heeft. In plaats daarvan wordt een nieuwe belofte (promise2
) geretourneerd, die ook de status afgewezen heeft met dezelfde fout. -
.catch()
plant een reactietaak met de opgegeven handler en een nieuwe reactiebelofte die in behandeling is, die wordt geretourneerd alspromise3
. Op dit punt weet de debugger dat de fout zal worden afgehandeld. - Wanneer de reactietaak wordt uitgevoerd, keert de afhandelingsroutine normaal terug en wordt de status van
promise3
gewijzigd infulfilled
.
Het volgende voorbeeld heeft een vergelijkbare structuur, maar de uitvoering is heel anders:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Dit komt overeen met:
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;
In dit voorbeeld gebeuren de volgende stappen:
- Een
Promise
wordt in defulfilled
staat aangemaakt en opgeslagen inpromise1
. - Er wordt een belofte-reactietaak gepland met de eerste anonieme functie en de
(pending)
reactie-belofte wordt geretourneerd alspromise2
. - Er wordt een reactie toegevoegd aan
promise2
met een vervulde handler en de bijbehorende reactiebelofte, die wordt geretourneerd alspromise3
. - Er wordt een reactie toegevoegd aan
promise3
met een afgewezen handler en een andere reactiebelofte, die wordt geretourneerd alspromise4
. - De in stap 2 geplande reactietaak wordt uitgevoerd.
- De handler genereert een uitzondering. Op dit punt moet de debugger beslissen of hij wil stoppen of niet. Momenteel is de handler uw enige actieve JavaScript-code.
- Omdat de taak eindigt met een uitzondering, wordt de bijbehorende reactiebelofte (
promise2
) ingesteld op de status afgewezen, waarbij de waarde is ingesteld op de fout die is opgetreden. - Omdat
promise2
één reactie had, en die reactie geen afgewezen handler had, is de reactiebelofte (promise3
) ook ingesteld oprejected
met dezelfde fout. - Omdat
promise3
één reactie had, en die reactie een afgewezen handler had, wordt er een beloftereactietaak gepland met die handler en zijn reactiebelofte (promise4
). - Wanneer die reactietaak wordt uitgevoerd, keert de handler normaal terug en wordt de status van
promise4
gewijzigd in vervuld.
Methoden voor vangstvoorspelling
Er zijn twee potentiële informatiebronnen voor vangstvoorspellingen. Eén daarvan is de call-stack. Dit is goed voor synchrone uitzonderingen: de debugger kan door de call-stack lopen op dezelfde manier als de code voor het afwikkelen van uitzonderingen en stopt als hij een frame vindt waarin hij zich in een try...catch
-blok bevindt. Voor afgewezen beloften of uitzonderingen in belofteconstructors of in asynchrone functies die nooit zijn opgeschort, vertrouwt de debugger ook op de call-stack, maar in dit geval kan zijn voorspelling niet in alle gevallen betrouwbaar zijn. Dit komt omdat in plaats van een uitzondering naar de dichtstbijzijnde handler te gooien, asynchrone code een afgewezen uitzondering retourneert, en de debugger een paar aannames moet doen over wat de beller ermee zal doen.
Ten eerste gaat de debugger ervan uit dat een functie die een geretourneerde belofte ontvangt, die belofte of een afgeleide belofte waarschijnlijk zal retourneren, zodat asynchrone functies verderop in de stapel de kans krijgen erop te wachten. Ten tweede gaat de debugger ervan uit dat als een belofte wordt teruggestuurd naar een asynchrone functie, deze er snel op zal wachten zonder eerst een try...catch
blok binnen te gaan of te verlaten. Geen van deze aannames is gegarandeerd correct, maar ze zijn voldoende om de juiste voorspellingen te doen voor de meest voorkomende coderingspatronen met asynchrone functies. In Chrome versie 125 hebben we nog een heuristiek toegevoegd: de debugger controleert of een aangeroepene op het punt staat .catch()
aan te roepen op de waarde die zal worden geretourneerd (of .then()
met twee argumenten, of een reeks aanroepen naar .then()
of .finally()
gevolgd door een .catch()
of een .then()
met twee argumenten). In dit geval gaat de debugger ervan uit dat dit de methoden zijn voor de belofte die we traceren of een methode die daarmee verband houdt, zodat de afwijzing wordt onderschept.
De tweede bron van informatie is de boom van beloftereacties. De debugger begint met een rootbelofte. Soms is dit een belofte waarvoor zojuist de methode reject()
is aangeroepen. Vaker, wanneer er een uitzondering of afwijzing plaatsvindt tijdens een belofte-reactietaak en niets op de call-stack deze lijkt op te vangen, volgt de debugger de belofte die bij de reactie hoort. De debugger kijkt naar alle reacties op de openstaande belofte en ziet of er afwijzingshandlers zijn. Als er reacties zijn die dat niet doen, wordt er gekeken naar de reactiebelofte en wordt er recursief van afgeleid. Als alle reacties uiteindelijk leiden tot een afwijzingsbehandelaar, beschouwt de debugger de afwijzing van de belofte als onderschept. Er zijn enkele speciale gevallen die behandeld moeten worden, waarbij bijvoorbeeld de ingebouwde afwijzingshandler voor een .finally()
-aanroep niet wordt meegerekend.
De belofte-reactieboom biedt een doorgaans betrouwbare informatiebron als de informatie aanwezig is. In sommige gevallen, zoals bij een aanroep van Promise.reject()
of in een Promise
-constructor of in een asynchrone functie die nog niet op iets heeft gewacht, zijn er geen reacties te traceren en moet de debugger alleen op de call-stack vertrouwen. In andere gevallen bevat de belofte-reactieboom gewoonlijk de handlers die nodig zijn om de vangstvoorspelling af te leiden, maar het is altijd mogelijk dat er later meer handlers worden toegevoegd die de uitzondering van gevangen naar niet-afgevangen zullen veranderen of omgekeerd. Er zijn ook beloften zoals die gemaakt door Promise.all/any/race
, waarbij andere beloften in de groep van invloed kunnen zijn op de manier waarop een afwijzing wordt behandeld. Bij deze methoden gaat de debugger ervan uit dat een afwijzing van een belofte wordt doorgestuurd als de belofte nog in behandeling is.
Kijk eens naar de volgende twee voorbeelden:
Hoewel deze twee voorbeelden van gevangen uitzonderingen op elkaar lijken, vereisen ze heel verschillende heuristieken voor het voorspellen van de vangst. In het eerste voorbeeld wordt een opgeloste belofte gemaakt, vervolgens wordt een reactietaak voor .then()
gepland die een uitzondering genereert, en vervolgens wordt .catch()
aangeroepen om een afwijzingshandler aan de reactiebelofte te koppelen. Wanneer de reactietaak wordt uitgevoerd, wordt de uitzondering gegenereerd en bevat de belofte-reactieboom de catch-handler, zodat deze als gevangen wordt gedetecteerd. In het tweede voorbeeld wordt de belofte onmiddellijk afgewezen voordat de code voor het toevoegen van een catch-handler wordt uitgevoerd. Er zijn dus geen afwijzingshandlers in de reactieboom van de belofte. De debugger moet naar de call-stack kijken, maar er zijn ook geen try...catch
-blokken. Om dit correct te voorspellen, scant de debugger vóór de huidige locatie in de code om de aanroep van .catch()
te vinden, en gaat er op basis daarvan van uit dat de afwijzing uiteindelijk zal worden afgehandeld.
Samenvatting
Hopelijk heeft deze uitleg licht geworpen op hoe vangstvoorspelling werkt in Chrome DevTools, wat de sterke punten en beperkingen ervan zijn. Als u foutopsporingsproblemen ondervindt als gevolg van onjuiste voorspellingen, kunt u deze opties overwegen:
- Verander het coderingspatroon in iets dat eenvoudiger te voorspellen is, zoals het gebruik van asynchrone functies.
- Selecteer deze optie om alle uitzonderingen te beëindigen als DevTools er niet in slaagt te stoppen wanneer dat zou moeten.
- Gebruik een breekpunt 'Hier nooit pauzeren' of een voorwaardelijk breekpunt als de debugger ergens stopt waar u niet wilt.
Dankbetuigingen
Onze diepste dank gaat uit naar Sofia Emelianova en Jecelyn Yeen voor hun onschatbare hulp bij het bewerken van dit bericht!