Как мы применили методы оптимизации, общие для веб-приложений, к нашему приложению React Native, чтобы сократить время запуска на 20%.

Дерево что?

По общему признанию, Tree Shaking может быть запутанным термином. Возможно, вы уже слышали об этом как об импортном исключении в TypeScript. Tree Shaking — это форма устранения мертвого кода, связанная конкретно с удалением неиспользуемых экспортов. Если бы мы объединили все модули, неиспользуемые экспорты оказались бы мертвым кодом и могли быть удалены. Однако процесс определения неиспользованного экспорта не является тривиальным. Tree Shaking обычно реализуется на уровне компилятора/упаковщика (например, Webpack или ESBuild), а не с помощью движка JavaScript (такого как V8 или Hermes). Многие шаблоны в JavaScript могут сломать Tree Shake, но в этой статье я хочу сосредоточиться на одном аспекте: системе модулей. Две соответствующие модульные системы, которые нам нужно понять, — это модули CommonJS и модули ES.

CommonJS используется, когда вы пишете module.exports = {} или exports.someMethod = () => {}. Модули ES идентифицируются синтаксисом import и export. Компиляторам сложнее применять Tree Shaking к коду, использующему CommonJS, чем к модулям ES. Модули CommonJS часто являются динамическими, в то время как модули ES можно анализировать статически. Например, не так просто статически определить все идентификаторы экспорта в следующем коде:

Поскольку ES-модули по своей структуре поддаются статическому анализу, компиляторам легче обнаружить неиспользуемые экспорты.
Поэтому в ваших же интересах предоставить оптимизирующему компилятору ES-модули для работы, а не модули CommonJS.

Фон

До того, как я присоединился к Klarna, у меня не было опыта работы с React Native. Во время рутинного рефакторинга я применил следующий diff:

Предполагая, что используемый упаковщик будет рассматривать someFeatureMethod как неиспользованный, если SOME_STATIC_FLAG ложно, и, таким образом, удалить some-feature-module из окончательного пакета. Во время проверки кода этот diff был отмечен как проблемный, поэтому я сел и перепроверил свои предположения и то, где они были нарушены. К счастью, мы уже перешли на Webpack (в виде Re.Pack) пару месяцев назад, чтобы включить разбиение пакетов с React.lazy. Это упростило мне настройку процесса сборки таким образом, чтобы я мог проверить окончательный пакет JavaScript. В нашем случае нужно было отключить только Гермес, чтобы увидеть окончательный вывод JavaScript.

