Tree Shaking React App native

Nov 28 2022
Come abbiamo applicato tecniche di ottimizzazione comuni alle app Web alla nostra app React Native per ridurre i tempi di avvio del 20%. Albero cosa? Certo, Tree Shaking potrebbe essere un termine confuso.

Come abbiamo applicato tecniche di ottimizzazione comuni alle app Web alla nostra app React Native per ridurre i tempi di avvio del 20%.

Albero cosa?

Certo, Tree Shaking potrebbe essere un termine confuso. Potresti averne già sentito parlare come "import elision" in TypeScript. Tree Shaking è una forma di eliminazione del codice morto correlata specificamente alla rimozione delle esportazioni inutilizzate. Se dovessimo concatenare tutti i moduli, le esportazioni inutilizzate sono effettivamente dead-code e possono essere rimosse. Tuttavia, il processo di determinazione delle esportazioni inutilizzate non è banale. Tree Shaking è solitamente implementato a livello di compilatore/bundler (es. Webpack o ESBuild ) non da un motore JavaScript (come V8 o Hermes). Molti schemi in JavaScript possono interrompere Tree Shaking ma in questo articolo voglio concentrarmi su un aspetto: il sistema di moduli. I due sistemi di moduli rilevanti che dobbiamo comprendere qui sono i moduli CommonJS eModuli E.S.

CommonJS viene utilizzato quando scrivi module.exports = {}o exports.someMethod = () => {}. I moduli ES sono identificati da importe exportsintassi. È più difficile per i compilatori applicare Tree Shaking al codice utilizzando CommonJS rispetto ai moduli ES. I moduli CommonJS sono spesso dinamici mentre i moduli ES possono essere analizzati staticamente. Ad esempio, non è banale rilevare staticamente tutti gli identificatori di esportazione nel codice seguente:

Poiché i moduli ES sono analizzabili staticamente in base alla progettazione, i compilatori hanno più facilità a rilevare le esportazioni inutilizzate.
È quindi nel tuo interesse dare al tuo compilatore di ottimizzazione i moduli ES con cui lavorare e non i moduli CommonJS.

Sfondo

Prima di entrare in Klarna non avevo esperienza di lavoro con React Native. Durante un refactoring di routine ho applicato il seguente diff:

Supponendo che il bundler utilizzato considererebbe someFeatureMethodinutilizzato se SOME_STATIC_FLAGè false e rimuovendo quindi some-feature-moduledal bundle finale. Durante la revisione del codice questa differenza è stata contrassegnata come problematica, quindi mi sono seduto e ho ricontrollato le mie ipotesi e dove erano interrotte. Fortunatamente siamo già passati a Webpack (sotto forma di Re.Pack ) un paio di mesi prima per abilitare la suddivisione dei bundle conReact.lazy . Ciò ha reso più semplice per me configurare il processo di raggruppamento in modo da poter ispezionare il pacchetto JavaScript finale. Solo Hermes doveva essere disabilitato nel nostro caso per visualizzare l'output finale di JavaScript.

