Встряхивание дерева React Native Apps

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

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. В нашем случае нужно было отключить только Hermes , чтобы увидеть окончательный вывод 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 , чтобы не пропустить другие подобные статьи.