Render khác gì Commit trong React? Nếu chưa rõ, bạn chưa thật sự hiểu Rendering Cycle

rendering-cycle-trong-react

Gần đây mình đang ôn lại các kiến thức chuyên môn để đi phỏng vấn, đọc kỹ lại docs vì giờ React docs đã cập nhật lại khá nhiều. Đôi khi đi làm thì biết nó là như vậy nhưng để hiểu ngọn ngành, giải thích luồng hoạt động thì có khi giải thích không được chính xác hoàn toàn. Một trong số kiến thức React mà mình nghĩ nhiều người(trong đó có mình) còn chưa thật sự hiểu hoàn toàn đó chính là Rendering Cycle.

Khi làm việc hoặc đi phỏng vấn với React chắc chắn các bạn sẽ không ít lần thắc mắc hoặc được nhà tuyển dụng hỏi như là:

  • Tại sao gọi setState rồi nhưng giá trị chưa đổi ngay
  • Tại sao console.log vẫn in ra state cũ
  • Component re-render nhưng nhìn giao diện chẳng thấy thay đổi gì
  • Nghe mọi người nói về render, re-render, effect, commit… nhưng càng nghe càng thấy rối não

Nếu các bạn từng có cảm giác như vậy, thì khả năng cao vấn đề không nằm ở việc bạn code chưa đủ nhiều mà là các bạn chưa tách bạch được hai khái niệm rất quan trọng trong React đó chính là render phasecommit phase.

Nhiều người nghĩ rằng:

React re-render nghĩa là giao diện(UI) được cập nhật ngay.

Nhưng đó không phải là cách React vận hành.

Muốn hiểu đúng về Rendering Cycle, các bạn cần nắm được:

  • Render là lúc React tính toán UI mới trông như thế nào
  • Commit mới là lúc React áp dụng những thay đổi cần thiết lên DOM thật

Sự khác nhau này nghe có vẻ đơn giản, nhưng lại là gốc rễ của rất nhiều thứ khác trong React mà các bạn hay thắc mắc:

  • Vì sao setState không đổi giá trị ngay trong dòng code hiện tại
  • Vì sao state được xem như một snapshot
  • Vì sao useEffect chạy sau
  • Vì sao component render lại chưa chắc DOM đã đổi
  • Vì sao tối ưu React không đơn giản là thêm useMemo hay React.memo

Trong bài viết này, mình sẽ đi từ bản chất đến ví dụ thực tế để giúp các bạn có thể hiểu rõ được render khác commit như thế nào.

Render phase là gì ?

Trong React, render là: React gọi component function để tính xem UI mới nên trông như thế nào. Tức là ở bước này, React đang tính toán, chưa phải thay đổi giao diện thật. Mình có đoạn code đơn giản dưới đây:

function Counter() {
  const [count, setCount] = useState(0);

  return <h1>{count}</h1>;
}

Khi count = 0, component trả về:

<h1>0</h1>

Khi count = 1, React gọi lại Counter() và nhận được:

<h1>1</h1>

Đó chính là render. Ở đây có một điểm rất quan trọng mà các bạn nên chú ý:

Render là lúc React tạo ra một “phiên bản UI mới” trên mặt logic, chưa phải lúc sửa DOM(Document Object Model) thật.

Bạn có thể hình dung mỗi lần render là một lần React chụp lại một snapshot của UI dựa trên:

  • State hiện tại
  • Props hiện tại
  • Context hiện tại

Snapshot này trả lời câu hỏi:

Với dữ liệu hiện tại, UI nên trông như thế nào?

Commit phase là gì ?

Sau khi React đã render xong và biết UI mới trông ra sao, nó mới đi sang bước tiếp theo chính là commit.

Cụ thể, commit là lúc React:

  • Áp dụng những thay đổi cần thiết lên DOM thật
  • Cập nhật ref
  • Chuẩn bị cho các effects(useEffects) chạy theo timing phù hợp

Ví dụ, nếu DOM thật trước đó là:

<h1>0</h1>

và sau render, React biết UI mới phải là:

