Компромиссы React Native в масштабе: состояние, списки, устройства, токены, обновления
Пять проблем React Native, выявляющих качество инженерных решений: состояние сервера и клиента, задержки при работе с FlatLists, устройства с слабыми характеристиками Android, хранение токенов и поэтапные обновления.
Создание интерфейса в React Native — простая задача. Сложности появляются по мере роста приложения: состояние распространяется повсюду, списки работают с перебоями, бюджетные Android-телефоны падают, токены хранятся в открытом виде, а фреймворк отстает на годы. Ниже приведены пять таких ситуаций, часто используемых для тестирования опытных инженеров на собеседованиях, вместе с критериями хорошего ответа, чтобы вы могли применить их к собственному коду.
Разделение серверного состояния от клиентского состояния
Когда локальное состояние, ответы API, кэш и общие флаги интерфейса сливаются в одну запутанную структуру, первый вопрос должен звучать не «Redux или Context?», а «кто является владельцем этих данных?»
Данные из API, такие как профили, ленты новостей или списки товаров, являются состоянием сервера. Они устаревают, поэтому их необходимо перезагружать, кешировать и аннулировать. Если сохранять их в глобальном хранилище, придётся вручную реализовывать всю эту логику, а также обрабатывать флаги загрузки и ошибок. Приведённый ниже фрагмент демонстрирует этот антипаттерн.
// ❌ Server data forced into a global store —
// now YOU own caching, staleness, invalidation, loading states…
dispatch(setProducts(await fetchProducts()));
TanStack Query и RTK Query предназначены для управления этим слоем. Здесь функция useQuery сортирует данные по категориям и сохраняет их актуальными в течение 60 секунд благодаря параметру staleTime. Вторая часть блока (объединённая в одну в исходном коде) показывает, что остаётся для глобального хранилища: небольшой объект сессии.
// ✅ Server state managed by a tool built for it
const { data, refetch } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});// ✅ Truly shared client state — small and intentional
const useSession = create<SessionStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
Оставшаяся проблема гораздо меньше. Поля форм, переключатели и значения анимаций остаются внутри компонента, где их обработка не требует значительных ресурсов, они изолированы и исчезают вместе с экраном. Глобальный хранилище должно содержать только те данные, которые нужны нескольким экранам и принадлежат самому клиенту, например авторизованный пользователь, тема интерфейса, флаги функциональности или состояние UI, характерное для нескольких экранов.
Что ломается, когда всё глобально
Обновления приводят к перерисовке не связанных экранов, каждая функция зависит от структуры хранилища, тесты вынуждены имитировать реальные условия, а рефакторинг превращается в сложную задачу. Раздутое хранилище выглядит как архитектура, но ведет себя как долг.
Диагностика медленной работы FlatList без угадываний
FlatList кажется медленной, хотя сам API работает быстро. Применение React.memo везде — это угадывание; правильный подход — сначала измерить.
Профилирование с использованием React DevTools (или библиотеки why-did-you-render) во время прокрутки. Если все строки перерисовываются при каждом движении прокрутки, причиной является проблема с идентичностью ссылок: встроенные функции-стрелки в renderItem, объекты стилей, пересоздаваемые при каждой рендеринге, или отсутствие keyExtractor, что приводит к повторной установке компонента. В данном случае при каждой рендеринге создается новый объект закрытия onPress и объект style, поэтому ни одна строка не может пропустить рендеринг.
// ❌ New function + new object on EVERY render → every row re-renders
<FlatList
data={items}
renderItem={({ item }) => (
<Row item={item} onPress={() => open(item.id)} style={{ padding: 12 }} />
)}
/>
Решение заключается в предоставлении React стабильных ссылок: мемизированный компонент Row, функция renderItem, обернутая в useCallback, стабильный ключ, а также метод getItemLayout, который предотвращает измерение высоты строк. Использование типизированных свойств делает код TSX.
// ✅ Stable identities + memoized rows
const Row = React.memo(({ item, onPress }: RowProps) => { /* … */ });const renderItem = useCallback(
({ item }: ListRenderItemInfo<Item>) => <Row item={item} onPress={handlePress} />,
[handlePress],
);<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
/>
getItemLayout подходит только для строк фиксированной высоты; неверные значения для строк с переменной высотой приводят к скачкам и пустым промежуткам между элементами.
Чтение монитора производительности
Если результаты отрисовки выглядят нормально, но кадры всё равно пропадают, сравните два показателя Perf Monitor:
- Низкое значение JS FPS означает перегрузку потока JavaScript, обычно из-за тяжёлой функции
renderItemили неконтролируемого вызоваonScroll. - Низкое значение UI FPS указывает на проблемы с нативным кодом, чаще всего связанные с изображениями. Декодирование фотографий в полном разрешении в миниатюры размером 80 пикселей тратит много памяти и кадров; лучше изменять размеры на стороне сервера или использовать библиотеки
expo-imageилиFastImageс правильными параметрами.
Только после этого можно настроить параметры такие как windowSize, removeClippedSubviews или перейти на использование FlashList. Настройка свойств списка до анализа данных лишь маскирует симптомы.
Обнаружение сбоев на бюджетных устройствах Android
Приложение, которое работает стабильно на флагманских устройствах, может выходить из строя на дешевых Android-телефонах, которыми обладают большинство пользователей. Начните с анализа данных: Play Console или инструменты аналитики показывают реальный состав устройств, который редко совпадает с телефонами в вашей команде.
Включите использование оборудования низкого класса в повседневную работу: держите под рукой физическое устройство с 2–3 ГБ ОЗУ или используйте Firebase Test Lab для тестирования на моделях, указанных в аналитике.
gcloud firebase test android run \
--app app-release.apk \
--device model=a10,version=29 \
--device model=redmi9,version=30 # the phones in your analytics, not yours
Всегда тестируйте версии для публикации. Версии для отладки скрывают реальную производительность, а Hermes в режиме публикации ведет себя иначе, чем при отладке. Типичные проблемы на слабом оборудовании — это нехватка памяти из-за крупных изображений или сохраненных списков, перегрузка основного потока и сбои из-за нехватки памяти, которые никогда не возникают на флагманских телефонах.
Плавное снижение качества работы вместо сбоя
Вы можете настроить работу приложения под конкретные устройства. Библиотека react-native-device-info предоставляет информацию об общем объеме памяти.
import DeviceInfo from 'react-native-device-info';
С её помощью можно отмечать устройства с объемом памяти менее 3 ГБ как низкоклассные, отображать миниатюры вместо полноформатных изображений, а также исключать эффекты размытия, параллакса и тяжелые анимации. Поскольку функция getTotalMemory является асинхронной, необходимо вычислить соответствующий флаг один раз при запуске, а не ждать её результатов во время отрисовки.
const totalMemory = await DeviceInfo.getTotalMemory();
const isLowEnd = totalMemory < 3 * 1024 ** 3; // < 3 GB RAM<Image
source={{ uri: isLowEnd ? item.thumbUrl : item.fullResUrl }}
// skip blur, parallax and heavy animations on low-end devices
/>
Затем следует действовать осторожно: разделять отчеты из Sentry или Crashlytics по категориям устройств, использовать поэтапную развертывание в Play Store (5%, 20%, 100%) и останавливаться до того, как бракованная версия приложения дойдет до всех пользователей. Цель не в полном отсутствии сбоев, а в их своевременной и недорогой диагностике.
Безопасное хранение токенов аутентификации
AsyncStorage записывает данные без шифрования на диск: файл SQLite в Android, обычные файлы в sandbox в iOS. Устройство с root-доступом или jailbreak, злонамеренная копия данных или доступ к файловой системе позволяют напрямую получить токены. Он предназначен для хранения настроек, а не секретов.
Токены должны храниться в хранилищах, поддерживаемых аппаратным обеспечением — Keychain в iOS и Keystore в Android, которые обрабатываются библиотекой react-native-keychain.
import * as Keychain from 'react-native-keychain';
Этот вызов сохраняет сериализованные токены с параметром WHEN_UNLOCKED_THIS_DEVICE_ONLY: они доступны только при разблокированном устройстве и никогда не мигрируют на другое устройство.
await Keychain.setGenericPassword('auth', JSON.stringify(tokens), {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
Процесс обновления, устойчивый к одновременным ошибкам 401
Установите срок действия токенов доступа в несколько минут, а не дней; обеспечьте их поддержку с помощью токена обновления, который заменяется каждый раз при обмене, и централизуйте этот процесс в одном слое аутентификации, который обновляется только один раз, независимо от того, сколько запросов потерпели неудачу одновременно. Для этого используется общий обещание выполнения.
let refreshing: Promise<string> | null = null;
Интерцептор Axios перебрасывает любую ошибку, кроме 401. При коде 401 оператор ??= запускает процесс обновления только в том случае, если такой процесс уже не выполняется; таким образом, одновременные ошибки ожидают выполнения того же обещания; блок finally сбрасывает это обещание, после чего первоначальный запрос повторяется с новым токеном. В исходном коде несколько операций размещены на одной строке. Для более подробного рассмотрения см. почему одновременные ошибки 401 выводят пользователей и как это исправить с помощью единственного процесса обновления.
api.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401) throw error; // Concurrent 401s all await the SAME refresh — no refresh storm
refreshing ??= refreshTokens().finally(() => (refreshing = null));
const newToken = await refreshing; error.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(error.config); // retry the original request
});
Ещё два момента: не храните токены в глобальном состоянии, доступном из JS, дольше, чем это необходимо; внедрите фиксацию сертификатов для особо чувствительных API; ни в коем случае не логируйте токены, поскольку инструменты для отладки аварий с удовольствием захватывают заголовки запросов. Инструмент под названием «Use SecureStore» помогает с этим; хороший ответ должен охватывать вопросы срока службы, обновления и работу при сбоях.
Обновление двухлетнего приложения React Native
Приложение отстает на два года, простои недопустимы, и бюджета на полную переработку нет.
Никогда не переходите сразу к самой последней версии. Инструмент React Native Upgrade Helper показывает точные различия между версиями; делайте одно-два незначительных обновления за раз, сохраняя возможность сборки и выпуска приложения на протяжении всего процесса. Каждое такое обновление является обычным релизом, что позволяет избежать простоев.
Сначала проведите аудит зависимостей
Проблемы с обновлениями возникают из-за устаревших и необслуживаемых нативных библиотек, а не из-за самого React Native. Ещё до первого шага определите зависимости, которые мешают использованию новой архитектуры или требованиям более новых версий Gradle и Xcode, и замените или скопируете те, которые брошены в упущение.
Автоматизация проверки каждого шага
Перед началом настройте тесты от начала до конца для критически важных процессов, чтобы каждый шаг проверялся за несколько минут вместо того, чтобы это делало ручное тестирование. Этот процесс Maestro входит с помощью электронной почты из переменной окружения и проверяет, доступны ли страницы домашнего экрана, корзины и оформления заказа.
# smoke-test.yaml — run with Maestro on every upgrade hop
appId: com.myapp
---
- launchApp
- tapOn: "Log in"
- inputText: ${EMAIL}
- tapOn: "Continue"
- assertVisible: "Home"
- tapOn: "Cart"
- assertVisible: "Checkout"
Представьте эту работу руководству как способ снижения рисков, а не как рефакторинг: каждая отставание в версиях увеличивает стоимость следующего принудительного обновления, вызванного правилами магазина, устареванием ОС и патчами безопасности. Небольшие шаги превращают страшный проект в серию скучных выпусков, а скучность — в конечной цели.
Основные выводы
- Определите, кто будет управлять каждым фрагментом данных; пусть библиотека запросов отвечает за состояние сервера, чтобы глобальное хранилище оставалось небольшим.
- Сначала проведите анализ, прежде чем оптимизировать: проблемы с идентификацией, работа в потоках JavaScript и декодирование изображений требуют разных решений.
- Тестируйте версии приложения на реальных устройствах ваших пользователей и выпускайте их поэтапно.
- Рассматривайте токены как часть системы: обеспечьте их безопасное хранение, ограничьте срок их действия, регулярно обновляйте их и позволяйте использовать только один раз.
- Выполняйте обновления по малым, готовым к развертыванию этапам с использованием автоматизированных тестов.