Головна / Статті

Проєктування фронтендів, орієнтованих на роботу без інтернету, як копій: черги, повторні спроби, конфлікти

Дізнайтеся, чому підхід «спочатку офлайн» перетворює браузер на копію даних, та як місцеві сховища, стійкі черги змін, ідемпотентні спроби та правила конфліктів взаємодіють між собою.

Видавець: REACTAPP.TOP12 хв читання2736 слів

Джерела: The Architecture Behind an Offline-First Frontend

Фронтенд, який добре працює при швидкому та стабільному з’єднанні, мало що доводить. Цікаві ситуації виникають, коли запит застрягає, з’єднання Wi-Fi втрачається, поїзд заходить у тунель, ноутбук перемикається між мережами або телефон втрачає сигнал через мить після того, як хтось натискає Зберегти. Традиційна архітектура фронтенду розглядає всі ці випадки як винятки: викликається API, чекається, оновлюється стан, і процес продовжується.

Дизайн, орієнтований на роботу без інтернету, ґрунтується на протилежній передумові: мережа може зламатися, а додаток має залишатися корисним у будь-якому разі. Це звучить як вдосконалення користувацького інтерфейсу, але насправді це структурна зміна. Як тільки браузер може читати дані, приймати зміни, зберігати їх та синхронізувати пізніше, він перестає бути простим клієнтом та стає одним з вузлів розподіленої системи. У цьому посібнику розглядається, що це означає на практиці: де мають знаходитися дані, як безпечно чергувати та повторювати операції запису, як інтерфейс має відображати статус синхронізації та чому обробка конфліктів у кінцевому підсумку є рішенням з боку розробника продукту.

Видалення мережі з критичного шляху

User → React → Server
              ↓
            Data

Сервер знаходиться безпосередньо в процесі взаємодії. Повільний сервер означає повільний інтерфейс користувача; втрата з’єднання призводить до зупинки процесу збереження даних; невдала запит може змусити додаток миттєво вирішувати, що робити з змінами користувача.

Дизайн, орієнтований на роботу в автономному режимі, переносить значну частину цієї відповідальності на браузер:

User → React → Local Store
                    ↓
                Sync Later

Сервер не стає неважливим. Для більшості систем він все ще є джерелом спільної інформації. Змінюється лише те, що його більше не потрібно мати під рукою для кожної окремої взаємодії. Це розділення між підтвердженням дії користувача та її передачею на сервер-бекенд є основою для всього іншого.

Тепер браузер зберігає важливий стан

Якщо додаток має продовжувати працювати без підключення до мережі, йому потрібне надійне зберігання даних на пристрої. Для чогось більшого, ніж просто дрібні налаштування, це зазвичай означає використання IndexedDB або бази даних вищого рівня, побудованої на його основі. Конкретна бібліотека має менше значення, ніж наслідки: тепер браузер контролює стан даних, який ви не можете собі дозволити втратити.

Уявіть собі закешовані записи та надіслані чернетки, зміни, які чекають на оприлюднення, метадані синхронізації, такі як часи створення та номери версій, а також облік того, що вже було завантажено. Це породжує питання, на яке додаток з простою моделлю запит-відповідь ніколи не мусить відповідати: що робити, коли копія в браузері та копія на сервері не збігаються? У додатку, орієнтованому на роботу в автономному режимі, це не є винятком; це звичайна ситуація.

Кеширування даних для читання — це не те саме, що робота в автономному режимі

Це розрізнення легко затуманити. Додаток може кешувати відповіді API, проте все одно повністю залежати від мережі. Припустимо, він отримує список проектів та зберігає результат локально, тож список знову з’являється без підключення. Це корисно, але це стосується лише читання даних.

Дизайн стає справді офлайн-орієнтованим тоді, коли користувачі можуть змінювати дані поза мережею: створювати проекти, редагувати документи, позначати завдання як виконані, налаштовувати параметри. Ці дії не можуть просто зникнути через те, що HTTP-запит не було надіслано. Додатку потрібне місце для запису того, що користувач хотів зробити, щоб ця намір було збережено до моменту його виконання.

Кожна операція запису в офлайн-режимі стає обов’язком синхронізації

Коли хтось редагує документ офлайн, зазвичай спочатку застосовують зміни до локального сховища, а потім фіксують операцію, яка ще має надійти на сервер. У спрощеній формі:

User Saves
    ↓
Save Locally
    ↓
Update UI
    ↓
Add To Queue
    ↓
Sync When Online

Зверніть увагу на порядок: запис у локальному сховищі та оновлення інтерфейсу відбуваються ще до того, як щось потрапляє у чергу, саме тому інтерфейс може негайно відповісти. Черга наприкінці потребує більшої уваги, ніж зазвичай. Це не просто низка запитів, які викликали помилки, а надійний журнал робіт, виконання яких користувач очікує від системи.

