jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

How Browsers Work · Part 15 — Rendering Scheduling & the Frame Lifecycle

Browser tạo một frame như thế nào: cơ hội update rendering, rAF, style/layout, observers, paint và composite; cùng content-visibility, idle callback và Scheduler API.

Phần 7 giới thiệu event loop. Phần này phóng to vào một khung hình (frame) — một cơ hội để browser cập nhật màn hình. Ở 60Hz, hai lần refresh cách nhau khoảng 16.7ms; ở 120Hz khoảng 8.3ms, nhưng đó không phải toàn bộ ngân sách dành cho JavaScript. Hiểu các mốc chính giúp bạn biết khi nào code có thể chạy. Đừng nhầm mental model với lịch cứng: browser có thể bỏ qua frame, gom việc, hoặc buộc style/layout sớm khi script đọc hình học.


1. Giải phẫu một khung hình

Giữa các task của event loop, user agent có thể chọn một update-the-rendering opportunity. Bản đồ thực dụng (đã đơn giản hoá) là:

│ Một khung (~16.6ms @ 60Hz)                                        │
├──────────────────────────────────────────────────────────────────┤
│ Input/event tasks có thể chạy trước cơ hội render                  │
│ 1. Cập nhật animation, resize/scroll/media-query state cần thiết    │
│ 2. requestAnimationFrame callbacks                                 │
│ 3. Style + layout khi cần; chạy vòng ResizeObserver phù hợp         │
│ 4. Cập nhật IntersectionObserver ở mốc do spec quy định             │
│ 5. Paint/commit; compositor/raster/display tiếp tục pipeline        │
├──────────────────────────────────────────────────────────────────┤
│ Thời gian thừa → idle period → requestIdleCallback                 │

Điểm mấu chốt: requestAnimationFrame chạy trước paint của cơ hội render liên quan, nên đây là khe đúng để cập nhật trạng thái animation. Tuy vậy, style/layout không bị giới hạn “một lần mỗi frame”: callback hoặc task trước đó có thể ép browser tính chúng sớm rồi làm bẩn lại.


2. requestAnimationFrame — đồng bộ với khung

requestAnimationFrame(cb) yêu cầu browser gọi cb trước một lần repaint phù hợp tiếp theo; nó không hứa callback sẽ chạy đúng frame kế hoặc đúng refresh rate. Dùng nó cho animation bằng JS để gom cập nhật theo cơ hội render.

function tick(now) {
  // 'now' là timestamp khung hiện tại (cùng gốc với performance.now())
  el.style.transform = `translateX(${x(now)}px)`; // ghi, sẽ render trong khung này
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

Đừng dùng setTimeout/setInterval làm clock cho animation: chúng không đồng bộ với refresh opportunity. rAF thường bị pause hoặc throttle mạnh ở tab nền, nhưng chính sách cụ thể thuộc user agent.


3. Bẫy: đọc–ghi xen kẽ trong cùng khung (layout thrashing)

Browser cố trì hoãn style/layout tới lúc cần. Nếu script đọc thuộc tính hình học (như offsetHeight) sau một thay đổi làm layout “bẩn”, browser có thể phải tính layout đồng bộ ngay — forced synchronous layout (Phần 5). Lặp đọc/ghi xen kẽ gọi là layout thrashing.

// ❌ từ vòng thứ hai, lần đọc có thể phải flush thay đổi của vòng trước
for (const el of items) {
  el.style.width = box.offsetWidth + 'px';
}

// ✅ tách đọc và ghi
const w = box.offsetWidth;          // đọc 1 lần
for (const el of items) el.style.width = w + 'px'; // chỉ ghi

Mẹo: gom đọc rồi gom ghi. Thư viện như FastDOM tự động hoá việc này bằng cách hoãn ghi vào rAF.


4. content-visibility — bỏ qua render phần ngoài màn hình

content-visibility: auto áp containment và cho user agent bỏ qua phần lớn style/layout/paint của cây con khi nó không liên quan tới user (thường là đủ xa viewport, không focus/selected/top-layer). Engine thường chuẩn bị lại trước khi nội dung đi vào viewport.

.card {
  content-visibility: auto;
  /* kích thước ước lượng để scrollbar không nhảy khi nội dung bị bỏ qua */
  contain-intrinsic-size: auto 320px;
}

contain-intrinsic-size cung cấp kích thước thay thế khi nội dung bị skip; auto 320px còn cho phép nhớ kích thước đã render. Chọn estimate kém vẫn có thể làm scrollbar nhảy. Nội dung auto vẫn phải sẵn cho find-in-page, focus và công nghệ hỗ trợ, nên đừng dùng trạng thái “skipped” để ngừng cập nhật dữ liệu vô thời hạn.


5. requestIdleCallback & scheduler.postTask — xếp việc theo độ ưu tiên

Khi user agent dự đoán có thời gian rảnh, nó có thể mở một idle period. requestIdleCallback phù hợp với việc best-effort không gấp, nhưng callback có thể bị trì hoãn lâu; timeout là giới hạn chờ, không tạo thêm CPU rảnh và không cứu được main thread đang bị một task dài chặn. API này cũng cần feature detection/fallback; đoạn dưới giả định browser hỗ trợ.

function drainIdle(deadline) {
  if (deadline.didTimeout && queue.length) process(queue.shift());
  while (deadline.timeRemaining() > 1 && queue.length) {
    process(queue.shift());
  }
  if (queue.length) requestIdleCallback(drainIdle, { timeout: 2000 });
}
requestIdleCallback(drainIdle, { timeout: 2000 });

Prioritized Task Scheduling API cung cấp scheduler.postTask() với mức ưu tiên và scheduler.yield() để continuation nhường luồng chính giữa một tác vụ dài. Đây vẫn là API cần feature detection/fallback trên browser chưa hỗ trợ:

if (globalThis.scheduler?.postTask) {
  await scheduler.postTask(work, { priority: 'background' });
}

const yieldToMain = globalThis.scheduler?.yield
  ? () => scheduler.yield()
  : () => new Promise((resolve) => setTimeout(resolve, 0));

async function chunkedWork(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 100 === 99) await yieldToMain();
  }
}

Con số 100 chỉ minh hoạ: chunk theo thời gian đo được và deadline UX, không theo một hằng số mù. scheduler.yield() ưu tiên continuation tốt hơn fallback setTimeout, nhưng cả hai chỉ giúp khi bạn thật sự await giữa các đoạn việc.


6. Tóm tắt

  • Event loop có các cơ hội update rendering, không phải lịch frame cứng; input task, animation/scroll state, rAF, observers, style/layout và paint được phối hợp theo spec lẫn quyết định của engine.
  • requestAnimationFrame chạy trước paint của cơ hội liên quan; callback có thể bị trì hoãn/throttle và không bảo đảm đúng refresh rate.
  • Đọc hình học sau khi ghi style trong cùng khung gây forced layout / thrashing; gom đọc rồi gom ghi.
  • content-visibility: auto cho phép skip subtree không relevant; contain-intrinsic-size giảm nhảy kích thước nhưng cần estimate hợp lý.
  • requestIdleCallback dành cho best-effort idle work; scheduler.postTask/scheduler.yield cho ưu tiên và continuation, luôn feature-detect và await khi yield.

Phần tiếp theo: V8 deep — Ignition, Sparkplug, Maglev, TurboFan, hidden class, inline cache và những gì thực sự xảy ra khi speculative assumption bị phá.