Hiểu hơn về React: React Fiber
Việc React sử dụng “DOM ảo” có lẽ ai cũng biết. Thế nhưng, cơ chế bên trong của React như thế nào, ví dụ như việc chuyển DOM “ảo” thành DOM “thật”, cơ chế thay đổi DOM mỗi khi state thay đổi lại ít khi được đề cập. Trong bài viết này, tôi sẽ trình bày những hiểu biết của mình về React Fiber và cách React cập nhật DOM.
Trong công việc, những kiến thức về hoạt động bên trong của thư viện React vốn không quá quan trọng với lập trình viên. Như tôi làm việc với React mấy năm mà không biết đến React Fiber vẫn lập trình được. Thế nhưng bản tính cũng có phần tò mò khiến tôi muốn tìm hiểu bên trong thư viện hoạt động thế nào.
React Fiber là gì?
Kể từ bản cập nhật 16.0, React đã được viết lại hoàn toàn. React Fiber cũng được giới thiệu trong bản cập nhật này. Có thể coi React Fiber là engine chính của React giúp nó hoạt động nhanh hơn và thông minh hơn.
Quá trình React chuyển đổi từ DOM ảo sang DOM thật được gọi là reconciliation và chương trình thực hiện việc chuyển đổi gọi là reconciler. Fiber reconciler là reconciler mặc định của React kể từ phiên bản 16 trở đi.
React Fiber chạy bất đồng bộ, vì vậy React có thể làm được nhiều thứ:
- Chạy, tạm dừng, chạy lại việc render các component khi có thay đổi.
- Tái sử dụng lại kết quả có trước.
- Chia việc thành các khối (chunk) và thực hiện theo thứ tự ưu tiên.
Bằng việc chạy bất đồng bộ, React có khả năng hoạt động tốt hơn cơ chế trước đây. Ví dụ với reconciler cũ, việc render các component bắt buộc phải thực hiện tuần tự, khó mà tạm dừng được. Đồng thời cơ chế mới có thứ tự ưu tiên giúp React có hiệu suất tốt hơn.
Trước khi tìm hiểu sâu hơn về React Fiber, hãy xem qua một chút về cơ chế cũ của React.
Stack reconciler (phiên bản 15 trở về trước)
Những dòng code rất quen thuộc với các lập trình viên React:
ReactDOM.render(<App />, document.getElementById('root'));
Trong dòng code trên, ReactDOM
sẽ nhận <App />
và truyền nó cho reconciler.
Component trong React
Các component trong React được xây dựng theo cấu trúc dạng cây giống như DOM của html. Trong đoạn code phần trước, <App />
(có dấu mở/đóng tag của JSX) là React element ở tầng cao nhất, còn App
là React Component tương ứng. Theo React blog, element là một object “mô tả” instance của component hoặc DOM node cùng với các thuộc tính của nó. Nhưng element không phải là DOM node hay instance của component, chỉ mô tả mà thôi.
React xây dựng 2 loại element: DOM element và component element.
DOM element là những element đơn giản, ví dụ:
<button class="okButton">OK</button>
Component element là class hoặc function component, ví dụ:
<ButtonComponent className="okButton">OK</ButtonComponent>
Trong đó, ButtonComponent
là class component (nếu phức tạp) hoặc function component (nếu đơn giản). Đây là những cách cơ bản để làm việc với React.
Trong phiên bản cũ của React, lập trình viên phải lập trình theo kiểu hướng đối tượng (OOP). Logic của app thường được cài đặt thông qua lifecycle của class component. Code sẽ trở nên rất phức tạp nếu state chứa nhiều giá trị khác nhau.
Reconciliation
Reconciliation là quá trình chuyển từ DOM ảo thành DOM thật. React làm việc này bằng cách duyệt qua các phần tử của DOM tree. Khi React gặp class hoặc function component, nó sẽ lấy thông tin về thành phần được render thông qua props.
Ví dụ, nếu App
component có nội dung như dưới đây, React sẽ lấy thông tin render từ Form
và Button
component.
<Form>
<Button>
Submit
</Button>
</Form>
Nếu Form
là function component kiểu như dưới đây, React sẽ gọi hàm (còn nếu là class thì sẽ gọi render
) và kết quả là component này sẽ render một <div>
cùng với con (child) của component (sẽ được truyền component <Button>
).
const Form = (props) => {
return (
<div className="form">
{props.form}
</div>
);
};
Quá trình này sẽ tiếp tục bằng cách đệ quy với những phần tử sâu hơn của DOM tree. Khi kết thúc quá trình duyệt, React sẽ có thông tin của toàn bộ DOM tree. Lúc này, những bộ render như ReactDOM sẽ được gọi để thực hiện việc cập nhật DOM thật. Để tối ưu hiện suất, React chỉ cập nhật những thành phần thay đổi trên DOM.
Reconciliation sẽ được thực hiện khi gọi ReactDOM.render()
để render lần đầu tiên hoặc khi gọi setState()
. Trong trường hợp của setState
, việc duyệt các phần tử còn phải kèm thêm sự so sánh với DOM tree trước đó để tìm ra sự thay đổi.
Stack reconciler
Reconciler trong các phiên bản cũ được gọi là stack reconciler, bởi vì nó hoạt động dựa trên stack (cấu trúc dữ liệu có cơ chế LIFO). Bởi vì cách hoạt động của React dựa trên việc duyệt đệ quy DOM tree, stack là cấu trúc dữ liệu phù hợp. Cơ chế này hoạt động ổn, nhưng có một vài hạn chế.
Điều dễ thấy nhất, không phải lần nào cập nhật state cũng cần cập nhật DOM ngay lập tức. Liên tục cập nhật DOM sẽ khiến hiệu suất của ứng dụng thấp đi và dẫn đến vấn đề gọi là giảm frame rate.
Một vấn đề khác, tùy vào nội dung của từng cập nhật, sẽ có những thứ cần phải ưu tiên hơn thứ khác. Nhưng cơ chế đệ quy của React không cho phép làm điều đó.
Vấn đề giảm frame rate của stack reconciler
Frame rate là khái niệm liên quan đến màn hình, chỉ tần số mà hình ảnh trên màn hình được cập nhật. Mọi thứ được hiển thị trên màn hình là các frame liên tiếp nhau. Và frame rate càng lớn, thì hình ảnh được cập nhật càng nhanh, người dùng sẽ cảm thấy “mượt” hơn.
Màn hình thông thường có frame rate 60Hz, hay 60 FPS, tức là cứ mỗi 16.67ms (milli giây), hình ảnh trên màn hình sẽ được cập nhật. Một số màn hình có thể có tần số cao hơn nhiều, lên tới 144Hz hoặc hơn nữa. Với các ứng dụng React, nếu mất hơn 16.67ms để cập nhật DOM mỗi khi có thay đổi, sự thay đổi đó sẽ không kịp cập nhật lên màn hình và phải chờ lần quét tiếp theo.
Hoạt động của React trên trình duyệt còn phức tạp hơn thế. Các trình duyệt có cơ chế render trang web của riêng mình. Và nó yêu cầu việc cập nhật DOM phải trong khoảng 10ms, nếu không sự thay đổi phải chờ lần quét tiếp theo. Việc các thông tin không được hiển thị sẽ gây ra delay, ảnh hướng lớn đến trải nghiệm người dùng.
Với cơ chế reconciliation duyệt qua toàn bộ DOM tree, sẽ mất rất nhiều thời gian để React xử lý, khiến cho nguy cơ giảm frame rate tăng lên. Những vấn đề này là động lực để Facebook viết lại toàn bộ thư viện React cũng như thuật toán reconciliation. Engine mới được gọi là React Fiber.
React Fiber hoạt động như thế nào?
Một số tính năng đang chú ý của React Fiber (nhắc lại phần trước):
- Sắp xếp xử lý theo độ ưu tiên
- Tạm dừng và chạy lại xử lý
- Hủy bỏ xử lý không cần thiết
- Tái sử dụng kết quả từ trước
Ngoài ra, React Fiber cũng đưa đến một phong cách lập trình mới: React hook. Giờ đây, lập trình viên có thể sử dụng React hook và function component thay thế cho các class component phức tạp. Để có thể cung cấp những tính năng đó không dễ dàng, do cơ chế hoạt động đặc thù của JavaScript.
Cơ chế stack mới
Khi JavaScript engine khởi động (cùng với trình duyệt), những global context như window
sẽ được khởi tạo sẵn. JavaScript sử dụng một stack gọi là execution stack để quản lý việc gọi hàm và context của chúng.
Với những hàm bất đồng bộ, như gọi fetch
chẳng hạn, JavaScript có một cơ chế khác. Cấu trúc dữ liệu queue sẽ được sử dụng, gọi là event queue. Hàm fetch
se được đưa vào stack còn mọi xử lý bất đồng bộ (ví dụ các callback sau khi lấy được dữ liệu) sẽ được đẩy vào event queue.
Vì JavaScript engine chỉ dùng single thread, các xử lý trong event queue phải đợi toàn bộ xử lý đồng bộ trong execution stack chạy hết mới được gọi. Với cơ chế hoạt động như vậy, việc xử lý bất đồng bộ của JavaScript không thực sự là “bất đồng bộ” theo nghĩa của hầu hết các ngôn ngữ lập trình khác.
Quay trở lại với stack reconciler trong phiên bản React cũ. Khi React duyệt qua các phần tử của DOM tree, các xử lý này không được đẩy vào execution stack. Mỗi khi có cập nhật, nó sẽ được đẩy vào event queue để xử lý bất đồng bộ. Và theo cơ chế chung của JavaScript, những xử lý này chỉ được thực hiện sau khi execution stack được xử lý hết.
React Fiber đã tìm cách giải quyết vấn đề này, bằng cách cài đặt lại cấu trúc stack với cơ chế thông minh hơn, cho phép tạm dừng, chạy lại hoặc hủy bỏ xử lý. Thuật toán reconciliation cũ yêu cầu React phải duyệt qua toàn bộ DOM tree bằng đệ quy và các phần tử của tree là immutable (không thay đổi được, phải gán object mới).
Với React Fiber, React cũng tạo một cấu trúc DOM tree dạng cây, nhưng mỗi phần tử của nó là một fiber node. Vì vậy cấu trúc này được gọi là fiber tree. Khác với cấu trúc dữ liệu cũ, fiber node là mutable, tức là object có thể thay đổi được. Fiber node sẽ chứa toàn bộ các thông tin của component như state, props, các component con, v.v…
Và bởi vì fiber node là mutable, các node có thể cập nhật trực tiếp mà không cần tạo node mới. Với fiber tree, việc duyệt qua các phần tử được tối ưu hơn rất nhiều. Thuật toán này không cần đệ quy, việc duyệt được thực hiện theo thứ tự component cha trước, con sau, anh trước, em sau (tương tự thuật toán tìm kiếm theo chiều sâu – DFS).
Hai fiber tree: current & workInProgress
Khi render lần đầu tiên, React xây dựng một fiber tree dựa theo state của ứng dụng. Sau khi render Fiber tree này gọi là current
fiber tree. Khi state được update, React sẽ thực hiện công việc update trên một fiber khác dựa trên state đang được update, gọi là workInProgress
fiber tree.
Thuật toán reconciliation đều được thực hiện trên workInProgress
fiber tree. Mỗi fiber node trên hai cấu trúc cây này sẽ có thuộc tính alternate
trỏ đến fiber node trên cây còn lại. Sau khi hoàn thành việc tính toán, workInProgress
fiber tree sẽ trở thành current
fiber tree.
Fiber tree workInProgress
được dùng trong tính toán và chưa được hiển thị trên trình duyệt. Việc hiển thị trên DOM chỉ được thực hiện sau khi mọi tính toán trên fiber tree đã hoàn thành.
Fiber node
Fiber node là một object mô tả instance của một component. Những thuộc tính của fiber node sẽ được trình bày trong những phần tiếp theo. Đầy đủ các thuộc tính của fiber node có thể xem ở đây. Trong phần này tôi chỉ trình bày một số thuộc tính quan trọng.
type
Dùng để phân loại component: strings, class hoặc function. Với các DOM element thông thường, thuộc tính này là một string chứa tên tag (ví dụ 'div'
), với class hoặc function component, thuộc tính này trỏ đến hàm khởi tạo của component.
child
Các component con khi render, ví dụ:
const Foo = (props) => {
return (
<div className="foo">
{props.foo}
</div>
);
};
Component Foo
sẽ có child là div
.
sibling
Các component cạnh nhau (anh em) khi render, ví dụ:
const Foo = (props) => {
return (
<>
<Bar />
<Baz />
</>
);
}
Trong ví dụ trên, Bar
và Baz
đều là component con của Foo
. Và hai component này sẽ là sibling (anh em) của nhau (tương tự như sibling trên DOM).
return
Đây là kết quả render trả về từ component.
pendingProps
& memoizedProps
Memoization là kỹ thuật lập trình lưu lại giá trị đã tính toán trước đó để sử dụng về sau. Thuộc tính pendingProps
là các props được truyền vào component, còn memoizedProps
là thuộc tính được gán giá trị khi các xử lý trong execution stack đã chạy hết.
Nếu pendingProps
và memoizedProps
có giá trị bằng nhau, điều đó có nghĩa là fiber node không có thay đổi, giá trị đã tính toán trước đó có thể được sử dụng lại.
alternate
Mỗi component luôn có 2 fiber node tương ứng: fiber node hiện tại và fiber node workInProgress
. Fiber node hiện tại là node đang được dùng để render, fiber node workInProgress
là fiber node đang được xử lý trong stack frame và chưa được render.
Mỗi node fiber của cùng một component sẽ có thuộc tính alternate
trỏ sang node còn lại: alternate
của fiber node hiện tại là fiber node workInProgress
và ngược lại.
output
Trong JSX, các tag viết bằng chữ thường, ví dụ div
hoặc span
là các tag cơ bản, được dùng để xây dựng các component khác. Các fiber node tương ứng với các phần tử này sẽ là node lá trên fiber tree.
Các fiber node đều có output
là giá trị trả về dùng để return. Thế nhưng output
chỉ thực sự có giá trị sau khi duyệt đến các node lá. Giá trị của output
sau đó sẽ được truyền ngược trong cây lên các node cao hơn.
Giá trị output
của component cao nhất (App
) được truyền cho bộ render (ví dụ ReactDOM) và được xử lý để hiển thị cho người dùng.
Thuật toán reconciliation
Render lần đầu
Component cao nhất – thường là App
– được render trong một DOM node có id root
. React sẽ tạo một fiber node cho component này. Fiber node đó cũng là gốc của fiber tree và được gọi là HostRoot
. Nếu trên DOM có nhiều ứng dụng React độc lập, nhiều fiber tree sẽ được xây dựng, mỗi tree có một node gốc riêng.
Trong lần render đầu tiên, fiber tree cần được xây dựng. React Fiber sẽ duyệt qua output của các component để xây dựng fiber node cho chúng. Hàm createFiberFromTypeAndProps
sẽ được gọi để tạo fiber cho React element.
Update
Sau khi có sự thay đổi về state, React sẽ tiến hành update lại DOM. Lúc này, current
fiber tree đã được xây dựng từ trước rồi. Để tiến hành update, một workInProgress
fiber tree sẽ được xây dựng.
Việc duyệt vẫn diễn ra từ node gốc. Thế nhưng, với mỗi React element, không phải một fiber node mới sẽ được xây dựng từ đầu, mà node hiện tại sẽ được tái sử dụng, kết hợp với những update để tạo nên node mới.
React Fiber chia việc update thành nhiều xử lý nhỏ với độ ưu tiên khác nhau. Ví dụ các xử lý liên quan đến input từ người dùng sẽ có độ ưu tiên cao hơn việc hiển thị dữ liệu lấy được từ API. Mỗi xử lý lại có thể tạm dừng, chạy lại hoặc hủy bỏ. Các xử lý này được lên lịch theo các frame, mỗi frame có thể có nhiều xử lý khác nhau.
Giai đoạn Render
Việc duyệt qua các node của fiber tree diễn ra ở giai đoạn này. Đây là xử lý bên trong của Fiber và sự thay đổi chưa tác động đến người dùng (chưa thay đổi DOM). Do đó, Fiber có thể chia nhỏ tác vụ, có thể tạm dừng rồi chạy lại.
Fiber sẽ duyệt qua các node của fiber tree và thực hiện tính toán. Hàm workLoopConcurrent
được gọi cho mọi xử lý. Việc xử lý ở giai đoạn có thể chia nhỏ thành 2 bước: begin và complete.
Begin
Nhìn vào hàm workLoopConcurrent
, nó sẽ gọi hàm performUnitOfWork
để xử lý các tác vụ nhỏ hơn. Bản thân hàm performUnitOfWork
lại gọi hàm beginWork
để bắt đầu việc xử lý.
React Fiber sẽ tiến hành duyệt fiber tree, nhưng sẽ bỏ qua các node đã xử lý hoặc không có update. Hàm beginWork
sẽ trả về node con (giá trị null
nếu node không có node con) của fiber node hiện tại. Hàm performUnitOfWork
sẽ tiếp tục việc tính toán với các node con này cho đến khi tất cả được xử lý hết. Sau khi hoàn thành xử lý toàn bộ, hàm completeUnitOfWork
sẽ được gọi để chuyển sang bước tiếp theo.
Complete
Hàm completeUnitOfWork
sẽ hoàn tất việc tính toán cho các node bằng cách gọi hàm completeWork
.
Sau đó, nếu fiber node hiện tại có node anh em, hàm completeUnitOfWork
sẽ trả về node anh em đó. Sau đó, node anh em này sẽ được duyệt và tiến hành tính toán tiếp. Nếu không, kết quả tính toán sẽ được trả về cho node cha ở tầng cao hơn. Quá trình này sẽ kết thúc khi kết quả được trả về cho node gốc.
Kết quả của giai đoạn render và một fiber tree cùng một linked list chứa các side-effect của các node. Các phần tử của linked list liên kết thông qua con trỏ nextEffect
. Side-effect này có thể là thêm, sửa, xóa các node trên DOM. Sau giai đoạn này, Fiber sẽ chuyển sang giai đoạn tiếp theo: commit.
Giai đoạn commit
Trong giai đoạn này, kết quả tính toán từ giai đoạn trước sẽ được sử dụng để cập nhật DOM trên trình duyệt. Vì tác động trực tiếp đến người dùng, giai đoạn này không thể chia nhỏ tác vụ và xử lý là xử lý đồng bộ.
Ở đầu giai đoạn này, workInProgress
fiber tree lúc này đã trở thành finishedWork
fiber tree. Trong giai đoạn này, hàm finishConcurrentRender
sẽ được gọi. Và linked list chứa các side-effect sẽ được dùng để cập nhật DOM như thêm, sửa, xóa các element.
Sau giai đoạn này, finishedWork
fiber tree sẽ trở thành current
fiber tree.
Kết luận
Bài viết trình bày những hiểu biết của tôi về React Fiber. Những kiến thức này chưa thật sự đầy đủ và chính xác 100%. Đó là chưa kể React vẫn đang được update từng ngày từng giờ. Nhưng cũng hy vọng bài giải đáp một phần nào các thắc mắc của mọi người muốn tìm hiểu sâu hơn bên trong thư viện React.
Welcome
Đây là thế giới của manhhomienbienthuy (naa). Chào mừng đến với thế giới của tôi!
Bài viết liên quan
Bài viết mới
Chuyên mục
Lưu trữ theo năm
Thông tin liên hệ
Cảm ơn bạn đã quan tâm blog của tôi. Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.