Це має конкретні вимоги. Очередь повинна залишатися доступною під час перезавантаження сторінки, перезапуску браузера, тимчасових помилок сервера та змін мережі, що означає, що вона повинна зберігатися у постійному сховищі, а не в масиві JavaScript. Вона також повинна правильно функціонувати, коли одна операція виконується двічі або більше разів. Ця друга вимога призводить до одного з найскладніших способів виникнення помилок у всій структурі проекту.

Коли успіх виглядає точно як невдача

Розгляньмо таку послідовність дій. Користувач зберігає дані. Надходить запит. Сервер успішно записує дані. Потім, перш ніж відповідь повернеться, з’єднання обривається. Клієнт робить висновок, що збереження не вдалося; сервер же знає, що все пройшло успішно.

Client
  |
  | Save
  v
Server
  |
  | Saved
  X
Response Lost
  |
  v
Client Retries

Якщо клієнт просто повторює спробу, сервер може застосувати ту саму зміну двічі. Для ідемпотентної операції „set title“ це може бути нешкідливо, але для операцій типу „add item“, „create order“ чи „increment counter“ це призводить до появи дублікатів.

Саме тому ідемпотентність є ключовою для систем, орієнтованих на роботу без підключення до мережі. Кожна зміна потребує стабільного ідентифікатора, щоб сервер міг визначити, чи є повторна спроба тією самою логічною операцією, чи новою. Поширеним підходом є генерація ідентифікатора операції на клієнті під час першого запису зміни, його зберігання разом із черговою зміною та надсилання його при кожній спробі. Сервер веде облік тих ідентифікаторів, які він вже обробив, і при виявленні повтору підтверджує його, але не застосовує знову.

Це не просто оптимізація продуктивності. Це те, що запобігає ненадійній мережі перетворювати звичайні спроби повторного виконання на дублювання роботи. Практична деталь, яку варто правильно налаштувати: ідентифікатор має бути створений один раз та збережений разом із записом у черзі, а не генеруватися заново за кожним запитом, інакше кожна спроба повторного виконання знову буде вважатися новою.

Оптимістичний інтерфейс як вираження наміру

Оптимістичні оновлення зазвичай пропонуються як спосіб зробити додатки швидшими. У додатках, орієнтованих на роботу без інтернету, вони мають ще більше значення. Якщо користувач виконує завдання поза мережею, а інтерфейс чекає на відповідь сервера перед тим, як її відобразити, додаток просто здається несправним. Місцеві зміни мають відображатися миттєво, а синхронізація може оновити віддалену частину пізніше.

