
Cách đây không lâu, khi đang lướt Twitter, tôi bắt gặp bài viết này.
Bài viết này thực sự rất hay. Nó tóm tắt rất nhiều điều về React. Trong bài đó có một đoạn khiến tôi phải suy nghĩ và muốn tìm hiểu sâu hơn về React.
React cuối cùng đã thoát khỏi sự phụ thuộc vào JS call stack. Thay vì render theo kiểu đệ quy, giờ đây nó lặp qua các fiber từng cái một. Và điều đó đồng nghĩa React có thể tạm dừng. Nó có thể thực sự dừng giữa quá trình render, bỏ tạm mọi thứ, để browser repaint, xử lý input, nghỉ một chút, rồi tiếp tục đúng chỗ nó đang dở trong cây UI khổng lồ đó. Không còn kiểu “ôi không, tôi bị kẹt sâu trong recursion”. Bạn không thể bị kẹt nữa vì React không dùng stack mặc định của JavaScript nữa. Nó tự tạo stack của riêng mình.
“react finally got freedom from the JS callstack” hmm
Điều này khá thú vị vì: vấn đề gì với JS call stack mà React phải loại bỏ nó?
Để hiểu giải pháp, trước tiên ta phải hiểu vấn đề: điều gì đang cản trở hiệu năng của React và đội ngũ React đã nghĩ ra điều gì.
Chuyện về một luồng duy nhất
Chúng ta biết JavaScript là một ngôn ngữ lập trình single-threaded, và với mỗi execution context nó chỉ có một thread cùng một call stack xử lý code từng dòng một.
Nó xử lý code từ trên xuống dưới.
function doSomethingHeavy() {
for (let i = 0; i < 1_000_000_000; i++) {} // blocks the thread
console.log("Finished heavy task");
}
doSomethingHeavy();
do_important_task(); // important task
Từ ví dụ trên, khi chúng ta bắt đầu chạy doSomethingHeavy, nó sẽ chạy cho đến khi hoàn thành. Nhưng giả sử phía dưới có một tác vụ quan trọng hơn.
Tác vụ quan trọng đó chỉ được thực thi sau khi vòng lặp doSomethingHeavy kết thúc.
Đây là một chương trình synchronous đơn giản. JavaScript cũng hỗ trợ lập trình bất đồng bộ, bạn có thể đã từng dùng async và await.
Điều này được thực hiện nhờ event loop.
Bạn có thể nghĩ rằng ta chỉ cần đổi vị trí của doSomethingHeavy() và do_important_task() nếu muốn do_important_task chạy trước.
Nhưng trong trường hợp này ta biết rõ task nào là quan trọng vì chính ta viết code đó.
Nhưng nếu điều đó là không thể dự đoán trước thì sao?
Khi bạn đang ở giữa một tác vụ nặng, một tác vụ cực kỳ quan trọng bất ngờ xuất hiện. Bạn không thể dừng chương trình đang chạy giữa chừng để xử lý nó.
Bạn cũng không thể xóa call stack ngay lập tức để xử lý việc khác.
Hãy xem một ví dụ.
Hãy thử bấm click +1 sau khi bạn bấm start work. start work sẽ mất 3 giây để hoàn thành. Trong khoảng thời gian đó, dù bạn bấm click +1, giá trị count cũng không tăng ngay lập tức. Bạn phải đợi đủ 3 giây.
Điều này tạo cảm giác lag và mang lại trải nghiệm người dùng rất tệ.
React 15
Trong React 15, reconciler duyệt qua cây component bằng các lời gọi hàm đệ quy. Một khi quá trình bắt đầu, nó không thể dừng lại.
function updateComponent(component) {
const children = component.render();
children.forEach(child => {
updateComponent(child); // ← Recursion
})
}
Mỗi lần gọi updateComponent sẽ thêm một stack frame mới vào JavaScript call stack.
Với một cây có 1.000 component, sẽ có 1.000 stack frame lồng vào nhau.
Hãy tưởng tượng có một ô input và mọi thứ người dùng gõ vào sẽ hiển thị trên màn hình.
Người dùng gõ ký tự đầu tiên s. React bắt đầu quá trình render, gọi updateComponent bên trong updateComponent.
Các lời gọi hàm đệ quy làm call stack nhanh chóng bị lấp đầy.
Trong khi đang xử lý được một nửa, người dùng gõ thêm chữ a. Nhưng React không thể dừng lại.
Nó không thể nói: khoan đã, người dùng vừa gõ thêm, để tôi restart với input mới.
JavaScript không có cơ chế nào để tạm dừng call stack, lưu trạng thái rồi tiếp tục sau.
React phải xử lý xong chữ s trước khi nó thậm chí nhận ra bạn đã gõ chữ a.
Mỗi lần gõ phím lại kích hoạt một lần reconciliation hoàn chỉnh.
Mỗi lần như vậy React bị kẹt trong recursion trong khi input của bạn tiếp tục tăng lên.
Đó là lý do các ứng dụng React trước đây có cảm giác bị lag.
Độ ưu tiên (Priority)

