本文首發於微信公眾號:大遷世界, 我的微信:qq449245884,我會第一時間和你分享前端行業趨勢,學習途徑等等。
更多開源作品請看 GitHub https://github.com/qq449245884/xiaozhi ,包含一線大廠面試完整考點、資料以及我的系列文章。
更新(重新渲染)是 React 的重要特性 —— 當使用者與應用互動的時候,React 需要重新渲染、更新 UI,以響應使用者的輸入。但是,React 為什麼會重新渲染呢?如果不知道 React 為什麼會重新渲染,我們如何才能避免額外的重新渲染呢?
TL; DR
狀態改變是 React 樹內部發生更新的唯二原因之一。
這句話是 React 更新的公理,不存在任何例外。本文也將會圍繞解釋這句話展開。為了避免有人抬槓,這句話引入了一些限制定語和關鍵詞:
名詞解釋
「更新」和「重新渲染」
在 React 中,「更新」和「重新渲染」是關係緊密,但是含義完全不同的兩個詞。下面這句話才能正確表達這兩個詞的正確含義:
React 的「更新」包含三個階段:渲染(Render),使用 createElement
或 jsx-runtime
產生全新的 React Element
物件、組裝出一顆 React 樹;Reconcilation
,React Reconciler 比較 新生成的 React 樹 和 當前的 React 樹,判斷如何用最高效的方法實現「更新」;Commit,操作 Host(如 DOM、Native 等),使新的 UI 呈現在使用者面前。
大部分開發者會把「更新」和「重新渲染」混為一談,因為在上述三個階段中,只有「渲染」這一階段是開發者可以控制的(「Reconcilation
」和「Commit
」分別由 react-reconciler
和 React Host
控制)。本文接下來的部分中,「重新渲染」一律指代 React 元件在「更新」時的「渲染」階段,而「更新」則一律指代(重新)渲染、Reconcilation 和 Commit 整個過程。
「React 樹」和「React 樹內部」
React Tree 本身可以在任意時候更新。實際上,如果你曾經透過 React 文件學習 React,你在「Hello World」一章就已經見過這個 Pattern 了:
const root = ReactDOM.createRoot(document.getElementById('root'));
function tick() {
const element = (
<div>
<h1>Hello, world!</h1>
<h2>It is {new Date().toLocaleTimeString()}.</h2>
</div>
);
root.render(element);
// 如果你是在 React 18 釋出以前學習的 React,你可能會用 ReactDOM.render():
// ReactDOM.render(element, document.getElementById('root'));
}
setInterval(tick, 1000);
每秒鐘呼叫一次 ReactDOM 提供的 render
使一整顆 React 樹進行了完整的更新。但是絕大部分時候,你不會更新一整顆 React 樹,而是 React 樹內的一部分元件(在 React 應用中,你只會呼叫一次 createRoot().render
或者 hydrateRoot()
)。
「唯二原因」
如果你在使用 React class 元件,那麼你可以使用繼承自 React.Component
的 forceUpdate
方法更新一個元件:
class MyComponent extends React.Component {
handleInput() {
this.forceUpdate();
}
}
因此,我們也可以把這句話改寫成:如果一顆 React 樹中所有的 class
元件都沒有使用 forceUpdate
方法,那麼狀態改變是這顆 React Tree 內部發生更新的唯一原因。
在正文開始之前,先放出一句非常具有迷惑性的話:
誤區 0:React 元件更新有三個原因:狀態改變,prop 改變,Context 改變。
如果你去問一些使用 React 的開發者「為什麼 React 會更新/重新渲染」,大概會得到這個答案。這句話不無道理,但是並不能反應真實的 React 更新機制。
本文只會介紹 React 為什麼會發生更新,不會介紹如何避免「不必要」的更新(也許我會以這個為話題另外寫一篇文章?)。
狀態更新和單向資料流
讓我們以計數器為例:
const BigNumber = ({ number }) => (
<div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
);
const Counter = () => {
const [count, setCount] = useState(0);
const handleButtonClick = useCallback(() => setCount(count => count + 1), []);
return (
<div>
<BigNumber number={count} />
<button onClick={handleButtonClick}>Increment</button>
</div>
);
};
const App = () => (
<>
<Counter />
<footer>
<a href="https://skk.moe/">Sukka</a>
</footer>
</>
);
在這個例子中,我們宣告瞭三個元件,根元件 <App />
渲染了 <Counter />
;而 <Counter />
渲染了 <BigNumber />
。在 <Counter />
元件中,我們宣告瞭一個元件內的狀態 count
,當點選按鈕時會改變狀態 count
、使其遞增。
當我們點選按鈕的時候,setCount
被呼叫、count
狀態發生改變,React 更新了 <Counter />
元件。而當 React 更新一個元件時,也會更新這個元件下的所有子元件(至於為什麼,很快就會講的)。因此 <Counter />
元件更新時,子元件 <BigNumber />
也會更新。
現在讓我們先釐清一個最簡單的誤區:
誤區 1:當一個狀態發生改變時,整顆 React 樹都會更新。
有少數使用 React 的開發者會相信這一點(還好不是大多數!)。實際上,當狀態發生改變的時候,React 只會更新「擁有這個狀態」的元件,和這個元件的所有子元件。
為什麼父元件(在這個例子中,<App />
是 <Counter />
的父元件)沒有發生更新呢?因為 React 的主要任務就是保持 React 內的狀態和 React 渲染的 UI 的同步。React 更新,就是找出如何改變 UI,使其和新的狀態同步。而在 React 中,資料是自上而下單向傳遞的(單向資料流,The Data Flows Down)。在這個例子中,<Counter />
元件的狀態 count
向下流向了 <BigNumber />
元件的 prop number
,但是不可能向上流向了 <App />
元件。因此,count
狀態改變,<App /> 元件並不需要更新。
當 count
狀態改變時,<Counter />
元件及其子元件 <BigNumber />
都發生了更新。而 <BigNumber />
元件更新時,使用了 prop number
的新的值進行渲染。那麼 <BigNumber />
元件更新的原因是因為 prop number
的改變嗎?
不,和 props 完全沒有關係
誤區 2:React 元件更新的其中一個原因是它的 prop 發生了改變。
現在讓我們修改一下上面那個例子:
import BigNumber from './big-number';
const SomeDecoration = () => <div>Hooray!</div>
const Counter = () => {
const [count, setCount] = useState(0);
const handleButtonClick = useCallback(() => setCount(count => count + 1), []);
return (
<div>
<BigNumber number={count} />
<button onClick={handleButtonClick}>Increment</button>
<SomeDecoration />
</div>
);
};
const App = () => (
<>
<Counter />
<footer>
<a href="https://skk.moe/">Sukka</a>
</footer>
</>
);
<SomeDecoration />
元件不接受任何 prop
、不使用其父元件 <Counter />
的 count
狀態,但是當 count
狀態發生改變時,<SomeDecoration />
元件仍然發生了更新。當一個元件更新時,React 會更新 所有的子元件,不管這個子元件是否接受一個 prop
:React 並不能百分之百肯定 <SomeDecoration />
元件是否直接/間接地依賴了 count
狀態。
理想中,每一個 React 元件都應該是一個 純函式 —— 一個「純」的 React 元件,當輸入相同的 props
時,總是會渲染相同的 UI。但是現實是骨感的,我們非常容易寫出一個「不純」的 React 元件:
const CurrentTime = () => <p>Last rendered at {new Date().toString()}</p>
包含了狀態(使用了 useState)的元件也不是純元件:即使 prop
不改變,元件也會因為狀態不同而渲染出不同的 UI。
有的時候,你很難判斷一個元件是否是純元件。你可能會將一個 Ref
作為 prop
傳遞給一個元件(forwardRef
,useImperativeHandle
,諸如此類的 case)。Ref 本身是 Reference Stable 的、React 並不能知道 Ref 中的值是否改變。
React 的目標是展示最新、維持一致的 UI。為了避免向使用者展示過時的 UI,當父元件更新時,React 會更新所有子元件,即使子元件不接受任何 prop。props 和元件更新沒有任何關係。
純元件和 memo
你大概很熟悉(或者至少聽說過)React.memo
、shouldComponentUpdate
或者 React.PureComponent
,這些工具允許我們「忽略更新」:
const SomeDecoration = memo(() => <div>Hooray!</div>);
當我們將 <SomeDecoration />
元件的宣告包裹在 memo
中時,我們實際上做的是告訴 React「嘿!我覺得這是個純元件,只要它的 prop
不改變,我們就別更新它」。
現在,讓我們把 <SomeDecoration />
和 <BigNumber />
都包裹在 memo
中,看看會發生什麼:
const BigNumber = memo(({ number }) => (
<div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
));
const SomeDecoration = memo(() => <div>Hooray!</div>);
const Counter = () => {
const [count, setCount] = useState(0);
const handleButtonClick = useCallback(() => setCount(count => count + 1), []);
return (
<div>
<BigNumber number={count} />
<button onClick={handleButtonClick}>Increment</button>
<SomeDecoration />
</div>
);
};
const App = () => (
<>
<Counter />
<footer>
<a href="https://skk.moe/">Sukka</a>
</footer>
</>
);
現在,當 count
狀態更新後,React 會更新 <Counter />
元件及其所有子元件,<BigNumber />
和 <SomeDecoration />
。由於 <BigNumber />
接受一個 prop number
,而 number
的值發生了改變,因此 <BigNumber />
會更新。但是 <SomeDecoration />
的 prop
沒有發生改變(因為不接受任何 prop
),所以 React 跳過了 <SomeDecoration />
的更新。
於是你想,為什麼 React 不預設所有元件都是純元件呢?為什麼 React 不 memo
所有元件呢?事實上,React 元件更新的開銷沒有想象中的那麼大。以 <SomeDecoration />
元件為例,它只需要渲染一個 <div />
。
如果一個元件接受很多複雜的 prop
,有可能渲染這個元件並對比 Virtual DOM 的效能開銷甚至小於等於淺比較所有 prop
的開銷。絕大部分時候,React 是足夠快的。因此,只有當一個 純元件 有大量純的子元件、或者這個 純元件 內部有很多複雜計算時,我們才需要將其包裹在 memo
中。
當一個包裹在 memo 中的元件使用了useState
、useReducer
或者useContext
,當這個元件內的狀態發生改變時,這個元件仍然會更新。
另外一個 React 預設不 memo
所有元件的原因是:讓 React 在 Runtime 中判斷子元件的全部依賴、以跳過子元件的不必要更新,是非常困難、非常不現實的。計運算元元件依賴的最好時機是編譯期間。關於這個 idea 的更多細節,可以看看黃玄在 React Conf 2021 上的演講 React without memo。
讓我們談談 Context
誤區 3:React 元件更新的其中一個原因是 Context.Provider 的 value 發生了更新。
如果說,當一個元件由於狀態改變而更新時,其所有子元件都要隨之更新。那麼當我們透過 Context 傳遞的狀態發生改變時,訂閱了這個 Context
的所有子元件都要更新也是毫不意外的了。
對於純元件來說,Context 可以視為一個「隱藏的」、或者「內部的」prop:
const User = memo(() => {
const user = useContext(UserContext);
if (!user) {
return 'Hello, new comer!';
}
return `Hello, ${user.name}!`;
})
在上面的例子中,<User />
元件是一個不接受任何 prop
、不使用 useState
、也沒有任何副作用的純元件。但是,<User />
元件依賴 UserContext
。當 UserContext
儲存的狀態發生改變時,<User />
元件也會更新。
眾所周知,當 Context
的 value
發生改變的時候,所有 <Context.Provider />
的子元件都會更新。那麼為什麼即使不依賴 Context 的子元件也會更新呢?Context 本身並不是一個狀態管理工具,只是一種狀態傳遞工具。Context 的 value
發生改變的根本原因還是狀態的改變:
const CountContext = createContext(0);
const BigNumber = memo(() => {
const number = useContext(CounterContext);
return (
<div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
)
});
const Counter = () => {
const [count, setCount] = useState(0);
const handleButtonClick = useCallback(() => setCount(count => count + 1), []);
return (
<div>
<CountContext.Provider value={count}>
<BigNumber number={count} />
</CountContext.Provider>
<SomeDecoration />
<button onClick={handleButtonClick}>Increment</button>
</div>
);
};
正如上面的例子,CountContext
發生改變的原因,是 <Counter />
元件的 count 狀態發生了改變;發生更新的,也不僅僅是 CountContext
的消費元件(及其子元件),還包括 <Counter />
所有的子元件。
程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
來源:https://blog.skk.moe/post/react-re-renders-101/
交流
有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。