يعني عدم الاتصال أولاً أن التطبيق يعمل من البيانات المحلية بشكل افتراضي ويعامل الشبكة على أنها تحسين. وهذا تصميم مختلف عن “يعمل عبر الإنترنت، ويتدهور عند عدم الاتصال بالإنترنت”، وينتج تطبيقًا يبدو فوريًا حتى عند الاتصال الجيد. يبني هذا الدليل واحدًا بشكل صحيح: التخزين المؤقت لعامل الخدمة، وقاعدة البيانات المفهرسة للبيانات المنظمة، ومزامنة الخلفية لعمليات الكتابة التي تتم أثناء عدم الاتصال بالإنترنت.
📋 Table of Contents
العمارة
- عامل خدمة يعترض طلبات الشبكة ويخدم من ذاكرة التخزين المؤقت
- تخزين ذاكرة التخزين المؤقت يحمل غلاف التطبيق — HTML، CSS، JavaScript، الخطوط
- قاعدة البيانات المفهرسة يحمل بيانات منظمة – يسجل قراءة المستخدم وكتابته
- مزامنة الخلفية عمليات إعادة الكتابة التي تتم في وضع عدم الاتصال بمجرد عودة الاتصال
التحول العقلي الرئيسي: تقرأ واجهة المستخدم من IndexedDB، وليس مباشرة من الشبكة. هناك عملية منفصلة تحافظ على تحديث IndexedDB. هذا القرار الوحيد هو ما يجعل التطبيق يعمل بشكل متطابق عبر الإنترنت وخارجه.
الخطوة 1: البيان
{
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">
الأيقونة القابلة للإخفاء مهمة على Android، حيث يتم اقتصاص الأيقونة غير القابلة للإخفاء في شكل غالبًا ما يقطع جزءًا من شعارك.
الخطوة الثانية: تسجيل عامل الخدمة
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
// Notify the user when a new version is waiting.
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(() => {
newWorker.postMessage({ type: 'SKIP_WAITING' });
});
}
});
});
} catch (err) {
console.error('Service worker registration failed:', err);
}
});
}
// Reload once the new worker takes control.
let refreshing = false;
navigator.serviceWorker?.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
لا تقم مطلقًا بتنشيط عامل جديد دون إخبار المستخدم. يؤدي تبديل الكود الموجود أسفل تطبيق قيد التشغيل في منتصف الجلسة إلى حدوث حالات فشل مربكة – قم بتقديم إعادة التحميل بدلاً من ذلك.
الخطوة 3: عامل الخدمة واستراتيجيات التخزين المؤقت الخاصة به
الموارد المختلفة تريد استراتيجيات مختلفة، واستخدام استراتيجية واحدة لكل شيء هو الخطأ المعتاد.
| استراتيجية | استخدم لـ |
|---|---|
| ذاكرة التخزين المؤقت أولا | الأصول الثابتة المجزأة – لا تتغير أبدًا |
| الشبكة أولا | بيانات واجهة برمجة التطبيقات (API) حيث تكون الحداثة مهمة |
| قديمة أثناء إعادة التحقق | المحتوى الذي يمكن أن يكون قديمًا بعض الشيء — الصور الرمزية، والقوائم |
| شبكة فقط | أي شيء له آثار جانبية |
// sw.js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const SHELL_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/app.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then(c => c.addAll(SHELL_ASSETS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k))
);
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Never cache anything that changes server state.
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Navigations: network first, fall back to the offline page.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
return;
}
// API: network first, fall back to the cached copy.
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache first.
event.respondWith(cacheFirst(request));
});
async function networkFirst(request) {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response(
JSON.stringify({ error: 'offline' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put(request, response.clone());
}
return response;
}
response.clone() مطلوب لأنه لا يمكن قراءة نص الاستجابة إلا مرة واحدة. يؤدي التخزين المؤقت للأصل وإعادته إلى الصفحة إلى استهلاكه مرتين، وتتلقى الصفحة نصًا فارغًا.
الخطوة 4: قاعدة البيانات المفهرسة للبيانات المنظمة
يحتوي التخزين المؤقت على استجابات HTTP كاملة. بالنسبة للسجلات التي تحتاج إلى الاستعلام عنها وفرزها وتحديثها بشكل فردي، استخدم IndexedDB. واجهة برمجة التطبيقات الأولية مطولة، لذا فإن الغلاف الرقيق يستحق ذلك.
import { openDB } from 'idb';
const db = await openDB('field-notes', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const notes = db.createObjectStore('notes', { keyPath: 'id' });
notes.createIndex('by-updated', 'updatedAt');
notes.createIndex('by-synced', 'synced');
}
if (oldVersion < 2) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
}
},
});
export async function saveNote(note) {
const record = { ...note, updatedAt: Date.now(), synced: false };
await db.put('notes', record);
await db.add('outbox', { type: 'saveNote', payload: record });
await requestSync();
return record;
}
export async function listNotes() {
return db.getAllFromIndex('notes', 'by-updated');
}
لاحظ الترتيب: اكتب إلى وحدة التخزين المحلية أولاً، ثم قم بوضع المزامنة في قائمة الانتظار. يتم تحديث واجهة المستخدم على الفور ولا تهتم بما إذا كانت الشبكة متاحة أم لا. هذا هو بيت القصيد من عدم الاتصال بالإنترنت أولاً.
الخطوة 5: مزامنة الخلفية
تتيح ميزة مزامنة الخلفية للمتصفح إعادة تشغيل عمليات الكتابة المدرجة في قائمة الانتظار بمجرد عودة الاتصال، حتى لو تم إغلاق الصفحة.
// In the page
async function requestSync() {
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
await reg.sync.register('sync-outbox');
} else {
await flushOutbox(); // fall back to an immediate attempt
}
}
// In sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-outbox') {
event.waitUntil(flushOutbox());
}
});
async function flushOutbox() {
const db = await openDB('field-notes', 2);
const items = await db.getAll('outbox');
for (const item of items) {
try {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await db.delete('outbox', item.id);
await db.put('notes', { ...item.payload, synced: true });
} catch {
// Leave it queued; the sync event fires again later.
return;
}
}
}
مزامنة الخلفية غير متوفرة في كل متصفح. احتفظ دائمًا بخيار احتياطي يقوم بمسح البريد الصادر عند تحميل الصفحة وعندonline حرائق الأحداث.
التعامل مع الصراعات
تؤدي عمليات الكتابة في وضع عدم الاتصال إلى حدوث تعارضات، والتظاهر بخلاف ذلك يؤدي إلى فقدان صامت للبيانات. قرر سياسة واضحة.
الكتابة الأخيرة تفوز أبسط شيء — إرسال طابع زمني والسماح للخادم بأخذ الطابع الأحدث. مناسب للملاحظات الشخصية، وخطأ لأي شيء تعاوني.
الخادم يفوز في الصراع آمن ويتطلب إخبار المستخدم برفض تغييره دون الاتصال بالإنترنت. لا تتجاهلها بصمت أبدًا.
دمج هو الأفضل عندما تدعمه بنية البيانات، وهو ما يعني عادةً CRDT للمستندات التعاونية الحقيقية.
const res = await fetch('/api/notes/' + note.id, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Unmodified-Since': new Date(note.serverUpdatedAt).toUTCString(),
},
body: JSON.stringify(note),
});
if (res.status === 412) {
await queueConflictForReview(note); // surface it, never drop it
}
حدود التخزين
تقوم المتصفحات بإخلاء مساحة التخزين تحت الضغط. بالنسبة لتطبيق تكون بياناته المحلية هي مصدر الحقيقة حتى تتم المزامنة، اطلب الثبات.
if (navigator.storage?.persist) {
const persisted = await navigator.storage.persist();
console.log('Persistent storage:', persisted);
}
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1024 / 1024).toFixed(1)}MB of ${(quota / 1024 / 1024).toFixed(0)}MB`);
الاختبار دون الاتصال بالإنترنت بشكل صحيح
يعد مربع الاختيار غير المتصل بـ DevTools نقطة بداية، وليس اختبارًا. تحقق أيضًا من: إعادة التحميل أثناء عدم الاتصال بالإنترنت، والاتصال البطيء وغير المستقر بدلاً من الاتصال الغائب تمامًا، وما يحدث عندما يقوم عامل الخدمة بالتحديث في منتصف الجلسة، وما إذا كانت الكتابة في قائمة الانتظار ستستمر بعد إعادة تشغيل المتصفح بالكامل.
# Verify the manifest, service worker, and installability
npx lighthouse https://your-app.com --only-categories=pwa --view
أخطاء شائعة
التخزين المؤقت لطلبات POST. يتعامل تخزين ذاكرة التخزين المؤقت مع GET فقط، وسيكون التخزين المؤقت لطلبات التأثير الجانبي خاطئًا على أي حال.
النسيانresponse.clone(). تتلقى الصفحة نصًا فارغًا.
عدم إبطال ذاكرة التخزين المؤقت القديمة مطلقًا. يحصل المستخدمون على أصول قديمة إلى أجل غير مسمى. أسماء ذاكرة التخزين المؤقت للإصدار وحذف الأسماء القديمة عند التنشيط.
اتصالskipWaiting() دون قيد أو شرط. يتغير الكود أسفل جلسة العمل.
القراءة من الشبكة في واجهة المستخدم. اقرأ من IndexedDB وقم بالمزامنة بشكل منفصل، أو لن يكون التطبيق غير متصل بالإنترنت أولاً.
فشل إسقاط عمليات الكتابة دون اتصال. إن فقدان البيانات الصامت هو أسوأ نتيجة ممكنة، حيث تظهر الصراعات دائمًا.
الخلاصة
يعتمد وضع عدم الاتصال بالإنترنت أولاً على قرار معماري واحد:تقوم واجهة المستخدم بقراءة وكتابة وحدة التخزين المحلية، وتحدث المزامنة بشكل منفصل في الخلفية. قم بتخزين غلاف التطبيق مؤقتًا باستخدام عامل خدمة تم إصداره، واحتفظ بالبيانات المنظمة في IndexedDB، وقم بالكتابة في قائمة الانتظار في صندوق الصادر وأعد تشغيلها باستخدام مزامنة الخلفية، واختر سياسة التعارض عمدًا بدلاً من اكتشاف واحدة عن طريق الصدفة. قم بالاختبار باستخدام اتصال غير مستقر بدلاً من التبديل دون الاتصال بالإنترنت، لأن الاتصال المتقطع هو ما يختبره المستخدمون بالفعل.
🔗 Share this article
✍️ Leave a Comment