IlmHamroh
JavaScript Full-stack/18-qism. React chuqur23/42-dars21 daqiqa
Mundarija (32)

Uzun ro'yxatlar: virtualizatsiya — faqat ko'rinadigan qatorlarni chizish

Qisqacha: 10 000 qatorli ro'yxatni to'liq chizish — 50 000 dan ortiq DOM elementi, yuzlab millisekund va o'nlab megabayt xotira. Ekranda esa bir vaqtda 15 qator ko'rinadi. Virtualizatsiya (windowing) — faqat ko'rinadigan qatorlarni (va bir nechta zaxira) chizish, qolganining o'rnida — bo'sh joy: scroll satri va umumiy balandlik o'zgarmaydi. Hisob oddiy: scrollTop va qator balandligidan boshlang'ich va oxirgi indeks. Balandlik har xil bo'lsa — o'lchash va prefiks yig'indi. vazifalar da o'z hook'imiz (useOynaVirtual, +0.8 KB): 10 000 vazifa bilan ochilish ~760 → ~170 ms, DOM 50 087 → 154 element, xotira 45 → 11 MB. TanStack Virtual tezlikda teng, lekin +8.3 KB.

Bu darsda

  • Ko'rinadigan oraliqni (start, end) hisoblaydigan funksiyani yozasiz.
  • React'da oddiy virtual ro'yxat quradigan va DOM'dagi qatorlarni sanaydigan bo'lasiz.
  • Har xil balandlikli qatorlar uchun prefiks yig'indi va ikkilik qidiruvni tushunasiz.
  • Virtualizatsiyaning narxini bilasiz: Ctrl+F, ekran o'quvchi, scroll holatini tiklash.
  • TanStack Virtual, react-window va react-virtuoso dan birini tanlay olasiz — yoki o'zingiz yozasiz.

Oldin bilishingiz kerak: Performance'ni o'lchash, memo, useMemo, useCallback, useSyncExternalStore, ResizeObserver va MutationObserver.

1. Nega bu kerak?

Performance'ni o'lchash darsi ikki raqam bilan tugadi. 10 000 vazifali ro'yxatning birinchi chizilishi (faqat ro'yxat qismi) — 163 ms. Qidiruvda "v" yozilganda hamma qator haqiqatan o'zgaradi — 285 ms. memo bu yerda yordam bermaydi: ish kerakli, shunchaki juda ko'p.

Lekin bu ishning katta qismi hech kimga ko'rinmaydi. Telefon ekranida (412×800 piksel) bir vaqtda taxminan 15 ta vazifa ko'rinadi. Qolgan 9 985 tasi — ekrandan pastda, DOM'da bor, xotirani egallaydi, har qidiruvda qayta chiziladi.

Hayotiy o'xshatish: «Bahor»ning yillik buyurtmalar daftari — 10 000 sahifa. Jasur aka uni stolga butunlay yoyib qo'ymaydi: faqat ochiq turgan ikki betni ko'radi. Varaqlasa — keyingi betlar ochiladi. Virtualizatsiya ham shu: "ochiq betlar" — ekran, qolgani — yopiq kitob, lekin uning qalinligi (scroll uzunligi) to'g'ri.

2. Ko'rinadigan oraliq

2.1 Hisob

Hamma qator bir xil balandlikda (masalan, 40 px) bo'lsa, hisob — bo'lish:

js
function visibleRange(scrollTop, viewport, rowHeight, count, over) {
  const first = Math.floor(scrollTop / rowHeight);
  const last = Math.ceil((scrollTop + viewport) / rowHeight);
  return {
    start: Math.max(0, first - over),
    end: Math.min(count, last + over),
  };
}

console.log(visibleRange(0, 400, 40, 10000, 3));
console.log(visibleRange(20000, 400, 40, 10000, 3));
console.log(visibleRange(399600, 400, 40, 10000, 3));

Konsolda:

text
{ start: 0, end: 13 }
{ start: 497, end: 513 }
{ start: 9987, end: 10000 }
  • first — tepadagi qator: 20 000 px pastga aylantirilgan → 20 000 / 40 = 500-qator.
  • last — ekran pastidagi qator: (20 000 + 400) / 40 = 510.
  • over — overscan (zaxira): ekrandan tashqarida ham bir nechta qator chiziladi. Tez aylantirganda React yangi qatorlarni chizguncha bo'sh joy ko'rinmasin.
  • Math.max/Math.min — boshida manfiy, oxirida mavjud bo'lmagan indeks bo'lmasin.

10 000 dan 16 qator. Qolgan hamma narsa — shu indekslar bilan ishlash.

2.2 Bo'sh joy