<h1>1</h1>

thì commit là lúc React thực sự cập nhật text node từ 0 thành 1.

Đây mới là thời điểm thay đổi chạm tới giao diện thật mà người dùng có thể nhìn thấy.

Một Rendering Cycle đầy đủ diễn ra như thế nào?

Khi có một thay đổi xảy ra, React không nhảy thẳng tới chuyện cập nhật giao diện ngay lập tức. Thay vào đó, nó đi qua một chu trình. Các bạn có thể hình dung flow cơ bản như sau:

Event xảy ra
→ React nhận update
→ React render lại để tính UI mới
→ React so sánh với UI cũ
→ React commit phần thay đổi lên DOM
→ Browser hiển thị giao diện mới

Nếu muốn chi tiết hơn một chút, hãy đọc như sau:

  1. Có update xảy ra
    Ví dụ: user click, input đổi, props đổi, context đổi
  2. React nhận yêu cầu update
    Thường là từ setState, parent re-render, context update…
  3. React render
    React gọi lại component function để tính snapshot UI mới
  4. React so sánh kết quả mới với kết quả cũ
    Để biết chỗ nào thực sự cần cập nhật
  5. React commit
    Cập nhật DOM thật với phần thay đổi cần thiết
  6. Browser paint
    Trình duyệt hiển thị giao diện mới
  7. Effects chạy
    Ví dụ useEffect chạy sau khi UI đã được commit

Đây chính là bức tranh tổng thể của Rendering Cycle mà bạn cần giữ trong đầu.

Từ setState đến lúc UI đổi: chuyện gì thực sự xảy ra?

Các bạn hãy xem ví dụ đơn giản dưới đây:

function Counter() {
  const [count, setCount] = useState(0);

  console.log("render", count);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

Giả sử ban đầu count = 0.

Màn hình hiển thị:

Count: 0

Khi user click button, chuyện gì diễn ra?

Bước 1: Event xảy ra

Người dùng click button.

Bước 2: setCount(count + 1) được gọi

Lúc này React nhận được một yêu cầu update.

Bước 3: React chuẩn bị một lần render mới

React lên lịch để render lại component Counter.

Bước 4: React gọi lại Counter()

Bây giờ React tính toán UI mới. Với state mới, nó kết luận rằng UI nên là:

<button>Count: 1</button>

Đây là render.

Bước 5: React so sánh với UI cũ

React thấy rằng chỉ có phần text thay đổi từ 0 sang 1.

Bước 6: React commit

React cập nhật text node tương ứng trong DOM thật.

Bước 7: Browser hiển thị UI mới

Lúc này người dùng mới nhìn thấy Count: 1.

Điểm cần chốt ở đây là:

setState không phải “đổi biến ngay”.
setState là “báo cho React biết rằng cần một lần render mới”.

Đây là chỗ rất nhỏ nhưng cực kỳ quan trọng.


Vì sao console.log sau setState vẫn là giá trị cũ?

Đây là một trong những câu hỏi phổ biến nhất khi học React.

Ví dụ:

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    console.log(count);
  };

  return <button onClick={handleClick}>Count: {count}</button>;
}

Nhiều người mong rằng click xong, console sẽ in ra 1.

Nhưng thực tế thường vẫn là 0.

Lý do là vì trong lúc handleClick đang chạy, bạn vẫn đang đứng trong ngữ cảnh của lần render hiện tại. Mà trong lần render đó, count0.

Lệnh setCount(count + 1) không làm cho biến count trong đoạn code hiện tại thay đổi ngay lập tức. Nó chỉ yêu cầu React tạo ra một lần render mới sau đó.

Một cách hình dung rất dễ nhớ:

  • Mỗi lần render là một bức ảnh chụp
  • Trong bức ảnh hiện tại, count = 0
  • Bạn gọi setCount(...)
  • React nói: “ok, tôi sẽ chuẩn bị bức ảnh mới”
  • Nhưng ngay lúc này, bạn vẫn đang ở trong bức ảnh cũ

Vì vậy console.log(count) vẫn là 0.