После некоторых проб и ошибок, чтобы было легче найти, куда импортируется some-feature-module, я заметил следующую строку: c=(n(463526),n(456189) Оператор запятая — это то, что вы обычно не используете, поэтому позвольте мне обобщить, что он делает: он оценивает все операнды и использует только возвращаемое значение последнего операнда. Другими словами, возвращаемое значение n(463526) не использовалось. Поскольку у меня уже был опыт работы с tree-shaking в сети, мне было довольно ясно, что это было до минификации: require('some-feature-module') (Webpack преобразует исходные строки импорта в числа).

Webpack действительно признал, что someFeatureMethod не используется, и поэтому прекратил его использование. Однако Webpack отказался от удаления неиспользуемых экспортов из модуля и, таким образом, сохранил импорт, потому что не знал, есть ли у модуля какие-либо побочные эффекты. Если у модуля есть побочные эффекты, мы не можем просто удалить его из пакета, так как это изменит ход программы.

Все, что нам нужно было сделать, чтобы оригинальный diff работал, как ожидалось, это убедиться, что Tree Shaking применяется к окончательному пакету.

Выполнение

Все сводится к тому, чтобы вы не транспилировали модули ES в CommonJS до того, как Webpack объединит все модули. Если вы используете предустановку Metro Babel (по умолчанию для новых приложений React Native), большая часть работы сводится к включению disableImportExportTransform:

Обратите внимание, что эта опция в настоящее время недокументирована и может быть удалена в любой момент.

Нам также нужно было указать Webpack использовать точки входа, которые используют модули ES вместо модулей CommonJS. Для отдельных файлов это означает предпочтение .mjs, в то время как для пакетов нам нужно было указать Webpack использовать module основное поле.

Однако это выявило проблемы с тем, как мы писали JavaScript и как писался код в экосистеме React Native. Мы определили 3 класса проблем.

Экспорт различного синтаксиса в main и module

Эти основные поля следует использовать только для различения модульной системы (main для CommonJS, module для модулей ES). Однако многие пакеты содержат более современный синтаксис из точки входа module. Например, синтаксис class в настоящее время не поддерживается Hermes.

На данный момент мы преобразуем все содержимое node_modules в синтаксис ES5 или, скорее, в синтаксис, который поддерживается Hermes, добавив пользовательский rule в конфигурацию Webpack:

Импорт модуля CommonJS с неоднозначным синтаксисом

Webpack не сможет найти экспорт из модулей, которые смешивают модульные системы. Однако сам React Native поставляет свои исходные файлы со смешанной модульной системой, например.

Решение здесь состоит в том, чтобы продолжать транспилировать эти модули в CommonJS (таким образом отключив Tree Shaking), добавив специальный rule в конфигурацию Webpack:

Идентификаторы импорта не существуют

На самом деле это SyntaxError в JavaScript, о которой большинство не знает. Например, import { doesNotExist } from 'some-module'; выдаст SyntaxError. Это в значительной степени неприятно для разработчиков, но может привести к реальным проблемам во время выполнения. Мы усилили эту строгую реализацию модулей ES в Webpack, включив module.parser.javascript.exportsPresence в конфигурации Webpack.

Большинство этих проблем были вызваны повторным экспортом типов в TypeScript, например.

К счастью, TypeScript может помечать эти проблемы на уровне типа, включив isolatedModules:

type модификаторы в именах импорта появились в TypeScript 4.5. Добавление поддержки модификаторов type в имена импорта было довольно сложной задачей, поскольку нам нужно было обновить используемый нами синтаксический анализатор ESLint, Prettier и TypeScript.

Добавление модификаторов type к именам импорта приводит к тому, что Babel удаляет импорты типов, которые на самом деле не существуют во время выполнения.

Полученные результаты

Первоначальная реализация была довольно хакерской. Тем не менее, самые первые результаты уже показали медианное улучшение запуска на 20% на обеих платформах (Samsung Galaxy S9 2.2s по сравнению с 2.8s и iPhone 11 640ms по сравнению с 802ms).

То, что мы увидели, было сокращением нашего первоначального критического фрагмента JavaScript на 46%. Общий размер поставляемого нами JavaScript уменьшился на 14%. Разница в значительной степени связана с перемещением кода из основного блока в асинхронные блоки (функции и маршруты).

Эти изображения созданы замечательным statoscope.tech, который помог нам проанализировать это изменение и будет продолжать помогать нам в дальнейшем улучшении размера пакета.

Обратите внимание, что сокращение происходит не только из-за удаления неиспользуемого экспорта, но и из-за того, что Webpack ModuleConcatenationPlugin может объединять больше модулей. Другими словами, мы можем поднять больше модулей. Мы еще не полностью используем подъем прицела. Сейчас поднято только 20% модулей. Мы ожидали большего увеличения размера пакета и увеличения времени выполнения, как только мы увеличим это число.

Сокращение JavaScript на 40% почти точно соответствует времени, необходимому для оценки пакета JavaScript перед его выполнением, 1:1. Оценка JavaScript блокирует результирующее время запуска, поэтому уменьшение количества отправляемого JavaScript напрямую сокращает время запуска.

После того, как через 2 недели был выполнен последний этап реализации, у нас все еще были те же результаты в нашей лаборатории, и мы были готовы добавить эту функцию в нашу основную ветку. Мы позаботились о том, чтобы внести окончательные изменения сразу после закрытия релиза. Это позволило нам тщательно протестировать новую модульную систему во внутренних версиях приложений, которыми пользуются наши сотрудники. После одной недели внутреннего тестирования функция была постепенно развернута для наших конечных пользователей. Мы очень надеялись, когда увидели, что стабильность приложения практически не пострадала. Производственные данные показали те же относительные улучшения среднего времени запуска, которые мы видели в наших лабораторных результатах:

Версия 22.37 без встряхивания дерева; 22.38 с встряхиванием деревьев

Эти улучшения достигаются за счет увеличения времени сборки. Сборка производственного пакета JavaScript занимает примерно на 30 % (4 минуты) больше времени. Мы с радостью принимаем это увеличенное время сборки, поскольку оно напрямую влияет на удобство работы пользователей. Некоторое увеличение времени сборки связано с транспилированием большего количества данных, чем нам нужно. Первоначальная реализация не тратила время на уменьшение количества, которое нам нужно транспилировать. Мы также компенсируем некоторое увеличение времени сборки, чем больше пакетов содержат правильные модули ES. Имейте в виду, что время сборки JavaScript — не единственная задача, необходимая для создания приложения React Native. С компиляцией двоичных файлов и т. д. увеличение связывания JavaScript в конечном итоге не так сильно влияет.

Что дальше

Похоже, что над модулями ES в экосистеме React Native активно не работали. Мы хотим настроить экосистему на правильное использование модулей ES (например, module записей, указывающих на JavaScript с эквивалентным синтаксисом). Таким образом, мы можем уменьшить нашу конфигурацию сборки и меньше транспилировать.

Несмотря на то, что в Metro поддерживается использование ES-модулей под флагом (experimentalImportSupport), он помечен как экспериментальный и не задокументирован. Включение этого флага в разработке не работает для нас (пока), но мы надеемся, что когда-нибудь сможем использовать одну и ту же модульную систему в разработке и производстве. Мы хотим возобновить обсуждение ES-модулей в нативном React, поскольку кажется, что поддержка ES-модулей в настоящее время активно не разрабатывается. Поддержка Tree Shaking даже была полностью заброшена несколько лет назад.

В конце концов, ES-модули — это языковая функция, которую со временем усваивают все, кто знает JavaScript. Мы не видим причин, по которым у React Native должен быть дополнительный шаг обучения, чтобы понять разделение пакетов и устранение мертвого кода.

Напиши один раз, беги куда угодно!

Понравился ли вам этот пост? Подпишитесь на Klarna Engineering в Medium и LinkedIn, чтобы не пропустить другие подобные статьи.