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ę.

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:

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

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.