chevron-left chevron-right

Jak efektywnie debugować kod JavaScript?

W tym wpisie poruszę temat debugowania kodu JavaScript. Temat ten niezmiennie się pojawia w trakcie codziennej pracy nad kodem. Po przeczytaniu tego tekstu poznasz kilka technik na efektywne debugowanie kodu JS.

Będąc programistą JavaScript od długiego czasu, wielokrotnie zmagałem się z problemami błędnie działającego kodu. Kod mógł przestać działać z wielu różnych powodów. Powody nie zawsze były oczywiste. Problem zwykle się pogarsza w momencie, gdy mamy do czynienia z funkcjami wywołującymi inne funkcje. Jak często miałeś/miałaś okazję zobaczyć błąd w podobnym stylu? Mogę się domyślać, że zdarzyło się.

Przykładowy błąd JS

Debugowanie za pomocą funkcji console.trace()

Jedną z możliwości jest wykorzystanie console.trace(). Ta funkcja wyświetla w konsoli listę funkcji, które były wywołane do momentu dojścia do miejsca wywołania console.trace().

Aby sprawdzić działanie tej funkcji przygotowałem prosty przykład w JS:

1
2
3
4
5
6
7
const add = (x, y) => {
    console.trace('add', x, y);
 
    return x + y;
};
const multiply = (x, by) => x * by;
const calc = () => add(multiply(add(1, 2), 4), 8);

Gdy uruchomimy powyższy skrypt, to w konsoli Google Chrome zobaczymy następujący rezultat:

console.trace() w Google Chrome

Podczas gdy, w przeglądarce Mozilla Firefox rezultat będzie wyglądał tak:

console.trace() w Mozilla Firefox

Ciekawa różnica, prawda? Wynika to z różnych sposobów implementacji funkcji console.trace() w obydwu przeglądarkach. W Firefox, funkcja ta nie obsługuje parametrów (nie wyświetla ich). Natomiast w Chrome, funkcja zwraca listing funkcji i działa też jak console.log(), co jest bardzo przydatne w debugowaniu.

Debugowanie za pomocą obiektu Error

Niestety, powyższy sposób ma pewną wadę. Jeśli korzystamy z funkcji w stylu window.setTimeout(), to użycie console.trace() nie pokaże listingu funkcji. Jest sposób na to, aby to obejść. Założenie jest jednak takie, że interesuje nas ostatnie efektywne wywołanie funkcji powodującej błąd aplikacji. Dzięki temu, będziemy w stanie prześledzić jak doszło do błędu oraz będziemy mogli przeanalizować ewentualne transformacje parametrów przekazywanych w funkcjach.

Załóżmy, że mamy sytuację, że w aplikacji mamy szereg niezależnych komponentów, które wywołują akcję powodującą wyświetlenie ekranu ładowania, gdy będą potrzebowały danych. W momencie, gdy dane zostaną pobrane komponenty wywołują akcję, która powoduje zamknięcie ekranu ładowania strony. Co więcej, komponenty wywołują swoje akcje niezależnie od tego co wykonały inne komponenty.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const Component = function () {
    return {
        getData: () => {
            this.sendEvent('showLoadingScreen');
            this.sendEvent('getData', {
                onSuccess: () => this.sendEvent('hideLoadingScreen');
            });
        }
    };
};
 
const LoadingScreen = function () {
    let loadingScreenTimeout;
    const emiters = [];
    const _showLoadingScreen = () => console.log('loading:screen:on');
    const _hideLoadingScreen = () => console.log('loading:screen:off');
    const _delayHidingScreen = () => {
        const stack = new Error().stack;
 
        window.clearTimeout(loadingScreenTimeout);
 
        loadingScreenTimeout = window.setTimeout(() => {
            console.log('hide:loading:screen:last:call', stack);
 
            _hideLoadingScreen();
        }, 250);
    };
 
    emiters.on('showLoadingScreen', _showLoadingScreen);
    emiters.on('hideLoadingScreen', _delayHidingScreen);
 
    return {
        listen: (emiter) => emiters.push(emiter)
    };
};
 
const Store = function () {
    const emiters = [];
    const data = [{id: 1, content: 'sample'}];
    const _setData = ({onSuccess}) => onSuccess(data);
 
    emiters.on('getData', this._setData);
 
    return {
        listen: (emiter) => emiters.push(emiter)
    };
};
 
const screen = new LoadingScreen();
const comp1 = new Component();
const comp2 = new Component();
const store = new Store();
 
