آفلاین‌بودن را از همگام‌سازی جدا کنید

قطع کوتاه اینترنت نباید متن طولانی دانش‌آموز یا یادداشت پشتیبان را از بین ببرد. IndexedDB برای داده ساختاریافته در مرورگر مناسب است و می‌تواند پیش‌نویس را پیش از ارسال به سرور نگه دارد. با این حال، ذخیره محلی معادل پشتیبان قابل اتکا نیست: کاربر ممکن است داده مرورگر را پاک کند، دستگاه مشترک باشد یا فضای ذخیره محدود شود. در رابط کاربر فرق «روی این دستگاه ذخیره شد» و «به سرور ارسال شد» را روشن نشان دهید.

پیش از طراحی پایگاه داده، مدل پیش‌نویس را مشخص کنید. شناسه فرم، شناسه کاربر، زمان ویرایش و نسخه محتوا معمولاً از خود متن مهم‌ترند. در مثال فقط یک سند ساختگی ذخیره می‌کنیم. برای دستگاه مشترک، داده کاربران مختلف نباید با کلید یکسان مخلوط شود و هنگام خروج باید سیاست پاک‌سازی مشخص باشد. رمز یا اطلاعات حساس را صرفاً به این دلیل که فضای محلی در دسترس است، در آن ننویسید.

ذخیره معتبر با تراکنش و نسخه‌بندی

عملیات IndexedDB ناهم‌زمان و در محدوده تراکنش انجام می‌شود. موفقیت request ذخیره‌سازی را با پایان موفق تراکنش یکی ندانید؛ اعلان ذخیره‌شدن باید بعد از complete صادر شود. کد زیر Promise را به رویداد complete وصل می‌کند و خطای بازشدن یا abort را به فراخواننده برمی‌گرداند. در پروژه واقعی، وضعیت قابل فهمی کنار فرم قرار دهید و بعد از شکست، متن موجود در حافظه را از کاربر نگیرید.

تغییر ساختار داده با نسخه پایگاه و رویداد upgradeneeded انجام می‌شود. یک تب قدیمی می‌تواند ارتقای تب جدید را مسدود کند؛ مدیریت versionchange و بسته‌شدن اتصال قبلی کمک می‌کند. نمونه اتصال‌های خود را بعد از تراکنش می‌بندد، اما رابط واقعی باید حالت blocked را نیز به کاربر توضیح دهد. مهاجرت را با نسخه قدیمی واقعی آزمایش کنید، نه فقط دیتابیس خالی توسعه‌دهنده.

برای ذخیره هنگام تایپ، هر فشار کلید را به یک عملیات شبکه تبدیل نکنید. یک تأخیر کوتاه کنترل‌شده و ثبت تغییرات محلی می‌تواند مناسب باشد، ولی نباید رویداد خروج صفحه تنها فرصت ذخیره باشد. پس از وصل‌شدن اینترنت، ارسال دوباره و تعارض نسخه به قرارداد سرور نیاز دارد. استفاده از شناسه پایدار و نسخه مورد انتظار سند، از بازنویسی بی‌خبر تغییرات دستگاه دیگر جلوگیری می‌کند.

نمونه کد و روش بررسی

پیش‌نیاز و خروجی مورد انتظار در توضیح مثال آمده است. ابتدا نمونه را در محیط آزمایشی اجرا کنید و سپس آن را با داده و محدودیت‌های پروژه خود تطبیق دهید.

1. JavaScript در صفحه آزمایشی روی localhost یا HTTPS

function saveDraft(record) {
  return new Promise((resolve, reject) => {
    const open = indexedDB.open("liyan-drafts-demo", 1);
    open.onupgradeneeded = () => {
      open.result.createObjectStore("drafts", { keyPath: "id" });
    };
    open.onerror = () => reject(open.error);
    open.onsuccess = () => {
      const db = open.result;
      db.onversionchange = () => db.close();
      const tx = db.transaction("drafts", "readwrite");
      tx.oncomplete = () => { db.close(); resolve(record.id); };
      tx.onabort = () => { db.close(); reject(tx.error); };
      tx.objectStore("drafts").put(record);
    };
  });
}

saveDraft({ id: "lesson-1", text: "My draft", updatedAt: Date.now() })
  .then(id => console.log("Saved:", id))
  .catch(error => console.error("Draft was not saved:", error));