Faqat 16 qatorni chizsak, scroll satri qisqarib qoladi — brauzer ro'yxat 640 px deb o'ylaydi. Shuning uchun konteynerga umumiy balandlik beriladi (10 000 × 40 = 400 000 px), ko'rinadigan qatorlar esa o'z joyiga qo'yiladi. Ikki usul bor:

  1. Konteyner position: relative, har qator position: absolute; top: indeks × 40px.
  2. Ko'rinadigan qatorlardan oldin va keyin ikkita bo'sh "to'ldiruvchi" element (height: start × 40px va (count − end) × 40px).
flowchart TD
  A["Yuqori bo'sh joy: 497 × 40 px"] --> B["Qator 497 (zaxira)"]
  B --> C["Qator 500–509: ekranda"]
  C --> D["Qator 510–512 (zaxira)"]
  D --> E["Pastki bo'sh joy: 9 487 × 40 px"]

Diagrammaga qarang: DOM'da 5 qism, lekin brauzer uchun ro'yxat to'liq 400 000 px balandlikda. Scroll satri, Page Down, sichqoncha g'ildiragi — hammasi oddiydek ishlaydi. vazifalar ikkinchi usulni tanladi — keyinroq ko'ramiz.

3. React'da virtual ro'yxat

3.1 Birinchi variant

scrollTop — state. Har scroll hodisasida yangilanadi va React faqat oraliqdagi qatorlarni chizadi. Natija oynasida aylantirishni taqlid qilamiz: «O'rtasi» tugmasi konteynerni 200 000 px ga aylantiradi (haqiqiy brauzerda konteynerni sichqoncha bilan ham aylantirib ko'ring):

jsx
import { useLayoutEffect, useRef, useState } from "react";

const ROW = 40;
const VIEW = 200;
const ORDERS = Array.from(
  { length: 10000 },
  (_, i) => `Buyurtma №${i + 1}`,
);

