Trzęsące się drzewo reaguje na natywne aplikacje

Jak zastosowaliśmy techniki optymalizacji typowe dla aplikacji internetowych w naszej aplikacji React Native, aby skrócić czas uruchamiania o 20%.
Drzewo co?
Trzeba przyznać, że potrząsanie drzewem może być mylącym terminem. Być może słyszałeś już o tym jako o „import elision” w TypeScript. Tree Shaking to forma eliminacji martwego kodu związana w szczególności z usuwaniem nieużywanych eksportów. Gdybyśmy połączyli wszystkie moduły, niewykorzystane eksporty są faktycznie martwym kodem i można je usunąć. Proces ustalania niewykorzystanego eksportu nie jest jednak trywialny. Tree Shaking jest zwykle implementowany na poziomie kompilatora/bundlera (np . Webpack lub ESBuild ), a nie przez silnik JavaScript (taki jak V8 lub Hermes). Wiele wzorców w JavaScript może złamać Tree Shaking, ale w tym artykule chcę skupić się na jednym aspekcie: systemie modułów. Dwa istotne systemy modułów, które musimy tutaj zrozumieć, to moduły CommonJS imoduły ES .

CommonJS jest używany podczas pisania module.exports = {}
lub exports.someMethod = () => {}
. Moduły ES są identyfikowane przez import
i export
składnię. Kompilatorom trudniej zastosować Tree Shaking do kodu przy użyciu modułów CommonJS niż modułów ES. Moduły CommonJS są często dynamiczne, podczas gdy moduły ES mogą być analizowane statycznie. Na przykład statyczne wykrycie wszystkich identyfikatorów eksportu w następującym kodzie nie jest trywialne:
Ponieważ moduły ES są projektowane pod kątem statycznej analizy, kompilatory mają łatwiejszy czas na wykrycie niewykorzystanych eksportów.
Dlatego w twoim najlepszym interesie jest zapewnienie modułom ES kompilatora optymalizacyjnego do pracy z modułami, a nie modułami CommonJS.
Tło
Zanim dołączyłem do Klarny nie miałem doświadczenia w pracy z React Native. Podczas rutynowej refaktoryzacji zastosowałem następującą różnicę:
Zakładając, że używany program pakujący będzie uważany someFeatureMethod
za nieużywany, jeśli SOME_STATIC_FLAG
jest fałszywy, a tym samym usuwany some-feature-module
z końcowego pakietu. Podczas przeglądu kodu ta różnica została oznaczona jako problematyczna, więc usiadłem i dwukrotnie sprawdziłem moje założenia i gdzie zostały one złamane. Na szczęście przestawiliśmy się już na Webpack (w postaci Re.Pack ) kilka miesięcy wcześniej, aby umożliwić dzielenie pakietów zReact.lazy
. Ułatwiło mi to skonfigurowanie procesu łączenia w taki sposób, abym mógł sprawdzić ostateczny pakiet JavaScript. W naszym przypadku wystarczyło wyłączyć Hermesa, aby wyświetlić końcowe dane wyjściowe JavaScript .
Po kilku próbach i błędach, aby ułatwić znalezienie miejsca some-feature-module
importu, zauważyłem następujący wiersz: c=(n(463526),n(456189)
Operator przecinka jest czymś, czego normalnie nie używasz, więc pozwól mi podsumować, co to robi: Ocenia wszystkie operandy i używa tylko zwracanej wartości ostatni argument. Innymi słowy, zwracana wartość n(463526)
była niewykorzystana. Ponieważ miałem już doświadczenie w pracy z potrząsaniem drzewem w sieci, było dla mnie całkiem jasne, co to było przed minifikacją: require('some-feature-module')
(Webpack przekształca łańcuchy źródłowe importu na liczby).
Webpack faktycznie rozpoznał, że someFeatureMethod
nie był używany, i tym samym usunął jego użycie. Jednak Webpack zrezygnował z usunięcia nieużywanych eksportów z modułu i tym samym zatrzymał import, ponieważ nie wiedział, czy moduł ma jakieś skutki uboczne. Jeśli moduł ma skutki uboczne, nie możemy po prostu usunąć go z pakietu, ponieważ zmieniłoby to przepływ programu.
Wszystko, co musieliśmy zrobić, aby oryginalna różnica działała zgodnie z oczekiwaniami, to upewnić się, że Tree Shaking zostanie zastosowany w ostatecznym pakiecie.
Realizacja
Wszystko sprowadza się do upewnienia się, że nie przetransponujesz modułów ES do CommonJS, zanim Webpack spakuje wszystkie moduły. Jeśli używasz ustawienia Metro Babel (domyślnego dla nowych aplikacji React Native), większość pracy sprowadza się do włączenia disableImportExportTransform
:
Pamiętaj, że ta opcja jest obecnie nieudokumentowana i może zostać usunięta w dowolnym momencie.
Musieliśmy również powiedzieć Webpackowi, aby używał punktów wejścia, które używają modułów ES zamiast modułów CommonJS. W przypadku pojedynczych plików oznacza to preferowanie .mjs
podczas gdy w przypadku pakietów musieliśmy powiedzieć Webpackowi, aby używał module
głównego pola .
Jednak ujawniło to problemy z tym, jak napisaliśmy JavaScript i jak kod jest pisany w ekosystemie React Native. Zidentyfikowaliśmy 3 klasy problemów.
Eksportowanie różnej składni w main
imodule
Te główne pola powinny być używane tylko do rozróżnienia systemu modułów ( main
dla CommonJS, module
dla modułów ES). Jednak wiele pakietów dostarcza bardziej nowoczesną składnię od punktu module
wejścia. Na przykład class
składnia nie jest obecnie obsługiwana przez Hermes .
Na razie przenosimy całą node_modules
zawartość do składni ES5, a raczej składni obsługiwanej przez Hermesa, dodając custom rule
do konfiguracji Webpacka:
Importowanie modułu CommonJS z niejednoznaczną składnią
Webpack nie będzie w stanie znaleźć eksportów z modułów, które mieszają systemy modułów. Jednak sam React Native dostarcza swoje pliki źródłowe z mieszanym systemem modułów, np
Rozwiązaniem tutaj jest dalsze przenoszenie tych modułów do CommonJS (wyłączając w ten sposób Tree Shaking) poprzez dodanie specjalnego rule
do konfiguracji Webpack:
Identyfikatory importu nie istnieją
W rzeczywistości jest to SyntaxError w JavaScript, o którym większość nie jest świadoma. Na przykład import { doesNotExist } from 'some-module';
rzuci SyntaxError
. Jest to w dużej mierze uciążliwe dla programistów, ale może prowadzić do rzeczywistych problemów ze środowiskiem wykonawczym. Wymusiliśmy tę ścisłą implementację modułów ES w Webpack, włączając module.parser.javascript.exportsPresence
w konfiguracji Webpack.
Większość z tych problemów była spowodowana ponownym eksportem typów w TypeScript, np
Na szczęście TypeScript może oflagować te problemy na poziomie typu, włączając isolatedModules
:
type
modyfikatory nazw importów są nowością w TypeScript 4.5. Dodanie obsługi type
modyfikatorów w nazwach importu było sporym wyzwaniem, ponieważ musieliśmy zaktualizować parser ESLint, którego używaliśmy , Prettier i TypeScript.
Dodanie type
modyfikatorów do nazw importów powoduje, że Babel usuwa importy typów, które w rzeczywistości nie istnieją w czasie wykonywania.
Wyniki
Początkowa implementacja była dość hacky. Jednak już pierwsze wyniki pokazały 20-procentową medianę poprawy uruchamiania na obu platformach (Samsung Galaxy S9 2.2s spadł z 2,8s i iPhone 11 640ms spadł z 802ms).
Zaobserwowaliśmy 46% redukcję naszego początkowego, krytycznego fragmentu kodu JavaScript. Całkowity rozmiar wysyłanego przez nas kodu JavaScript spadł o 14%. Różnica jest w dużej mierze przypisywana przenoszeniu kodu z głównego fragmentu do fragmentów asynchronicznych (funkcje i trasy).


Te obrazy są tworzone przez niesamowity serwis statoscope.tech , który pomógł nam przeanalizować tę zmianę i będzie nam nadal pomagał w dalszym udoskonalaniu rozmiaru pakietu.
Zauważ, że redukcja nie wynika tylko z usunięcia nieużywanego eksportu, ale także z ModuleConcatenationPlugin
możliwości łączenia większej liczby modułów przez Webpack. Innymi słowy, możemy podnieść więcej modułów . Nie wykorzystujemy jeszcze w pełni podnoszenia lunetą. W tej chwili tylko 20% modułów jest podnoszonych. Po zwiększeniu tej liczby spodziewaliśmy się większego wzrostu rozmiaru pakietu i czasu działania.
40% redukcja w JavaScript odwzorowuje prawie dokładnie 1:1 czas potrzebny do oceny pakietu JavaScript, zanim będzie można go wykonać. Ocena JavaScript blokuje wynikający z tego czas uruchamiania, więc zmniejszenie ilości wysyłanego JavaScript bezpośrednio skraca czas uruchamiania.
Po zakończeniu ostatniego etapu implementacji 2 tygodnie później nadal mieliśmy te same wyniki w naszym laboratorium i byliśmy gotowi do wylądowania tej funkcji w naszym głównym oddziale. Dołożyliśmy wszelkich starań, aby wprowadzić ostateczną zmianę bezpośrednio po zakończeniu wydawania. Pozwoliło nam to szeroko przetestować nowy system modułów w wewnętrznych wersjach aplikacji, z których korzystają nasi pracownicy. Po tygodniu wewnętrznych testów funkcja była stopniowo udostępniana naszym użytkownikom końcowym. Mieliśmy wielką nadzieję, gdy zobaczyliśmy, że stabilność aplikacji pozostała w dużej mierze niezmieniona. Dane produkcyjne wykazały taką samą względną poprawę mediany czasu uruchamiania, którą widzieliśmy w wynikach naszego laboratorium:
Wersja 22.37 jest pozbawiona potrząsania drzewem; 22.38 z potrząsaniem drzewami


Te ulepszenia kosztem wydłużenia czasu budowy. Łączenie produkcyjnego pakietu JavaScript zajmuje około 30% (4 minuty) więcej czasu. Z radością przyjmujemy te wydłużone czasy kompilacji, ponieważ bezpośrednio przekładają się one na lepsze wrażenia użytkownika. Niektóre wydłużone czasy budowy przypisuje się transpilacji większej niż jest to konieczne. Początkowa implementacja nie poświęciła czasu na zmniejszenie ilości, którą musimy przetransponować. Zwrócimy również część czasu budowy, im więcej pakietów zawiera odpowiednie moduły ES. Pamiętaj, że czas łączenia JavaScript nie jest jedynym zadaniem wymaganym do zbudowania aplikacji React Native. W przypadku kompilowania plików binarnych itp. wzrost łączenia JavaScript nie ma ostatecznie takiego wpływu.
Co dalej
Wygląda na to, że moduły ES nie były aktywnie rozwijane w ekosystemie React Native. Będziemy chcieli bardziej dostosować ekosystem do właściwego wykorzystania modułów ES (np module
. wpisy wskazujące na JavaScript z równoważną składnią). W ten sposób możemy zmniejszyć naszą konfigurację kompilacji i mniej transpilować.
Chociaż istnieje wsparcie dla używania modułów ES za flagą w Metro ( experimentalImportSupport
), jest to oznaczone jako eksperymentalne i nieudokumentowane. Włączenie tej flagi w fazie rozwoju nie działa (jeszcze), ale mamy nadzieję, że pewnego dnia będziemy mogli używać tego samego systemu modułów w fazie rozwoju i produkcji. Chcemy wznowić dyskusję na temat modułów ES w React native, ponieważ wygląda na to, że wsparcie dla modułów ES nie jest obecnie aktywnie rozwijane. Obsługa Tree Shaking została nawet całkowicie porzucona lata temu. .
W końcu moduły ES to funkcja języka, której każdy, kto zna JavaScript, w końcu się nauczy. Nie widzimy powodu, dla którego React Native miałby mieć dodatkowy etap nauki, aby zrozumieć podział pakietu i eliminację martwego kodu.
Napisz raz, biegnij gdziekolwiek!
Podobał Ci się ten post? Śledź Klarna Engineering na Medium i LinkedIn, aby być na bieżąco z podobnymi artykułami.