در Console صفحه آزمایشی اجرا کنید؛ Saved: lesson-1 باید بعد از پایان تراکنش چاپ شود. در Application → IndexedDB رکورد را ببینید. متن نمونه محرمانه نیست. این کد فرم، خواندن رکورد و همگام‌سازی سرور را نمی‌سازد.

2. ماژول کامل ذخیره، خواندن و حذف پیش‌نویس

برای ساخت فرم قابل استفاده، این نسخه را در draft-store.js ذخیره کنید. نام دیتابیس آزمایشگاهی با مثال کوتاه متفاوت است. ماژول، اتصال را پس از هر تراکنش می‌بندد، پایان تراکنش را معیار موفقیت می‌گیرد و خطای ارتقای مسدودشده را به رابط برمی‌گرداند.

// Save as draft-store.js.
function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("liyan-drafts-v2-demo", 1);
    let abandoned = false;
    request.onupgradeneeded = () => {
      request.result.createObjectStore("drafts", { keyPath: "id" });
    };
    request.onblocked = () => {
      abandoned = true;
      reject(new Error("Close older draft tabs and retry"));
    };
    request.onerror = () => reject(request.error);
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = () => db.close();
      if (abandoned) db.close();
      else resolve(db);
    };
  });
}

async function transaction(mode, operation) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    let tx;
    try {
      tx = db.transaction("drafts", mode);
      const request = operation(tx.objectStore("drafts"));
      tx.oncomplete = () => { db.close(); resolve(request.result); };
      tx.onabort = () => { db.close(); reject(tx.error || new Error("Aborted")); };
    } catch (error) {
      db.close();
      reject(error);
    }
  });
}

export function saveDraft(record) {
  if (!record.id || typeof record.text !== "string") {
    return Promise.reject(new TypeError("id and text are required"));
  }
  return transaction("readwrite", store => store.put(record));
}
export function loadDraft(id) {
  return transaction("readonly", store => store.get(id));
}
export function deleteDraft(id) {
  return transaction("readwrite", store => store.delete(id));
}

saveDraft و loadDraft و deleteDraft در مراحل بعد وارد می‌شوند. شناسه نمونه داده واقعی کاربر نیست؛ در محصول، کلید را به کاربر و سند متصل کنید و پاک‌سازی هنگام خروج را بر همان اساس طراحی کنید. قطع‌شدن برنامه پس از تکمیل تراکنش همچنان باید در مرورگرهای هدف آزموده شود.

3. صفحه فرم و وضعیت ذخیره

این فایل را کنار دو ماژول JavaScript قرار دهید و با python -m http.server 8000 در همان پوشه سرو کنید. ماژول‌ها را از file:// باز نکنید. وضعیت ذخیره با role=status به ابزارهای کمکی هم اعلام می‌شود.

<!-- Save as draft.html beside draft-store.js and draft-ui.js. -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Offline draft exercise</title>
<label for="draft">Lesson answer</label>
<textarea id="draft" rows="8"></textarea>
<p id="status" role="status" aria-live="polite">Loading draft…</p>
<button id="save" type="button">Save now</button>
<button id="remove" type="button">Delete local draft</button>
<script type="module" src="./draft-ui.js"></script>
</html>

با افزودن فایل بعدی، فرم باید پیش‌نویس را بازیابی کند. خاموش‌بودن اینترنت مانع نوشتن دیتابیس محلی نیست، اما برای بازشدن مجدد خود صفحه در حالت کاملاً آفلاین باید منابع برنامه جدا کش شوند؛ این دو نیاز را در معیار پذیرش یکی نگیرید.

4. اتصال فرم، تأخیر کوتاه ذخیره و بازیابی

آخرین نسخه متن را هنگام ذخیره snapshot بگیرید و نوشتن‌ها را به ترتیب اجرا کنید. شمارنده revision مانع می‌شود پایان یک ذخیره قدیمی، تغییر تازه کاربر را به‌اشتباه «ذخیره‌شده» اعلام کند. هنگام خواندن اولیه، تایپ موقتاً غیرفعال است تا متن تازه با بازیابی دیررس بازنویسی نشود.

// Save as draft-ui.js.
import { saveDraft, loadDraft, deleteDraft } from "./draft-store.js";

const input = document.querySelector("#draft");
const status = document.querySelector("#status");
const id = "demo-user:lesson-1";
let timer;
let revision = 0;
let writes = Promise.resolve();