React UI Runtime
Còn một vấn đề thứ hai: React coi tất cả update là như nhau.
Một lần click button có priority giống với một background data fetch.
Một animation có priority giống với logging.
Nhưng thực tế:
- có những update rất khẩn cấp (user typing, clicking)
- và có những update có thể chờ (analytics, prefetching)
React không có cách nào để biểu diễn điều này vì khi reconciliation bắt đầu, nó phải chạy cho đến khi hoàn thành.
Mọi thứ đều quan trọng như nhau vì mọi thứ đều block lẫn nhau.
Giả sử bạn fetch dữ liệu từ server — danh sách 500 sản phẩm.
Khi response trả về, React bắt đầu render 500 item đó lên màn hình.
Đang render được khoảng 250 sản phẩm thì bạn gõ một chữ vào ô search.
React nên làm gì?
React nên dừng việc render các sản phẩm đó.
Xử lý keystroke trước. Cập nhật input box ngay lập tức.
Đó là điều người dùng quan tâm: thấy ký tự họ vừa gõ xuất hiện ngay.
Các sản phẩm có thể chờ.
Delay 100ms khi hiển thị search results gần như không ai nhận ra.
Nhưng delay 100ms khi hiển thị ký tự bạn vừa gõ thì sẽ khiến ứng dụng trông như bị lỗi.
Chúng ta cần gì
React UI Runtime
Trước khi tìm giải pháp, chúng ta cần rõ mình đang tìm điều gì.
Chúng ta có hai vấn đề chính với cách reconciliation đệ quy:
- không thể pause quá trình render giữa chừng (ở cấp call stack)
- không thể gán priority khác nhau cho các update
Vì vậy giải pháp phải có khả năng tạm dừng rendering khi có task ưu tiên cao hơn, xử lý task đó trước rồi tiếp tục từ nơi đã dừng.
tại sao phải pause ở level call stack?

React UI Runtime
Bởi vì browser hoạt động như vậy.
Chừng nào call stack chưa rỗng, browser không thể đưa event (click, keystroke) từ macro queue vào call stack.
Browser giống như đang nói:
hãy xử lý xong việc bạn đang làm rồi tôi sẽ đưa thêm việc mới
Nếu các recursive function chạy quá lâu, browser không thể đưa event mới vào call stack.
Giải pháp