Dopo alcuni tentativi ed errori per rendere più facile trovare dove some-feature-moduleviene importato, ho individuato la seguente riga: c=(n(463526),n(456189)L' operatore virgola è qualcosa che normalmente non usi, quindi fammi riassumere cosa fa: valuta tutti gli operandi e usa solo il valore restituito di l'ultimo operando. In altre parole, il valore restituito di n(463526)era inutilizzato. Dato che avevo già esperienza nel lavorare con lo scuotimento degli alberi sul web, mi era abbastanza chiaro cosa fosse prima della minificazione: require('some-feature-module')(Webpack trasforma le stringhe di origine dell'importazione in numeri).

Webpack ha infatti riconosciuto che someFeatureMethodnon era utilizzato e quindi ne ha rimosso l'utilizzo. Tuttavia, Webpack ha evitato di rimuovere le esportazioni inutilizzate dal modulo e quindi ha mantenuto l'importazione perché non sapeva se il modulo avesse effetti collaterali. Se un modulo ha effetti collaterali, non possiamo semplicemente rimuoverlo dal pacchetto poiché ciò cambierebbe il flusso del programma.

Tutto quello che dovevamo fare per far funzionare la differenza originale come previsto, era assicurarci che Tree Shaking fosse applicato al pacchetto finale.

Implementazione

Tutto si riduce a garantire di non eseguire il transpile dei moduli ES in CommonJS prima che Webpack raggruppi tutti i moduli. Se stai utilizzando il preset Metro Babel (impostazione predefinita per le nuove app React Native), la maggior parte del lavoro si riduce all'abilitazione di disableImportExportTransform:

Si noti che questa opzione non è attualmente documentata e può essere rimossa in qualsiasi momento.

Avevamo anche bisogno di dire a Webpack di utilizzare punti di ingresso che utilizzano moduli ES anziché moduli CommonJS. Per i singoli file ciò significa preferire .mjsmentre per i pacchetti dovevamo dire a Webpack di utilizzare il modulecampo principale .

Tuttavia, questo ha rivelato problemi con il modo in cui abbiamo scritto JavaScript e come il codice è scritto nell'ecosistema React Native. Abbiamo identificato 3 classi di problemi.

Esportazione di sintassi diversa in mainemodule

Questi campi principali dovrebbero essere usati solo per differenziare il sistema di moduli ( mainper CommonJS, moduleper moduli ES). Tuttavia, molti pacchetti forniscono una sintassi più moderna dal punto di moduleingresso. Ad esempio, classla sintassi non è attualmente supportata da Hermes .

Per ora, trasferiamo tutti i node_modulescontenuti nella sintassi ES5 o meglio nella sintassi supportata da Hermes aggiungendo una personalizzazione rulealla configurazione del Webpack:

Importazione di un modulo CommonJS con sintassi ambigua

Webpack non sarà in grado di trovare esportazioni da moduli che mescolano sistemi di moduli. Tuttavia, React Native stesso spedisce i suoi file sorgente con un sistema di moduli misti, ad es

La soluzione qui è continuare a eseguire il transpiling di questi moduli su CommonJS (disabilitando così Tree Shaking) aggiungendo uno speciale rulealla configurazione del Webpack:

Identificatori di importazione non esistenti

Questo è in realtà un SyntaxError in JavaScript di cui la maggior parte non è a conoscenza. Ad esempio, import { doesNotExist } from 'some-module';genererà un SyntaxError. È in gran parte un fastidio per gli sviluppatori, ma può portare a reali problemi di runtime. Abbiamo applicato questa rigorosa implementazione dei moduli ES in Webpack abilitando module.parser.javascript.exportsPresencenella configurazione di Webpack.

La maggior parte di questi problemi è stata causata dalla riesportazione di tipi in TypeScript, ad es

Fortunatamente, TypeScript può segnalare questi problemi a livello di tipo abilitando isolatedModules:

typei modificatori sui nomi di importazione sono nuovi in ​​TypeScript 4.5. L'aggiunta del supporto per typei modificatori sui nomi di importazione è stata una vera sfida poiché dovevamo aggiornare il parser ESLint che abbiamo utilizzato , Prettier e TypeScript.

L'aggiunta typedi modificatori per importare i nomi fa sì che Babel rimuova le importazioni di tipi che in realtà non esistono in fase di esecuzione.

Risultati

L'implementazione iniziale è stata piuttosto confusa. Tuttavia, i primissimi risultati hanno già mostrato un miglioramento di avvio medio del 20% su entrambe le piattaforme (Samsung Galaxy S9 2.2s in meno rispetto a 2.8s e iPhone 11 640ms in meno rispetto a 802ms).

Ciò che abbiamo visto è stata una riduzione del 46% del nostro blocco JavaScript iniziale e critico. La dimensione complessiva di JavaScript che abbiamo spedito è diminuita del 14%. La differenza è in gran parte attribuita allo spostamento del codice dal blocco principale ai blocchi asincroni (funzionalità e percorsi).

Differenza assoluta delle statistiche del pacchetto dopo Tree Shaking
Differenza relativa delle statistiche dei bundle dopo Tree Shaking

Queste immagini sono state create dall'incredibile statoscope.tech che ci ha aiutato ad analizzare questo cambiamento e continuerà ad aiutarci a migliorare ulteriormente le dimensioni del bundle.

Si noti che la riduzione non deriva solo dalla rimozione dell'esportazione inutilizzata, ma anche dalla ModuleConcatenationPluginpossibilità di Webpack di concatenare più moduli. In altre parole, possiamo eseguire il sollevamento di più moduli . Non stiamo ancora utilizzando completamente il sollevamento dell'oscilloscopio. Al momento solo il 20% dei moduli viene sollevato. Ci aspettavamo maggiori dimensioni del pacchetto e guadagni di runtime una volta aumentato quel numero.

La riduzione del 40% in JavaScript corrisponde quasi esattamente 1:1 al tempo necessario per valutare il pacchetto JavaScript prima che possa essere eseguito. La valutazione di JavaScript sta bloccando il tempo di avvio risultante, quindi la riduzione della quantità di JavaScript spedito riduce direttamente il tempo di avvio.

Dopo che la fase finale dell'implementazione è stata completata 2 settimane dopo, avevamo ancora gli stessi risultati nel nostro laboratorio ed eravamo pronti per far atterrare questa funzione nel nostro ramo principale. Ci siamo presi molta cura di mettere a punto la modifica finale subito dopo l'interruzione del rilascio. Questo ci ha permesso di testare ampiamente il nuovo sistema di moduli nelle versioni interne dell'app utilizzate dai nostri dipendenti. Dopo una settimana di test interni, la funzione è stata gradualmente implementata per i nostri utenti finali. Eravamo molto fiduciosi dopo aver visto che la stabilità dell'app era in gran parte inalterata. I dati di produzione hanno mostrato gli stessi miglioramenti relativi al tempo di avvio medio che abbiamo visto nei nostri risultati di laboratorio:

La versione 22.37 è senza scuotimento dell'albero; 22.38 con scuotimento degli alberi

Dati di produzione per utenti Android
Dati di produzione per utenti iOS

Questi miglioramenti comportano un aumento dei tempi di costruzione. Il raggruppamento del pacchetto JavaScript di produzione richiede circa il 30% (4 minuti) in più di tempo. Prendiamo felicemente questi tempi di costruzione più lunghi poiché si traducono direttamente in una migliore esperienza utente. Alcuni dei tempi di costruzione aumentati sono attribuiti al transpiling più del necessario. L'implementazione iniziale non ha impiegato molto tempo a ridurre la quantità di cui abbiamo bisogno per eseguire il transpile. Recupereremo anche alcuni degli aumenti del tempo di compilazione, più pacchetti spediscono moduli ES adeguati. Tieni presente che il tempo di raggruppamento JavaScript non è l'unica attività richiesta per creare un'app React Native. Con la compilazione di binari, ecc., L'aumento del raggruppamento di JavaScript alla fine non ha un impatto così grande.

Qual è il prossimo

Sembra che i moduli ES non siano stati attivamente elaborati nell'ecosistema React Native. Vorremo allineare maggiormente l'ecosistema sull'uso corretto dei moduli ES (ad esempio modulevoci che puntano a JavaScript con sintassi equivalente). In questo modo possiamo ridurre la nostra configurazione di build e meno transpile.

Sebbene sia disponibile il supporto per l'utilizzo dei moduli ES dietro un flag in Metro ( experimentalImportSupport), è contrassegnato come sperimentale e non documentato. Abilitare quel flag in fase di sviluppo non funziona (ancora) per noi, ma speriamo di poter un giorno utilizzare lo stesso sistema di moduli in fase di sviluppo e produzione. Vogliamo riavviare la discussione sui moduli ES in React native poiché sembra che il supporto per i moduli ES non sia attualmente in lavorazione. Anche il supporto di Tree Shaking è stato completamente abbandonato anni fa. .

Dopotutto, i moduli ES sono una caratteristica del linguaggio che tutti coloro che conoscono JavaScript alla fine imparano. Non vediamo alcun motivo per cui React Native dovrebbe avere un ulteriore passaggio di apprendimento per comprendere la divisione dei bundle e l'eliminazione del codice morto.

Scrivi una volta, corri ovunque!

Ti è piaciuto questo post? Segui Klarna Engineering su Medium e LinkedIn per rimanere sintonizzato per altri articoli come questi.