Odświeżona architektura Narzędzi deweloperskich: migracja do modułów JavaScript

Tim van der Lippe
Tim van der Lippe

Narzędzia deweloperskie w Chrome to aplikacja internetowa napisana w językach HTML, CSS i JavaScript. Z czasem narzędzia deweloperskie stały się bogatsze o funkcje, mądrzejsze i bardziej świadome szerszej platformy internetowej. Chociaż przez lata Narzędzia deweloperskie się rozrastały, ich architektura w dużej mierze przypomina pierwotną architekturę, gdy były jeszcze częścią WebKit.

Ten post jest częścią serii postów na blogu, w których opisujemy zmiany wprowadzane w architekturze DevTools i sposób jej tworzenia. Wyjaśnimy, jak Narzędzia deweloperskie działały w przeszłości, jakie były korzyści i ograniczenia oraz co zrobiliśmy, aby je zmniejszyć. Przyjrzyjmy się więc bliżej systemom modułów, sposobom wczytywania kodu i temu, jak w naszym przypadku wykorzystaliśmy moduły JavaScript.

Na początku nie było nic

Chociaż obecna sytuacja na rynku front-endu obejmuje wiele systemów modułowych z dołączonymi narzędziami oraz obecnie znormalizowany format modułów JavaScriptu, w czasie tworzenia DevTools nie istniało nic z tych rozwiązań. Narzędzie DevTools zostało stworzone na podstawie kodu, który został pierwotnie dołączony do WebKit ponad 12 lat temu.

Pierwsza wzmianka o systemie modułów w DevTools pochodzi z 2012 roku: wprowadzenie listy modułów z powiązaną listą źródeł. Była to część infrastruktury Pythona używanej wówczas do kompilowania i tworzenia narzędzi deweloperskich. Kolejna zmiana pozwoliła wyodrębnić wszystkie moduły do osobnego pliku frontend_modules.json (commit) w 2013 roku, a następnie do osobnych plików module.json (commit) w 2014 roku.

Przykładowy plik module.json:

{
  "dependencies": [
    "common"
  ],
  "scripts": [
    "StylePane.js",
    "ElementsPanel.js"
  ]
}

Od 2014 roku wzorzec module.json jest używany w Narzędziach deweloperskich do określania jego modułów i plików źródłowych. Tymczasem ekosystem internetowy gwałtownie się rozwijał i powstało wiele formatów modułów, w tym UMD, CommonJS i ewentualnie ustandaryzowane moduły JavaScript. Narzędzia deweloperskie jednak używają formatu module.json.

Chociaż Narzędzia deweloperskie nadal działały, korzystanie z niestandardowego i wyjątkowego systemu modułów miało kilka wad:

  1. Format module.json wymagał niestandardowych narzędzi do kompilacji, podobnych do nowoczesnych pakietów SDK.
  2. Nie było integracji z IDE, co wymagało użycia niestandardowych narzędzi do generowania plików zrozumiałych dla nowoczesnych IDE (oryginalny skrypt do generowania plików jsconfig.json dla VS Code).
  3. Funkcje, klasy i obiekty zostały umieszczone w zakresie globalnym, aby umożliwić udostępnianie między modułami.
  4. Pliki były zależne od kolejności, co oznacza, że kolejność plików sources była ważna. Nie było żadnej gwarancji, że kod, na którym polegasz, zostanie załadowany, poza tym, że został zweryfikowany przez człowieka.

W rezultacie podczas oceny bieżącego stanu systemu modułów w Narzędziach deweloperskich i innych (powszechniej używanych) formatów modułów doszliśmy do wniosku, że wzorzec module.json powoduje więcej problemów, niż jest w nich rozwiązany, i nadszedł czas na zaplanowanie odejścia od nich.

Korzyści ze standardów

