Локально-ориентированная архитектура для прогрессивных веб-приложений
Сервис-воркеры переносят приложение на устройство пользователя; локально-ориентированный подход переносит данные. Это единственное различие объясняет, почему оболочка вашего PWA мгновенно загружается в офлайн-режиме, тогда как каждый список, запись и форма по-прежнему зависают в ожидании fetch(), который завершается ошибкой при потере сети. Оболочка закэширована; данные — нет. Они находятся на разных уровнях, и именно в этом разрыве большинство реализаций «офлайн-поддержки» в PWA незаметно ломается.
Эта статья предназначена для разработчиков, которые уже выпустили PWA — манифест корректен, сервис-воркер кэширует ресурсы, приложение устанавливается на главный экран — и которые подозревают, что «локально-ориентированный подход» означает нечто большее, чем уже проделанная ими работа по поддержке офлайн-режима. Так и есть. Локально-ориентированный подход — это архитектура данных, а не механизм времени выполнения. В остальной части статьи эти уровни чётко разграничиваются, показывается, как выглядит локально-ориентированное PWA в виде стека, который дополняется, а не перестраивается, рассматриваются варианты хранения данных и синхронизации, а также приводится руководство, специфичное для PWA, о том, когда этот паттерн оправдывает свою сложность.
Ключевые выводы
- Сервис-воркеры кэшируют ресурсы и обслуживают офлайн-оболочку; локально-ориентированный подход — это архитектура данных, при которой устройство хранит основную копию данных, а сервер, при его наличии, выступает в роли узла синхронизации, а не привратника.
- Локально-ориентированное PWA состоит из трёх компонуемых уровней: сервис-воркер (ресурсы и офлайн-оболочка), локальная база данных (чтение и запись данных) и движок синхронизации (согласование с сервером в конечном счёте) — второй и третий уровни добавляются без замены первого.
- IndexedDB является практическим минимумом для локального хранения данных; SQLite на основе OPFS, скомпилированный в WebAssembly, — это текущий максимум, обеспечивающий полноценную реляционную базу данных в браузере.
- Стратегия «последняя запись побеждает» на уровне полей разрешает большинство конфликтов в типичных приложениях; для совместного редактирования текста нужны CRDT, а семантические конфликты (например, два пользователя бронируют одну переговорную комнату) требуют серверной валидации во время синхронизации.
- Локально-ориентированный подход подходит для устанавливаемых приложений для заметок, полевых сервисных приложений и инструментов совместного редактирования; он ничего не даёт для дашбордов с серверно-генерируемыми данными или приложений, требующих гарантий ACID.
Почему данные вашего PWA по-прежнему обращаются к сети
PWA с кэшированной оболочкой, но слоем данных, зависящим от fetch(), является офлайн-приложением лишь в самом узком смысле — интерфейс загружается без сети, а затем каждый компонент, которому нужны данные, останавливается. Сервис-воркер может воспроизвести HTML, CSS и JavaScript из Cache API без обращения к сети, поэтому приложение отрисовывается в офлайн-режиме. Однако данные, которые отображают эти компоненты, по-прежнему поступают из сетевого запроса, и ServiceWorker, перехватывающий fetch() для динамических пользовательских данных, не имеет ничего полезного для возврата, если эти данные никогда не были закэшированы или изменились с момента последнего кэширования.
Именно по этой структурной причине работа по поддержке офлайн-режима в PWA останавливается на уровне оболочки. Стратегии кэширования — cache-first, network-first, stale-while-revalidate — отвечают на вопрос какую версию ресурса обслуживать и насколько устаревшей она может быть. Они не отвечают на вопрос где хранятся данные пользователя и кто владеет авторитетной копией. Cache API хранит HTTP-ответы с ключом по запросу. Это не база данных, к которой можно обращаться с запросами, записывать данные и читать их обратно внутри компонента. Чтобы устранить этот разрыв, необходимо реальное локальное хранилище данных и стратегия поддержания его согласованности с сервером. Это и есть локально-ориентированный подход.
Локально-ориентированный подход — это не офлайн-ориентированный подход, и ни то, ни другое не является сервис-воркером
Локально-ориентированный подход, офлайн-ориентированный подход, PWA и кэширование через сервис-воркеры — это четыре разные концепции, которые сочетаются, но не взаимозаменяемы. Их смешение является наиболее распространённым источником путаницы в этой области.
| Термин | Что это такое | На каком уровне работает |
|---|---|---|
| PWA | Модель доставки: устанавливаемое, управляемое манифестом, поддерживающее push-уведомления веб-приложение | Упаковка |
| Сервис-воркер | Механизм времени выполнения, перехватывающий запросы и обслуживающий кэшированные ресурсы | Сеть / время выполнения |
| Офлайн-ориентированный подход | Цель проектирования: корректная деградация при недоступности сети, при этом сервер остаётся источником истины | Поведение |
| Локально-ориентированный подход | Архитектура данных: устройство хранит основную копию; сервер является узлом синхронизации | Данные |
Офлайн-ориентированный подход означает, что приложение корректно обрабатывает потерю сети, но сервер остаётся источником истины — локальный кэш является удобной копией, которая уступает приоритет удалённому хранилищу. Локально-ориентированный подход инвертирует это соотношение: устройство пользователя хранит основную копию данных, приложение читает и записывает данные в локальную базу данных, а сервер, при его наличии, является одним из узлов, которые согласовываются в фоновом режиме. Чёткое разграничение важно, поскольку оно показывает, что можно повторно использовать. Ваш сервис-воркер и ваш фреймворк не изменяются. Вы добавляете уровень данных под ними.
В руководстве Plainvanilla по локально-ориентированному подходу бэкенд-для-фронтенда встраивается в сервис-воркер как часть архитектуры. Это одна из возможных реализаций, а не определение. Сохраняйте разграничение уровней: локально-ориентированный подход касается того, где хранятся данные и какая копия является авторитетной; сервис-воркер — это механизм, который оказывается доступным в той же среде выполнения.
Discover how at OpenReplay.com.
Что на самом деле означает локально-ориентированный подход
Локально-ориентированный подход — это архитектура данных, при которой устройство пользователя хранит основную копию данных приложения, приложение читает и записывает данные в локальную базу данных, а сервер, при его наличии, является узлом синхронизации с особыми полномочиями, а не привратником, который должен одобрять каждое чтение и запись. С точки зрения пользователя, приложение читает и записывает данные в локальную базу данных синхронно, а синхронизация с сервером или другими устройствами происходит асинхронно в фоновом режиме. Архитектурный сдвиг заключается в переклассификации клиента: он перестаёт быть тонким представлением, запрашивающим разрешение на отображение данных, и становится полноправным участником, владеющим репликой.
Каноническим источником является эссе 2019 года Local-First Software: You Own Your Data, in Spite of the Cloud от Ink & Switch, в котором изложены семь идеалов: быстрота, мультиустройственность, офлайн-работа, совместность, долговечность, приватность и контроль пользователя. Единственное свойство, которое меняет способ написания кода, — первое: поскольку чтение и запись обращаются к локальной базе данных, при чтении нет флага isLoading, а запись может немедленно обновить локальное состояние. Локальная запись и есть состояние, тогда как синхронизация и разрешение конфликтов происходят в фоновом режиме.
В коде компонентов это сворачивает цепочку fetch-and-cache в прямой запрос. Вместо useQuery, возвращающего { data, isLoading, error }, локально-ориентированный запрос подписывается на локальную базу данных и перерисовывается при её изменении:
// Локально-ориентированный подход: запрос читает локальную БД и перерисовывается при изменении.
// Нет isLoading, нет error boundary для чтения, нет инвалидации кэша.
function TaskList({ projectId }) {
const tasks = useLiveTasks(projectId); // подписывается на локальную БД
return (
<ul>
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
Хук useLiveTasks предоставляется выбранным движком синхронизации или слоем запросов; важна именно форма взаимодействия. Запись — это обычная вставка в локальное хранилище, подписка срабатывает, и UI обновляется. Синхронизация с сервером происходит при наличии сети.
Три уровня локально-ориентированного PWA
Локально-ориентированное PWA состоит из трёх компонуемых уровней: сервис-воркер, кэширующий ресурсы и обслуживающий офлайн-оболочку; локальная база данных (IndexedDB или SQLite на основе OPFS), хранящая данные пользователя и обслуживающая все операции чтения и записи; и движок синхронизации, согласующий локальную базу данных с сервером в фоновом режиме. Каждый уровень отвечает за одну задачу и ничего не знает о внутреннем устройстве других.
| Уровень | Роль | Отвечает за | НЕ отвечает за |
|---|---|---|---|
| Уровень 3 — Движок синхронизации | Согласует локальную базу данных с сервером. | Разрешение конфликтов, согласованность в конечном счёте, фоновый push/pull. | Отрисовку, кэширование ресурсов. |
| Уровень 2 — Локальная база данных | Хранит данные пользователя. IndexedDB или SQLite на основе OPFS (WASM). | Все операции чтения и записи, выполняемые UI. | Сеть, офлайн-оболочку. |
| Уровень 1 — Сервис-воркер | Кэширует ресурсы и обслуживает офлайн-оболочку. | Кэширование ресурсов, обслуживание офлайн-оболочки, жизненный цикл install/activate, push. | Пользовательские данные. |
Внедрение локально-ориентированного подхода в существующее PWA не требует замены сервис-воркера или фреймворка приложения — оно требует добавления уровня данных, из которого приложение читает и в который записывает данные локально, и движка синхронизации, поддерживающего согласованность этого уровня с сервером. Уровень 1 уже существует в вашем PWA. Вы добавляете уровни 2 и 3 под компонентами, которые в настоящее время вызывают fetch(), и перенаправляете эти компоненты на чтение из локальной базы данных.
Где хранятся данные: краткий обзор локального хранилища
localStorage — не подходящий вариант. Он синхронный, что блокирует основной поток; хранит только строки; и, согласно документации MDN по Web Storage API, его ёмкость невелика и зависит от браузера — подходит для хранения настроек темы, но не для уровня данных.
IndexedDB является минимальным вариантом — асинхронный, доступный во всех современных браузерах и способный хранить значительно больше данных, чем localStorage, хотя квота хранилища привязана к источнику и зависит от браузера, а не является фиксированным ограничением. Его нативный API низкоуровневый и многословный — одна запись требует открытия транзакции, обращения к хранилищу объектов и настройки обратных вызовов запроса — именно поэтому большинство приложений используют обёртки.
Максимальным вариантом является SQLite, скомпилированный в WebAssembly и сохраняющий данные в Origin Private File System (OPFS). SQLite на основе OPFS предоставляет полноценную реляционную базу данных в браузере — транзакции, индексы и привычный SQL-интерфейс, работающий локально на клиенте. OPFS поддерживает высокопроизводительный синхронный доступ к файлам через createSyncAccessHandle(), который доступен только внутри Web Worker — именно тот паттерн доступа, который нужен SQLite. Официальный дистрибутив SQLite WebAssembly документирует OPFS как поддерживаемый бэкенд для хранения данных.
В коде запись в SQLite-over-WASM выглядит как серверный SQL — один оператор к реляционному хранилищу:
// SQLite (WASM) через официальный пакет sqlite-wasm: обычный SQL.
await db.exec({
sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
bind: [crypto.randomUUID(), "Review draft"],
});
Поддержка OPFS в браузерах широка по состоянию на конец 2025 года — актуальный статус по каждому браузеру см. в таблице совместимости MDN для File System API, которая является авторитетным источником для сверки с вашей матрицей поддержки, а не жёстко заданным списком версий, поскольку он меняется. Распространённой проблемой в продакшене является то, что поведение OPFS может незначительно отличаться между движками браузеров и контекстами встраивания, поэтому резервный путь на основе IndexedDB по-прежнему стоит сохранять для браузеров и сред, где поддержка OPFS недоступна, ограничена или ведёт себя иначе, чем на вашей основной целевой платформе.
Обзор движков синхронизации
Движок синхронизации — это уровень, который делает локально-ориентированный подход чем-то большим, чем офлайн-ориентированный: он согласует локальную реплику с сервером и другими устройствами. Вам не нужно строить его самостоятельно. Несколько производственных и развивающихся движков занимают разные ниши, и правильный выбор зависит от структуры вашего приложения и существующего бэкенда. Веб-стандарта для синхронизации не существует, поэтому каждый движок определяет собственный протокол — держите уровень синхронизации абстрактным, чтобы замена движков была осуществима.
- PowerSync — реплицирует бэкенд Postgres в клиентскую базу данных SQLite с путём обратной записи; отлично подходит, когда вы уже используете Postgres и хотите добавить офлайн-поддержку без перестройки сервера.
- Electric — движок синхронизации Postgres, построенный вокруг «Shapes», определяющих, какие данные получает каждый клиент. Отлично подходит для приложений, которым нужна частичная репликация и синхронизация данных для каждого пользователя поверх существующего бэкенда Postgres.
- Replicache и Zero (оба от Rocicorp) — системы синхронизации на основе запросов, приоритизирующие локально-ориентированный пользовательский опыт над репликацией на уровне строк. Отлично подходят для приложений, построенных вокруг клиентских мутаций, оптимистичных обновлений и серверного согласования.
- Triplit — полностековая база данных со встроенной синхронизацией, так что клиентская и серверная базы данных представляют собой одну ментальную модель, а не две; подходит для новых приложений, в которых синхронизация является поведением по умолчанию.
- Yjs и Automerge — CRDT-библиотеки для совместного редактирования; правильный выбор, когда несколько пользователей одновременно редактируют один и тот же форматированный текст или структурированный документ и вам нужно слияние на уровне символов.
Для большинства бизнес-ориентированных PWA, синхронизирующих записи, принадлежащие пользователям, движок репликации строк поверх существующей базы данных подходит лучше, чем CRDT-библиотека. Обращайтесь к Yjs или Automerge именно тогда, когда совместное редактирование текста в реальном времени является основным продуктом, а не как к универсальному решению конфликтов.
Разрешение конфликтов
Когда две реплики изменяют одни и те же данные, не видя изменений друг друга, движок синхронизации должен их согласовать. Конфликты делятся на три категории, каждая из которых требует своей стратегии разрешения.
-
Структурные конфликты — «последняя запись побеждает» на уровне полей. Стратегия «последняя запись побеждает» на уровне полей разрешает большинство конфликтов в типичных приложениях: если два пользователя редактируют разные поля одной записи в офлайн-режиме, оба изменения сохраняются; если они редактируют одно и то же поле, побеждает более поздняя временная метка. Это широко используемое эмпирическое правило в сообществе локально-ориентированной разработки, а не измеренная константа — книга Мартина Клеппманна Designing Data-Intensive Applications подробно рассматривает компромиссы согласованности при использовании стратегии «последняя запись побеждает».
-
Конфликты при совместном редактировании текста — CRDT. Когда два пользователя печатают в одном абзаце, стратегия «последняя запись побеждает» отбрасывает нажатия клавиш одного из них. Бесконфликтные реплицируемые типы данных объединяют параллельные правки на уровне символов, так что оба набора символов появляются согласованно. Yjs и Automerge реализуют это; механика слияния различается, но гарантия одна — параллельные правки сходятся без центрального координатора.
-
Семантические конфликты — серверная валидация. Некоторые конфликты корректно объединяются на структурном уровне, но при этом дают результат, нарушающий доменный инвариант. Пример: два офлайн-пользователя бронируют одну переговорную комнату на 14:00. Обе записи затрагивают разные записи, поэтому слияние на уровне полей принимает обе — структурно корректно, но возникает двойное бронирование. Семантические конфликты, при которых данные корректно объединяются, но результат нарушает доменный инвариант, требуют серверной валидации во время синхронизации. Паттерн, позволяющий избежать потери данных, — принять конфликтующую запись, пометить нарушение и показать его пользователю в виде уведомления, требующего разрешения, а не отклонять запись молча — отклонение оставляет клиента с записями, которые сервер отказывается признавать.
Наиболее сложные ошибки в локально-ориентированных PWA — это не сбои синхронизации, которые отображаются в логах. Это тихие перезаписи: оптимистичная запись успешно выполняется локально, синхронизация завершается без ошибок, и изменение пользователя незаметно заменяется удалённой версией, победившей в гонке «последняя запись побеждает». Сбой невидим для серверных логов, мониторинга ошибок и трассировки сети; он становится различимым только в инструменте, записывающем последовательность состояний UI — оптимистичное обновление и последующий тихий откат. Воспроизведение сессии — одно из немногих мест, где это несоответствие проявляется как наблюдаемая последовательность, а не как чистая строка лога.
Когда локально-ориентированный подход подходит именно вашему PWA
Локально-ориентированный подход подходит для PWA, когда данные, которыми управляет приложение, принадлежат пользователю и выигрывают от мгновенного локального взаимодействия; он ничего не добавляет, когда данные генерируются сервером или требуют строгих транзакционных гарантий. Определяющий вопрос — не категория приложения в абстрактном смысле, а то, должны ли данные находиться на устройстве.
| Сценарий использования PWA | Помогает ли локально-ориентированный подход? | Почему |
|---|---|---|
| Устанавливаемое приложение для заметок | Да | Данные принадлежат пользователю, мгновенное чтение/запись, офлайн-редактирование — основная ценность; устанавливаемая оболочка и локальные данные усиливают друг друга. |
| Полевое сервисное приложение при нестабильном соединении | Да | Работа выполняется там, где сеть слабая; записи должны успешно выполняться в офлайн-режиме и синхронизироваться при восстановлении покрытия. |
| Инструмент совместного редактирования | Да | Параллельное редактирование — это и есть продукт; синхронизация на основе CRDT обеспечивает слияние в реальном времени без обращения к серверу при каждом нажатии клавиши. |
| Аналитический дашборд | Нет | Данные генерируются сервером; кэширование оболочки полезно, но локально-ориентированный подход ничего не добавляет к данным, которые пользователь не владеет и не изменяет. |
| Оформление заказа в интернет-магазине | Нет | Платежи и инвентаризация требуют гарантий ACID и единой авторитетной базы данных; согласованность в конечном счёте может привести к перепродаже товаров или двойному списанию. |
| Лента социальной сети | Нет | Лента ранжируется и принадлежит серверу; клиент потребляет её, а не создаёт. |
Паттерн точно совпадает с преимуществами PWA там, где пользователь создаёт и владеет данными. Для PWA-приложения для заметок устанавливаемая оболочка, офлайн-запись и фоновая синхронизация складываются в опыт, близкий к нативному приложению. Для дашборда сервис-воркер может кэшировать оболочку для более быстрой загрузки, но цифры по-прежнему поступают с сервера — локально-ориентированный подход решает проблему, которой у этого сценария использования нет.
Вы добавляете уровень данных, а не перестраиваете приложение
Внедрение локально-ориентированного подхода в существующее PWA означает добавление двух уровней, а не перестройку того, что уже есть: локальной базы данных и слота движка синхронизации под компонентами, которые в настоящее время зависают в ожидании fetch(), тогда как сервис-воркер и фреймворк остаются на своих местах. Вы не заменяете уже проделанную работу по PWA — вы завершаете её, перемещая данные к пользователю так же, как сервис-воркер уже переместил приложение. Конкретный следующий шаг невелик: выберите одну функцию, данные которой принадлежат пользователю и которую он редактирует, подкрепите её IndexedDB или SQLite на основе OPFS, настройте её компонент на чтение из этого хранилища и позвольте движку синхронизации согласовывать данные в фоновом режиме. Эта единственная функция покажет вам быстрее, чем любое дальнейшее чтение, подходит ли туда и остальная часть вашего приложения.
Часто задаваемые вопросы
Как правило, да, но его роль меняется. В локально-ориентированной архитектуре сервер перестаёт быть привратником, одобряющим каждое чтение и запись, и становится узлом синхронизации, согласующим локальную базу данных между устройствами, хранящим долговечную копию и обеспечивающим серверную валидацию для семантических конфликтов, таких как двойное бронирование. Приложения, которым нужны гарантии ACID, платежи или авторитетное общее состояние, по-прежнему требуют полноценного бэкенда; только путь чтения и записи через UI перемещается в локальную базу данных.
Нет. Они работают на разных уровнях и дополняют, а не конкурируют друг с другом. Сервис-воркер кэширует ресурсы и обслуживает офлайн-оболочку; локально-ориентированный подход добавляет локальную базу данных и движок синхронизации под компонентами, которые в настоящее время вызывают fetch. Внедрение локально-ориентированного подхода в существующее PWA означает сохранение сервис-воркера и фреймворка на месте и добавление уровней данных и синхронизации под ними, а не переписывание первого уровня.
Выбирайте CRDT, когда параллельное редактирование одного и того же форматированного текста или структурированного документа является самим продуктом, поскольку стратегия «последняя запись побеждает» отбрасывает нажатия клавиш одного пользователя, когда два человека печатают в одном абзаце. Бесконфликтные реплицируемые типы данных объединяют параллельные правки на уровне символов, так что оба набора символов сходятся без центрального координатора. Для большинства бизнес-ориентированных PWA, синхронизирующих записи, принадлежащие пользователям, «последняя запись побеждает» на уровне полей поверх движка репликации строк является более подходящим вариантом; используйте Yjs или Automerge для совместного редактирования текста в реальном времени.
Это тихая перезапись. Оптимистичная запись успешно выполняется локально, синхронизация завершается без ошибок, и удалённая версия побеждает в гонке «последняя запись побеждает», незаметно заменяя локальное изменение. Сбой невидим для серверных логов, которые показывают успех, для мониторинга ошибок, который не генерирует исключений, и для трассировки сети, которая показывает, что запрос прошёл. Он становится различимым только в инструменте, записывающем последовательность состояний UI, фиксирующем как оптимистичное обновление, так и последующий откат.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.