Aplicativos nativos React para sacudir a árvore

Nov 28 2022
Como aplicamos técnicas de otimização comuns a aplicativos da web em nosso aplicativo React Native para reduzir os tempos de inicialização em 20%. Árvore o quê? É certo que Tree Shaking pode ser um termo confuso.

Como aplicamos técnicas de otimização comuns a aplicativos da web em nosso aplicativo React Native para reduzir os tempos de inicialização em 20%.

Árvore o quê?

É certo que Tree Shaking pode ser um termo confuso. Você já deve ter ouvido falar dele como “import elision” no TypeScript. Tree Shaking é uma forma de eliminação de código morto relacionada especificamente à remoção de exportações não utilizadas. Se concatenarmos todos os módulos, as exportações não utilizadas são efetivamente código morto e podem ser removidas. No entanto, o processo de determinação das exportações não utilizadas não é trivial. O Tree Shaking geralmente é implementado no nível do compilador/empacotador (por exemplo , Webpack ou ESBuild ) e não por um mecanismo JavaScript (como V8 ou Hermes). Muitos padrões em JavaScript podem quebrar o Tree Shaking, mas neste artigo quero focar em um aspecto: o sistema de módulos. Os dois sistemas de módulos relevantes que precisamos entender aqui são módulos CommonJS eMódulos ES .

CommonJS é usado quando você escreve module.exports = {}ou exports.someMethod = () => {}. Os módulos ES são identificados por importe exportsintaxe. É mais difícil para os compiladores aplicar o Tree Shaking ao código usando o CommonJS do que os módulos ES. Os módulos CommonJS geralmente são dinâmicos, enquanto os módulos ES podem ser analisados ​​estaticamente. Por exemplo, não é trivial detectar estaticamente todos os identificadores de exportação no seguinte código:

Como os módulos ES são analisáveis ​​estaticamente por design, os compiladores têm mais facilidade em detectar exportações não utilizadas.
Portanto, é de seu interesse fornecer módulos ES do seu compilador de otimização para trabalhar e não módulos CommonJS.

Fundo

Antes de ingressar na Klarna, não tinha experiência em trabalhar com React Native. Durante uma refatoração de rotina, apliquei o seguinte diff:

Assumindo que o empacotador usado consideraria someFeatureMethodcomo não utilizado se SOME_STATIC_FLAGfor falso e, portanto, removendo some-feature-moduledo pacote final. Durante a revisão do código, esse diff foi marcado como problemático, então sentei-me e verifiquei novamente minhas suposições e onde elas foram quebradas. Felizmente, já mudamos para o Webpack (na forma de Re.Pack ) alguns meses antes para permitir a divisão do pacote comReact.lazy . Isso tornou mais fácil para mim configurar o processo de empacotamento de forma que eu pudesse inspecionar o pacote JavaScript final. Apenas o Hermes precisou ser desabilitado em nosso caso para visualizar a saída final do JavaScript.

