透過 children props 解決 React 渲染浪費的效能問題

2024/02/08閱讀時間約 13 分鐘

一粥一飯,當思來處不易。

從小我們便被如此諄諄教誨,浪費就是 bad,應愛惜手上擁有的資源。

而在軟體開發的世界,用越少的資源完成越多的功能,絕對是大家爭先搶後的應許之地,因此效能優化永遠停在十八歲,那最令人心神嚮往的狀態。

再把視野聚焦在 React 開發上,我們知道,每當原始資料更新,其所綁定的元件 (component) 函式會被呼叫,進行渲染 (render)。

雖然 React 的渲染僅止於元件函式呼叫,並未包含真實 DOM 的重繪,但仍是一筆可觀的效能開銷。此外,隨著應用程式複雜化,很容易發生沒必要更新 DOM 的元件,在控管不到位的狀況下也跟著進行渲染,造成浪費。

這篇學習筆記會記錄 children props 這個老朋友如何實現效能優化。不過在那之前,我們先來溫習一下,React 會在什麼情況下進行重新渲染,畢竟效能浪費時常出現在這個環節。


元件實體 (component instance) 何時會重新渲染?

如果對於元件實體 (component instance) 不熟悉,這邊先簡單溫習一下。在 React 中,元件 (component) 就像建築藍圖,而這份建築藍圖可以用在各種地方蓋房子,建立元件實體。實體內含的狀態資料 (state) 和事件處理函式等等,全部都是獨立互不影響的。

舉個例子,我們定義了一個名為 Button 的元件,但可以在不同的地方建立該元件的實體:

function Button() {
return <button>我是個社畜按鈕</button>;
}

function App() {
<ul>
<li>
<Button> // 獨立的元件實體
</li>
<li>
<Button> // 獨立的元件實體
</li>
<li>
<Button> // 獨立的元件實體
</li>
</ul>
}


了解元件實體的概念後,讓我們回到重新渲染的主題上。

一個元件實體,通常會在以下三種情況下被重新渲染:

  • state 更新
  • context 更新
  • 父層元件重新渲染

頭兩種狀況比較好理解,但第三種狀況需要留意一下。曾幾何時,我和其他很多人一樣,以為 props 是造成元件重新渲染的來源之一,但其實不然。

追根究柢,是因為父層元件重新渲染了,才造成子層的元件們一同重新渲染。這是 React 起初設計便有的機制,符合單向資料流和 virtual dom 的運作。

props 正好就是從父層傳遞到子層元件的資料,因此容易產生是因為 props 改變層才引發子層元件重新渲染的錯覺。

以上三種情況可說是 React 元件重新渲染的觸發點,觸發之後,React 內部會開始 diffing,也就是比對本次渲染所產生的 virtual dom 和上一次渲染所產生的 virtual dom 有什麼區別。區別之處,就是最後請 DOM 更新的部分。

然而正如先前所提,若我們重新渲染的元件,其實壓根沒有經歷任何變化,那該次的比對可說是徒勞無功,形成被浪費的渲染 (wasted render)

該次渲染沒有產生任何的 DOM 變更

對於小規模的應用程式來說,這不是什麼大問題。然而對於渲染頻繁或是元件緩慢的應用程式,我們不得不正襟危坐,好好正視渲染浪費的問題。


Children props 竟然可以拿來優化效能?!

children props 是一種特別的 props,讓元件接受任何內容當作子元素,內容可以是 JSX 元素、字串、函式,或甚至其他元件。

以往我都把 children props 當作 component composition 的其中一塊拼圖,解決 prop drilling 的問題。但其實透過 chidren props,我們就可以達到效能優化。

以下提供一個範例:

import { useState } from "react";

function SlowComponent() {
const words = Array.from({ length: 100_000 }, () => "WORD");
return (
<ul>
{words.map((word, i) => (
<li key={i}>
{i}: {word}
</li>
))}
</ul>
);
}

export default function Test() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Slow counter?!?</h1>
<button onClick={() => setCount((c) => c + 1)}>Increase: {count}</button>
<SlowComponent />
</div>
);
}