async function persist() {
  const version = revision;
  const text = input.value;
  const write = writes.then(() => saveDraft({ id, text, updatedAt: Date.now() }));
  writes = write.catch(() => {});
  try {
    await write;
    if (version === revision) status.textContent = "Saved on this device";
  } catch (error) {
    status.textContent = "Not saved: " + error.message;
  }
}
input.disabled = true;
try {
  const draft = await loadDraft(id);
  if (draft) input.value = draft.text;
  status.textContent = draft ? "Draft restored" : "No local draft yet";
} catch (error) {
  status.textContent = "Recovery failed: " + error.message;
}
input.disabled = false;

input.addEventListener("input", () => {
  revision++;
  status.textContent = "Unsaved changes";
  clearTimeout(timer);
  timer = setTimeout(persist, 400);
});
document.querySelector("#save").onclick = () => {
  clearTimeout(timer);
  persist();
};
document.querySelector("#remove").onclick = async () => {
  clearTimeout(timer);
  const deletingRevision = ++revision;
  const removal = writes.then(() => deleteDraft(id));
  writes = removal.catch(() => {});
  try {
    await removal;
    if (deletingRevision === revision) {
      input.value = "";
      status.textContent = "Local draft deleted";
    }
  } catch (error) {
    status.textContent = "Delete failed: " + error.message;
  }
};

متن بنویسید، پیام ذخیره را ببینید و صفحه را reload کنید؛ همان متن باید برگردد. دکمه حذف نیز باید پس از reload نبود پیش‌نویس را نشان دهد. اگر پیش از پایان ۴۰۰ میلی‌ثانیه صفحه بسته شود، آخرین تغییر تضمین نشده است؛ برای ثبت فوری از Save now استفاده کنید و در محصول دوره ذخیره را با نیاز واقعی تنظیم کنید.

5. همگام‌سازی اختیاری با کنترل تعارض نسخه

این بخش پس از خواندن نسخه فعلی سرور کاربرد دارد. If-Match فقط وقتی از بازنویسی جلوگیری می‌کند که سرور آن را به‌صورت اتمیک با به‌روزرسانی بررسی کند. CSRF و احراز هویت هم قرارداد جدا دارند؛ هدر ساختگی به‌تنهایی محافظت ایجاد نمی‌کند.

// The server must implement this contract; the local demo has no such endpoint.
export async function uploadDraft({ url, text, serverEtag, csrfToken }) {
  if (!serverEtag) throw new Error("Load the current server version first");
  const response = await fetch(url, {
    method: "PUT",
    credentials: "same-origin",
    headers: {
      "Content-Type": "application/json",
      "If-Match": serverEtag,
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify({ text }),
  });
  if (response.status === 412) {
    return { status: "conflict", localText: text };
  }
  if (!response.ok) throw new Error("Upload failed: " + response.status);
  return { status: "uploaded", serverEtag: response.headers.get("ETag") };
}

برای 412، متن محلی را نگه دارید و تصمیم ادغام را به کاربر نشان دهید. در قطع شبکه نیز پیش‌نویس محلی نباید پاک شود. وضعیت «ارسال شد» فقط پس از پاسخ موفق سرور نمایش داده می‌شود؛ خطای شبکه یا conflict را به ذخیره محلی موفق تبدیل نکنید.

تجربه کاربر در زمان خطا و تعارض

آزمون پذیرش را از دید نویسنده انجام دهید: متن بنویسید، اینترنت را قطع کنید، صفحه را دوباره باز کنید و بازگشت پیش‌نویس را ببینید. سپس سهمیه پر، تب قدیمی و تغییر هم‌زمان سرور را شبیه‌سازی کنید. IndexedDB فقط بخشی از راه‌حل است؛ آفلاین بازشدن خود صفحه به کش منابع برنامه نیاز دارد و در این مثال پیاده‌سازی نشده است. همین مرزبندی، پروژه آموزشی را دقیق‌تر و کاربرد واقعی را قابل اعتمادتر می‌کند.

چک‌لیست اجرای عملی

  • اعلان ذخیره بعد از complete تراکنش نمایش داده شود.
  • کاربران مختلف کلید و سیاست پاک‌سازی جدا داشته باشند.
  • ارتقای نسخه با تب قدیمی بررسی شود.
  • ذخیره محلی را از ارسال سرور و کش صفحه تفکیک کنید.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.