Всем привет! Меня зовут Евгений Прокопьев, я разработчик на React Native с 9-летним стажем. В этой статье расскажу, как мы в Купере написали собственный CodePush, который совсем не похож на продукт Microsoft.
Наш подход позволяет:
уменьшить вес доставки изменений примерно в 1 000 раз (для обычных продуктовых фич);
сократить время запуска (хотя это зависит от того, насколько большой проект и что нужно инициализировать на старте);
разделить ответственность за функционал на команды, где каждая отвечает за свои фичи как за отдельные независимые пакеты.
Если интересно попробовать, загляните на GitHub. Большая часть логики реализована на JS. Проект — это proof of concept, а не отдельная библиотека (хотя почти весь код лежит в отдельной папке, и вытащить его в либу довольно легко).

Важно: по тексту я говорю про JS-файлы, но все работает и для байткода. Просто «JS-файл» звучит для меня более органично.
Почему стандартный CodePush не подходит
В этой статье я не буду подробно разбирать, как работает CodePush от Microsoft, сразу расскажу о его недостатках:
Загружается весь JS-бандл, вместе со всеми require-ассетами, даже если изменена всего одна строчка кода.
Из-за этого невозможно бесшовно и незаметно накатывать обновления: пользователь каждый раз качает десятки мегабайт.
Невозможно релизить или обновлять отдельную фичу, потому что JS полностью пересобирается, изменения не изолированы.
Исторически так сложилось, что весь код собирается в один JS-файл и поставляется вместе с приложением. Ну правда, зачем много отдельных файлов, если приложение доставляется через сторы и в APK/IPA можно положить все что угодно?
Но техническая возможность догружать JS-код в runtime в React Native есть уже из коробки. Нужно всего лишь написать нативный модуль для выноса этой логики в JS.
Нативный модуль на Android и iOS
Для Android код в файле Execute.kt:
package com.codepush
import com.facebook.react.bridge.ReactApplicationContext
import java.io.File
object Execute {
fun execute(path: String, reactContext: ReactApplicationContext) {
val file = File(path)
if (!file.exists()) {
throw Exception("File does not exist at path: $path")
}
val catalystInstance = reactContext.catalystInstance
catalystInstance?.let {
it.loadScriptFromFile(path, path, false)
} ?: throw Exception("CatalystInstance is not available")
}
}
В общем, ничего специфичного: просто передаем путь к файлу в метод, а дальше React Native делает все сам.
Для iOS все выглядит аналогично, только нужно дополнительно привести к правильному типу объект моста, который React прокидывает в экземпляр модуля. Код есть в Execute.mm:
#import "Execute.h"
@interface RCTCxxBridge
- (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async;
@end
@implementation Execute
+ (void)execute:(NSString *)path bridge:(RCTBridge *)bridge {
NSFileManager* manager = [NSFileManager defaultManager];
NSData* data = [manager contentsAtPath:path];
NSURL *url = [NSURL URLWithString:path];
__weak RCTCxxBridge *castedBridge = (RCTCxxBridge *)bridge;
[castedBridge executeApplicationScript:data url:url async:YES];
}
@end
И тут становится ясно: запустить отдельный файл — это скорее легкая часть истории. Теперь нужно как-то при релизной сборке получить разные JS-файлы.
Разбиение на бандлы и порядок сборки
Тут напрашивается идея разбить проект на разные бандлы/пакеты и собирать их по отдельности.
Разбить можно разными способами — например, так:

Это простейший вариант: при старте приложения запускается бандл, в котором собраны все JS-зависимости из package.json и логика, отвечающая за сам code-push, его обновление и старт первого модуля — в данном случае home. На нем уже рисуется какой-то экран, и следующий модуль грузится при навигации на него.
Но проект может быть большим, и хочется предусмотреть разные кейсы. Если стартовый экран может поменяться, если есть дизайн-система, которую используют все модули, если есть ядро приложения, то схема модулей может выглядеть уже так:

В init-бандл вынесены все нативные зависимости (их JS-часть), потому что через code-push их все равно никак не обновить, а также модуль code-push, как и в случае выше.
Дальше загружается модуль с JS-зависимостями: они вынесены в отдельный бандл на случай, если нужно будет их обновить без релиза в сторы.
Еще у приложения, вероятно, есть свое ядро, которое управляет логикой. Оно может обновляться часто — значит, лучше и его в бандл вынести, просто чтобы не грузить каждый раз лишнего.
Ну и бандл с навигацией, в котором создается стек навигации, обрабатываются диплинки, переходы… В этом же бандле живет логика, определяющая, какой модуль стартанет и, наконец, покажет приложение.
Все эти модули можно загружать параллельно, но подгружать в runtime надо именно в таком порядке, потому что навигация, вероятнее всего, зависит от core, а core строит свою логику как минимум поверх React.
Таким образом, все — от стартового бандла до навигации — можно считать основой приложения, в которой порядок загрузки бандлов вряд ли изменится.
В итоге приложение стартует, по цепочке загружаются все бандлы, навигация запускает стартовый экран, и дальше от действий пользователя будет зависеть, какой бандл запустится следующим.
Сборка отдельных бандлов и нюансы Metro
Понятно, как разбить на бандлы. Теперь о том, как отдельно этот бандл собрать (итоговый код — generateBundles.js).
Для варианта простого приложения нам нужно как минимум два бандла: init и home. Это, соответственно, две точки входа.
React Native собирает свой единственный JS-бандл примерно такой командой:
npx react-native bundle --platform ios --minify true --dev false --entry-file ./index.ts --bundle-output ./dist/index.ios.bundle --assets-dest=./dist --reset-cache
Если попробовать запустить ее для home-бандла, Metro соберет бандл — но получится не то, что ожидаешь. Для полного контекста покажу, как именно Metro собирает модули и что вообще попадает в сам бандл.
Как Metro собирает модули
Предположим, я хочу собрать файл только с одной функцией:
export function debounce(func: Function, ms: number) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function () {
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, arguments);
}, ms);
};
}
Вызываю команду, которую указывал выше, и получаю в результате файл. В нем можно найти этот debounce, много вопросов и еще кучу всего.
__d(
function (g, r, i, a, m, e, d) {
Object.defineProperty(e, '__esModule', {value: !0}),
(e.debounce = function (n, t) {
var u = null;
return function () {
var o = arguments,
c = this;
u && clearTimeout(u),
(u = setTimeout(function () {
n.apply(c, o);
}, t));
};
});
},
5,
[],
);
Тут __d
— это функция, которую добавляет сборщик. Она регистрирует модули (любой файл, который подключается через import или require).
Первым аргументом идет функция, которая при вызове отдает результат выполнения модуля.
Вторым аргументом сборщик устанавливает id модуля, чтобы потом легко было брать нужные модули и указывать их как зависимости.
Третий аргумент — массив зависимостей этого модуля, по сути, все импорты будут перечислены тут как массив id зарегистрированных модулей.
К нюансам
В каждый бандл, который собирается, сборщик кладет свой runtime (та самая
__d
).В каждый бандл попадают все зависимости, которые встретятся на пути.
Каждый раз id назначается просто инкрементом: первый файл, который встретился — id 1, следующий — 2, и т. д.
Что нужно от сборщика и как мы это решили
Перед нами стояло три больших задачи:
Нужно, чтобы id для одного и того же модуля (читай файла) был всегда одинаковым, независимо от бандла, в котором он собирается или есть в зависимостях.
Нужно, чтобы файлы попадали в сборку только один раз. Если это JS-либы, core или код какой-то фичи — все это должно быть в соответствующих бандлах и собрано только один раз.
В дев-режиме сборщик должен работать по-старому.
Все это можно настроить под себя в файлe metro.config. Не буду разбирать все возможности — у них есть довольно подробная документация. Сразу к решению проблем.
Первая задача. Надо задать уникальные id для модулей, которые будут одинаковыми вне зависимости от собираемого бандла. По дефолту Metro задает их инкрементом, и в каждой сборке начинает с 1.
В голову сразу приходит задать id строкой. Но Metro так делать не дает — runtime в таком случае падает с ошибкой. Поэтому надо придумать какое-то число.
У нас всегда есть статичный путь к файлу — давайте просто рассчитаем от него хеш, который будет всегда одинаковым для этого пути, и используем во всех местах для генерации id модуля.
export const makeHashFunc = (str) => {
const seed = 0;
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};
Эта найденная на просторах интернета функция для вычисления хеша всегда возвращает 53-битное число. Да, возможно, будут коллизии, но в Купере за все время использования они ни разу не происходили. А если такое все-таки случится, то в дев-режиме сборщик выкинет исключение с просьбой переназвать новый файл.
Вторая задача. Чтобы обеспечить дедубликацию модулей и нахождение их в ожидаемых бандлах, нужно:
собирать бандлы в определенном порядке (согласно принципу инверсии зависимостей — см. схему из первой части статьи);
где-то хранить информацию об уже собранных модулях.
Для хранения используется обычный текстовый файл, назовем его fileToIdMap.txt. Каждый раз, когда модуль при сборке попадает в бандл, делается запись в этот файл. При сборке следующего бандла становится понятно, был ли этот модуль уже собран.
Помимо этого, для сборки фича-бандлов (это все бандлы, которые не относятся к архитектуре/старту приложения, и отвечают исключительно за фичи/экраны) есть смысл еще сильнее ограничить модули, которые могут в них входить. Эти бандлы могут зависеть:
от других фича-бандлов, которые собираются в другой момент;
от любых JS-либ, которые уже должны быть собраны в init-бандле.
Поэтому сборку модулей для фича-бандлов я ограничиваю папкой, в которой этот бандл лежит. Таким образом, в фича-бандл попадет только код текущей фичи.
Третью задачу обсудим в конце.
Metro config — магия в serializer
Если все это реализовать, то для простого случая получается два Metro-конфига: metro.config.js и metro.bundle.config.js. Разберу изменения относительно стандартного конфига на примере второго из них.
Вся магия происходит в объекте serializer. Там я переопределил две функции:
createModuleIdFactory отвечает за вычисление id модуля. Ее результат должен возвращать целое число, чтобы все правильно работало в runtime. Также именно она записывает в fileToIdMap.txt модули, которые уже были собраны.
processModuleFilter отвечает за то, попадет ли текущий модуль в собираемый сейчас бандл или нет. Тут все просто: если вернет true, модуль попадет в бандл, если false — не попадет. Это конфиг для фича-бандлов, поэтому внутри есть проверка на process.env?.MODULE_PATH: в такой бандл может попасть только модуль из папки бандла.
Конфиг для сборки init-бандла выглядит похожим образом, только в нем нет ограничений на то, что туда может попасть.
Как управлять бандлами и подключать их на девайсе
Итак, я написал нативный код для добавления отдельных бандлов в runtime, а также процесс сборки этих бандлов. Осталось в JS добавить возможность запускать нужные бандлы в нужный момент.
Предположим, что все эти бандлы и конфиг (meta.json-файл) с их названиями/версиями/зависимостями есть на девайсе. Нужно просто сделать прослойку для управления ими.
Для этого я написал свой Import — функцию, которая принимает на вход имя модуля, возвращает то, что у него экспортируется из index-файла, и попутно загружает бандл в runtime, если его там еще нет.
Import ищет информацию об актуальной версии пакета в meta.json. На основе имени бандла и нужной версии он строит путь к файлу. Если у бандла есть зависимости, Import рекурсивно запускает их, чтобы они тоже подгрузились в runtime.
После загрузки в runtime всех зависимостей бандла туда подгружается и сам бандл. Важно делать это именно в такой последовательности, иначе при попытке вызвать модуль, которого нет, приложение упадет.
Потом такой кастомный импорт из бандла можно использовать с React.lazy:
const ProfileScreenLazy = React.lazy(async () => {
const data = await CPImport('profile');
return {default: data.ProfileScreen};
});
export const ProfileScreen = () => {
return (
<Suspense fallback={<Indicator title={'Profile'} />}>
<ProfileScreenLazy />
</Suspense>
);
};
Здесь ProfileScreen просто импортируется как обычный React-компонент в провайдер навигации, и при навигации на экран будет показана загрузка (или скелетон). Когда бандл подгрузится в runtime, из CPImport вернется содержимое index-файла в виде объекта.
Сборка, деплой, meta.json и работа через сеть
У нас есть весь код для сборки, загрузки в runtime и использования бандлов. Осталось добавить возможность загружать новые бандлы по сети.
Я уже упоминал конфиг meta.json. Его нет на GitHub, но вы можете его получить, запустив команду yarn build в проекте. Эта команда собирает все бандлы. Получается слепок актуального на текущий момент приложения.
Последний шаг — загрузить на сервер все архивы с бандлами и meta.json-файл, докинуть в него урлы к только что загруженным бандлам и создать простой роут, который будет отдавать этот meta-файл. Это уже не относится напрямую к code-push, поэтому реализации никакой не будет.
Это та самая третья задача. Для корректной работы дебаг-сборки я написал Babel-плагин. По сути, он просто заменяет CPImport на обычный импорт, в остальном сборка работает в привычном режиме.
Итого: что дает свой Code Split Push
При переходе на описанный механизм разработки и деплоя приложения вы точно сильно ускорите обновление приложения и сможете перейти на синхронный режим (именно он реализован в репозитории и позволяет видеть изменения в текущей сессии, а не следующей). Работает это следующим образом:
Актуальный meta-файл получается на старте.
Пользователь переходит на какой-то функционал — в этот момент код обновленного бандла загружается по сети (обычно размер не больше 50 Кб, поэтому работает быстро).
Код закидывается в runtime, и пользователь видит актуальный экран.
При этом вы можете обновлять бандлы независимо и делать релизы хоть несколько раз в день. Из-за маленького размера пользователь не заметит разницы по сравнению с обычной загрузкой данных для экрана.
Когда мы в Купере разбили приложение на несколько бандлов и грузили их по очереди, мы заметили, что старт приложения происходил быстрее примерно на 10%. Эффект будет варьироваться в зависимости от того, какую часть получится изолировать от старта и вынести в отдельную сущность.
Небольшая ремарка: в Купере похожая версия использовалась раньше, но пока мы вернулись к классическому CodePush.
Релизьте фичи независимо и часто, разделяйте ответственность по зонам и радуйтесь более чистой архитектуре, в которой ядро, навигация и фичи — это отдельные бандлы с четкими границами.
Если я что-то упустил или у вас есть другие идеи по реализации кастомного CodePush — пишите в комментариях. Буду рад горячей дискуссии!
Комментарии (4)
Mox
31.07.2025 09:49А как у вас построен workflow? И что получает юзер после установки из стора - там сразу качаются все актуальные обновления?
Мы просто обновления по воздуху (expo-updates) используем только для доставки хотфиксов (есть же правило сторов - не менять существенно функционал в обход).
А доставку фич реализовали постоянно делая релизы через CI/CD (закрывая не реализованные фичи фиче-флагами).Evgen175 Автор
31.07.2025 09:49И что получает юзер после установки из стора - там сразу качаются все актуальные обновления?
Все последние на момент сборки самого приложения бандлы зашиваются в apk/ipa, поэтому юезр на первом старте не качает десятки Мб. Но если на момент запуска есть новые бандлы, то они будут обновляться.
(есть же правило сторов - не менять существенно функционал в обход).
На сколько я его понимаю, они просят категорически не менять приложение, а что-то дополнять всем ок. Многие компании используют те же самые флаги для показа того или иного функционала, BDUI или еще что-то и я не знаю случаев что бы за это прилетали баны. Тут подробнее инфа, пункт 3.3.1 B
ltmrv
Спасибо за интересную статью! Давно тут на хабре не было новых статей про РН.
Подскажите пожалуйста, правильно ли я понял, что приложение на реакт-нейтив может обновляться прямо "на лету" во время пользовательской сессии? Условно, юзер открыл приложение, переходит на какую-нибудь определённую страницу, которая может лениво подгрузиться с новым обновлением?
И может ли быть такое, что юзер зашел на эту страницу, увидел, условно, старый интерфейс. Потом закрыл её и спустя какое-то время снова её открыл, а там уже новый обновлённый интерфейс? При этом саму апку он не закрывал?
Evgen175 Автор
Да, все так, на гитхабе есть пример, там как раз фичи/экраны с их логикой грузятся только при переходе на них. Это небольшие кусочки кода (если надо, то с ресурсами/картинками), потом код подгружается в движок в рантайм, запускается и рисуется UI.
Кейс обновления интерфейса без закрытия апки мы не пытались решить никак. Мысли такие: технически конечно сложная вещь, но звучит вполне реальной. В таком кейсе есть смысл только если мы юзера оставляем на экране, на котором он был, а значит надо стейт сохранять. А тут в голову сразу приходят проблемы: если стейт поменялся между версиями, то это уже миграции писать надо какие-то... ну и все такое. В общем все это сделать как будто можно, но сложность несравнимо высока с профитом от этого.