IlmHamroh
JavaScript Full-stack/21-qism. React: media, integratsiyalar, PWA29/32-dars23 daqiqa
Mundarija (30)

Offline-first React ilova: internetsiz yozuvlar navbati, qayta sinxronlash va konflikt

Qisqacha: Offline-first — ilova avval mahalliy ishlaydi, server bilan keyin kelishadi. Internet yo'qligida o'zgarish rad etilmaydi: ekranda darhol ko'rinadi va navbatga yoziladi. Internet qaytganda navbat tartib bilan serverga ketadi, keyin ro'yxat serverdan olinadi. Ikki qurilma bitta narsani o'zgartirsa — konflikt: kim yutishini oldindan hal qilasiz va foydalanuvchiga aytasiz. vazifalar v11 da: oflayn qo'shish, belgilash, tahrir va o'chirish — localStorage navbatida, qaytganda POST → PATCH → DELETE → GET, 404 — konflikt e'loni. Narxi +0.8 KB.

Bu darsda

  • navigator.onLine nimani bildiradi va nimani bildirmasligini tushuntirasiz.
  • Mutatsiyalar navbatini yozasiz: saqlash, siqish, tartib bilan yuborish, mahalliy id → server id.
  • TanStack Query'ning networkMode va to'xtatilgan (paused) mutatsiyalarini jonli ko'rasiz.
  • Background Sync va persistQueryClient qachon kerakligini bilasiz.
  • Konfliktni hal qilish strategiyalarini ajratasiz va kanon tanlovini asoslaysiz.

Oldin bilishingiz kerak: Kesh strategiyalari va offline ishlash, IndexedDB kutubxonalari React'da, TanStack Query III: mutatsiyalar, useSyncExternalStore, PWA React loyihada.

1. Nega bu kerak?

Jasur aka bozorga tushdi. Yerto'ladagi do'konda internet yo'q. U telefonda «Bahor» ro'yxatiga «Zira olish» deb yozadi. v10 vazifalar shunday javob berardi:

text
Internet yo'q — o'zgarish saqlanmaydi.

Ilova Service Worker tufayli oflayn ochiladi, ro'yxatni ko'rsatadi — lekin yozishga ruxsat bermaydi. Jasur aka yozganini eslab qolishi, tashqariga chiqib qayta kiritishi kerak. Bu "offline-ready", lekin "offline-first" emas.

Offline-first — boshqa yondashuv: ilova avval qurilmada ishlaydi, server esa keyin "yetib oladi". Internet — tezlashtiruvchi, shart emas. Telegram shunday: oflayn yozilgan xabar soat belgisi bilan turadi, aloqa tiklanganda o'zi ketadi.

Hayotiy o'xshatish: pochta qutisi. Xatni yozib, qutiga tashlaysiz — pochtachi qachon kelishini kutib turmaysiz. U kelganda qutidagi hamma xatni tartib bilan olib ketadi.

Bu darsda vazifalar ga shu "quti" ni qo'shamiz. Lekin avval uch savol: internet borligini qanday bilamiz, navbat qanday tuzilgan va ikki qurilma bir-biriga zid o'zgarish qilsa nima bo'ladi.

2. Internet bormi?

2.1 navigator.onLine — yarim haqiqat

Brauzer navigator.onLine va online/offline hodisalarini beradi. vazifalar da 17-qismdan beri useOnlayn hook'i bor (kanon):

ts
function obuna(tinglovchi: () => void): () => void {
  addEventListener("online", tinglovchi);
  addEventListener("offline", tinglovchi);
  return () => {
    removeEventListener("online", tinglovchi);
    removeEventListener("offline", tinglovchi);
  };
}

export function useOnlayn(): boolean {
  return useSyncExternalStore(
    obuna,
    () => navigator.onLine,
    () => true, // serverda (SSR) — taxmin; bizda SSR yo'q
  );
}

Lekin navigator.onLine ning ma'nosi assimetrik:

Qiymat Ma'nosi
false aniq oflayn: tarmoq ulanishi yo'q (Wi-Fi o'chiq, samolyot rejimi)
true "ehtimol onlayn": tarmoq bor, lekin internet bormi — noma'lum

Wi-Fi'ga ulangan, lekin router internetga chiqmayapti — true. Mehmonxona Wi-Fi'si login sahifasini kutyapti — true. Server o'chgan — true. Shuning uchun true ga ishonib bo'lmaydi: haqiqiy javob — so'rovning o'zi muvaffaqiyatli bo'ldimi.

vazifalar ikkalasini ajratadi: oflayn — navigator.onLine === false, xato — so'rov yiqildi. Kanon navbati faqat birinchisida ishlaydi. Internet bor-u server javob bermasa — yozuv avvalgidek rad etiladi (TEXNIK-QARZ 66). Halol cheklov: "vaqtincha xato" va "rad etildi" ni ajratish — alohida qaror, u 25-qismda idempotent API bilan keladi.

2.2 TanStack Query va internet

TanStack Query o'zining onlineManager iga ega: u ham online/offline hodisalarini tinglaydi. Muhim tafsilot — Query v5 sahifa ochilganda internetni bor deb hisoblaydi. Kanon e2e shu yerdan xato topgan edi (kanon izohi):