Spośród dotychczasowych systemów modułów wybraliśmy moduły JavaScriptu. W momencie podjęcia tej decyzji moduły JavaScript były nadal dostępne w ramach flagi w Node.js, a wiele pakietów dostępnych w NPM nie zawierało pakietu modułów JavaScript, którego moglibyśmy użyć. Mimo to doszliśmy do wniosku, że moduły JavaScript są najlepszą opcją.

Główną zaletą modułów JavaScriptu jest to, że są one standardowym formatem modułów JavaScriptu. Gdy wymieniliśmy wady module.json (patrz wyżej), zdaliśmy sobie sprawę, że prawie wszystkie z nich są związane z użyciem niestandardowego i niepowtarzalnego formatu modułu.

Wybór niestandardowego formatu modułu oznacza, że musimy poświęcić czas na tworzenie integracji z narzędziami kompilacji i narzędziami używanymi przez naszych opiekunów.

Te integracje były często niestabilne i nie obsługiwały niektórych funkcji, wymagały dodatkowego czasu na konserwację i czasami powodowały drobne błędy, które ostatecznie trafiały do użytkowników.

Ponieważ moduły JavaScript były standardem, oznaczało to, że IDE, takie jak VS Code, sprawdzacze typów, takie jak Closure Compiler/TypeScript, oraz narzędzia do kompilacji, takie jak Rollup/minifiers, były w stanie zrozumieć napisany przez nas kod źródłowy. Ponadto, gdy nowy opiekun dołączy do zespołu DevTools, nie będzie musiał poświęcać czasu na zapoznanie się z formatem module.json, ponieważ (prawdopodobnie) zna już moduły JavaScript.

Oczywiście, gdy narzędzia deweloperskie były tworzone po raz pierwszy, nie było żadnych z tych zalet. Dojście do obecnego stanu zajęło wiele lat pracy w grupach opracowujących standardy, implementacjach w czasie wykonywania i używających modułów JavaScriptu deweloperów, którzy przekazywali opinie. Gdy jednak pojawiły się moduły JavaScriptu, musieliśmy dokonać wyboru: kontynuować utrzymywanie własnego formatu lub zainwestować w migrację na nowy.

Koszt nowego

Mimo że moduły JavaScriptu miały wiele zalet, z których chcielibyśmy skorzystać, pozostaliśmy w niestandardowym środowisku module.json. Aby czerpać korzyści z modułów JavaScript, musieliśmy znacząco zainwestować w uporządkowanie zaległości technicznych, przeprowadzić migrację, która mogłaby potencjalnie spowodować przerwanie działania funkcji i wprowadzić błędy regresji.

W tym momencie nie chodziło o to, czy chcemy używać modułów JavaScript, ale o to, jak drogie jest używanie modułów JavaScript. Musieliśmy tu zrównoważyć ryzyko wystąpienia regresji, koszty, które inżynierowie poświęcali (dużo czasu) na migrację, i tymczasowo gorszy stan, w którym moglibyśmy pracować.

Ten ostatni punkt okazał się bardzo ważny. Chociaż teoretycznie można by użyć modułów JavaScript, podczas migracji powstałby kod, który musiałby uwzględniać zarówno moduły module.json, jak i moduły JavaScript. Nie tylko było to trudne do wykonania pod względem technicznym, ale też oznaczało, że wszyscy inżynierowie pracujący nad Narzędziami deweloperskimi musieli wiedzieć, jak działać w tym środowisku. Programista musi stale zadawać sobie pytanie: „Czy w przypadku tej części kodu źródłowego należy użyć module.json czy modułów JavaScript i jak wprowadzić zmiany?”.

Wstępny przegląd: ukryte koszty przeprowadzenia innych osób zajmujących się konserwacją przez proces migracji były większe, niż się spodziewaliśmy.