Cách hiểu này cực kỳ mạnh, vì nó dẫn bạn đến mental model đúng:

State trong mỗi lần render là một snapshot.


Vì sao gọi setCount(count + 1) ba lần nhưng chỉ tăng một?

Hãy xem ví dụ này:

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  return <button onClick={handleClick}>Count: {count}</button>;
}

Nhiều người nghĩ click một cái sẽ tăng từ 0 lên 3.

Nhưng thực tế thường chỉ lên 1.

Lý do là vì trong lần render hiện tại, count vẫn là 0. Nên cả ba dòng này thực chất đều tương đương với:

setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);

Kết quả cuối cùng vẫn là 1.

Nếu bạn muốn cộng dồn đúng, hãy dùng function updater:

const handleClick = () => {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
};

Lúc này mỗi lần update sẽ lấy giá trị mới nhất trong chuỗi update, nên kết quả sẽ là 3.

Ví dụ này giúp bạn thấy rất rõ rằng:

  • state không đổi ngay trong đoạn code hiện tại
  • React xử lý update theo queue
  • snapshot của lần render hiện tại rất quan trọng

Re-render không có nghĩa DOM chắc chắn thay đổi

Ví dụ:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase</button>
      <Child />
    </div>
  );
}

function Child() {
  console.log("Child render");
  return <p>Hello</p>;
}

Mỗi lần Parent render lại, Child cũng có thể bị gọi lại.

Nhưng Child luôn trả về cùng một kết quả:

<p>Hello</p>

Vậy điều gì xảy ra?

  • Child có thể render lại
  • nhưng DOM thật của Child chưa chắc phải thay đổi

Đây là chỗ bạn nên tách bạch hai loại chi phí:

  • Render cost: chi phí React gọi lại component và tính toán UI
  • DOM / commit cost: chi phí thay đổi DOM thật

Hai loại chi phí này không giống nhau.

Điều này rất quan trọng trong performance. Nhiều người thấy console.log("render") xuất hiện nhiều lần là hoảng, rồi vội vàng thêm memo, useMemo, useCallback khắp nơi. Nhưng nếu component render lại rất nhẹ và DOM gần như không đổi, thì chưa chắc đó là bottleneck(nút chai) thực sự.


useEffect nằm ở đâu trong chu trình này?

Hãy xem ví dụ:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return <div>{user ? user.name : "Loading..."}</div>;
}

Lần đầu component mount, chuyện gì xảy ra?

  1. React render với user = null
  2. React commit UI là Loading...
  3. Browser hiển thị Loading...
  4. Sau đó useEffect mới chạy
  5. Fetch xong, setUser(data) được gọi
  6. React bắt đầu một rendering cycle mới
  7. Render lại với dữ liệu user
  8. Commit tên user thật lên DOM

Điểm quan trọng là:

useEffect không chạy trong render.
Nó chạy sau khi React đã commit UI.


Kết luận

Rất nhiều người học React nói về render, re-render, state update, effect… nhưng trong đầu vẫn đang gộp tất cả thành một quá trình khá mơ hồ.

Khi bạn tách rõ được:

  • Render là lúc React tính xem UI mới nên trông như thế nào
  • Commit là lúc React áp dụng những thay đổi cần thiết lên DOM thật

thì rất nhiều thứ trong React sẽ trở nên sáng tỏ hơn:

  • Vì sao setState không cập nhật ngay tại chỗ
  • Vì sao state trong mỗi lần render là snapshot
  • Vì sao useEffect chạy sau
  • Vì sao component render lại chưa chắc DOM đổi
  • Vì sao performance cần được nhìn đúng bản chất

Hiểu render khác commit không chỉ giúp bạn “biết thêm lý thuyết”. Nó giúp bạn xây được mental model đúng về React.

Đây là một trong những bài viết trong series React Fundamentals. Sắp tới mình sẽ cố gắng viết thêm nhiều bài nữa. Hi vọng các bạn ủng hộ. Đừng quên để lại bình luận để mình có thêm động lực viết bài nha.

guest

0 Comments
Inline Feedbacks
View all comments