Depois de algumas tentativas e erros para facilitar a descoberta de onde some-feature-moduleé importado, localizei a seguinte linha: c=(n(463526),n(456189)O operador vírgula é algo que você normalmente não usa, então deixe-me resumir o que isso faz: Ele avalia todos os operandos e usa apenas o valor de retorno de o último operando. Em outras palavras, o valor de retorno de n(463526)não foi utilizado. Como eu já tinha experiência em trabalhar com tree-shaking na web, ficou bem claro para mim o que era antes da minificação: require('some-feature-module')(o Webpack transforma as strings de origem da importação em números).

O Webpack de fato reconheceu que someFeatureMethodnão era usado e, portanto, removeu seu uso. No entanto, o Webpack desistiu de remover as exportações não utilizadas do módulo e, portanto, manteve a importação porque não sabia se o módulo tinha algum efeito colateral. Se um módulo tiver efeitos colaterais, não podemos simplesmente removê-lo do pacote, pois isso mudaria o fluxo do programa.

Tudo o que tínhamos que fazer para fazer o diff original funcionar como esperado era garantir que o Tree Shaking fosse aplicado ao pacote final.

Implementação

Tudo se resume a garantir que você não transpile os módulos ES para o CommonJS antes que o Webpack agrupe todos os módulos. Se você estiver usando a predefinição Metro Babel (padrão para novos aplicativos React Native), a maior parte do trabalho se resume a habilitar disableImportExportTransform:

Observe que esta opção não está documentada no momento e pode ser removida a qualquer momento.

Também precisávamos dizer ao Webpack para usar pontos de entrada que usam módulos ES em vez de módulos CommonJS. Para arquivos individuais, isso significa preferir .mjsenquanto para pacotes, precisamos dizer ao Webpack para usar o modulecampo principal .

No entanto, isso revelou problemas com a forma como escrevemos JavaScript e como o código é escrito no ecossistema React Native. Identificamos 3 classes de problemas.

Exportando diferentes sintaxes em mainemodule

Esses campos principais devem ser usados ​​apenas para diferenciar o sistema do módulo ( mainpara CommonJS, modulepara módulos ES). No entanto, muitos pacotes fornecem uma sintaxe mais moderna do ponto de moduleentrada. Por exemplo, classa sintaxe atualmente não é suportada pelo Hermes .

Por enquanto, transpilamos todo node_moduleso conteúdo para a sintaxe ES5 ou melhor, a sintaxe suportada pelo Hermes, adicionando um custom ruleà configuração do Webpack:

Importando um módulo CommonJS com sintaxe ambígua

O Webpack não poderá encontrar exportações de módulos que combinam sistemas de módulos. No entanto, o próprio React Native envia seus arquivos de origem com um sistema de módulos mistos, por exemplo

A solução aqui é continuar transpilando esses módulos para o CommonJS (desativando o Tree Shaking) adicionando um especial ruleà configuração do Webpack:

Identificadores de importação não existentes

Este é realmente um SyntaxError em JavaScript que a maioria não conhece. Por exemplo, import { doesNotExist } from 'some-module';lançará um arquivo SyntaxError. É um grande incômodo para os desenvolvedores, mas pode levar a problemas reais de tempo de execução. Reforçamos esta implementação estrita de módulos ES no Webpack habilitando module.parser.javascript.exportsPresencena configuração do Webpack.

A maioria desses problemas foi causada pela reexportação de tipos no TypeScript, por exemplo

Felizmente, o TypeScript pode sinalizar esses problemas no nível do tipo ativando isolatedModules:

typemodificadores em nomes de importação são novos no TypeScript 4.5. Adicionar suporte para typemodificadores em nomes de importação foi um grande desafio, pois precisávamos atualizar o analisador ESLint que usamos , Prettier e TypeScript.

Adicionar typemodificadores para importar nomes resulta na remoção de importações de tipo do Babel que não existem realmente em tempo de execução.

Resultados

A implementação inicial foi bastante hacky. No entanto, os primeiros resultados já mostraram uma melhoria média de inicialização de 20% em ambas as plataformas (Samsung Galaxy S9 2,2s abaixo de 2,8s e iPhone 11 640ms abaixo de 802ms).

O que vimos foi uma redução de 46% de nosso bloco inicial e crítico de JavaScript. O tamanho geral do JavaScript que enviamos caiu 14%. A diferença é amplamente atribuída à movimentação do código do bloco principal para os blocos assíncronos (recursos e rotas).

Diferença absoluta das estatísticas do pacote após o Tree Shaking
Diferença relativa das estatísticas do pacote após o Tree Shaking

Essas imagens são criadas pelo incrível statoscope.tech, que nos ajudou a analisar essa mudança e continuará a nos ajudar a melhorar ainda mais o tamanho do pacote.

Observe que a redução não vem apenas da remoção da exportação não utilizada, mas também da capacidade do Webpack de ModuleConcatenationPluginconcatenar mais módulos. Em outras palavras, podemos fazer o escopo de elevação de mais módulos . Ainda não estamos utilizando totalmente o içamento de osciloscópio. Neste momento, apenas 20% dos módulos são içados. Esperávamos mais tamanho de pacote e ganhos de tempo de execução assim que aumentássemos esse número.

A redução de 40% no JavaScript mapeia quase exatamente 1:1 para o tempo que leva para avaliar o pacote JavaScript antes que ele possa ser executado. Avaliar o JavaScript está bloqueando o tempo de inicialização resultante, portanto, reduzir a quantidade de JavaScript enviada diretamente reduz o tempo de inicialização.

Depois que a etapa final da implementação foi concluída 2 semanas depois, ainda tínhamos os mesmos resultados em nosso laboratório e estávamos prontos para colocar esse recurso em nossa filial principal. Tomamos cuidado extra para fazer a alteração final logo após o corte de lançamento. Isso nos permitiu testar extensivamente o novo sistema de módulos em versões de aplicativos internos usados ​​por nossos funcionários. Após uma semana de testes internos, o recurso foi implementado gradualmente para nossos usuários finais. Ficamos muito esperançosos depois que vimos que a estabilidade do aplicativo não foi afetada. Os dados de produção mostraram as mesmas melhorias relativas ao tempo médio de inicialização que vimos em nossos resultados de laboratório:

A versão 22.37 é sem trepidação da árvore; 22h38 com balanço de árvore

Dados de produção para usuários do Android
Dados de produção para usuários do iOS

Essas melhorias vêm com o custo de aumentar os tempos de construção. O empacotamento do pacote JavaScript de produção leva aproximadamente 30% (4 minutos) a mais de tempo. Ficamos felizes com esses tempos de compilação aumentados, pois eles se traduzem diretamente em uma melhor experiência do usuário. Alguns dos tempos de construção aumentados são atribuídos a transpilar mais do que o necessário. A implementação inicial não gastou tempo na redução da quantidade que precisamos transpilar. Também recuperaremos alguns dos aumentos de tempo de compilação, quanto mais pacotes enviarem módulos ES adequados. Lembre-se de que o tempo de empacotamento do JavaScript não é a única tarefa necessária para criar um aplicativo React Native. Com a compilação de binários, etc., o aumento no agrupamento de JavaScript não é tão impactante no final.

Qual é o próximo

Parece que os módulos ES não foram trabalhados ativamente no ecossistema React Native. Queremos alinhar o ecossistema mais no uso adequado de módulos ES (por exemplo module, entradas apontando para JavaScript com sintaxe equivalente). Dessa forma, podemos reduzir nossa configuração de compilação e transpilar menos.

Embora haja suporte para o uso de módulos ES atrás de um sinalizador no Metro ( experimentalImportSupport), ele é marcado como experimental e não documentado. Ativar esse sinalizador no desenvolvimento não funciona para nós (ainda), mas esperamos que algum dia possamos usar o mesmo sistema de módulos no desenvolvimento e na produção. Queremos reiniciar a discussão sobre os módulos ES no React nativo, pois parece que o suporte para módulos ES não está sendo trabalhado ativamente no momento. O suporte do Tree Shaking foi completamente abandonado anos atrás. .

Afinal, os módulos ES são um recurso de linguagem que todo mundo que conhece JavaScript também aprende eventualmente. Não vemos razão para que o React Native tenha uma etapa extra de aprendizado para entender a divisão do pacote e a eliminação do código morto.

Escreva uma vez, corra em qualquer lugar!

Gostou deste post? Siga a Klarna Engineering no Medium e no LinkedIn para ficar atento a mais artigos como esses.