ts
// Query v5 sahifa ochilganda internetni BOR deb hisoblaydi va faqat
// online/offline hodisalarini tinglaydi. Sahifa internetsiz ochilsa
// (SW keshidan) — so'rov yiqilib "xato" bo'lardi va internet
// qaytganda "online" hodisasi holatni o'zgartirmagani uchun qayta
// so'ralmasdi (e2e topdi: 13/#22 "onlayn: sinxron qaytdi").
// Boshlang'ich holat — brauzerdan: oflayn bo'lsa so'rov KUTADI va
// internet qaytishi bilan ketadi
onlineManager.setOnline(navigator.onLine);

onlineManager oflayn bo'lsa, so'rovlar va mutatsiyalar to'xtatiladi (paused) — yiqilmaydi. Bu xulq networkMode sozlamasiga bog'liq:

networkMode Oflayn bo'lsa
"online" (sukut) so'rov ketmaydi, to'xtab turadi; qaytganda davom etadi
"always" internetga qaramaydi — doim yuboradi (masalan, mahalliy API)
"offlineFirst" bir marta urinadi (SW keshi javob berishi mumkin), keyin to'xtaydi

2.3 Jonli: to'xtatilgan mutatsiya

onlineManager.setOnline(false) — "internet uzildi" ni taqlid qiladi. Mutatsiya yuborib ko'ring:

jsx
import {
  QueryClient,
  QueryClientProvider,
  onlineManager,
  useMutation,
} from "@tanstack/react-query";

const mijoz = new QueryClient();
const serverga = (matn) =>
  new Promise((hal) => setTimeout(() => hal({ id: 7, matn }), 200));

function Vazifa() {
  const m = useMutation({
    mutationFn: serverga,
    onSuccess: (v) => console.log("serverda:", v.id, v.matn),
  });
  return (
    <>
      <button onClick={() => onlineManager.setOnline(false)}>
        Internetni uzish
      </button>{" "}
      <button onClick={() => onlineManager.setOnline(true)}>
        Internetni ulash
      </button>{" "}
      <button onClick={() => m.mutate("Non olish")}>Non olish</button>
      <p>
        Holat: {m.status}
        {m.isPaused ? ", paused" : ""}
      </p>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={mijoz}>
      <Vazifa />
    </QueryClientProvider>
  );
}

Konsolda:

text
serverda: 7 Non olish

Ekranda:

text
Internetni uzish Internetni ulash Non olish
Holat: success

Oflayn paytda mutatsiya pending, paused edi (oraliq nuqta): yiqilmadi, kutdi. «Internetni ulash» dan keyin o'zi davom etdi va success. Bu — xotiradagi navbat. Lekin bitta katta "lekin" bor: sahifani yopsangiz, to'xtatilgan mutatsiya yo'qoladi. Jasur aka telefonni cho'ntagiga solib, brauzer tabni o'chirsa — «Non olish» hech qachon serverga yetmaydi.

Tekshirib ko'ring: navigator.onLine === true, lekin «Bahor» serveri o'chgan. TanStack Query'ning sukut networkMode ida mutatsiya to'xtab turadimi yoki yiqiladimi?

Javob

Yiqiladi. onlineManager faqat brauzerning online/offline hodisalariga qaraydi — true bo'lgani uchun so'rov yuboriladi, server javob bermaydi, fetch xato beradi. retry bo'lsa — qayta urinadi, keyin error. "To'xtab turish" faqat brauzer o'zi "tarmoq yo'q" degan holatda. Server o'chganini ajratish — sizning ishingiz (xato turiga qarab).

3. Navbat: sahifa yopilsa ham yo'qolmaydigan "quti"

3.1 Tuzilma

vazifalar ning yechimi — o'z navbati, localStorage da ("vazifalar:navbat"). Har yozuv — bitta amal:

ts
type NavbatAmali =
  /** id — mahalliy (vaqtincha) id: server o'zinikini beradi */
  | { readonly tur: "qosh"; readonly id: number; readonly matn: string }
  | {
      readonly tur: "yangila";
      readonly id: number;
      /** xabar uchun — navbatga qo'yilgandagi matn */
      readonly nomi: string;
      readonly maydonlar: {
        readonly bajarildi?: boolean;
        readonly matn?: string;
      };
    }
  | {
      readonly tur: "ochir";
      readonly id: number;
      readonly nomi: string;
    };

Bu — discriminated union: tur maydoni qaysi shaklligini aytadi. Uch tafsilot:

  • qosh dagi id — mahalliy (vaqtincha). Server vazifani qabul qilganda o'z id'sini beradi. Navbatdagi keyingi amallar shu vazifaga mahalliy id bilan murojaat qiladi — yuborishda almashtiriladi.
  • nomi — konflikt xabari uchun: "«Sut olish» boshqa joyda o'chirilgan". Server javob bermaydigan paytda matnni boshqa qayerdan olamiz?
  • Amal — o'zgarish, butun ro'yxat emas. Server ro'yxatini butunlay almashtirish ikki qurilmada bir-birining ishini o'chirib yuboradi.