SlowComponent 被呼叫後,會產出一份巨大的清單,內含十萬個 <li> 元素。而它同時也是 Test 的子元件。

Test 元件除了 SlowComponent 之外,還回傳了一個 h1 元素以及一個按鈕。按鈕綁定了點擊處理事件,每點擊一下,便透過 setCount 改變 count 的狀態資料,然後顯示成按鈕文字。

非常容易理解,運作起來也沒有問題,但經過前面的回顧,我們知道當父層元件重新渲染,其麾下的子層元件也將跟著重新渲染,所以每當使用者點擊按鈕, SlowComponent 都會隨著父層元件 Test 重新渲染。

問題就這麼出現了:

  1. SlowComponent 沒有任何資料更新,根本不必隨著父層元件重新渲染
  2. SlowComponent 產生的 <li> 多達十萬個,造成按鈕 UI 的數字更新延遲


所以該怎麼辦呢?

直覺的想法,是把按鈕獨立成 Button 元件,然後將 count state 移動到 Button 裡面。如此一來,SlowComponent 便不會在 count state 更新後,連同 Button 重新渲染。進一步來說,更外層的 Test 元件也不會重新渲染了。

import { useState } from "react";

function SlowComponent() {
// If this is too slow on your maching, reduce the `length`
const words = Array.from({ length: 100_000 }, () => "WORD");
return (
<ul>
{words.map((word, i) => (
<li key={i}>
{i}: {word}
</li>
))}
</ul>
);
}

// 獨立出 Button 元件​
function Button() {
const [count, setCount] = useState(0);

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

export default function Test() {
return (
<div>
<h1>Slow counter?!?</h1>
<Button /> // 使用 Button 元件
<SlowComponent />
</div>
);
}


這麼做的確有達到目的,但如果今天 state 需要在 SlowComponent 的更上層被使用呢?那可就無法將使用 state 和不使用 state 的元件拆分獨立了 😥

export default function App() {
let [color, setColor] = useState('red');
return (
<div style={{ color }}> // color state 需要在 SlowComponent 上層被使用
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p>Hello, world!</p>
<SlowComponent /> // 罪魁禍首
</div>
);
}


遇到這種狀況,children props 便能發揮作用!

我們將原先 App 元件的 color state 和其他相關 JSX 移動到新建立的 ColorPicker,然後透過 children props,將 <p> 元素以及重頭戲 SlowComponent 傳遞進去。

export default function App() {
return (
<ColorPicker>
<p>Hello, world!</p>
<SlowComponent />
</ColorPicker>
);
}

function ColorPicker({ children }) {
let [color, setColor] = useState("red");
return (
<div style={{ color }}>
<input value={color} onChange={(e) => setColor(e.target.value)} />
{children}
</div>
);
}


可、可是之前不是說父層元件會帶動子層元件一起重新渲染嗎?這樣當 ColorPicker 因為 color state 更新而重新渲染,SlowComponent 也會跟著一起......

先別著急,我們再看仔細些,SlowComponent 其實一直待在 App 元件裡面,它是以 JSX 內容的形式,被傳遞到 ColorPicker ,也就是我們熟悉的 children props。

children props 裡面有個「子」含意的 children,容易與父層元件 vs 子層元件混淆,但定下心好好想一下,整個重新渲染的流程會變成這樣:

  1. color state 更新,而它是 ColorPicker 的 local state,所以造成 ColorPicker 重新渲染
  2. ColorPicker 重新渲染,需要接取 children props 當作渲染的資料來源之一
  3. children 是從 App 傳遞下來的 props,而 App 並沒有經歷重新渲染,所以傳遞下來的 children props,和上一回渲染的 children props 內容相同。
  4. children props 內容之一的 SlowComponent,因為上一步驟的理由,被 React 判定不需要重新渲染
  5. 透過 children props 原本的運作機制,我們可以巧妙運用來節省掉不必要的渲染 ✨
  6. 補充:針對第三步驟的 children props 內容,可以參考這篇文章的詳細說明。
  7. 補充資料
  8. The mystery of React Element, children, parents and re-renders
  9. Before You memo()
  10. React components - when do children re-render?
  11. The Ultimate React Course 2024: React, Redux & More
16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!