Главная копия — в телефоне
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части IV правда жила на сервере: серверный компонент читал базу, изменение уходило действием на сервер, а у экрана было три состояния — загрузка, ошибка, данные. «Подход» записывают в зале, а зал — это подвал, где связь в одну полоску или её нет вовсе. Эта глава переворачивает схему: главная копия дневника лежит в телефоне, экран читает и пишет её без сети и без ожидания, а сервер в главе 7 станет партнёром по синхронизации и резервной копией, но не хозяином. Начнём со стены — одного запуска приложения, устроенного «как у всех», — а потом соберём хранилище, на котором будет стоять вся глава. Всё, что вы соберёте в этой статье, — ваш собственный JavaScript: ни React Native, ни Expo своего сюда не добавляют. Expo появится в последней статье главы вместе с настоящей базой expo-sqlite.
В подложке лежит заглушка, которую вы писать не будете: так устроено большинство приложений с данными. Правда — на сервере: список подходов приходит запросом, новый подход уходит запросом, номер ему выдаёт сервер. Сети в песочнице нет, поэтому заглушка её изображает: при хорошей связи ответ приходит через 400 мс, без связи приложение ждёт 1200 мс и сдаётся. Ждёт она по-настоящему, но в сто раз короче, а обещанные миллисекунды складывает в счётчик — так вывод не зависит от скорости вашего компьютера. Сценарий — обычный визит в зал: открыть тренировку, записать пять подходов, открыть её снова. Прогноз для двух прогонов, со связью и без: сколько подходов сохранится, сколько будет ошибок и сколько миллисекунд человек простоит над телефоном?
Со связью всё сохранилось, но семь запросов по 400 мс — почти три секунды колеса загрузки на пять нажатий. Без связи — ноль из пяти: каждое нажатие честно подождало 1200 мс и кончилось ошибкой. Ошибок семь, а не пять, потому что открыть тренировку — тоже запрос, и сценарий делает его дважды. 8400 мс человек с гантелью в руке смотрел на колесо, а в итоге дневник пуст. Повтор не спасёт: сети не будет до выхода из зала, а веса к тому времени забудутся.
На телефоне бывает хуже, чем в заглушке. 1200 мс здесь — таймер самого приложения, а своего срока у fetch в React Native нет. fetch там — полифил whatwg-fetch поверх XMLHttpRequest, его подключает Libraries/Network/fetch.js:
Без сети промис отклоняется с TypeError: Network request failed. Обработчик ontimeout тоже есть, но сам xhr.timeout полифил не ставит нигде в файле — срабатывать ему не от чего. Этажом ниже, на Android, сетевой модуль создаёт клиент OkHttp с connectTimeout(0), readTimeout(0) и writeTimeout(0), и в OkHttpClientProvider.kt react-native 0.86 над ними так и написано: «No timeouts by default» — ноль у OkHttp значит «без срока». Если сеть в зале есть, но сервер не отвечает, запрос висит, пока не сдастся система или ваш собственный таймер.
Колесо загрузки и «нет связи» — не плохой дизайн заглушки, а прямое следствие того, где лежит правда. Пока хозяин — сервер, экран без сети может только ждать и извиняться.
Выход один: главная копия должна лежать там, где человек, — в телефоне. Сервер при этом не пропадает: в главе 7 он станет местом, куда дневник уезжает, когда связь появится, и откуда приезжает на второе устройство. Но экран спрашивает уже не его.
Хранилище «Подхода» — объект в памяти JavaScript. Строки лежат в Map по id, write кладёт или заменяет строку, get и list читают, subscribe даёт экранам узнать о записи, а version растёт на каждой записи. Ни одного промиса: запись и чтение укладываются в один синхронный кусок кода, поэтому ждать нечего и сломаться по дороге негде. Id пока наивные — «s1», «s2»; откуда на самом деле берутся имена строк, когда сервера рядом нет, — предмет следующей статьи. Сценарий тот же, что у заглушки. Прогноз для трёх строк: сколько сохранено и какие из сравнений дадут true?
Пять из пяти, и сети для этого не понадобилось вовсе: в коде нет ни одного await, а подход s5 читается строкой ниже записи. Два других сравнения — про то, как list отдаёт массив. Он не собирается заново на каждый вызов: хранилище помнит массив вместе с версией, при которой его собрало, и пересобирает только после записи. Поэтому массив до записей и массив после — разные объекты, а два чтения подряд — один и тот же. Зачем это нужно, покажет раздел про ловушку: React сравнивает то, что прочитал, по ссылке.
Хранилище — замыкание, а не класс: ни один его метод не трогает this. Это пригодится сразу же: экраны будут отдавать React функции хранилища поодиночке, store.list без скобок, и метод класса при таком вызове потерял бы свой объект. Это та самая потеря контекста из статьи «Потеря контекста и как её чинить» части I: у класса её обходят полями-стрелками, а здесь её обходит замыкание.
Хранилище переехало в подложку, переходы делает стек из главы про навигацию — useStack и вкладки TabBar из статьи «Собираем стек экранов с историей». Экраны подключаются к хранилищу хуком из трёх строк на useSyncExternalStore: функция подписки и функция чтения, как в «Своём хуке» части IV, — заново его не разбираем. Читателей хранилища двое: шапка со счётчиком и лента тренировки, а пишет только лента. Прогноз: что напечатается после нажатия «+ подход», после ухода в справочник и после возврата?
Одна запись — и шапка, и лента перерисовались, хотя друг о друге они не знают: обе подписаны на хранилище, а не держат копию в своём состоянии. Уход в справочник не напечатал ничего: лента ушла из дерева, шапка не изменилась. А возврат — одна строка, и подход в ней уже есть. Лента смонтировалась заново, но первая же её отрисовка прочитала хранилище синхронно: ни эффекта с запросом, ни «загрузка…», ни мелькнувшей пустоты.
Три состояния экрана из части IV — статьи про use() и Suspense, границы ошибок и loading.tsx — здесь экрану не нужны: у чтения из памяти нечему «грузиться» и нечему «не удаться». Ожидание останется в одном месте — пока хранилище поднимается с диска на старте приложения; ему посвящена статья «Первый кадр: пока хранилище читается».
Отличие от части IV не в React — useSyncExternalStore в React Native тот же самый, — а в том, где стоит программа. В Next.js данные читались на сервере, а у телефона сервера под рукой нет: весь «Подход» — один процесс JavaScript на устройстве, и хранилище живёт в его памяти. Память процесса на телефоне ненадёжна по-своему: свёрнутое приложение система выгружает, когда ей нужна память, и никого не спрашивает. Пока хранилище только в памяти, такая выгрузка стирает дневник целиком, поэтому через статью под память ляжет диск.
Экрану тренировки нужны не все подходы, а только её собственные. Проще всего отфильтровать прямо в функции чтения: одна строка, и хук возвращает ровно то, что нужно. Прогноз: что напечатается?
React предупредил, и почти сразу экран упал: следом идут ошибка из подключённой библиотеки и сообщение React о том, что упал <WorkoutSets>. Функцию чтения — в документации React она называется getSnapshot — React зовёт на каждой отрисовке и после каждого оповещения и сравнивает результат с прошлым через Object.is. filter каждый раз возвращает новый массив, пусть и с теми же подходами, — значит, по мнению React, хранилище всё время меняется: он рисует снова и снова получает «новое». Отладочная сборка замечает это заранее — два вызова подряд дали разное, отсюда предупреждение, — а затем цикл упирается в предел вложенных обновлений. Текст этой второй ошибки песочница не показывает: браузер прячет сообщения ошибок, выброшенных из подключённой библиотеки. Граница ошибок из части IV его поймала бы: Maximum update depth exceeded — React ограничивает число вложенных обновлений. Счётчик показывает, сколько кругов успело пройти до падения.
На телефоне будет то же самое: это поведение самого React, а не react-native-web. Разница только в том, кто вас предупредит. Строка про кеширование getSnapshot есть лишь в отладочном рендерере React Native (ReactFabric-dev.js), в выпускном (ReactFabric-prod.js) её нет вовсе, а Maximum update depth exceeded есть в обоих. У пользователя экран упадёт без всякого предупреждения, поэтому такую ошибку надо увидеть до выпуска.
Лечится снимком по номеру версии, и половина работы уже сделана: list возвращает один и тот же массив, пока версия не сменилась. Отдаём React его, а фильтр переносим в отрисовку, под useMemo с этим массивом в зависимостях: новый отфильтрованный массив появится только тогда, когда хранилище правда изменилось. Прогноз: сколько отрисовок будет теперь, если после монтирования записать ещё один подход в эту тренировку?
Две отрисовки — при монтировании и после записи — и ни одного предупреждения. Можно было написать и стрелку () => store.list(): важно не то, новая ли функция, а то, одинаков ли её результат, пока хранилище не менялось. Массив текущей версии одинаков по построению, поэтому снимок по версии — правило для всех читателей хранилища, а не только для этого экрана. Опасен любой снимок, который собирает новый объект: filter, map, { ...row }, Object.values.
Слово local-first ввело эссе «Local-first software: You own your data, in spite of the cloud» исследовательской лаборатории Ink & Switch — Мартин Клеппман, Адам Виггинс, Петер ван Харденберг и Марк Макгранаган, 2019 год. В нём семь идеалов, и честнее сразу сверить с ними «Подход», чем обещать все семь:
| Идеал эссе | «Подход» | Чем обеспечен или почему нет |
|---|---|---|
| Без спиннеров: работа под рукой | берёт | запись и чтение — в памяти телефона, в один такт: хранилище из разделов выше |
| Работа не заперта на одном устройстве | берёт | телефон и веб-версия сходятся через сервер-партнёр — глава 7 |
| Сеть необязательна | берёт | вся эта глава: дневник пишется и читается без сети, сеть нужна только для обмена |
| Бесшовная совместная работа | нет | дневник ведёт один человек; конфликты главы 7 — это он же на двух устройствах, а не двое в одном документе |
| «Долгое сейчас»: данные переживут сервис | частично | журнал выгружается в JSON и CSV и загружается обратно — глава 12 |
| Безопасность и приватность по умолчанию | нет | сервер части VI видит дневник: сквозного шифрования нет |
| Владение и контроль у пользователя | берёт | главная копия на телефоне, писать можно без входа — вход нужен только для синхронизации |
Четыре идеала «Подход» берёт целиком, «долгое сейчас» — частично, а два не берёт, и это выбор, а не упущение. Совместной работы в дневнике одного человека нет. Шифрование, при котором сервер не может прочесть данные, — отдельная большая тема, и она перевернула бы сервер части VI. Всё, что берём, стоит на одном: главная копия — в телефоне, и хранилище из этой статьи — её первый кирпич.
Хранилище переезжает в подложку следующей статьи. Там у него появится журнал: каждая запись станет операцией с id, рождённым на телефоне, — потому что номер от сервера, как у addSet в заглушке, без сети не получить.
- Пока правда лежит на сервере, экран без сети может только ждать и извиняться: в сценарии зала — 0 подходов из 5, 7 ошибок и 8400 мс ожидания. В «Подходе» главная копия лежит в телефоне, а сервер — партнёр по синхронизации (глава 7).
- Своего срока у
fetchв React Native нет: на Android клиент OkHttp создан с нулевыми таймаутами, и ожидание заканчивает только система или ваш код. Без сети промис отклоняется сTypeError: Network request failed. - Хранилище —
Mapстрок,write,get,list,subscribeи номер версии. Запись и чтение укладываются в один такт, промисов нет. - Экраны подписаны на хранилище через
useSyncExternalStore, как в части IV: одна запись обновляет всех читателей, а вернувшийся экран читает данные в первой же отрисовке — без загрузки и без запроса. - Снимок сравнивается по ссылке:
filterв функции чтения даёт новый массив на каждый вызов, React предупреждает и роняет экран. Лечится массивом текущей версии и фильтром вuseMemo; число безопасно само. - Из семи идеалов Ink & Switch «Подход» берёт четыре, «долгое сейчас» — частично; совместной работы и шифрования от сервера нет.
1/4Где в «Подходе» лежит главная копия дневника и что тогда делает сервер?
2/4Сеть в зале есть, но сервер не отвечает. Сколько прождёт fetch в приложении на React Native под Android, если своего таймера вы не поставили?
3/4Почему useSyncExternalStore(store.subscribe, () => store.list().filter(…)) роняет экран?
4/4Лента ушла с экрана и вернулась. Что увидит человек в первой отрисовке после возврата?
Шапке нужно только число
В подложке — хранилище из статьи. Шапка подписана на весь массив подходов и перерисовывается на каждую запись, даже когда подходов столько же: например, когда человек поправил вес уже записанного подхода. Сделайте так, чтобы шапка перерисовывалась только тогда, когда меняется число подходов. И подумайте, почему useMemo здесь не помог бы.
Функция чтения возвращает не массив, а число:
Вывод становится 0, 1, 2. После правки веса хранилище оповещает шапку, React зовёт функцию чтения, получает ту же единицу и сравнивает её с прошлой через Object.is: снимок не изменился — отрисовки нет. Стрелка при этом новая на каждой отрисовке, и это не страшно: число сравнивается по значению, в отличие от массива из filter. А useMemo не помог бы потому, что он живёт внутри отрисовки: когда он выполняется, решение перерисовать шапку уже принято.
Запись, которая ничего не меняет
Форма подхода на каждое «Сохранить» записывает строку целиком, даже если человек ничего не менял. Хранилище честно поднимает версию, собирает новый массив и будит подписчиков, и лента рисуется зря. Допишите write так, чтобы запись строки, равной уже лежащей, ничего не делала: ни версии, ни оповещения.
Перед записью сравнить новую строку с лежащей — по полям:
Вывод: лента дважды — пусто и 80×5, — и версия: 1. Сравнивать old === row бесполезно: форма присылает «ту же» строку новым объектом, и ссылки всегда разные. А версия теперь значит «хранилище правда изменилось», а не «кто-то позвал write» — на этом смысле и стоит снимок по версии.