Nega IndexedDB emas? IndexedDB katta ma'lumot uchun. Navbat — o'nlab kichik yozuv. localStorage sinxron (o'qish oddiy), va boshqa tab o'zgartirsa storage hodisasini beradi — «Navbatda: N» hisobi barcha tablarda yangilanadi. Bitta muhim istisno keyinroq — Background Sync'da.

3.2 Siqish: keraksiz so'rov ketmasin

Jasur aka oflayn paytda «Non» qo'shdi, keyin uni «Non olish» deb tuzatdi, belgiladi va oxirida o'chirdi. Navbatda to'rt amal. Lekin server bu vazifani hech qachon ko'rmagan — to'rttasi ham keraksiz. Kanon navbatniSiq funksiyasi — kod aynan, sinov bilan:

ts
type NavbatAmali =
  | { tur: "qosh"; id: number; matn: string }
  | {
      tur: "yangila";
      id: number;
      nomi: string;
      maydonlar: { bajarildi?: boolean; matn?: string };
    }
  | { tur: "ochir"; id: number; nomi: string };

export function navbatniSiq(
  royxat: readonly NavbatAmali[],
): NavbatAmali[] {
  let natija: NavbatAmali[] = [];
  for (const kelgan of royxat) {
    let amal = kelgan;
    const qoshilgan = natija.find(
      (a) => a.tur === "qosh" && a.id === amal.id,
    );
    if (amal.tur === "ochir") {
      natija = natija.filter((a) => a.id !== amal.id);
      if (qoshilgan === undefined) {
        natija.push(amal);
      }
      continue;
    }
    if (amal.tur === "yangila") {
      const { matn, bajarildi } = amal.maydonlar;
      if (qoshilgan?.tur === "qosh" && matn !== undefined) {
        natija = natija.map((a) =>
          a === qoshilgan ? { ...qoshilgan, matn } : a,
        );
        if (bajarildi === undefined) {
          continue;
        }
        amal = { ...amal, maydonlar: { bajarildi } };
      }
      const oxirgi = natija.at(-1);
      if (oxirgi?.tur === "yangila" && oxirgi.id === amal.id) {
        natija[natija.length - 1] = {
          ...amal,
          maydonlar: { ...oxirgi.maydonlar, ...amal.maydonlar },
        };
        continue;
      }
    }
    natija.push(amal);
  }
  return natija;
}

const yangila = (id: number, nomi: string, m: object) =>
  ({ tur: "yangila", id, nomi, maydonlar: m }) as NavbatAmali;

// 1) qo'shildi, tuzatildi, belgilandi, o'chirildi — server ko'rmagan
console.log(navbatniSiq([
  { tur: "qosh", id: 9, matn: "Non" },
  yangila(9, "Non", { matn: "Non olish" }),
  yangila(9, "Non", { bajarildi: true }),
  { tur: "ochir", id: 9, nomi: "Non" },
]).length);
// 2) serverdagi vazifa: belgilandi, keyin matni o'zgardi
console.log(JSON.stringify(navbatniSiq([
  yangila(2, "Sut", { bajarildi: true }),
  yangila(2, "Sut", { matn: "Sut olish" }),
])));
// 3) serverdagi vazifa o'zgardi va o'chirildi
console.log(JSON.stringify(navbatniSiq([
  yangila(3, "Kitob", { bajarildi: true }),
  { tur: "ochir", id: 3, nomi: "Kitob" },
])));

Konsolda:

text
0
[{"tur":"yangila","id":2,"nomi":"Sut","maydonlar":{"bajarildi":true,"matn":"Sut olish"}}]
[{"tur":"ochir","id":3,"nomi":"Kitob"}]

Uchta qoida ishladi:

  1. Mahalliy qo'shilgan va o'chirilgan — navbatda hech narsa qolmaydi (0). Server bu vazifani hech qachon bilmaydi.
  2. Ketma-ket yangilashlar (bir id) — bittaga: PATCH bitta, ikkala maydon bilan.
  3. Serverdagini o'chirish — undan oldingi yangilashlar keraksiz: faqat ochir.

Turni blokda takrorladik (kanon faylining boshida shu tur bor) — blok mustaqil ishlashi uchun. yangila yordamchisi — faqat sinov qatorlarini qisqartirish uchun.

3.3 Yuborish: tartib, id xaritasi va to'xtash

Internet qaytdi. Navbat tartib bilan, bittalab yuboriladi — parallel emas. «Non» ni qo'shish (POST) va uni belgilash (PATCH) teskari tartibda kelsa, server "bunday vazifa yo'q" deydi. Kanon navbatniYubor — kod aynan, xotiradagi ombor va soxta server bilan:

ts
type Amal =
  | { tur: "qosh"; id: number; matn: string }
  | {
      tur: "yangila";
      id: number;
      nomi: string;
      maydonlar: { bajarildi?: boolean; matn?: string };
    }
  | { tur: "ochir"; id: number; nomi: string };
type VazifaMalumoti = {
  id: number;
  matn: string;
  bajarildi: boolean;
};
type NavbatOmbori = {
  oqi: () => readonly Amal[];
  yoz: (royxat: readonly Amal[]) => void;
};
type NavbatApi = {
  qosh: (matn: string) => Promise<VazifaMalumoti>;
  yangila: (
    id: number,
    maydonlar: { bajarildi?: boolean; matn?: string },
  ) => Promise<VazifaMalumoti>;
  ochir: (id: number) => Promise<void>;
};
type YuborishNatijasi = {
  readonly yuborildi: number;
  readonly konfliktlar: readonly string[];
};

// Sinov uchun: navbat xotirada (ilovada — localStorage)
let xotira: readonly Amal[] = [];
const mahalliyOmbor: NavbatOmbori = {
  oqi: () => xotira,
  yoz: (r) => (xotira = r),
};

const kodi = (xato: unknown): unknown =>
  xato !== null && typeof xato === "object" && "kod" in xato
    ? xato.kod
    : undefined;

export async function navbatniYubor(
  api: NavbatApi,
  ombor: NavbatOmbori = mahalliyOmbor,
): Promise<YuborishNatijasi> {
  const idlar = new Map<number, number>(); // mahalliy → server
  const konfliktlar: string[] = [];
  let yuborildi = 0;
  for (;;) {
    const [amal, ...qolgan] = ombor.oqi();
    if (amal === undefined) {
      break;
    }
    const id = idlar.get(amal.id) ?? amal.id;
    try {
      if (amal.tur === "qosh") {
        const v = await api.qosh(amal.matn);
        idlar.set(amal.id, v.id);
      } else if (amal.tur === "yangila") {
        await api.yangila(id, amal.maydonlar);
      } else {
        await api.ochir(id);
      }
      yuborildi++;
    } catch (xato) {
      if (kodi(xato) !== "TOPILMADI") {
        // Keyingi urinishda xaritalangan id'lar bilan davom etsin
        ombor.yoz(
          [amal, ...qolgan].map((a) => ({
            ...a,
            id: idlar.get(a.id) ?? a.id,
          })),
        );
        throw xato;
      }
      if (amal.tur === "yangila") {
        konfliktlar.push(
          `«${amal.nomi}» boshqa joyda o'chirilgan — o'zgarish qo'llanmadi.`,
        );
      }
    }
    ombor.yoz(
      qolgan.map((a) => ({ ...a, id: idlar.get(a.id) ?? a.id })),
    );
  }
  return { yuborildi, konfliktlar };
}

// Soxta server: 1 va 3 bor; 2 — boshqa telefondan o'chirilgan
const server: VazifaMalumoti[] = [
  { id: 1, matn: "Non", bajarildi: false },
  { id: 3, matn: "Kitob", bajarildi: false },
];
const topilmadi = () =>
  Object.assign(new Error("404"), { kod: "TOPILMADI" });
const api: NavbatApi = {
  async qosh(matn) {
    console.log(`POST ${matn}`);
    const v = { id: 100, matn, bajarildi: false };
    server.push(v);
    return v;
  },
  async yangila(id, m) {
    console.log(`PATCH ${id} ${JSON.stringify(m)}`);
    const v = server.find((x) => x.id === id);
    if (v === undefined) throw topilmadi();
    return Object.assign(v, m);
  },
  async ochir(id) {
    console.log(`DELETE ${id}`);
    const i = server.findIndex((x) => x.id === id);
    if (i === -1) throw topilmadi();
    server.splice(i, 1);
  },
};

xotira = [
  { tur: "qosh", id: 4, matn: "Zira" },
  {
    tur: "yangila",
    id: 4,
    nomi: "Zira",
    maydonlar: { bajarildi: true },
  },
  {
    tur: "yangila",
    id: 2,
    nomi: "Sut",
    maydonlar: { bajarildi: true },
  },
  { tur: "ochir", id: 3, nomi: "Kitob" },
];
const n = await navbatniYubor(api);
console.log(n.yuborildi, n.konfliktlar);
const idlar = server.map((v) => v.id);
console.log("navbat:", xotira.length, "server:", idlar);

Konsolda:

text
POST Zira
PATCH 100 {"bajarildi":true}
PATCH 2 {"bajarildi":true}
DELETE 3
3 [ "«Sut» boshqa joyda o'chirilgan — o'zgarish qo'llanmadi." ]
navbat: 0 server: [ 1, 100 ]

Jurnalni o'qing:

  • POST Zira — server id 100 berdi. Keyingi amal mahalliy 4-id bilan yozilgan edi — idlar xaritasi uni 100 ga almashtirdi: PATCH 100.
  • PATCH 2 — server 404 berdi. Bu — konflikt: "Sut" boshqa telefondan o'chirilgan. O'zgarish tashlandi va xabar yig'ildi. Yuborish to'xtamadi.
  • DELETE 3 — o'tdi. yuborildi: 3 (konfliktli amal sanalmaydi).
  • Navbat bo'sh, serverda — 1 va 100.

Kodning eng nozik joyi — har amaldan keyin ombor.yoz(qolgan). Yuborish o'rtasida internet yana uzilsa (catch — 404 emas), yuborilganlari navbatdan chiqib bo'lgan, qolgani xaritalangan id'lar bilan turadi. Qayta urinishda «Zira» ikkinchi marta qo'shilmaydi.

Turlarni yana takrorladik va mahalliyOmbor o'rniga xotiradagi ombor qo'ydik — blok Node'da ishlashi uchun. Funksiyaning o'zi kanondagidek. Bitta kanon qatori 70 belgidan uzun — o'zgartirmadik.

Tekshirib ko'ring: Nega kanon navbatni ro'yxatni serverdan olishdan (GET) oldin yuboradi, keyin emas?

Javob

GET avval bo'lsa, server hali eski ro'yxatni beradi — unda oflayn qo'shilgan «Zira» yo'q, «Kitob» esa bor. Bu ro'yxat ekrandagi mahalliy holatning ustiga yoziladi: foydalanuvchi o'z o'zgarishlari "yo'qolib", keyin navbat yuborilgach "qaytib" kelganini ko'radi. Navbat avval ketsa, GET allaqachon yangilangan ro'yxatni oladi — ekran bir marta, to'g'ri holatga o'tadi. Kanon izohi: "aks holda eski server ro'yxati navbatdagi o'zgarishlarni ekrandan "o'chirib" qo'yardi".

4. Konflikt: kim yutadi?

4.1 Konflikt qanday paydo bo'ladi

Jasur aka bozorda oflayn «Sut olish» ni belgiladi. Shu paytda Malika uyda, o'z telefonida, o'sha vazifani o'chirdi. Jasur akaning telefoni qayta ulanadi va "2-vazifani belgila" deb yuboradi. Server: "2-vazifa yo'q". Bu — konflikt: ikki qurilmaning bir-biriga zid o'zgarishi.

sequenceDiagram
  participant J as Jasur (oflayn)
  participant S as Server
  participant M as Malika
  J->>J: «Sut olish» ✓ — navbatga
  M->>S: DELETE 2
  S-->>M: 204
  Note over J: internet qaytdi
  J->>S: PATCH 2 {bajarildi: true}
  S-->>J: 404 — konflikt
  J->>J: o'zgarish tashlandi, e'lon
  J->>S: GET ro'yxat

Diagrammaga qarang: Jasur akaning telefoni Malikaning o'zgarishini bilmagan. Server yagona hakam.

4.2 Strategiyalar

Strategiya G'oya Qachon
server yutadi server holati — haqiqat; mijoz o'zgarishi tashlanadi va aytiladi ko'p ilovalar (vazifalar)
oxirgi yozuv yutadi (last-write-wins) kim keyin yuborsa, o'shaniki oddiy sozlamalar
birlashtirish (merge) turli maydonlar — ikkalasi ham qo'llanadi matn va belgi alohida maydon
foydalanuvchi tanlaydi ikki variantni ko'rsatib so'rash hujjatlar, muhim ma'lumot
CRDT ma'lumot tuzilmasi o'zi birlashadi Google Docs kabi birgalikda tahrir

Versiya bilan aniqlash. Konfliktni faqat "yo'q" (404) emas, "o'zgargan" holatda ham bilish uchun har yozuvga versiya beriladi. Mijoz "men 5-versiyani o'zgartiryapman" deb yuboradi (HTTP'da If-Match sarlavhasi), server versiya 6 bo'lsa rad etadi (412 Precondition Failed). Bu server tomonda — 25-qismda.

CRDT (Conflict-free Replicated Data Type) — har o'zgarish shunday tuzilganki, qaysi tartibda kelmasin, natija bir xil. Birgalikda tahrirlash ilovalari shunga tayanadi; bu alohida katta mavzu.

vazifalar ning tanlovi: server yutadi, va konflikt jim emas. Kanonning 404 qoidasi: yangila — tashlanadi va aytiladi; ochir — "allaqachon yo'q", jim (maqsadga erishilgan). Konflikt xabari role="status" hududiga tushadi — Malika ham eshitadi.

Tekshirib ko'ring: Oflayn paytda Jasur aka vazifa matnini «Sut olish» → «Qatiq olish» qildi. Malika esa o'sha vazifani bajarildi deb belgiladi. "Server yutadi" va "birlashtirish" strategiyalari qanday natija beradi?

Javob

Kanon API'sida PATCH faqat yuborilgan maydonni o'zgartiradi. Jasur akaning PATCH {matn} i Malikaning bajarildi siga tegmaydi — natija: «Qatiq olish», bajarilgan. Bu amalda birlashtirish: turli maydonlar to'qnashmaydi. "Server yutadi" faqat bir xil maydon yoki yo'q yozuvda sezilardi. Agar mijoz butun vazifani (PUT) yuborganda edi — Malikaning belgisi yo'qolardi. Shuning uchun navbatda o'zgarish (maydonlar), butun obyekt emas.

5. Background Sync va persist

5.1 Background Sync

Kanon navbati faqat sahifa ochiq bo'lganda yuboriladi: internet qaytdi — Query qayta so'raydi — navbat ketadi. Jasur aka ilovani yopib qo'ygan bo'lsa, navbat keyingi ochilishgacha kutadi.

Background Sync API buni hal qilishga urinadi: sahifa SW'ga "internet qaytganda meni eslat" deydi, brauzer esa sahifa yopiq bo'lsa ham SW'ni uyg'otadi. Hujjat bo'yicha ko'rinishi:

js
// Sahifada: yozuv navbatga tushgach
const reg = await navigator.serviceWorker.ready;
await reg.sync.register("navbat");

// sw.js da: internet qaytganda brauzer chaqiradi
self.addEventListener("sync", (event) => {
  if (event.tag === "navbat") {
    event.waitUntil(navbatniYuborSWdan());
  }
});

Ikki jiddiy cheklov:

  1. Faqat Chromium (Chrome, Edge, Android brauzerlari). Firefox va Safari'da yo'q — baribir sahifadagi yo'l kerak.
  2. SW'da localStorage yo'q (SW hayot sikli). Navbat SW'dan o'qilishi uchun IndexedDB'da bo'lishi shart. Ya'ni Background Sync qo'shish — navbatni IndexedDB'ga ko'chirish.

vazifalar Background Sync'ni ishlatmaydi: brauzerlarning yarmida yo'q, navbat esa localStorage da. Bu ongli soddalik — mos ilovada (masalan, oflayn buyurtma oladigan ofitsiant planshet) to'g'ri tanlov bo'lishi mumkin.

5.2 persistQueryClient — Query keshi va to'xtatilgan mutatsiyalar

TanStack Query'ning o'z yo'li ham bor: @tanstack/react-query-persist-client keshni (va to'xtatilgan mutatsiyalarni) localStorage yoki IndexedDB'ga saqlaydi. Sahifa qayta ochilganda tiklaydi va resumePausedMutations() bilan davom ettiradi. Hujjat bo'yicha asosiy qism (kanonda ishlatilmagan):

tsx
import {
  PersistQueryClientProvider,
} from "@tanstack/react-query-persist-client";
import {
  createSyncStoragePersister,
} from "@tanstack/query-sync-storage-persister";

const persister = createSyncStoragePersister({
  storage: localStorage,
});

// Mutatsiya funksiyasi saqlanmaydi (funksiyani JSON qilib
// bo'lmaydi) — tiklangan mutatsiya qaysi funksiya bilan davom
// etishini kalit aytadi
mijoz.setMutationDefaults(["vazifa", "qosh"], {
  mutationFn: (matn: string) => api.qosh(matn),
});

<PersistQueryClientProvider
  client={mijoz}
  persistOptions={{ persister }}
  onSuccess={() => mijoz.resumePausedMutations()}
>
  <App />
</PersistQueryClientProvider>;

Nega kanon o'z navbatini yozdi? Uch sabab ko'rinadi. Birinchisi — siqish: Query mutatsiyalarni birlashtirmaydi, oflayn qo'shilib o'chirilgan vazifa ikki so'rov bo'lib ketadi. Ikkinchisi — mahalliy id xaritasi: keyingi mutatsiya oldingisining server id'sini bilishi kerak. Uchinchisi — ikki qo'shimcha paket. Kanon o'lchovi bo'yicha o'z navbati — +0.8 KB.

6. Vazifalar qadami: oflayn navbat (holat 29)

bash
git switch -c feat/oflayn-navbat

6.1 Navbat moduli

src/navbat.ts — «Navbat» bo'limidagi tur, navbatniSiq, navbatniYubor, va ikki qo'shimcha: mahalliyOmbor (localStorage, buzuq yozuv — bo'sh navbat) va navbatObunasi (shu tab va storage hodisasi — boshqa tablar). Testlar — tekshiruv/navbat.test.js, +7.

6.2 Yozuvlar navbatga

App.tsx dagi yozuv funksiyasi uchinchi parametr oldi — oflayn bo'lsa navbatga nima yozish (kanon):

tsx
// 21/#29: internet yo'q — rad etilmaydi: keshga (ekran, localStorage,
// boshqa tablar) darhol, serverga — navbatga (navbat.ts). Qaytganda
// Query qayta so'raydi va navbat avval yuboriladi (server-sinxron.ts)
if (aloqa === "oflayn" && navbat !== undefined) {
  tasdiqniQoy(mijoz, amal);
  const royxat =
    mijoz.getQueryData<readonly VazifaMalumoti[]>(royxatKaliti) ??
    [];
  navbatgaQosh(navbat(royxat));
  elonQil(
    "Internet yo'q — o'zgarish navbatda, qaytganda yuboriladi.",
  );
  return true;
}

tasdiqniQoy — o'zgarish darhol Query keshiga (ekran, localStorage, boshqa tablar). Qo'shish, belgilash, tahrir va o'chirishdagi if (!onlaynmi()) return … qatorlari olib tashlandi — o'rniga har biri o'z navbat amalini beradi. Masalan, o'chirish:

tsx
() => ({ tur: "ochir", id, nomi: matn }),

6.3 Qaytganda — GET'dan oldin

server-sinxron.ts dagi royxatniYukla boshiga (kanon):

ts
// 21/#29: oflayn navbat — GET'dan OLDIN (aks holda eski server
// ro'yxati navbatdagi o'zgarishlarni ekrandan "o'chirib" qo'yardi)
if (mahalliyOmbor.oqi().length > 0) {
  const n = await navbatniYubor(api);
  useIlovaHolati
    .getState()
    .elonQil(
      n.konfliktlar.length > 0
        ? n.konfliktlar.join(" ")
        : `Oflayn o'zgarishlar yuborildi: ${n.yuborildi} ta.`,
    );
}

Va Aloqa.tsx da — navbat hisobi, useSyncExternalStore bilan:

tsx
<p id="navbat" className="izoh" hidden={navbatda === 0}>
  {`Navbatda: ${navbatda} ta o'zgarish — internet qaytganda yuboriladi.`}
</p>

6.4 Natija

Tekshiruvlar (haqiqiy, holat 29):

  • npm test — node:test 255 (avval 248, +7 navbat.test.js), Vitest 65; lint, format:check, tip, knip — exit 0.
  • Birinchi yuklash 224.4 → 225.2 KB (+0.8). Bu — 21-qismdagi eng yuqori nuqta; uni Yuklanishni optimallashtirish darsida kamaytiramiz.
  • e2e-21 — 76. Oflayn navbat bo'limi (natija-21-29.txt):
text
✅ oflayn qo'shish: ro'yxatda, maydon tozalandi, e'lon, navbat 1: ["","Internet yo'q — o'zgarish navbatda, qaytganda yuboriladi.","Navbatda: 1 ta o'zgarish — internet qaytganda yuboriladi."]
✅ navbat 3; serverga birorta yozuv ketmadi: ["Navbatda: 3 ta o'zgarish — internet qaytganda yuboriladi.",[]]
✅ oflayn yangi tab: navbat va o'zgarishlar joyida: ["Navbatda: 3 ta o'zgarish — internet qaytganda yuboriladi.",["1","2","Oflayn"],false]
✅ onlayn: navbat tartib bilan, keyin GET: ["POST","PATCH 2","DELETE 3","GET"]
✅ konflikt aytildi (2-vazifa boshqa joyda o'chirilgan): "«Sut olish #bozor» boshqa joyda o'chirilgan — o'zgarish qo'llanmadi."
✅ yakuniy ro'yxat = server ro'yxati: ["1","Oflayn"]
✅ navbat: CSP buzilishi yo'q: []

To'rtinchi qator — darsning mohiyati: POST, PATCH, DELETE, va faqat keyin GET. Uchinchi qator — oflayn paytda yangi tab ochildi (sahifa yopilib qayta ochilgandek): navbat va o'zgarishlar joyida.

Halol cheklovlar (TEXNIK-QARZ v11): 66 — navbat faqat navigator.onLine === false da, server xatosida emas; import va tartiblash oflayn rad etiladi. 67 — ikki tab bir vaqtda qayta ulansa, ikkalasi ham navbatni yuborishi mumkin; yechim — Web Locks (navigator.locks.request("vazifalar-navbat", …)), keyingi qadam.

bash
git add -A
git commit

Commit (kanon): feat: oflayn yozuvlar navbati — qaytganda tartib bilan, konflikt aytiladi.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
"onlayn" deydi, lekin so'rov ketmaydi navigator.onLine === true — faqat tarmoq bor haqiqiy javob — so'rov natijasi
oflayn o'zgarishlar ekrandan "yo'qolib", keyin qaytadi GET navbatdan oldin navbat — GET'dan oldin
qayta urinishda vazifa ikki marta qo'shildi yuborilgan amal navbatdan o'chirilmagan har amaldan keyin ombor.yoz(qolgan)
PATCH 4 — 404 (4 — mahalliy id) id xaritasi yo'q idlar.get(amal.id) ?? amal.id
sahifa yopilgach mutatsiya yo'qoldi to'xtatilgan mutatsiya faqat xotirada navbatni saqlash yoki persistQueryClient
Background Sync SW'da navbatni topmaydi navbat localStorage da IndexedDB

8. Mashqlar

Mashqlar kurs/mashqlar/21/29-offline-first/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri?

Har gapga «to'g'ri» yoki «noto'g'ri» yozing:

  • navigator.onLine === true — internet aniq bor:
  • TanStack Query sukut bo'yicha oflayn mutatsiyani to'xtatib turadi:
  • To'xtatilgan mutatsiya sahifa yopilsa ham saqlanadi:
  • Background Sync hamma brauzerda bor:
  • Navbat tartib bilan yuboriladi, chunki keyingi amal oldingisiga bog'liq bo'lishi mumkin:
Yechim
  • noto'g'ri — faqat tarmoq ulanishi bor; internet va server — noma'lum.
  • to'g'ri — networkMode: "online", isPaused: true.
  • noto'g'ri — xotirada; saqlash uchun persist yoki o'z navbatingiz.
  • noto'g'ri — faqat Chromium.
  • to'g'ri — POST dan oldin PATCH kelsa, server vazifani bilmaydi.

2-mashq (o'rta): bitta vazifaga ketma-ket o'zgarishlar

Funksiya birlashtir(amallar) yozing: amallar — { id, maydonlar } obyektlari. Ketma-ket kelgan bir xil id li amallar bittaga birlashsin (maydonlar qo'shiladi, keyingisi ustun). Boshqa id oraga tushsa — birlashmaydi. Tekshiring:

js
console.log(JSON.stringify(birlashtir([
  { id: 2, maydonlar: { bajarildi: true } },
  { id: 2, maydonlar: { matn: "Sut olish" } },
  { id: 5, maydonlar: { bajarildi: true } },
  { id: 2, maydonlar: { bajarildi: false } },
])));
// [{"id":2,"maydonlar":{"bajarildi":true,"matn":"Sut olish"}},
//  {"id":5,…}, {"id":2,…}]

Ishora: natijaning oxirgi elementiga qarang (at(-1)) — kanon navbatniSiq dagi «ketma-ket yangila» qoidasi. Obyektlarni qo'shish — spread.

Yechim
js
function birlashtir(amallar) {
  const natija = [];
  for (const amal of amallar) {
    const oxirgi = natija.at(-1);
    if (oxirgi?.id === amal.id) {
      natija[natija.length - 1] = {
        id: amal.id,
        maydonlar: { ...oxirgi.maydonlar, ...amal.maydonlar },
      };
    } else {
      natija.push(amal);
    }
  }
  return natija;
}

console.log(JSON.stringify(birlashtir([
  { id: 2, maydonlar: { bajarildi: true } },
  { id: 2, maydonlar: { matn: "Sut olish" } },
  { id: 5, maydonlar: { bajarildi: true } },
  { id: 2, maydonlar: { bajarildi: false } },
])));
console.log(birlashtir([]).length);

Konsolda:

text
[{"id":2,"maydonlar":{"bajarildi":true,"matn":"Sut olish"}},{"id":5,"maydonlar":{"bajarildi":true}},{"id":2,"maydonlar":{"bajarildi":false}}]
0

Nega oxirgi 2-id birinchisi bilan birlashmadi? Oraga 5-id tushdi. Tartibni saqlash muhim: oradagi amal 2-vazifaga bog'liq bo'lishi mumkin. Kanon ham faqat ketma-ket amallarni birlashtiradi.

3-mashq (qiyin): navbat hisoblagichi

«Jonli: to'xtatilgan mutatsiya» misolini kengaytiring:

  1. «Non olish», «Sut olish», «Zira olish» — uch tugma, har biri mutatsiya.
  2. Ekranda Navbatda: N — kutayotgan mutatsiyalar soni. useIsMutating() hook'idan foydalaning (u @tanstack/react-query da, pending mutatsiyalar sonini beradi).
  3. Oflayn paytda uchalasini bosing — Navbatda: 3; ulang — hammasi serverga ketadi va konsolga tartib bilan yoziladi.

Ishora: har tugma m.mutate("…") — bitta useMutation ko'p marta chaqirilishi mumkin; onSuccess ni mutate ga emas, useMutation ga bering — har biri uchun ishlaydi.

Yechim
jsx
import {
  QueryClient,
  QueryClientProvider,
  onlineManager,
  useIsMutating,
  useMutation,
} from "@tanstack/react-query";

const mijoz = new QueryClient();
const serverga = (matn) =>
  new Promise((hal) => setTimeout(() => hal(matn), 100));

function Vazifalar() {
  const m = useMutation({
    mutationFn: serverga,
    onSuccess: (matn) => console.log("serverda:", matn),
  });
  const navbatda = useIsMutating();
  const tugma = (matn) => (
    <button onClick={() => m.mutate(matn)}>{matn}</button>
  );
  return (
    <>
      <button onClick={() => onlineManager.setOnline(false)}>
        Internetni uzish
      </button>{" "}
      <button onClick={() => onlineManager.setOnline(true)}>
        Internetni ulash
      </button>
      <p>
        {tugma("Non olish")} {tugma("Sut olish")}{" "}
        {tugma("Zira olish")}
      </p>
      <p>Navbatda: {navbatda}</p>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={mijoz}>
      <Vazifalar />
    </QueryClientProvider>
  );
}

Konsolda:

text
serverda: Non olish
serverda: Sut olish
serverda: Zira olish

Ekranda:

text
Internetni uzish Internetni ulash
Non olish Sut olish Zira olish
Navbatda: 0

Oflayn paytda Navbatda: 3 (oraliq nuqta). Ulanganda uchalasi bosilgan tartibda ketdi. Lekin bu navbat xotirada: sahifani yangilasangiz, uchalasi ham yo'qoladi. Kanon navbati localStorage da — shu farq uchun yozilgan.

4-mashq: Vazifalar qadami

feat/oflayn-navbat branch'ida «Vazifalar qadami» dagi o'zgarishlarni qiling.

Yechim

Tekshiruv ro'yxati:

  • npm test — node 255, Vitest 65.
  • npm run build && npm run preview. DevTools → Network → «Offline». Vazifa qo'shing, birini belgilang, birini o'chiring — ekranda o'zgaradi, «Navbatda: 3 ta o'zgarish …».
  • Application → Local Storage → vazifalar:navbat — uchta amal.
  • Ikkinchi tab oching — o'sha hisob. Birinchi tabni yoping.
  • «Offline» ni o'chiring — Network'da POST, PATCH, DELETE, keyin GET. E'lon: «Oflayn o'zgarishlar yuborildi: 3 ta.»
  • Konflikt: ikkinchi brauzerda vazifani o'chiring, birinchisida oflayn belgilang, ulang — «… boshqa joyda o'chirilgan» e'loni.

9. Real ishda

  • Dala ilovalari — kuryer, sotuvchi agent, ofitsiant planshet, omborchi: internet uzilib turadigan joyda ishlaydi. Ular uchun offline-first — asosiy talab.
  • Kod ko'rigida savollar: "oflayn yozuv qayerda saqlanadi?", "qayta urinishda dublikat bo'lmaydimi?", "konflikt foydalanuvchiga ko'rinadimi?".
  • Intervyu savollari: "navigator.onLine ga ishonish mumkinmi?", "optimistik yangilash va offline-first farqi?", "konfliktni qanday hal qilasiz?", "CRDT nima (bir gap bilan)?".

Xulosa

  • Offline-first — avval qurilmada, keyin serverda. O'zgarish rad etilmaydi: ekranga darhol, serverga — navbatga.
  • navigator.onLine === false — aniq oflayn; true — "ehtimol". Haqiqat — so'rov natijasi.
  • TanStack Query oflayn mutatsiyani to'xtatadi (isPaused), lekin xotirada; saqlash — persist yoki o'z navbatingiz.
  • Navbat: siqish, tartib bilan, har amaldan keyin saqlash, mahalliy id → server id, GET'dan oldin.
  • Konflikt: strategiyani oldindan tanlang; vazifalar — server yutadi, 404 aytiladi. Background Sync — Chromium va IndexedDB talab qiladi.

Keyingi dars: Web push bildirishnomalar — sayt yopiq bo'lsa ham eslatma yuborish: ruxsat, obuna, VAPID va SW push hodisasi.

Manbalar

  • MDN: Navigator.onLine, online/offline hodisalari, Background Synchronization API (SyncManager), Web Locks API
  • TanStack Query v5: Network Mode, onlineManager, Mutations — paused, persistQueryClient, useIsMutating
  • RFC 9110 (HTTP Semantics): If-Match, 412 Precondition Failed
  • Kanon: vazifalar holat 29, navbat.ts, tekshiruv-29.txt, natija-21-29.txt, TEXNIK-QARZ v11 (66, 67) (2026-10-17)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Offline-first React ilova: internetsiz yozuvlar navbati, qayta sinxronlash va konflikt — IlmHamroh