function VirtualList({ items }) {
  const [top, setTop] = useState(0);
  const box = useRef(null);
  const start = Math.max(0, Math.floor(top / ROW) - 2);
  const end = Math.min(
    items.length,
    Math.ceil((top + VIEW) / ROW) + 2,
  );
  useLayoutEffect(() => {
    const lis = box.current.querySelectorAll("li");
    const text = (li) => li.textContent;
    console.log(
      `DOM'da: ${lis.length} ta <li>,`,
      `${text(lis[0])} … ${text(lis[lis.length - 1])}`,
    );
  });
  function jump(y) {
    box.current.scrollTop = y;
    setTop(y);
  }
  return (
    <div>
      <button onClick={() => jump(0)}>Boshi</button>
      <button onClick={() => jump(200000)}>O'rtasi</button>
      <div
        ref={box}
        onScroll={(e) => setTop(e.currentTarget.scrollTop)}
        style={{ height: VIEW, overflowY: "auto" }}
      >
        <ul
          style={{ height: items.length * ROW, position: "relative" }}
        >
          {items.slice(start, end).map((text, i) => (
            <li
              key={start + i}
              style={{ position: "absolute", top: (start + i) * ROW }}
            >
              {text}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

function App() {
  return <VirtualList items={ORDERS} />;
}

DOM'dagi <li> soniga qarang — 10 000 emas. Birinchi qator — sahifa ochilganda, ikkinchisi — «O'rtasi» dan keyin:

Konsolda:

text
DOM'da: 7 ta <li>, Buyurtma №1 … Buyurtma №7
DOM'da: 9 ta <li>, Buyurtma №4999 … Buyurtma №5007

10 000 buyurtmadan DOM'da 7 tasi (boshida — 5 ta ko'rinadigan va 2 ta pastki zaxira), o'rtada — 9 tasi (2 + 5 + 2). Hisob: 200 000 / 40 = 5 000 — ekranda №5001 dan №5005 gacha. Lekin ORDERS massivi to'liq xotirada: virtualizatsiya ma'lumotni emas, DOM'ni qisqartiradi.

3.2 Nima uchun key={start + i}

Har qatorning key i — uning ro'yxatdagi haqiqiy indeksi, slice dagi o'rni emas. Aylantirganda №5001 ekranning tepasidan pastroqqa sursa, React o'sha <li> ni saqlaydi va faqat top ni o'zgartiradi; chiqib ketgan qatorlarni olib tashlab, yangilarini qo'shadi (Reconciliation). Haqiqiy ilovada — ma'lumotdagi id (key={order.id}).

3.3 Scroll hodisasini kamaytirish

Har piksel aylantirish — setTop — butun ro'yxatning qayta chizilishi. 60 kadr/soniyada scroll hodisalari juda tez keladi. Ikki odatiy yechim:

  • Ko'rinadigan oraliq o'zgarmagan bo'lsa — renderni o'tkazib yuborish (state'ga piksel emas, start indeksini yozish — bir xil qiymat bailout beradi, Qayta render sabablari).
  • Scroll'ni qadamlarga bo'lish: vazifalar dagi hook har 50 px dan keyingina yangilanadi (qadam — QADAM = 50). Qadam ichidagi siljishni zaxira qatorlar yopadi.

Ko'pchilik bu yerda throttle ni eslaydi (Og'ir ishni UI'dan ajratish — keyingi dars). Lekin throttle'dan keyin oxirgi holat kechikib keladi — tez to'xtaganda bo'sh joy bir lahza ko'rinadi. Qadam usuli har hodisani darhol ko'radi, faqat keraksiz render qilmaydi.

4. Har xil balandlik

4.1 Muammo

Vazifa matni uzun bo'lsa, qator ikki qatorga o'tadi — 61 px emas, 85 px. Hamma qator bir xil balandlikda degan taxmin buziladi: scrollTop / balandlik endi to'g'ri indeksni bermaydi. Uch narsa kerak:

  1. Chizilgan qatorni o'lchash (offsetHeight, ref callback yoki ResizeObserver).
  2. Hali chizilmagan qatorlar uchun taxmin.
  3. Har qatorning boshlanish joyini tez topish — prefiks yig'indi.

4.2 Prefiks yig'indi va ikkilik qidiruv

joylar[i] — i-qatorning tepadan boshlanish joyi: oldingi hamma balandliklar yig'indisi. joylar[n] — umumiy balandlik. Bu bir marta O(n) da hisoblanadi. "Qaysi qator y pikselda?" savoli esa — tartiblangan massivda ikkilik qidiruv, O(log n) (Binary search asoslari). vazifalar dagi sof funksiyalar (src/virtual-oraliq.ts, holat 23), aynan:

ts
/**
 * O'rinlar: joylar[i] — i-qatorning boshi (ro'yxat ichida, px),
 * joylar[n] — jami balandlik. O'lchanmagan qatorga — taxmin. O(n)
 */
export function joylarniHisobla(
  kalitlar: readonly number[],
  balandliklar: ReadonlyMap<number, number>,
  taxmin: number,
): Float64Array {
  const joylar = new Float64Array(kalitlar.length + 1);
  let y = 0;
  for (let i = 0; i < kalitlar.length; i++) {
    const kalit = kalitlar[i];
    y +=
      (kalit === undefined ? undefined : balandliklar.get(kalit)) ??
      taxmin;
    joylar[i + 1] = y;
  }
  return joylar;
}

/** y nuqtasini o'z ichiga olgan qator (ikkilik qidiruv, O(log n)) */
function qatorIndeksi(joylar: Float64Array, y: number): number {
  let past = 0;
  let yuqori = joylar.length - 1;
  while (past < yuqori) {
    const orta = (past + yuqori) >> 1;
    if ((joylar[orta + 1] ?? 0) <= y) {
      past = orta + 1;
    } else {
      yuqori = orta;
    }
  }
  return past;
}

export type Oraliq = {
  readonly boshi: number; // birinchi chiziladigan indeks
  readonly oxiri: number; // oxirgisi (kirmaydi)
  readonly yuqori: number; // ustidagi bo'sh joy (px)
  readonly pastki: number; // ostidagi bo'sh joy (px)
};

/**
 * Ko'rinadigan oraliq + zaxira (ekrandan tashqarida ham chiziladigan
 * qatorlar — tez aylantirganda bo'sh joy ko'rinmasin)
 * @param dan ko'rinadigan qismning boshi (ro'yxat ichidagi
 *   koordinata)
 * @param gacha oxiri
 */
export function korinadiganOraliq(
  joylar: Float64Array,
  dan: number,
  gacha: number,
  zaxira: number,
): Oraliq {
  const n = joylar.length - 1;
  const jami = joylar[n] ?? 0;
  const boshi = Math.max(0, qatorIndeksi(joylar, dan) - zaxira);
  const oxiri = Math.min(n, qatorIndeksi(joylar, gacha) + 1 + zaxira);
  return {
    boshi,
    oxiri,
    yuqori: joylar[boshi] ?? 0,
    pastki: jami - (joylar[oxiri] ?? jami),
  };
}

// 6 ta vazifa; 2- va 3-si o'lchangan (61 va 85 px),
// qolgani — taxmin
const kalitlar = [11, 12, 13, 14, 15, 16];
const balandliklar = new Map([[12, 61], [13, 85]]);
const joylar = joylarniHisobla(kalitlar, balandliklar, 61);
console.log(Array.from(joylar).join(" "));
console.log(korinadiganOraliq(joylar, 130, 250, 1));

Konsolda:

text
0 61 122 207 268 329 390
{ boshi: 1, oxiri: 5, yuqori: 61, pastki: 61 }

Bitta JSDoc izohi telefon uchun ikkiga bo'lindi, qolgani — kanondagidek. Oxirgi qatorlar (6 ta vazifa bilan sinov) — kanonda yo'q, biz qo'shdik (kanonda shu funksiyalar uchun tekshiruv/virtual-oraliq.test.js da 5 ta test bor). Natijani o'qiymiz:

  • joylar: uchinchi qator (13, 85 px) 122 dan boshlanib 207 da tugaydi — undan keyingilar 85 ga siljigan.
  • Ko'rinadigan qism 130–250 px: 130 — uchinchi qator ichida (indeks 2), 250 — to'rtinchi (indeks 3). Zaxira 1: boshi = 2 − 1 = 1, oxiri = 3 + 1 + 1 = 5.
  • yuqori: 61 — 0-qator o'rnidagi bo'sh joy; pastki: 61 — oxirgi qator o'rnidagi.

Float64Array (Typed arrays) — oddiy massivdan ixcham va tez: 10 000 qatorda hisob ~0.1 ms. Funksiyalar React va DOM'siz — node:test bilan sinash oson (Kichik React loyihani tashkil qilish dagi "sof mantiq alohida" qoidasi).

Tekshirib ko'ring: Nega qatorIndeksi oddiy sikl (for bilan birinchi mos qatorni topish) emas, ikkilik qidiruv?

Javob

Har scroll qadamida chaqiriladi. 10 000 qatorda oddiy sikl o'rtacha 5 000 qadam, ikkilik qidiruv — ~14 qadam (log₂ 10 000). joylar o'sib boruvchi (tartiblangan) massiv, shuning uchun ikkilik qidiruv mumkin. Bu Algoritmlar darsidagi g'oya haqiqiy UI'da.

5. Virtualizatsiyaning narxi

5.1 Nima yo'qoladi

DOM'da yo'q qator — brauzer uchun mavjud emas:

Narsa Nima bo'ladi Yechim
Ctrl+F (sahifada qidirish) ko'rinmagan qatorlarni topmaydi ilova ichidagi qidiruv; kichik ro'yxat — virtualizatsiyasiz
Ekran o'quvchi (Malika) "ro'yxat, 15 element" deydi aria-setsize, aria-posinset
Fokus (Bobur, Tab) fokusdagi qator ekrandan chiqsa — o'chiriladi fokusli qatorni doim chizish yoki fokusni ko'chirish
Chop etish faqat chizilganlar chop etishda to'liq ro'yxat
e2e testlar querySelectorAll("li") — 15 ta test kichik ro'yxatda yoki aylantirib

Shuning uchun virtualizatsiya chegara bilan: kam qatorda — to'liq ro'yxat. vazifalar da chegara — 200.

5.2 Scroll holatini tiklash

Foydalanuvchi 5 000-buyurtmaga aylantirdi, boshqa sahifaga o'tdi, "orqaga" bosdi. Brauzer scroll joyini odatda o'zi tiklaydi (history.scrollRestoration). Lekin virtual ro'yxatda bir muammo bor: tiklash paytida ro'yxat hali chizilmagan yoki balandligi taxminiy bo'lishi mumkin. Brauzer 200 000 px ga aylantirmoqchi, sahifa esa hali 1 000 px.

Amaliy qoidalar:

  1. Umumiy balandlik darhol bo'lsin — birinchi renderdayoq taxmin bilan to'liq bo'sh joy. Shunda brauzer tiklashga joy topadi.
  2. Piksel emas, indeks saqlang — balandliklar har xil bo'lsa, "5 000-qator" piksellardan ishonchliroq. Kutubxonalarda: TanStack'da initialOffset/scrollToIndex, react-virtuoso'da initialTopMostItemIndex va holatni saqlash.
  3. Saqlash joyi — sessionStorage yoki history.state (History API).

6. Kutubxonalar

Kutubxona Usul Kuchli tomoni
TanStack Virtual (@tanstack/react-virtual) headless hook: useVirtualizer, useWindowVirtualizer markup sizniki, dinamik balandlik, gorizontal va grid
react-window (2.x) tayyor List/Grid komponentlari kichik, oddiy, qat'iy balandlikda eng sodda
react-virtuoso (4.x) tayyor komponent, balandlikni o'zi o'lchaydi chat (pastdan yuqoriga), guruhlar, cheksiz yuklash

Versiyalar — 2026-10 holati. Uchalasining peer'i React 18 va 19 ni qo'llaydi (React 18 dan 19 ga dagi tekshiruv). TanStack Virtual — headless naqsh (Headless komponentlar darsida batafsil): hook faqat raqamlarni beradi (getVirtualItems(), getTotalSize()), qanday <li> chizish — sizning ishingiz.

Qachon kutubxona, qachon o'zingiz? Kutubxona — sinovdan o'tgan qirralar: o'lcham o'zgarishi, gorizontal, grid, scrollToIndex, RTL. O'zingizniki — faqat kerakli qism, nol bog'liqlik va to'liq nazorat. vazifalar ikkalasini o'lchab tanladi.

7. Vazifalar qadami: useOynaVirtual (holat 23)

7.1 Qaror

Yangi branch: perf/virtual-royxat. Kanon ikki variantni qurdi va o'lchadi: TanStack Virtual 3.14.13 bilan va o'z hook'i bilan (natijalar/23-*, 10 000 vazifa, oyna 412×800, Chrome headless):

holat 22 TanStack 3.14.13 o'zimizniki (kanon)
ochilish (load) 752–770 ms 180–218 ms 163–181 ms
DOM element / <li> 50 087 / 10 000 154 / 15 154 / 15
JS heap 45 MB 12 MB 11 MB
JS+CSS gzip 86 041 B +8 290 B +827 B
10 000 vazifa: sahifa ochilishi (load, uch o'lchovning o'rtachasi)
  • holat 22: to'liq ro'yxatDOM 50 087759 ms
  • TanStack Virtual 3.14.13+8.3 KB200 ms
  • useOynaVirtual (kanon)+0.8 KB171 ms

Manba: O'lchandi: vazifalar holat 22/23, olchov/katta-royxat.mjs, Chrome headless, 412×800, har biri 3 marta (natijalar/23-oldin.txt, 23-tanstack.txt, 23-ozimiz.txt), 2026-10-13

Tezlik — teng (farq shovqin ichida). Hajm — 10 barobar farq. TanStack Virtual'ning eng yangi versiyasi (3.14.18) o'lchov kuni chiqqan edi — kanon bir oylik 3.14.13 ni oldi (Ta'minot zanjiri xavfsizligi: yangi chiqqan paketni darhol olmaslik). Qaror: o'zimizniki, chunki bizga oyna scroll'i va vertikal ro'yxat kerak, xolos.

7.2 VazifaRoyxati: ikki xil ro'yxat

tsx
/** Shundan ko'p bo'lsa — faqat ko'rinadigan qatorlar chiziladi */
export const VIRTUAL_CHEGARA = 200;
// Qator balandligi taxmini (px): haqiqiysi chizilgach o'lchanadi —
// uzun matn ikki qatorga o'tsa ham to'g'ri. Zaxira — ekrandan
// tashqaridagi qatorlar (tez aylantirganda bo'sh joy ko'rinmasin)
// 2.75rem tugma + 2 × 0.5rem + 1px chegara
const TAXMINIY_BALANDLIK = 61;
const ZAXIRA = 8;
…
export const VazifaRoyxati = memo(function VazifaRoyxati(props: Props) {
  // Ikki xil komponent: hooklar shart ichida chaqirilmaydi (17/#25)
  return props.vazifalar.length > VIRTUAL_CHEGARA ? (
    <VirtualRoyxat {...props} />
  ) : (
    <ToliqRoyxat {...props} />
  );
});

Bu bo'limdagi kanon parchalarida izohlar telefon uchun qayta bo'lindi (TAXMINIY_BALANDLIK izohi uning ustiga ko'chdi), kod — aynan. 200 tagacha — avvalgi to'liq ro'yxat (Ctrl+F, ekran o'quvchi, e2e — hammasi o'zgarmadi). Ko'p bo'lsa — VirtualRoyxat. Ikki alohida komponent, chunki virtual variantda hook bor, hooklar esa shart ichida chaqirilmaydi (Hooklar qoidalari). Chegaradan o'tilganda type o'zgaradi va ro'yxat qayta yaratiladi — Reconciliation darsida buni ko'rgan edik.

7.3 VirtualRoyxat

tsx
function VirtualRoyxat({ vazifalar, sorov, tahrirId }: Props) {
  const kalitlar = useMemo(
    () => vazifalar.map((v) => v.id),
    [vazifalar],
  );
  // Destrukturizatsiya shart: boshniBelgila ref prop'ga beriladi
  // va React Compiler lint'i butun obyektni "ref" deb biladi —
  // yuqori ni render'da o'qish "Cannot access refs during render"
  // (o'lchandi)
  const { boshi, oxiri, yuqori, pastki, olchov, boshniBelgila } =
    useOynaVirtual(kalitlar, TAXMINIY_BALANDLIK, ZAXIRA);
  return (
    <>
      <li
        ref={boshniBelgila}
        className="joy"
        aria-hidden="true"
        style={{ height: yuqori }}
      />
      {vazifalar.slice(boshi, oxiri).map((vazifa, i) => (
        <VazifaQatori
          key={vazifa.id}
          vazifa={vazifa}
          sorov={sorov}
          tahrirda={vazifa.id === tahrirId}
          joy={boshi + i}
          jami={vazifalar.length}
          olchov={olchov}
        />
      ))}
      <li
        className="joy"
        aria-hidden="true"
        style={{ height: pastki }}
      />
    </>
  );
}

«Bo'sh joy» bo'limidagi ikkinchi usul: ikkita bo'sh <li class="joy" aria-hidden> — ustida va ostida. aria-hidden — ekran o'quvchi ularni qator deb o'qimasin. style={{ height }} — CSSOM orqali, CSP'ning style-src i bilan mos (Brauzer xavfsizlik modeli: CSP). key={vazifa.id} — haqiqiy id.

Izohdagi tuzoq — React Compiler lint'i (react-hooks/refs, React Compiler). useOynaVirtual(...) natijasini bitta obyektga olib, oyna.yuqori ni o'qisa — lint xato berdi: obyektdagi boshniBelgila ref'ga beriladi, demak butun obyekt "ref". Destrukturizatsiya bilan har qiymat alohida o'zgaruvchi — xato yo'qoldi.

VazifaQatori ga joy, jami, olchov — uchta primitiv yoki barqaror props. Obyekt emas: { indeks, jami, olchov } har renderda yangi bo'lib, memo ni buzardi (memo). Obyekt VazifaQatori ichida yasaladi.

7.4 useOynaVirtual: asosiy qismlar

Hook oyna (window) scroll'iga ulanadi — ro'yxat alohida aylanadigan quti emas, butun sahifa aylanadi:

ts
// Scroll — tashqi manba (useSyncExternalStore, 17/#38). Snapshot —
// 50 px lik qadam: har piksel emas, faqat qadam o'zgarganda qayta
// chiziladi
const QADAM = 50;
…
const scrollQadami = () =>
  `${Math.floor(scrollY / QADAM)}:${Math.ceil(innerHeight / QADAM)}`;

Scroll — React'dan tashqaridagi manba, shuning uchun useSyncExternalStore. Snapshot — satr, "qadam:oyna balandligi". Bir xil satr — Object.is teng — render yo'q. Shunday qilib, 50 px ichida aylantirish React'ni umuman bezovta qilmaydi («Scroll hodisasini kamaytirish» bo'limidagi ikkinchi usul).

O'lchash — ref callback: chizilgan har <li> balandligi state'dagi Map ga yoziladi (o'zgargan bo'lsa — yangi Map, State III). Ro'yxatning sahifadagi boshi — ResizeObserver (ResizeObserver): ro'yxat ustidagi forma yoki statistika balandligi o'zgarsa, qayta o'lchanadi.

7.5 e2e topgan xato

Birinchi variantda o'lchanmagan qatorlar uchun taxmin — 49 px edi. Brauzer tekshiruvi (e2e-18) qizarib ketdi: haqiqiy qator 61 px. Aylantirilganda har yangi o'lchov ro'yxat pastidagi bloklarni siljitdi — «Batafsil statistika» tugmasi qochib ketdi va bosish tegmadi. Tuzatish — kanon izohida:

ts
  // O'lchanmagan qatorga — o'lchanganlarning o'rtachasi (bo'lmasa
  // — taxmin). Aks holda har yangi o'lchov ro'yxatdan keyingi
  // bloklarni siljitadi: taxmin 49 px, haqiqiy qator 61 px —
  // aylantirilganda «Batafsil statistika» tugmasi qochib ketdi va
  // bosish tegmadi (e2e topdi)

Taxmin endi doimiy son emas — o'lchanganlarning o'rtachasi. TAXMINIY_BALANDLIK ham 61 ga to'g'rilandi va hisobi izohda: tugma balandligi + paddinglar + chegara. Dinamik balandlikdagi eng ko'p uchraydigan xato — layout siljishi (CLS); uni faqat haqiqiy brauzer testi ko'rsatdi.

Malika uchun: har qatorda aria-posinset (1 dan) va aria-setsize (jami) — DOM'da 15 ta <li> bo'lsa ham, ekran o'quvchi "5 001-element, jami 10 000" deydi. Bobur uchun: belgilangan qator uzoqqa ko'chsa (bajarildi → pastga), fokus qo'shni qatorga o'tadi (useRef II dagi zaxira).

7.6 Natija va commit

holat 22 holat 23
katak 1 000 / 10 000 (mediana) 32 / 120 ms 24 / 40 ms
qidiruv 10 000 (eng uzun) 200 ms 24 ms
JS heap (10 000) 45 MB 11 MB

Qidiruv 200 → 24 ms: "v" yozilganda endi 10 000 emas, ~15 qator qayta chiziladi. useTransition darsidagi boshlang'ich 1 184 ms dan — 24 ms. Testlar: node 169 (+5 — virtual-oraliq.test.js), Vitest 39. Brauzer: 159/159, e2e-18 40/40. Diff: 10 fayl, +373 −21.

text
perf: 200 dan ko'p vazifa — virtual ro'yxat (useOynaVirtual)

8. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
aylantirganda bo'sh oq joy zaxira yo'q yoki kichik overscan 3–8 qator
scroll satri "sakraydi" taxmin haqiqiy balandlikdan uzoq o'lchanganlar o'rtachasi, yaxshiroq taxmin
pastdagi tugma qochadi, bosish tegmaydi o'lchovlar sahifani siljitadi (CLS) o'rtacha taxmin; e2e bilan tekshiring
har pikselda butun ro'yxat chiziladi scrollTop piksel state'da indeks yoki qadam state'da
qatordagi yozilgan matn aylantirganda yo'qoladi qator DOM'dan chiqib, qayta yaratildi holatni ro'yxat darajasida saqlang
key={i} bilan qatorlar aralashadi slice ichidagi indeks haqiqiy id

9. Mashqlar

Mashqlar kurs/mashqlar/18/23-virtualizatsiya/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): qancha qator?

Qator 50 px, oyna 600 px, zaxira 2, jami 5 000 qator.

  • scrollTop = 0: start [:0], end [:14]
  • scrollTop = 10000: start [:198], end [:214]
Yechim

scrollTop = 0: first = 0, last = ceil(600 / 50) = 12; start = max(0, 0 − 2) = 0, end = 12 + 2 = 14. scrollTop = 10000: first = 200, last = ceil(10 600 / 50) = 212; start = 198, end = 214. DOM'da 14 va 16 qator — 5 000 o'rniga.

2-mashq (o'rta): aria-setsize va aria-posinset

«Birinchi variant» dagi VirtualList ni Malika uchun yaxshilang: har <li> ga aria-posinset (1 dan boshlanadigan haqiqiy o'rni) va aria-setsize (jami) qo'shing. «O'rtasi» dan keyin birinchi chizilgan qator uchun konsolga №4999: 4999 / 10000 chiqsin (aria-posinset / aria-setsize).

Yechim
jsx
import { useLayoutEffect, useRef, useState } from "react";

const ROW = 40;
const VIEW = 200;
const ORDERS = Array.from(
  { length: 10000 },
  (_, i) => `Buyurtma №${i + 1}`,
);

function VirtualList({ items }) {
  const [top, setTop] = useState(0);
  const box = useRef(null);
  const start = Math.max(0, Math.floor(top / ROW) - 2);
  const end = Math.min(
    items.length,
    Math.ceil((top + VIEW) / ROW) + 2,
  );
  useLayoutEffect(() => {
    const li = box.current.querySelector("li");
    const pos = li.getAttribute("aria-posinset");
    const size = li.getAttribute("aria-setsize");
    console.log(`№${pos}: ${pos} / ${size}`);
  });
  function jump(y) {
    box.current.scrollTop = y;
    setTop(y);
  }
  return (
    <div>
      <button onClick={() => jump(200000)}>O'rtasi</button>
      <div
        ref={box}
        onScroll={(e) => setTop(e.currentTarget.scrollTop)}
        style={{ height: VIEW, overflowY: "auto" }}
      >
        <ul
          style={{ height: items.length * ROW, position: "relative" }}
        >
          {items.slice(start, end).map((text, i) => (
            <li
              key={start + i}
              aria-posinset={start + i + 1}
              aria-setsize={items.length}
              style={{ position: "absolute", top: (start + i) * ROW }}
            >
              {text}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

function App() {
  return <VirtualList items={ORDERS} />;
}

Konsolda:

text
№1: 1 / 10000
№4999: 4999 / 10000

aria-posinset — indeks + 1 (ekran o'quvchi 1 dan sanaydi), aria-setsize — items.length, DOM'dagi <li> soni emas. Malika endi "4 999-element, jami 10 000" eshitadi. vazifalar dagi Qator.tsx aynan shunday qiladi (joy.indeks + 1, joy.jami).

3-mashq (qiyin): indeks bo'yicha o'tish

VirtualList ga «№7500 ga o'tish» tugmasini qo'shing: bosilganda ro'yxat shunday aylansinki, №7500 ekranning eng tepasida bo'lsin. Konsolga birinchi ko'rinadigan (zaxira emas) qatorni chiqaring: tepada: Buyurtma №7500. Ishora: indeksdan piksel — indeks × ROW; zaxirasiz birinchi qator — Math.floor(top / ROW).

Yechim
jsx
import { useLayoutEffect, useRef, useState } from "react";

const ROW = 40;
const VIEW = 200;
const ORDERS = Array.from(
  { length: 10000 },
  (_, i) => `Buyurtma №${i + 1}`,
);

function VirtualList({ items }) {
  const [top, setTop] = useState(0);
  const box = useRef(null);
  const first = Math.floor(top / ROW);
  const start = Math.max(0, first - 2);
  const end = Math.min(
    items.length,
    Math.ceil((top + VIEW) / ROW) + 2,
  );
  useLayoutEffect(() => {
    console.log("tepada:", items[first]);
  });
  function scrollToIndex(index) {
    const y = index * ROW;
    box.current.scrollTop = y;
    setTop(y);
  }
  return (
    <div>
      <button onClick={() => scrollToIndex(7499)}>
        №7500 ga o'tish
      </button>
      <div
        ref={box}
        onScroll={(e) => setTop(e.currentTarget.scrollTop)}
        style={{ height: VIEW, overflowY: "auto" }}
      >
        <ul
          style={{ height: items.length * ROW, position: "relative" }}
        >
          {items.slice(start, end).map((text, i) => (
            <li
              key={start + i}
              style={{ position: "absolute", top: (start + i) * ROW }}
            >
              {text}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

function App() {
  return <VirtualList items={ORDERS} />;
}

Konsolda:

text
tepada: Buyurtma №1
tepada: Buyurtma №7500

№7500 — indeks 7 499 (0 dan). scrollToIndex — kutubxonalardagi o'sha nomli funksiyaning eng sodda ko'rinishi. Balandliklar har xil bo'lsa, index × ROW o'rniga joylar[index] («Prefiks yig'indi» bo'limi) — va hali o'lchanmagan qatorlar taxminiy bo'lgani uchun kutubxonalar o'tgandan keyin yana bir marta tuzatadi. Scroll holatini tiklashda ham aynan shu funksiya ishlatiladi: indeksni saqlab, qaytganda scrollToIndex.

4-mashq: vazifalar qadami

perf/virtual-royxat branch'ida «Vazifalar qadami» dagi o'zgarishlarni kiriting: virtual-oraliq.ts (+ 5 ta node:test), useOynaVirtual.ts, VazifaRoyxati (chegara, ToliqRoyxat/VirtualRoyxat), VazifaQatori/Qator (joy, aria-*), CSS. 10 000 vazifa bilan DevTools → Elements'da #royxat ichidagi <li> larni sanang, Performance'da sahifa ochilishini yozing. Commit va PR.

Yechim

document.querySelectorAll("#royxat li").length — 15–20 atrofida (ikki joy elementi bilan; aniq soni oyna balandligiga bog'liq). Aylantirganda son deyarli o'zgarmaydi, aria-posinset lar esa o'sadi. 200 tadan kam vazifada — hamma <li> joyida, joy elementlari yo'q. Ochilish — kanonda ~170 ms (holat 22 da ~760). Agar e2e'dagi «Batafsil statistika» bosilmasa — «e2e topgan xato» bo'limidagi o'rtacha taxminni tekshiring.

10. Real ishda

  • Jadvallar: admin panel, logistika, bank ko'chirmalari — minglab qator; TanStack Table + TanStack Virtual — keng tarqalgan juftlik.
  • Chat va lenta: Telegram'ga o'xshash chatlar pastdan yuqoriga virtualizatsiya (react-virtuoso), cheksiz yuklash bilan.
  • Mobil: React Native'da FlatList — sukut bo'yicha virtual ro'yxat; g'oya o'sha.
  • Intervyu savollari: "10 000 qatorli ro'yxatni qanday tezlashtirasiz?", "Virtualizatsiya qanday ishlaydi?", "Har xil balandlikli qatorlar bilan nima qilasiz?", "Virtualizatsiyaning a11y narxi?".

Xulosa

  • Virtualizatsiya — faqat ko'rinadigan qatorlar + zaxira; qolgani — bo'sh joy, umumiy balandlik o'zgarmaydi. Ma'lumot xotirada to'liq, DOM esa kichik.
  • Qat'iy balandlik: start = floor(scrollTop / h) − overscan, end = ceil((scrollTop + oyna) / h) + overscan.
  • Har xil balandlik: o'lchash (ref callback, ResizeObserver), taxmin (o'lchanganlar o'rtachasi), prefiks yig'indi va ikkilik qidiruv.
  • Narxi: Ctrl+F, ekran o'quvchi, fokus, chop etish — chegara (200) va aria-setsize/aria-posinset; scroll tiklashda indeks saqlang.
  • vazifalar 10 000: ochilish ~760 → ~170 ms, DOM 50 087 → 154, heap 45 → 11 MB, qidiruv 200 → 24 ms; o'z hook'i +0.8 KB (TanStack +8.3 KB, tezlik teng).

Keyingi dars: Og'ir ishni UI'dan ajratish: debounce, throttle va Web Worker — statistika hisobi asosiy oqimdan alohida oqimga chiqadi.

Manbalar

  • TanStack Virtual — tanstack.com/virtual
  • react-window — github.com/bvaughn/react-window
  • react-virtuoso — virtuoso.dev
  • MDN: aria-setsize, aria-posinset — developer.mozilla.org/docs/Web/Accessibility/ARIA
  • web.dev: "Virtualize large lists" — web.dev/articles/virtualize-long-lists-react-window
  • Kanon: sinov/18/QADAMLAR.md (#23), natijalar/23-oldin.txt, 23-ozimiz.txt, 23-tanstack.txt, natijalar/tekshiruv-23.txt
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Uzun ro'yxatlar: virtualizatsiya — faqat ko'rinadigan qatorlarni chizish — IlmHamroh