screen.listen(comp1);
screen.listen(comp2);
store.listen(comp1);
store.listen(comp2);

Jest to pseudokod JS, którego celem jest zobrazowanie działania komponentów (po uruchomieniu nie będzie działał poprawnie). To co nas interesuje, to moment w którym ekran ładowania będzie skutecznie zamknięty. Nie interesują nas przypadki, kiedy będzie wywołane żądanie zamknięcia ekranu ładowania (czyli samo wywołanie eventu hideLoadingScreen). Korzystając z zapisu new Error().stack jesteśmy w stanie zapamiętać jaka była ścieżka wywołań funkcji zanim uruchomiona została funkcja window.setTimeout().

Możesz się zastanawiać, dlaczego to jest ważne. To jest ważne z tego względu, że wstawiając wywołanie funkcji console.trace() wewnątrz funkcji wywoływanej przez timeout tracimy dostęp do ścieżki wywołań poszczególnych funkcji i console.trace() pokaże wywołanie tylko i wyłącznie funkcji wykonanej przez timeout.

Podsumowanie

Mam nadzieję, że udało mi się wyjaśnić w sposób jasny i klarowny, kiedy obydwa sposoby na debugowanie kodu JS są przydatne. Najczęściej korzystam z samego console.trace(), ale warto znać też ten drugi sposób. Bywają momenty, kiedy nie jesteśmy w stanie zapanować nad tym kiedy określone funkcje są wywoływane asynchronicznie. Drugi sposób może nam w tym wydatnie pomóc.

W moim przypadku, był to problem związany z tym, że wiele niezależnych komponentów w różnym momencie włącza i wyłącza ekran ładowania aplikacji. Niestety, tego typu zachowanie rodzi komplikacje, gdy ładujemy jakiś widok główny aplikacji za pierwszym razem, np. bezpośrednio z podanego linka (wtedy wszystkie komponenty niezależnie od siebie pobierają dane z różnych źródeł, a utworzenie ekranu ładowania danych wewnątrz każdego komponentu nie wchodzi w rachubę). Wtedy naprawdę przydał się drugi sposób na debugowanie kodu JavaScript i odkrycie ścieżki wywołań powodującej określony problem w aplikacji.

Zapraszam do komentowania i zadawania pytań w przypadku jakichkolwiek nieścisłości.

  • I tak najlepsze jest nieśmiertelne d*a debugging ( ͡° ͜ʖ ͡°)

  • Debugowanie uważam za jeden z tematów poważnie zaniedbywanych na rozmowach rekrutacyjnych. Od dłuższego czasu można np. ustawić breakpoint w Chrome na zdarzeniu (do Firefoksa chyba trafiło to niedawno), są breakpointy warunkowe (żeby nie klikać continue sto razy), można też zmienić domyślne ustawienie debuggera i zatrzymywać się na wszystkich wyjątkach (przydatne, gdy obsługa błędów i sama architektura aplikacji są słabe). Nie każdy jest świadom tego, że gdy zatrzyma się na breakpoincie, to może w danym miejscu (czyli scopie i kontekście) wykonać dowolny kod w konsoli.
    Szkoda, że Firebug już nie jest rozwijany, jakoś zawsze go lubiłem. Gorzej, że przy jednej z aplikacji, nad którymi pracowałem samo jego otwarcie powodowało błędy. Chyba coś tam było namieszane z globalami, ale nie było czasu na dochodzenie. Bywają też błędy, których z otwartym narzędziem do debugowania się nie rozwiąże np. gdy źródło problemu tkwi w cache’u, który otwarty debugger zwykle domyślnie wyłącza.

  • Zniwus

    No ja nie wiem czy to jest efektywne debugowanie. Napisz coś o debugger;, blackbox script, async stack traces, pause on caught exceptions. Jak szukać ukryte błędy. Jeszcze fajną wtyczką do chrome jest Overrides daje dużo jeśli chcemy symulować różne przypadki i odpalać swój własny kod przed startem prawdziwego kodu strony.

  • Masz rację, debugger jest bardzo ciekawym sposobem na debugowanie. Lecz dla osób dopiero rozpoczynających przygodę z tym tematem może on być zbyt zaawansowany.

  • To wszystko od zależy od tego jak zdefiniujemy sobie pojęcie „efektywne debugowanie”. Dla mnie efektywne debugowanie polega na rozwiązaniu problemu jak najmniejszym nakładem sił.
    Dzięki za sugestie tematów. Będą stanowiły doskonałe rozwinięcie zagadnień wyjaśnianych w tym wpisie 🙂