How Browsers Work · Part 14 — Scrolling Internals
Vì sao cuộn thường mượt kể cả khi JS bận: compositor scrolling, main-thread scrolling, passive listeners, scroll anchoring, và cơ chế của scroll-linked effects.
Cuộn là tương tác phổ biến nhất trên web, và nó là minh hoạ hoàn hảo cho kiến trúc đa luồng từ Phần 13. Câu hỏi trung tâm: vì sao nhiều trang vẫn cuộn được khi luồng chính đang kẹt chạy JS? Vì engine có một compositor scrolling fast path — miễn là input, hit testing và nội dung không buộc nó chờ main thread.
1. Compositor scrolling (đường nhanh)
Trong Chromium, nhiều thao tác cuộn đi theo fast path: compositor xử lý offset và tạo khung mới mà không chờ Blink/main thread ở từng frame.
Người dùng lăn chuột
│
▼
Compositor thread ── dịch chuyển layer/tile sẵn có ──► composite ──► màn hình
(không phải chờ luồng chính ở từng frame trên fast path)
Nếu tile đã raster sẵn, cuộn chủ yếu là đổi scroll offset rồi composite lại. Luồng chính bận vì thế không nhất thiết chặn chuyển động đang đi trên fast path. Nhưng tile mới chưa sẵn sàng, event cần main thread, hay áp lực GPU vẫn có thể làm trễ/rớt frame (Phần 13).
2. Main-thread scrolling (đường chậm)
Có những vùng hoặc pha input buộc browser đồng bộ với luồng chính. Khi đó độ trễ của JS có thể đi thẳng vào độ trễ bắt đầu cuộn hoặc cập nhật hiệu ứng.
Nguyên nhân thường gặp:
- Blocking
wheel/touchstart/touchmovelistener: trình duyệt phải hỏi luồng chính “bạn có gọipreventDefault()không?” trước khi thực hiện default scroll. - Gesture tuỳ biến cần hit testing/xử lý input trên main thread.
- Hiệu ứng JS đọc vị trí cuộn rồi cập nhật DOM mỗi frame: bản thân scroll có thể vẫn async, nhưng effect phụ thuộc main thread và dễ lệch nhịp.
wheel listener KHÔNG passive
│
▼
Compositor: "đợi đã, main thread có preventDefault không?"
│ (round-trip)
▼
Main thread (đang bận?) ──► trả lời ──► mới cuộn được ← giật
3. Passive listeners — mở khoá đường nhanh
Vì trình duyệt không biết trước listener có gọi preventDefault() hay không, nó phải chờ. Passive listener là lời hứa “tôi sẽ không preventDefault()”, cho phép compositor cuộn ngay.
// Hứa không chặn cuộn → compositor cuộn ngay, không round-trip
window.addEventListener('wheel', onWheel, { passive: true });
window.addEventListener('touchstart', onTouch, { passive: true });
Đừng dựa vào default giữa mọi browser/target. Cụ thể, Chromium coi wheel/mousewheel trên root targets (window, document, body) là passive mặc định, và có intervention tương tự cho touch listeners ở root. Khai báo { passive: true } vẫn làm ý định rõ ràng. Nếu thật sự cần huỷ default gesture, dùng { passive: false } có chủ đích và cân nhắc touch-action để mô tả gesture bằng CSS.
Gọi
preventDefault()trong một passive listener sẽ bị bỏ qua và cảnh báo trong console.
4. Scroll anchoring — chống “nhảy” layout
Bạn đang đọc giữa trang, một ảnh phía trên load xong và đẩy nội dung xuống — nhưng vị trí đọc không nhảy. Đó là scroll anchoring: trình duyệt chọn một phần tử neo trong viewport và điều chỉnh offset cuộn để giữ nó đứng yên khi nội dung phía trên thay đổi chiều cao.
Trước: [ảnh đang load] Sau khi ảnh load (cao thêm 200px)
▼ bạn đang đọc trình duyệt +200px offset
đoạn văn ───────► đoạn văn ← vẫn đứng yên
Tắt khi cần bằng overflow-anchor: none. Lưu ý: scroll anchoring giảm layout shift do cuộn, nhưng CLS (Core Web Vitals) vẫn tính các dịch chuyển không mong muốn khác — hai thứ khác nhau.
5. Scroll-linked effects — làm đúng cách
“Scroll-linked effect” là hiệu ứng chạy theo vị trí cuộn (parallax, thanh tiến trình đọc, header thu nhỏ). Cách JS truyền thống dễ giật vì:
scrollevent và code cập nhật DOM chạy trên luồng chính, trong khi scroll có thể đã tiến lên bất đồng bộ.- Work nặng hoặc đọc/ghi layout xen kẽ trong handler làm effect trễ frame. Riêng đọc
scrollYrồi ghitransformkhông tự động đồng nghĩa forced reflow.
Cách JS (vẫn phụ thuộc main thread):
window.addEventListener('scroll', () => {
const y = window.scrollY; // đọc
el.style.transform = `translateY(${y * 0.5}px)`; // vẫn là main-thread work
});
Cách khai báo — scroll-driven animations:
/* Engine gắn animation timeline trực tiếp với scroll progress */
.progress {
animation: grow linear;
animation-timeline: scroll(root block);
transform-origin: left;
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
animation-timeline: scroll() / view() cho phép engine đồng bộ effect với scroll mà không cần listener JS; trong Chromium, animation của property compositor-friendly như transform/opacity có thể chạy off-main-thread. Điều này vẫn phụ thuộc property, engine và support hiện tại. Nếu buộc dùng JS, dùng IntersectionObserver cho mốc xuất hiện, giữ input listener passive khi có thể, và gom cập nhật vào requestAnimationFrame; rAF vẫn chạy trên main thread nên callback phải ngắn.
6. Tóm tắt
- Nhiều thao tác cuộn đi theo compositor fast path: đổi offset và composite mà không chờ main thread ở từng frame; không có bảo đảm tuyệt đối về FPS.
- Blocking
wheel/touchlistener làm browser phải hỏi main thread trước default scroll; effect JS vẫn có thể giật dù bản thân scroll là async. - Passive listeners (
{ passive: true }) hứa khôngpreventDefault(); default passive chỉ áp dụng cho một số event/target/engine, nên khai báo rõ ý định. - Scroll anchoring giữ vị trí đọc khi nội dung phía trên đổi chiều cao; tắt bằng
overflow-anchor: none. - Scroll-driven animations loại bỏ listener JS;
transform/opacitycó thể chạy off-main-thread. Với JS fallback, gom cập nhật vào rAF, giữ input listener passive khi phù hợp và tránh đọc/ghi layout xen kẽ.
Phần tiếp theo: Rendering scheduling & frame lifecycle — cơ hội update rendering phối hợp requestAnimationFrame, style/layout, observers và paint ra sao; content-visibility cùng Scheduler API giúp giảm việc thế nào.