Po analizie kosztów doszliśmy do wniosku, że warto przejść na moduły JavaScript. W związku z tym nasze główne cele to:

  1. Upewnij się, że korzystanie z modułów JavaScriptu przynosi jak największe korzyści.
  2. Upewnij się, że integracja z dotychczasowym systemem opartym na module.json jest bezpieczna i nie ma negatywnego wpływu na użytkowników (błędy związane z regresją, frustracja użytkowników).
  3. Przeprowadź migrację wszystkich pracowników Narzędzi deweloperskich przez cały proces migracji, przede wszystkim z wbudowanym mechanizmem sprawdzania i równoważenia, aby zapobiegać przypadkowym pomyłkom.

Arkusze kalkulacyjne, przekształcenia i dług technologiczny

Chociaż cel był jasny, ograniczenia narzucone przez format module.json okazały się trudne do obejścia. Zanim udało nam się opracować odpowiednie rozwiązanie, musieliśmy przejść przez kilka iteracji, stworzyć prototypy i wprowadzić zmiany w architekturze. Ostatecznie opracowaliśmy dokument projektowy ze strategią migracji. Dokument z projektem zawierał też nasz wstępny szacowany czas realizacji: 2–4 tygodnie.

Spoiler: najbardziej intensywna część migracji zajęła 4 miesiące, a całość – 7 miesięcy.

Pierwotny plan przetrwał jednak próbę czasu: wczytywanie wszystkich plików wymienionych w tablicy scripts w pliku module.json odbywało się w stary sposób, a wszystkie pliki wymienione w tablicy modules – za pomocą dynamicznego importu modułów JavaScript. Każdy plik, który znajduje się w tablicy modules, może korzystać z importu/eksportu ES.

Dodatkowo przeprowadzimy migrację w 2 etapach (ostatni etap podzieliliśmy na 2 podetapy, patrz poniżej): etap export i etap import. W dużym arkuszu kalkulacyjnym śledzono stan modułu na poszczególnych etapach:

Arkusz kalkulacyjny migracji modułów JavaScript

Fragment arkusza postępów jest publicznie dostępny tutaj.

export-faza

Pierwszym etapem byłoby dodanie instrukcji export do wszystkich symboli, które miały być wspólne dla modułów/plików. Przekształcenie zostanie zautomatyzowane przez uruchomienie skryptu w poszczególnych folderach. Ze względu na podany niżej symbol występuje w świecie module.json:

Module.File1.exported = function() {
  console.log('exported');
  Module.File1.localFunctionInFile();
};
Module.File1.localFunctionInFile = function() {
  console.log('Local');
};

