Tìm hiểu cơ chế render của React
Hiểu rõ bản chất cơ chế hoạt động của React là cách mà chúng ta có thể quản lý được React hiệu quả hơn cả về mặt tổ chức và hiệu suất của ứng dụng.
Trong phần Tìm hiểu useCallback, mình nghĩ là mấu chốt vấn đề của việc lạm dụng useCallback
là do chúng ta chưa nắm rõ cơ chế render của React. Vì vậy, mình soạn nội dung này để đào sâu việc tìm hiểu cách React render như thế nào, từ đó chúng ta có cách áp dụng hợp lý hơn, tránh lạm dụng và dẫn đến việc performance nhiều lúc còn tệ hơn là không dùng.
Cốt lõi của React vẫn dựa trên các cơ chế so sánh, cập nhật “cây” React và cuối cùng là cập nhật giao diện. Mình sẽ cố gắng giải thích nó một cách trực quan nhất, không chỉ là dành cho những bạn mới đang làm quen React, giúp các bạn rút ngắn thời gian tìm hiểu về React, mà còn củng cố lại cho một số bạn tuy đã sử dụng nhiều nhưng chưa hẳn đã hiểu rõ nó.
Khái quát về cách hoạt động của React
Render là quá trình mà React thực hiện để hiển thị các nội dung trên giao diện người dùng (UI) dựa trên thay đổi của thuộc tính (props) và trạng thái (state).
Để hiển thị giao diện trên trình duyệt, tiến trình của React thông qua 2 giai đoạn chính:
- Giai đoạn Render: Giai đoạn này bao gồm các công việc liên quan đến khởi tạo hoặc tính toán thay đổi component trước khi hiển thị trên DOM. React sử dụng thuật toán React Fiber dựa trên cơ chế bất đồng bộ (asynchronous) để tính toán sự thay đổi.
- Giai đoạn Commit: Quá trình dựa trên cơ chế đồng bộ (synchronous) để thực hiện các thay đổi trên DOM
Khi React cập nhật DOM ở quá trình commit, nó đồng thời cập nhật toàn bộ các tham chiếu (refs) thích hợp để trỏ đến các DOM node hay các component đã được gọi.
Tiếp theo nó mới chạy đồng bộ useLayoutEffect
(nếu có), và cuối cùng React đặt một khoảng timeout ngắn, khi hết hạn, nó sẽ chạy toàn bộ useEffect
(nếu có). Bước này còn được biết đến như là một hiệu ứng thụ động (“Passive Effect”).
https://wavez.github.io/react-hooks-lifecycle/
Mấu chốt quan trọng nhất mà chúng ta cần nắm bắt là quá trình “render” và cập nhật DOM là hai khái niệm khác nhau, một component có thể được render mà chẳng có biểu hiện gì ở phía giao diện. Khi React render một component thì kết quả có thể giống với dữ liệu của lần render trước đó, không có thay đổi cần thiết, nó có thể render lại mà mắt mình không thấy.
Giai đoạn Render
Các giai đoạn tính toán thay đổi của component được gọi là Giai đoạn Render. Giai đoạn này được thực hiện bất đồng bộ. Hành vi này có thể được ưu tiên, tạm dừng, hay bỏ qua.
Khởi tạo render
Suốt quá trình render, react sẽ bắt đầu từ gốc của cây component thực hiện lặp từ trên xuống để tìm component đang được “đánh dấu” cần cập nhật.
Với mỗi component được đánh dấu sẽ gọi để lưu vào render output. Thông thường ta hay viết một component react bằng jsx, jsx đó sẽ được chuyển thành hàm React.createElement()
<SomeComponent a={42} b="testing">Text here</SomeComponent>
// Được chuyển thành
React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// Cú pháp của
React.createElement(type,props,...children)
// Và hàm createElement này sẽ chuyển component thành một object như sau
{
type: SomeComponent,
props: {a: 42, b: "testing"},
children: ["Text Here"]
}
Sau khi thu thập xong render output (tức là cái object ở trên) của toàn bộ cây component, React sẽ so sánh (diff) cây object mới (được biết đến với cái tên virtual DOM) và tập hợp những thay đổi cần được áp dụng trên DOM thực để cập nhật như mong muốn. Tiến trình tính toán này gọi tên là reconciliation.
Cuối cùng React sẽ áp tất cả những thay đổi đã tính toán xong lên DOM dựa trên chuỗi tiến trình đồng bộ.
Xếp lượt chờ render
Sau bước render ban đầu hoàn thành, một component có thể được xếp hàng chờ render lại (re-render) bằng nhiều cách:
- useState
- useReducer
- Khác: Gọi hàm ReactDOM.render(<App>) thêm phát nữa (cũng tương tự hàm forceUpdate ở component gốc).
Hành vi re-render tiêu chuẩn
Một điều quan trọng phải luôn ghi nhớ là: Hành vi render tiêu chuẩn của React là khi một component cha render, React sẽ đệ quy render toàn bộ component con bên trong nó.
Lấy ví dụ, ta có một cây component bao gồm A>B>C>D, và nó đã được hiển thị ở trên giao diện. Người dùng tương tác với button B để cập nhật trạng thái của B (ví dụ như đổi màu).
- Ta gọi
setState()
cho B, tương đương với việc tạo một lượt chờ render cho B. - React bắt đầu render từ trên đỉnh cây thư mục (A)
- React thấy A không có gì thay đổi, nó chuyển xuống B.
- React thấy B cần thay đổi, react render B, và trả về <C/> vì C vốn là con của B.
- Mặc dù C không được đánh dấu phải re-render. Tuy nhiên, vì B đã được re-render, React vẫn sẽ render lại C, và trả <D/>.
- Tương tự C, D không được đánh dấu là cần re-render, nhưng vì C đã render nên D cũng render lại luôn.
Tức là nếu cha render thì toàn bộ con cũng render lại hết. Và một điểm mấu chốt khác nữa là:
Trong quá trình render thông thường, React không quan tâm props có thay đổi hay không, nó render component con vô điều kiện chỉ vì component cha đã render.
Tức là, nếu chúng ta gọi hàm setState()
ở component gốc <App/>
, React sẽ render từng component một bên dưới nó. Hầu hết các component trong cây component sẽ trả về các render output y chang với lần render trước đó, nên React sẽ không thay đổi gì trên DOM. Nhưng, React vẫn phải làm việc truy vấn component có render lại hay không, và so sánh để trả về render output, hai việc này đều mất thời gian và công sức.
Re-render không phải là issue, mà nó là cách để React biết rằng khi nào cần thay đổi DOM trên giao diện.
Quy tắc của React rendering
Một trong những quy tắc đầu tiên của React là render phải “thuần” (pure), không có bất kỳ hiệu ứng phụ nào. Điều này hơi phức tạp, vì có nhiều side effect không rõ ràng và cũng không ảnh hưởng gì đến kết quả. Ví dụ như console.log()
, nó là một side effect nhưng cũng không ảnh hưởng gì. Làm đột biến giá trị của prop chắc chắn là một effect, nhưng chưa chắc nó ảnh hưởng gì cả. Gọi AJAX giữa lúc đang render chắc kèo là đột biến dữ liệu, nhưng cũng tùy vào kiểu truy vấn của app, nó vẫn có thể không có đột biến ngoài ý muốn.
Trong tài liệu “Nguyên tắc của React” sebmarkbage/The Rules.md Sebastian Markbage có nhắc đến các phương thức được coi là “thuần” và các phương thức không an toàn. Tóm lược:
Logic của render:
- Không được đột biến giá trị biến và các đối tượng hiện tại.
- Không được tạo những giá trị ngẫu nhiên như Math.random() hay Date.now()
- Không truy vấn dữ liệu từ network
- Không xếp lượt (queue) cập nhật trạng thái
Logic của Render logic có thể :
- Có thể đột biến object được tạo mới trong quá trình render.
- Trả lỗi (throw error)
- “Khởi tạo trễ” (lazy initialize) dữ liệu chưa được tạo, ví dự như giá trị cache.
Fiber và Component Metadata
React lưu trữ cấu trúc dữ liệu nội bộ để theo dõi tất cả các component hiện có bên trong ứng dụng. Phần của cấu trúc này là một đối tượng được gọi là “fiber”, nơi chứa những metadata được mô tả như sau:
- Loại component (component type) gì được render trong cây component
- Props và states hiện tại của các component đó
- Con trỏ để nhận biết cấp cha mẹ, hoặc anh em ngang cấp
- Các metadata khác để theo dõi quá trình render
Mình tóm tắt cấu trúc fiber note cơ bản như sau:
{
type: any, // function, class hoặc DOM element
key: null | string, // unique key
stateNode: any, // Save the references to class instances of components, DOM nodes, or other React element types associated with the fiber node
child: Fiber | null, // cấu trúc linked list
sibling: Fiber | null, // cấu trúc linked list
return: Fiber | null, // node cha
tag: WorkTag, // tag để nhận dạng type của fiber
nextEffect: Fiber | null, // linked list pointer tới node kế tiếp
updateQueue: mixed, // queue chờ cập nhật trạng thái và callback
memoizedState: any, // state được sử dụng tạo output
pendingProps: any, // props được cập nhật từ dữ liệu mới của React elements và cần được truyền vào các component con hoặc phần tử DOM
memoizedProps: any, // props được sử dụng để tạo output trong lần render trước đó
// ……
}
Trong suốt quá trình render, React sẽ lần lượt duyệt cây fiber object này và nó tạo dựng một cây cập nhật dựa trên kết quả render.
Lưu ý là các fiber object sẽ lưu những giá trị props và states thật của component. Nghĩa là khi ta sử dụng props và states trong component, thực chất là React cho chúng ta truy vấn vào giá trị lưu trong fiber object.
React hooks hoạt động là nhờ cách nó lưu trữ toàn bộ hooks của component vào một linked listed (data structure) đính kèm vào đối tượng React fiber. Khi React render một function component, nó sẽ lấy linked list đã lưu trữ trong fiber, và mỗi lần ta gọi hook, nó sẽ trả các giá trị thích hợp đã lưu trữ (như là state, hay giá trị của dispatch trong useReducer
).
Khi một component cha render con của nó lần đầu, React tạo ngay 1 fiber object để theo dõi “thực thể” component đó. Với function component, nó gọi hàm YourComponentType(props)
.
Xem thêm Getting Closure on React Hooks by Shawn Wang | JSConf.Asia 2019
Theo bối cảnh như vậy, nên có thể coi component của React là đại diện của đối tượng React fiber.
Component types và sự đồng nhất (reconciliation)
Để tối ưu hoạt động thì React sẽ tối đa tận dụng việc tái sử dụng cây component và cấu trúc DOM hiện có. Nếu ta yêu cầu React render cùng một loại component hay là HTML node cùng một chỗ trên cây DOM, thì React sẽ dùng chính component hay node đó để render thay vì tạo lại mọi thứ từ đầu. Điều này đồng nghĩa là React sẽ vẫn lưu giữ các phiên bản component một khi chúng ta còn gọi component đó ở cùng một chỗ.
Vậy làm sao React biết khi nào và cách nào output thực sự thay đổi?
React sử dụng thuật toán để tối ưu việc update object có độ phức tạp O(n)
, đó là thay vì phải so sánh từng element trên toàn bộ object thì nó sẽ đưa ra 2 giả định:
1.Hai phần tử có type khác nhau thì sẽ tạo ra 2 cây khác nhau
Logic của React render là so sánh các phần tử trước tiên là dựa trên type
và dùng phép so sánh ===
. Nếu một element gốc có kiểu khác nhau, ví dụ như <div>
thành <span>
hay là <ComponentA>
trở thành <ComponentB>
thì React sẽ tăng tốc bằng cách loại bỏ hoàn toàn cây object cũ tạo một cây mới hoàn toàn
// cũ
<div>
<Counter />
</div>
// mới
<span>
<Counter />
</span>
Thì nhóm component cũ sẽ bị loại bỏ và tạo lại nguyên 1 component mới như bên dưới bao gồm cả <Counter/>
bên trong nó. Chính cơ chế như trên, chúng ta phải lưu ý hạn chế việc viết Component bên trong một Component khác
// hạn chế viết component như thế này
function ParentComponent() {
function ChildComponent() {}
return <ChildComponent />
}
// Sửa lại
function ChildComponent() {}
function ParentComponent() {
return <ChildComponent />
}
Với các DOM element có chung type thì React xử lý bằng cách so sánh các props bên trong và cập nhật các thuộc tính bị thay đổi, ví dụ:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
React chỉ thay đổi thuộc tính className
Khi update style thì React chỉ thay đổi properties nào bị thay đổi
// Thay đổi từ
<div style={{color: 'red', fontWeight: 'bold'}} />
// sang
<div style={{color: 'green', fontWeight: 'bold'}} />
// Thì nó chỉ thay đổi giá trị color từ red sang green
2.Vai trò của key
prop là gợi ý phần tử con nào đã “ổn định” trong quá trình render
Key props được coi như là một chỉ dẫn và không truyền vào bên trong component như các prop khác, và trên một cây component thì key cần phải được xác định là “duy nhất”.
Đa số các trường hợp dùng key là khi chúng ra cần render danh sách (list), key
còn đóng vai trò đặc biệt quan trọng trong các trường hợp thay đổi danh sách, như là thêm, bớt, thay đổi trình tự hạng mục trong list. Với các mảng dữ liệu, tốt nhất là chúng ta nên sử dụng một unique Id cho key, hạn chế sử dụng index, và chỉ nên sử dụng index cho một danh sách cố định hoặc không còn cách nào khác.
Ví dụ ta có một danh sách Todo có keys từ 0...9
nếu chúng ta xóa item số 6, 7, và thêm 3 items mới, tức là kết cục chúng ta sẽ render các phần tử từ 0...10
tức là đối với React, chúng ta chỉ mới thêm 1 mục mới vào cuối danh sách, thay đổi từ 10 sang 11 items. React sẽ sử dụng lại các DOM hiện hữu cùng các phiên bản của component, tức là những thành phần đáng phải xóa thực chất vẫn tồn tại nhưng lại có dữ liệu khác với trước đó. Với một danh sách có các item cố định thì có thể vẫn ổn, nhưng nhiều trường hợp lại gây ra lỗi không mong muốn, chẳng hạn như khi chúng ta cập nhật item trong list, React sẽ không nhận biết được đâu là item cần được cập nhật.
Demo cho lỗi khi update input https://jsbin.com/wohima/edit?output
key
có thể được coi là nhận diện của component, chúng ta có thể thêm bớt key cho component ở bất kỳ, khi key thay đổi, React sẽ xóa key đó đi và tạo ra phiên bản mới.
Render hàng loạt (batching) và thời điểm (timing)
React mặc định sẽ tạo một render mới khi mỗi lần gọi setState()
và thực thi đồng bộ. Tuy nhiên, React cũng có thể tự động tối ưu bằng cách render hàng loạt. Render hàng loạt xuất hiện khi setState()
gọi nhiều lần, các kết quả được thực thi và sắp xếp trong một lần render, có một độ trễ nhẹ.
Tài liệu React có nhắc đến việc "cập nhật states có thể bất đồng bộ", cái này liên quan đến hành vi render hàng loạt. Cụ thể là React tự động cập nhật hàng loạt state xảy ra bên trong event handlers. Phần lớn một app React thường hay có một lượng lớn code liên quan đến event handlers nên hầu hết các state trong app cũng được cập nhật hàng loạt.
React triển khai render hàng loạt cho event handlers bằng cách bao lấy chúng trong một hàm nội bộ tên là unstable_batchedUpdates
. React theo dõi tất cả các cập nhật của state đang được xếp lượt khi unstable_batchedUpdates
chạy, sau đó thực hiện chúng trong một một lượt render. Đối với event handlers thì nó hoạt động tốt vì React đã biết được các handler nào được gọi cho một sự kiện nhất định.
Về mặt nguyên lý, bạn có thể hình dung React đã làm như thế nào theo đoạn code sau:
// Đoạn code ví dụ cách hoạt động batch update, không phải code thật
function internalHandleEvent(e) {
const userProvidedEventHandler = findEventHandler(e);
let batchedUpdates = [];
unstable_batchedUpdates( () => {
// bất kỳ cập nhật nào trong đây đều được đẩy vào batchedUpdates
userProvidedEventHandler(e);
});
renderWithQueuedStateUpdates(batchedUpdates);
}
Tuy nhiên, điều này có nghĩa là bất kỳ một cập nhật state được xếp hàng ngoài call stack sẽ không được cập nhật hàng loạt cùng lúc.
Ví dụ cụ thể
const [counter, setCounter] = useState(0);
const onClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
}
Nó sẽ thực thi 3 lần render. Lần thứ 1 sẽ render setCounter(0)
và setCounter(1)
cùng lúc, vì chúng xảy ra đồng thời trong một call stack của event handler, vì vậy chúng sẽ cùng thực hiện khi gọi unstable_batchedUpdates()
Tuy nhiên, setCounter(2)
lại gọi sau await
. Tức là call stack gốc đồng bộ đã xong, và phần sau của hàm chạy muộn hơn nhiều, hoàn toàn trong một vòng lặp call stack khác. Chính vì vậy, React sẽ đồng bộ thực thi render như là một bước cuối trong việc gọi setCounter(2)
, hoàn thành lượt, và trả lại từ setCounter(2)
.
Điều tương tự cũng sẽ xảy ra với setCounter(3)
, bởi vì nó cũng chạy ngoài event handler gốc, và ngoài batching.
React cung cấp useLayoutEffect
cho phép chúng ta có thể thêm một số logic sau khi render, nhưng trước khi trình duyệt hiển thị. Xét một số trường hợp cụ thể thường thấy như sau:
- Render một phần component lần đầu tiên với dữ liệu chưa đầy đủ (loading).
- Sừ dụng ref trong giai đoạn commit để đo lường kích thước thực của DOM node trên trang.
- Đặt một số trạng thái của component dựa vào các đo lường kích thước đó.
- Re-render lập tức với dữ liệu cập nhật.
Với trường hợp này, chúng ta không muốn “phần” render khởi đầu hiển thị trên UI cho user thấy chút nào mà chỉ muốn phần “cuối cùng” của UI hiển thị. Trình duyệt sẽ tính toán cấu trúc DOM trong quá trình nó đang cập nhật nhưng sẽ không hiển thị trên màn hình vì JS còn đang thực thi và event loop đang block. Vì vậy, ta có thể thực hiện nhiều thay đổi trên DOM như div.innerHTML = "a"; div.innerHTML = "b";
và "a"
không bao giờ xuất hiện.
Đó là lý do tại sao React luôn thực hiện đồng bộ (synchronously) render trong giai đoạn commit. Nghĩa là nếu ta muốn cập nhật chuyển "partial->final" trong giai đoạn này thì chỉ có “final” hiển thị trên màn hình.
Và cuối cùng, các hành vi update state bên trong hàm callback của useEffect
được xếp hàng đợi, và chúng sẽ được xóa vào cuối giai đoạn “hiệu ứng thụ động” khi hoàn thành việc thự thi toàn bộ callback trong các useEffect
.
Đáng chú ý là unstable_batchedUpdates
API được export và sử dụng nhưng nó có prefix "unstable", và nó không là một phần của chính thức của React API. Tuy nhiên thì team React cũng nói là “nó là API stable nhất trong tất cả các ‘unstable’ API, một nửa code của Facebook phụ thuộc vào hàm này”.
Từ phiên bản 18 React đã cho phép tự động batching https://github.com/reactwg/react-18/discussions/21
Hành vi render với các trường hợp phụ
React sẽ gấp đôi lần render component nằm trong tag <StrictMode>
khi đang trong môi trường development. Điều này nghĩa là số lượt rendering logic của chúng ta không tương ứng với số lượng render được thực hiện, do đó ta không thể phụ thuộc vào console.log()
khi đang render để đếm số lần render được thực hiện. Cách hiệu quả nhất là dùng React DevTools Profiler để truy xuất và đếm số lượt render, hoặc là log ở trong useEffect
, đây là hai cách hiệu quả nhất để kiểm tra vòng đời của React component.
Các function component cho phép cập nhật state trực tiếp trong quá trình render. Nếu một function component xếp các update state trong quá trình render, React sẽ lập tức thực hiện cập nhật state và đồng bộ (synchronously) re-render component đó trước khi tiếp tục các bước kế tiếp. Nếu component liên tục lặp lại update state và bắt React re-render lại liên tục, sau 50 lần tối đa thực hiện (hiện tại), React sẽ dừng lại và throw error.
Giai đoạn Commit
Giai đoạn commit cũng thực hiện đồng bộ, quá trình này không được can thiệp giữa chừng. Khi React hoàn tất việc so sánh giữ các component cũ và mới qua thuật toán so sánh, lập thành một danh sách các cập nhật, nó sẽ cho hiển thị trên DOM thực tế. Điều này có nghĩa là nó chỉ có thể thay đổi những element có thay đổi cụ thể lên DOM, chứ không phải toàn bộ từng element. Đây gọi là giai đoạn commit.
Giai đoạn commit là giai đoạn React chính thức can thiệp vào DOM và thay đổi chúng. Theo quy tắc thuật toán so sánh vừa nêu, chúng ta phải hiểu là React có thể có giai đoạn render nhưng không nhất thiết phải commit. Điều này xảy ra kho component trả kết quả y hệt với lần gần nhất, thông thường điều này xảy nếu một component cha update và re-render, nhưng component con vẫn có output tương tự với trước đó.
Do bài hơi dài nên chúng ta sẽ đi sâu hơn về mặt kỹ thuật trong một dịp khác, cơ bản thì nó cũng chỉ là các vòng lặp để thực hiện các bước tạo, sửa, xóa trên DOM.
Tóm lại
- Mặc định, React luôn đệ quy render các component, vì vậy cha render thì con render
- Bản thân re-render hoàn toàn không phải là issue, vì nó là cách React hiểu khi nào DOM cần được thay đổi.
- Nhưng, re-rendering tốn một khoảng thời gian, và những render không cần thiết có thể vẫn xảy ra.
- Việc truyền các reference mới như hàm callback hay object thường xuyên không có vấn đề gì.
Việc tối ưu re-render chỉ thực sự cần thiết khi nào nó ảnh hưởng đến performance trên ứng dụng. Trong phần tiếp theo, chúng ta sẽ tìm hiểu những phương pháp cải tiến hiệu suất của React.
Nội dung này có tham khảo nhiều nguồn khác nhau và cả các nội dung trong tài liệu của React, nhưng chủ yếu dựa vào bài viết rất hay Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior , mọi người có thể đọc để tham khảo thêm.