Hiểu hơn về React: React Fiber

Hiểu hơn về React: React Fiber
Photo by JJ Ying from Unsplash

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ừ FormButton 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, BarBaz đề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 pendingPropsmemoizedProps 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.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

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.