Це дозволяє чітко розділити обов’язки:

  • Локальний стан відображає те, що зробив користувач.
  • Статус синхронізації відображає, чи оновився спільний системний ресурс.
  • Ці два показники пов’язані, але не є ідентичними, і розгляд їх окремо дозволяє інтерфейсу надавати більш точну інформацію, ніж єдиний індикатор онлайн/офлайн. Наприклад, елемент може бути позначений як:

    • збережений на цьому пристрої
    • у черзі на синхронізацію
    • синхронізується зараз
    • вже синхронізовано
    • требує уваги користувача

    Користувач, який бачить позначку „у черзі на синхронізацію“, розуміє, що дані збережені та безпечні, але ще не поділені, що є набагато більш заспокійливим, ніж неоднозначний індикатор завантаження чи повідомлення про помилку.

    Нехай локальний сховище керує даними, а React їх відображає

    Звичайна причина випадкової складності виглядає так: дані зберігаються у IndexedDB, завантажуються до стану React, потім копіюються ще раз у окремий клієнтський сховище, і все це вручну підтримується у синхронності. Тепер додаток має кілька версій одних і тих самих записів, що призводить до застарілих значень, ситуацій конкуренції, зайвих оновлень відображення та помилок синхронізації, які існують виключно у фронтенді.

    Більш чиста модель надає власність на постійні дані стійкому локальному сховищу, а React виступає як інтерфейс для їх перегляду:

    Local Store
         ↓
      React UI
         ↓
       User
         ↓
    Local Store
    

    Саме цикл є ключовим елементом: інтерфейс користувача читає дані з локального сховища, користувач виконує дію, а ця дія знову записується у локальне сховище, що потім знову запускає інтерфейс користувача. React виконує свою функцію обробки взаємодії та відображення інформації; база даних відповідає за стан на клієнтській стороні, який має залишатися постійним. На практиці це зазвичай означає підписку компонентів на запити до локального сховища замість копіювання результатів у стан компонентів. Користь від цього зростає з розміром додатку, адже існує лише одне місце, де можна перевірити дані, якщо щось виглядає неправильно.

    Коли обидва пристрої мають рацію

    Вирішення конфліктів — це момент, коли підхід «спочатку офлайн» перестає бути питанням зберігання даних та стає проблемою розподілених систем. Припустимо, хтось відкриває один і той самий документ на ноутбуці та телефоні, і обидва виходять з мережі. На ноутбуці заголовок змінюється на Frontend Architecture; на телефоні — на Offline Architecture. Кожен пристрій прийняв законну зміну, і жоден не знає про існування іншого. Коли обидва знову під’єднуються, виникає конфлікт:

    Laptop              Phone
       |                   |
       v                   v
    "Frontend App"     "Offline App"
       |                   |
       +--------+----------+
                |
                v
             Conflict
    

    Не існує універсально правильного способу вирішення. Чи має перемогти остання зміна? Чи має мати пріоритет версія з сервера? Чи можна об’єднати поля? Чи має вирішувати користувач? Чи мають різні поля підкорятися різним правилам?

    Ключове розуміння полягає у тому, що вирішення конфліктів — це рішення, пов’язане з продуктом, яке виглядає як технічне. Принцип „останній, хто написав — перемагає“ може бути цілком прийнятним, наприклад, для налаштувань інтерфейсу. Однак для спільних документів, фінансових даних, обліку запасів чи важливих бізнес-даних цей підхід може бути неприйнятним, адже мовчазне ігнорування однієї з сторін призводить до втрати реальної роботи чи отримання неправильних підсумків. Архітектура має визначати, що означає „правильно“ для кожного типу даних, тому власники продукту повинні брати участь у цих обговореннях, а не залишати їх тим, хто пише код синхронізації. Також варто пам’ятати, що сам термін „найновіший“ є неоднозначним: годинники пристроїв відхиляються, тож політики, засновані на часових позначках клієнта, можуть обрати неправильного переможця, якщо порядок не визначається чимось більш надійним, наприклад версіями, призначеними сервером.

    Для віддалених змін потрібна та сама точка входу

    Підхід «спочатку офлайн» не означає синхронізацію лише під час запуску додатка. Інші користувачі та пристрої змінюють одні й ті самі дані, а сервер продовжує отримувати зміни з інших джерел. Тому канал у реальному часі додає другий напрямок потоку:

    Local Changes
          ↓
         Sync
          ↓
        Server
          ↓
      New Changes
          ↓
     Local Store
          ↓
          UI
    

    Це призводить до важливого правила: зміни, що надходять з сервера, мають потрапляти в ту саму локальну модель даних, яку вже читає інтерфейс. Якщо локальні зміни відбуваються одним шляхом, а віддалені оновлення — іншим, компоненти React змушені використовувати складну логіку для їх узгодження. Якщо зробити локальний сховище єдиним точкою зустрічі, процес відображення залишиться простим: інтерфейсу не має значення, чи зміна походить з цього пристрою, чи з іншого.

    Припустімо, що сам браузер може зламатися

    Існує ще один спосіб збою, який легко проігнорувати: браузер може закритися раніше, ніж мережа знову стане доступною. Вкладка закривається, браузер падає, операційна система завершує процес, акумулятор ноутбука розряджається або процес зникає під час синхронізації. Усі незавершені завдання, які існують лише в пам’яті, також втрачаються.

    Саме тому стан синхронізації має бути стабільним. Серйозний додаток, орієнтований на роботу без інтернету, повинен розглядати браузер як ненадійний вузол, а його локальна база даних має містити достатньо інформації для відновлення незавершених завдань після повторного запуску додатка: які операції залишилися незавершеними, які були у процесі виконання та який був останній відомий стан сервера. Корисним тестом є завершення роботи вкладки під час синхронізації під час розробки та перевірка на те, що під час наступного запуску нічого не втрачено та не дублюється.

    Service workers — це лише одна частина, а не вся архітектура

    Сервісні працівники є цінними, особливо для кешування ресурсів додатку та перехоплення мережевих запитів; якщо вам потрібно ознайомитися з основами їх функціонування, перегляньте активацію підтримки роботи офлайн за допомогою сервісних працівників. Однак реєстрація такого працівника не робить додаток офлайн-першим:

    • Зареєстрований працівник нічим не запобігає втраті незбережених змін користувача.
    • Кешовані пакети дозволяють додатку запускатися офлайн, проте не визначають, куди будуть зберігатися дані під час роботи офлайн.
    • Надання застарілих відповідей API з кешу не є політикою вирішення конфліктів.

    Підхід «спочатку офлайн» є властивістю архітектури, а не функцією браузера, яку можна увімкнути. Те, що насправді забезпечує його роботу, — це локальний шар даних, стабільні зміни, процес синхронізації, чітке керування помилками, політика вирішення конфліктів та інтерфейс, який приймає те, що віддалена синхронізація може зайняти деякий час.

    Складність переміщується; вона не зникає

    Неприємною реальністю є те, що програми з підходом «спочатку офлайн» не є простішими. Складність лише переміщується. Звичайний додаток може просто надіслати запит та чекати відповіді. Додаток з підходом «спочатку офлайн» мусить вирішувати низку складніших питань:

    • Що робити, якщо до сервера неможливо дістатися?
    • Що робити, якщо браузер закрився до завершення синхронізації?
    • Що робити, якщо запит надійшов, але відповіді немає?
    • Що робити, якщо та сама інформація змінилась на іншому пристрої?
    • Що робити, якщо обидві зміни є дійсними?
  • Який результат вважається правильним для цього продукту?
  • Це класичні проблеми розподілених систем, і наявність браузера серед учасників не робить їх простішими. Перш ніж обрати підхід «спочатку офлайн», варто замислитися, чи справді продукту потрібна можливість запису даних офлайн. Для панелі керування, яку користувачі лише читають, кешовані запити разом із чітким повідомленням про режим офлайн можуть забезпечити більшу частину користі при значно менших витратах.

    Локальні дані потребують такого ж рівня безпеки, як і серверні дані

    Дані, збережені на пристрої, залишаються даними з усіма наслідками, які це несе. Якщо конфіденційна інформація знаходиться в браузері, необхідно враховувати, що станеться, якщо пристрій буде скомпрометований, якщо ним користуватимуться кілька осіб, або якщо один користувач вийде з системи, а інший візьме її під контроль. Механізми локального зберігання мають дотримуватися тих самих принципів безпеки, що й серверне зберігання:

    • Зберігайте лише те, що клієнт дійсно потребує.
    • Визначте правила закінчення терміну дії та те, що відбувається після виходу з системи.
    • Не розглядайте пам’ять браузера як захищений сховище.
    • Усвідомлено вирішуйте, які записи взагалі можуть знаходитися на пристрої.

    Підхід «спочатку офлайн» покращує доступність, але ця перевага ніколи не повинна йти за рахунок розголошення інформації, яку з самого початку не слід було зберігати локально.

    Проектування з урахуванням можливих помилок, а не ідеального сценарію

    Яскравою рисою підходу «спочатку офлайн» є те, що архітектура стає зрозумілішою, як тільки ви припиняєте проектувати з урахуванням ідеального сценарію. Питання змінюються:

    • Замість питання «Як мені отримати ці дані?» запитайте, де мають знаходитися дані під час відсутності мережі.
    • Замість питання «Як мені спробувати цей запит ще раз?» запитайте, як клієнт може дізнатися, чи вже обробив його сервер.
  • Замість того, щоб думати про те, як внести дані до стану React, запитайте, яка частина системи насправді керує цими даними.
  • Замість того, щоб розглядати суперечливі запити як баг, який потрібно виправити, запитайте, що означає коректність для цього продукту.
  • Систематичне відповідання на другу версію кожного запитання призводить до міцнішого дизайну.

    Мета — досвід, якого користувачі майже не помічають

    Користувачі не цікавляться механізмами синхронізації, транзакціями баз даних, політиками відстрочки, алгоритмами об’єднання чи реплікацією. Вони хочуть продовжувати працювати. Вони очікують, що чернетка залишиться доступною після втрати з’єднання, завдання, виконане у поїзді, залишиться завершеним, а додаток не стане марним у момент зміни іконки Wi-Fi. Механізми, що знаходяться під спудом, можуть бути складними; досвід користувача не повинен таким бути.

    Ключові висновки

    Єднаюча всю концепцію дизайну ідея полягає у наступному: фронтенд, орієнтований на роботу без підключення до мережі, — це розподілена система, у якій браузер зберігає другу копію даних. Якщо дивитися на речі з цієї точки зору, окремі елементи отримують більш чіткі функції:

    • IndexedDB — це не просто місце зберігання в браузері; це частина шару даних додатку.
    • Очікувальна черга змін — це не інструмент для повторних спроб; це надійний запис наміру користувача, і кожен елемент потребує стабільного ID для ідемпотентних повторних спроб.
    • Оптимістичний інтерфейс — це не трюк для прискорення; він забезпечує можливість використання продукту під час обробки даних на бекенді.
    • Обробка конфліктів — це не окремий випадок; це частина механізму, за допомогою якого продукт визначає свою коректність.
    • Синхронізація — це не щось, що додається наприкінці; це частина архітектури.

    Мережі виходять з ладу, сервери працюють повільно, пристрої тимчасово не погоджуються між собою, а браузери зникають без попередження. Добре спроєктований фронтенд, орієнтований на роботу без інтернету, гарантує, що робота користувача залишиться недоторканою попри все це, дотримуючись своїх обіцянок навіть тоді, коли з’єднання недоступне.