Реализация оффлайн-режима десктоп-приложения
Оффлайн-режим в десктоп-приложении — это не просто «показать заглушку при отсутствии сети». Это архитектурное решение: приложение должно работать полноценно без сети и синхронизироваться, когда она появится. Между этими состояниями — очередь операций, разрешение конфликтов и честное отображение статуса данных.
Детектирование состояния сети
В Electron из main process:
// main/network-monitor.js
const { net } = require('electron');
class NetworkMonitor {
constructor() {
this.isOnline = true;
this.listeners = new Set();
this.checkInterval = null;
}
start(mainWindow) {
this.window = mainWindow;
// Встроенная проверка Electron (не надёжна — показывает наличие интерфейса, не интернета)
net.online; // true/false
// Реальная проверка через HTTP
this.checkInterval = setInterval(() => this.checkConnectivity(), 15000);
this.checkConnectivity();
}
async checkConnectivity() {
const wasOnline = this.isOnline;
try {
// Ping к надёжному эндпоинту с коротким таймаутом
const response = await Promise.race([
fetch('https://connectivity-check.your-api.com/ping', { method: 'HEAD' }),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);
this.isOnline = response.ok;
} catch {
this.isOnline = false;
}
if (wasOnline !== this.isOnline) {
this.window?.webContents.send('network:change', { isOnline: this.isOnline });
this.emit('change', this.isOnline);
}
}
on(event, listener) {
this.listeners.add({ event, listener });
}
emit(event, data) {
this.listeners.forEach(l => {
if (l.event === event) l.listener(data);
});
}
stop() {
clearInterval(this.checkInterval);
}
}
module.exports = new NetworkMonitor();
Локальная база данных: SQLite через better-sqlite3
Основа оффлайн-режима — локальное хранилище. SQLite — лучший выбор для структурированных данных:
// main/db.js
const Database = require('better-sqlite3');
const path = require('path');
const { app } = require('electron');
const dbPath = path.join(app.getPath('userData'), 'app.db');
const db = new Database(dbPath);
// Включаем WAL для лучшей производительности
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Миграции
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
updated_at INTEGER NOT NULL,
server_updated_at INTEGER,
sync_status TEXT NOT NULL DEFAULT 'synced' -- 'synced' | 'pending' | 'conflict'
);
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL, -- 'create' | 'update' | 'delete'
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(attempts, created_at);
CREATE INDEX IF NOT EXISTS idx_documents_sync ON documents(sync_status);
`);
module.exports = db;
Операции с оффлайн-поддержкой
Паттерн «оптимистичное обновление» — записываем локально сразу, синхронизируем потом:
// main/documents.js
const db = require('./db');
function createDocument(doc) {
const id = doc.id || crypto.randomUUID();
const now = Date.now();
// Записываем локально немедленно
db.prepare(`
INSERT INTO documents (id, title, content, updated_at, sync_status)
VALUES (@id, @title, @content, @updated_at, @sync_status)
`).run({
id,
title: doc.title,
content: doc.content,
updated_at: now,
sync_status: 'pending'
});
// Добавляем в очередь синхронизации
db.prepare(`
INSERT INTO sync_queue (operation, entity_type, entity_id, payload, created_at)
VALUES (@operation, @entity_type, @entity_id, @payload, @created_at)
`).run({
operation: 'create',
entity_type: 'document',
entity_id: id,
payload: JSON.stringify({ id, title: doc.title, content: doc.content }),
created_at: now
});
return { id, title: doc.title, content: doc.content, sync_status: 'pending' };
}
function updateDocument(id, changes) {
const now = Date.now();
db.prepare(`
UPDATE documents
SET title = COALESCE(@title, title),
content = COALESCE(@content, content),
updated_at = @updated_at,
sync_status = 'pending'
WHERE id = @id
`).run({ ...changes, id, updated_at: now });
db.prepare(`
INSERT INTO sync_queue (operation, entity_type, entity_id, payload, created_at)
VALUES ('update', 'document', @id, @payload, @created_at)
`).run({
id,
payload: JSON.stringify({ id, ...changes }),
created_at: now
});
}
module.exports = { createDocument, updateDocument };
Процесс синхронизации
// main/sync.js
const db = require('./db');
const networkMonitor = require('./network-monitor');
class SyncManager {
constructor() {
this.isSyncing = false;
networkMonitor.on('change', (isOnline) => {
if (isOnline) this.sync();
});
}
async sync() {
if (this.isSyncing || !networkMonitor.isOnline) return;
this.isSyncing = true;
try {
await this.pushPendingOperations();
await this.pullRemoteChanges();
} finally {
this.isSyncing = false;
}
}
async pushPendingOperations() {
const pending = db.prepare(`
SELECT * FROM sync_queue
WHERE attempts < 3
ORDER BY created_at ASC
LIMIT 50
`).all();
for (const item of pending) {
try {
await this.executeOperation(item);
// Успех — удаляем из очереди
db.prepare('DELETE FROM sync_queue WHERE id = ?').run(item.id);
// Обновляем статус документа
db.prepare(`
UPDATE documents SET sync_status = 'synced'
WHERE id = ? AND sync_status = 'pending'
`).run(item.entity_id);
} catch (error) {
// Неудача — увеличиваем счётчик попыток
db.prepare(`
UPDATE sync_queue
SET attempts = attempts + 1, last_error = ?
WHERE id = ?
`).run(error.message, item.id);
}
}
}
async executeOperation(item) {
const { getAuthToken } = require('./auth');
const token = await getAuthToken();
const payload = JSON.parse(item.payload);
const response = await fetch(`https://api.your-app.com/${item.entity_type}s`, {
method: item.operation === 'create' ? 'POST'
: item.operation === 'update' ? 'PUT'
: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
return response.json();
}
async pullRemoteChanges() {
const { lastSync = 0 } = db.prepare(
"SELECT value FROM kv_store WHERE key = 'last_sync'"
).get() ?? {};
const response = await fetch(
`https://api.your-app.com/sync?since=${lastSync}`,
{ headers: { Authorization: `Bearer ${await getAuthToken()}` } }
);
if (!response.ok) return;
const { changes, serverTime } = await response.json();
// Применяем в транзакции
db.transaction(() => {
for (const change of changes) {
this.applyRemoteChange(change);
}
db.prepare("INSERT OR REPLACE INTO kv_store VALUES ('last_sync', ?)").run(serverTime);
})();
}
applyRemoteChange(change) {
if (change.type === 'document') {
const local = db.prepare('SELECT * FROM documents WHERE id = ?').get(change.id);
if (!local) {
// Новый документ от сервера
db.prepare(`
INSERT INTO documents (id, title, content, updated_at, server_updated_at, sync_status)
VALUES (@id, @title, @content, @updated_at, @server_updated_at, 'synced')
`).run(change);
} else if (local.sync_status === 'pending') {
// Конфликт: у нас есть несинхронизированные изменения
db.prepare(`
UPDATE documents SET sync_status = 'conflict', server_updated_at = ?
WHERE id = ?
`).run(change.updated_at, change.id);
} else {
// Обновляем из сервера
db.prepare(`
UPDATE documents
SET title = ?, content = ?, updated_at = ?, server_updated_at = ?, sync_status = 'synced'
WHERE id = ?
`).run(change.title, change.content, change.updated_at, change.updated_at, change.id);
}
}
}
}
module.exports = new SyncManager();
Разрешение конфликтов в UI
// renderer/components/ConflictResolver.tsx
interface ConflictDocument {
id: string;
title: string;
localContent: string;
serverContent: string;
localUpdatedAt: number;
serverUpdatedAt: number;
}
export function ConflictResolver({ doc, onResolve }: { doc: ConflictDocument; onResolve: (choice: 'local' | 'server') => void }) {
return (
<div className="conflict-modal">
<h3>Конфликт синхронизации: {doc.title}</h3>
<p>Этот документ был изменён и на этом устройстве, и на сервере.</p>
<div className="conflict-diff">
<div className="version local">
<h4>Ваша версия ({new Date(doc.localUpdatedAt).toLocaleString()})</h4>
<pre>{doc.localContent}</pre>
<button onClick={() => onResolve('local')}>Оставить мою версию</button>
</div>
<div className="version server">
<h4>Серверная версия ({new Date(doc.serverUpdatedAt).toLocaleString()})</h4>
<pre>{doc.serverContent}</pre>
<button onClick={() => onResolve('server')}>Принять серверную версию</button>
</div>
</div>
</div>
);
}
Индикация статуса синхронизации
Пользователь должен всегда понимать, сохранены ли его данные:
- Иконка облака с крестиком = нет сети, есть несохранённые изменения
- Иконка облака со стрелкой = синхронизация идёт
- Иконка облака с галочкой = всё синхронизировано
- Иконка предупреждения = есть конфликты
Статус должен быть в постоянно видимой части UI — строка состояния или хедер.
Полная реализация оффлайн-режима с конфликтами, очередью и синхронизацией занимает 3-5 дней разработки только для базового функционала. Сложность растёт с количеством типов сущностей и сложностью бизнес-логики разрешения конфликтов.