(Tutaj Module to nazwa modułu, a File1 to nazwa pliku. W naszym sourcetree jest to front_end/module/file1.js.

Zostanie on przekształcony w ten sposób:

export function exported() {
  console.log('exported');
  Module.File1.localFunctionInFile();
}
export function localFunctionInFile() {
  console.log('Local');
}

/** Legacy export object */
Module.File1 = {
  exported,
  localFunctionInFile,
};

Początkowo planowaliśmy też na tym etapie przerobić importowanie plików o tej samej nazwie. Na przykład w przykładzie powyżej zastąpilibyśmy Module.File1.localFunctionInFile wartością localFunctionInFile. Zdaliśmy sobie jednak sprawę, że rozdzielenie tych 2 przekształceń byłoby łatwiejsze i bezpieczniejsze ich stosowanie. Dlatego „migracja wszystkich symboli w tym samym pliku” stanie się drugą fazą podrzędną etapu import.

Dodanie do pliku słowa kluczowego export powoduje, że plik przekształca się z „skryptu” w „moduł”, dlatego należało odpowiednio zaktualizować wiele elementów infrastruktury DevTools. Obejmował on środowisko wykonawcze (z dynamicznym importowaniem), ale również narzędzia takie jak ESLint działające w trybie modułu.

Podczas rozwiązywania tych problemów odkryliśmy, że nasze testy były wykonywane w trybie „niedbały”. Ponieważ moduły JavaScriptu wskazują, że pliki są uruchamiane w trybie "use strict", wpłynie to również na nasze testy. Jak się okazało, na tej niepewności polegała niezwykła liczba testów, w tym test, w którym użyto wyrażenia with 😱.

W rezultacie zaktualizowanie pierwszego folderu, aby zawierał instrukcje export, zajęło około tygodnia i kilka prób z przekierowaniem.

import-fazowy

Po wyeksportowaniu wszystkich symboli za pomocą instrukcji export i pozostawieniu ich w zakresie globalnym (starego typu) musieliśmy zaktualizować wszystkie odwołania do symboli w wielu plikach, aby można było używać importów ES. Ostatecznym celem jest usunięcie wszystkich „starszych obiektów eksportu”, aby posprzątać w zakresie globalnym. Przekształcenie zostanie zautomatyzowane przez uruchomienie skryptu w poszczególnych folderach.

Na przykład w przypadku tych symboli występujących w świecie module.json:

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
SameModule.AnotherFile.moduleScoped();

Zostaną one przekształcone w:

import * as Module from '../module/Module.js';
import * as AnotherModule from '../another_module/AnotherModule.js';

import {moduleScoped} from './AnotherFile.js';

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
moduleScoped();

Takie podejście miało jednak pewne ograniczenia:

  1. Nie wszystkie symbole mają nazwę Module.File.symbolName. Niektóre symbole miały nazwy Module.File, a nawet Module.CompletelyDifferentName. Ta niespójność oznaczała, że musieliśmy utworzyć wewnętrzne mapowanie starego obiektu globalnego na nowy zaimportowany obiekt.
  2. Czasami dochodzi do kolizji nazw na poziomie modułu. Najwyraźniej użyliśmy wzorca deklarowania niektórych typów wartości Events, gdzie każdy z nich miał nazwę Events. Oznacza to, że jeśli nasłuchujesz wielu typów zdarzeń zadeklarowanych w różnych plikach, w przypadku tych import wystąpi konflikt nazw.
  3. Okazało się, że między plikami występowały zależności cykliczne. W kontekście globalnym było to w porządku, ponieważ symbol został użyty po załadowaniu całego kodu. Jeśli jednak potrzebujesz import, zależność cykliczna zostanie wyraźnie zaznaczona. Nie stanowi to problemu, chyba że w kodzie o zasięgu globalnym masz wywołania funkcji, które wywołują skutki uboczne, co miało miejsce w przypadku DevTools. Ogólnie rzecz biorąc, aby bezpiecznie przeprowadzić transformację, musieliśmy wykonać pewne zmiany i refaktoryzację.

Nowy świat z modułami JavaScript

W lutym 2020 r., czyli 6 miesięcy po rozpoczęciu procesu w wrześniu 2019 r., w folderze ui/ zostały przeprowadzone ostatnie działania porządkujące. To oznaczało nieoficjalne zakończenie migracji. Gdy emocje opadły, 5 marca 2020 r. oficjalnie zakończyliśmy migrację. 🎉

Obecnie wszystkie moduły w DevTools używają modułów JavaScript do udostępniania kodu. Niektóre symbole nadal umieszczamy w zakresie globalnym (w plikach module-legacy.js) na potrzeby starszych testów lub integracji z innymi częściami architektury DevTools. Z czasem usuniemy je, ale nie blokujemy ich rozwoju w przyszłości. Mamy też poradnik dotyczący stosowania modułów JavaScript.

Statystyki

Konserwatywne szacunki dotyczące liczby list zmian (skrót od changelist – termin używany w Gerricie, który reprezentuje zmianę – podobny do żądania pull request w GitHubie) zaangażowanych w tę migrację wynosi około 250 list zmian, w większości wykonywanych przez 2 inżynierów. Nie mamy dokładnych statystyk dotyczących rozmiaru wprowadzonych zmian, ale ostrożna ocena liczby zmienionych linii kodu (obliczona jako suma bezwzględna różnicy między wstawkami a usunięciami w każdym CL) wynosi około 30 tys. wierszy kodu (~20% całego kodu interfejsu DevTools).

Pierwszy plik korzystający z export został dostarczony w Chrome 79, który został wydany w stabilnej wersji w grudniu 2019 r. Ostatnia zmiana, która umożliwia migrację do import, została wprowadzona w Chrome 83, która została udostępniona w wersji stabilnej w maju 2020 r.

Wiemy o jednym regresie, który został wprowadzony w Chrome stabilnej w ramach tej migracji. Autouzupełnianie fragmentów w menu poleceń przestało działać z powodu obcego eksportu default. Wystąpiło kilka innych regresji, ale zostały zgłoszone przez automatyczne zestawy testów i użytkowników Chrome Canary. W rezultacie udało nam się naprawić je, zanim dotarły do użytkowników stabilnej wersji Chrome.

Pełną ścieżkę (nie wszystkie pliki CL są powiązane z tym błędem, ale większość z nich) znajdziesz w crbug.com/1006759.

Czego się nauczyliśmy?

  1. Decyzje podjęte w przeszłości mogą mieć długotrwały wpływ na Twój projekt. Moduły JavaScript (i inne ich formaty) były dostępne już od dłuższego czasu, jednak Narzędzia deweloperskie nie były w stanie uzasadnić migracji. Decyzja o tym, kiedy, a kiedy nie przeprowadzić migracji, jest trudna i opiera się na przypuszczeniach.
  2. Nasz początkowy szacowany czas był podany w tygodniach, a nie miesiącach. Wynika to głównie z tego, że znaleźliśmy więcej nieoczekiwanych problemów niż przewidywaliśmy w ramach wstępnej analizy kosztów. Mimo że plan migracji był solidny, to problemy techniczne (częściej niż nam się podobało) blokowały postępy.
  3. Migracja modułów JavaScriptu obejmowała dużą liczbę (pozornie niezwiązanych) długów technicznych. Migracja do nowoczesnego, ujednoliconego formatu modułów pozwoliła nam dostosować nasze sprawdzone metody kodowania do współczesnego tworzenia stron internetowych. Udało nam się na przykład zastąpić niestandardowy pakiet Pythona minimalną konfiguracją Rollup.
  4. Pomimo dużego wpływu na nasz kod źródłowy (zmieniliśmy ok. 20% kodu) odnotowaliśmy bardzo niewiele regresje. Mieliśmy wiele problemów z migracją pierwszych kilku plików, ale po jakimś czasie mieliśmy solidny, częściowo zautomatyzowany przepływ pracy. Oznacza to, że w przypadku tej migracji wpływ na użytkowników korzystających z wersji stabilnej był minimalny.
  5. Wyjaśnienie zawiłości konkretnej migracji innym administratorom jest trudne, a czasami niemożliwe. Migracje na taką skalę są trudne do prześledzenia i wymagają dużej wiedzy w danej dziedzinie. Przekazywanie tej wiedzy innym osobom pracującym w ramach tej samej bazy kodu nie jest w ogóle pożądane. Wiedza o tym, co udostępniać, a czego nie udostępniać, to sztuka, ale niezbędna. Dlatego ważne jest, aby ograniczyć liczbę dużych migracji lub przynajmniej nie przeprowadzać ich w tym samym czasie.

Pobierz kanały podglądu

Rozważ użycie przeglądarki Chrome Canary, Dev lub Beta jako domyślnej przeglądarki deweloperskiej. Te kanały wersji wstępnej zapewniają dostęp do najnowszych funkcji DevTools, umożliwiają testowanie najnowocześniejszych interfejsów API platformy internetowej i pomagają znaleźć problemy w witrynie, zanim zrobią to użytkownicy.

Kontakt z zespołem ds. Narzędzi deweloperskich w Chrome

Aby omówić nowe funkcje, aktualizacje lub inne kwestie związane z Narzędziami deweloperskimi, skorzystaj z tych opcji.