React UI Runtime
Đội ngũ React cuối cùng nhận ra rằng vấn đề không chỉ nằm ở reconciliation algorithm mà còn ở cách nó được thực thi.
Không có tối ưu nhỏ nào có thể giải quyết điều này vì recursion trong JavaScript về bản chất là không thể bị interrupt.
Thay vì cố ép JavaScript call stack làm điều nó không được thiết kế để làm, React bỏ hoàn toàn mô hình recursive.
Họ ngừng để JS engine điều khiển reconciliation và xây dựng abstraction của riêng mình.
Abstraction đó chính là Fiber.
Fiber không điều khiển call stack — điều đó là không thể.
Call stack là một phần cốt lõi của JavaScript.
Điều Fiber kiểm soát là cách chúng ta cấu trúc và thực thi rendering work.
Thay vì chạy toàn bộ render process trong một lần đệ quy, React chia reconciliation thành các unit of work nhỏ.
React xử lý một unit, sau đó trả quyền điều khiển lại cho browser.
Call stack được giải phóng.
Browser xử lý các event đang chờ.
Sau đó React tiếp tục unit tiếp theo.
Điều này xảy ra theo các time slice nhỏ.
React làm việc khoảng ~5ms rồi yield.
Browser thở.
React tiếp tục từ nơi đã dừng.
Scheduler cũ (trước React 18) sử dụng heuristic cố định khoảng ~5ms trong một số trường hợp. Scheduler hiện đại của React được căn theo frame và có tính động. Nó cố gắng hoàn thành trước deadline của frame tiếp theo (~16.7ms ở 60fps, ~8.3ms ở 120fps).
Chi tiết cách React scheduler hoạt động có thể là chủ đề của một bài blog khác, nhưng để đơn giản trong bài này ta cứ coi là ~5ms.
Fiber như một cấu trúc dữ liệu
React bỏ recursion.
Nhưng nếu không dùng call stack nữa thì cần cách khác để theo dõi tiến trình trong component tree.
React cần một cấu trúc dữ liệu mà nó có thể kiểm soát hoàn toàn.
Một cấu trúc có thể:
- duyệt qua tree
- tạm dừng giữa chừng
- tiếp tục lại sau
Đó chính là Fiber.
Khi bạn viết JSX component, React không render trực tiếp.
Nó xây dựng một representation của toàn bộ UI tree trong memory.
const fiber = {
type: 'div',
child: h1Fiber,
sibling: buttonFiber,
return: AppFiber,
// ... lots of other stuff React needs
};
Mỗi fiber đại diện cho một phần của UI.
Một component. Một div. Một button.
Ba thuộc tính quan trọng là child, sibling và return.
Chúng là các pointer kết nối các fiber với nhau giống như một linked structure.
Điều quan trọng là React sở hữu hoàn toàn data structure này.
Nó chỉ là object trong memory.
React có thể duyệt nó theo cách mình muốn, dừng bất cứ lúc nào và tiếp tục bất cứ khi nào.
Cách Fiber traversal hoạt động
React UI Runtime
- Bước 1: đi xuống child
- Bước 2: nếu không có child thì đi sang sibling
- Bước 3: nếu không có sibling thì đi ngược lên parent
- Bước 4: dừng khi tới root
Khi React đến root fiber và không còn fiber nào để xử lý, render phase hoàn thành.
Time slicing thực sự hoạt động như thế nào

React UI Runtime
Giả sử bạn gõ vào input box. Điều đó trigger render 500 search results.
Browser cho React khoảng 5ms để làm việc.
React bắt đầu duyệt fiber tree.
Nó xử lý fiber đầu tiên, rồi fiber tiếp theo thông qua pointer child, rồi fiber tiếp theo nữa.
Sau khoảng 10–15 fiber, React kiểm tra thời gian.
Nếu đã hết 5ms, React dừng lại. Nó lưu pointer hiện tại và thoát khỏi function.
Call stack được giải phóng.
Browser lấy lại control và xử lý các event mới như keystroke, scroll hoặc click.
Sau đó browser gọi React lại.
React tiếp tục từ fiber đã lưu.
Quá trình này lặp lại cho đến khi toàn bộ 500 fiber được xử lý.
Typing của bạn cảm thấy gần như ngay lập tức vì browser có thể xử lý mỗi keystroke trong vòng 5ms.
React render trong background theo từng chunk nhỏ mà không block bạn.
Kết luận
Đó chính là ý tưởng cốt lõi của Fiber.
Có nhiều cuộc thảo luận trên internet về việc tại sao React dùng fiber architecture trong khi các thư viện khác như Vue hoặc SolidJS dùng reactive approach.
Vue và SolidJS sử dụng reactive signals hoặc fine-grained tracking để chỉ update những phần thực sự thay đổi.
Điều đó nhanh hơn trong một số trường hợp, nhưng cũng có những trade-offs riêng.
Approach của React tập trung vào:
- flexibility
- predictability
- backward compatibility
Component vẫn là pure function của state.
Mental model của developer vẫn đơn giản.
Và Fiber âm thầm xử lý toàn bộ scheduling phức tạp phía sau.
Cuối cùng, React vẫn giữ triết lý cốt lõi của mình.
Function là representation của UI dựa trên state