深入 React 函數(shù)組件的 re-render 原理及優(yōu)化
對(duì)于函數(shù)組件的 re-render,大致分為以下三種情況:
組件本身使用?
useState?或?useReducer?更新,引起的 re-render;父組件更新引起的 re-render;
組件本身使用了?
useContext,context?更新引起的 re-render。
下面我們將詳細(xì)討論這些情況。
PS:如無(wú)特殊說(shuō)明,下面的組件都指函數(shù)組件。
1、組件本身使用 useState 或 useReducer 更新,引起的 re-render
1.1、常規(guī)使用
以計(jì)數(shù)組件為例,如下每次點(diǎn)擊 add,都會(huì)打印 'counter render',表示引起了 re-render :
const Counter = () => {
console.log('counter render');
const [count, addCount ] = useState(0);
return (
<div className="counter">
<div className="counter-num">{count}div>
<button onClick={() => {addCount(count + 1)}}>addbutton>
div>
)
}
1.2、immutation state
下面我們將上面計(jì)數(shù)組件中的 state 值改成引用類(lèi)型試試,如下,發(fā)現(xiàn)點(diǎn)擊并不會(huì)引起 re-render:
const Counter = () => {
console.log("counter render");
const [count, addCount] = useState({ num: 0, time: Date.now() });
const clickHandler = () => {
count.num++;
count.time = Date.now();
addCount(count);
};
return (
<div className="counter">
<div className="counter-num">
{count.num}, {count.time}
div>
<button onClick={clickHandler}>addbutton>
div>
);
};
真實(shí)的原因在于,更新 state 的時(shí)候,會(huì)有一個(gè)新老 state 的比較,用的是?Object.is?進(jìn)行比較,如果為 true 則直接返回不更新,源碼如下(objectIs 會(huì)先判斷?Object.is?是否支持,如果不支持則重新實(shí)現(xiàn),eagerState 就是 oldState ):
if (objectIs(eagerState, currentState)) {
return;
}
所以更新 state 時(shí)候要注意,state 為不可變數(shù)據(jù),每次更新都需要一個(gè)新值才會(huì)有效。
1.3、強(qiáng)制更新
相比于類(lèi)組件有個(gè)?forceUpdate?方法,函數(shù)組件是沒(méi)有該方法的,但是其實(shí)也可以自己寫(xiě)一個(gè),如下,由于?Object.is({}, {})?總是?false,所以總能引起更新:
const [, forceUpdate] = useState({});
forceUpdate({})
說(shuō)完?useState?的更新,其實(shí)?useReducer?就不用說(shuō)了,因?yàn)樵创a里面?useState?的更新其實(shí)調(diào)用的就是?useReducer?的更新,如下:
function updateState(initialState) {
return updateReducer(basicStateReducer);
}
2、父組件更新引起子組件的 re-render
2.1、常規(guī)使用
現(xiàn)在稍微改造上面計(jì)數(shù)的組件,添加一個(gè)子組件?Hello,如下點(diǎn)擊會(huì)發(fā)現(xiàn),每次都會(huì)輸出 "hello render",也就是說(shuō),每次更新都引起了?Hello?的 re-render,但是其實(shí)?Hello?組件的屬性根本就沒(méi)有改變:
const Hello = ({ name }) => {
console.log("hello render");
return<div>hello {name}div>;
};
const App = () => {
console.log("app render");
const [count, addCount] = useState(0);
return (
<div className="app">
<Hello name="react" />
<div className="counter-num">{count}div>
<button
onClick={() => {
addCount(count + 1);
}}
>
add
button>
div>
);
};
對(duì)于這種不必要的 re-render,我們有手段可以?xún)?yōu)化,下面具體聊聊。
2.2、優(yōu)化組件設(shè)計(jì)
2.2.1、將更新部分抽離成單獨(dú)組件
如上,我們可以講計(jì)數(shù)部分單獨(dú)抽離成?Counter?組件,這樣計(jì)數(shù)組件的更新就影響不到?Hello?組件了,如下:
const App = () => {
console.log("app render");
return (
<div className="app">
<Hello name="react" />
<Counter />
div>
);
};
2.2.2、將不需要 re-render 的部分抽離,以插槽形式渲染(children)
// App 組件預(yù)留 children 位
const App = ({ children }) => {
console.log("app render");
const [count, addCount] = useState(0);
return (
<div className="app">
{children}
<div className="counter-num">{count}div>
<button
onClick={() => {
addCount(count + 1);
}}
>
add
button>
div>
);
};
// 使用
<App>
<Hello name="react" />
App>除此以外,也可以以其他屬性的方式傳入組件,其本質(zhì)就是傳入的變量,所以也不會(huì)引起 re-render 。
2.3、React.memo
對(duì)于是否需要 re-render,類(lèi)組件提供了兩種方法:PureComponent?組件和?shouldComponentUpdate?生命周期方法。
對(duì)于函數(shù)組件來(lái)說(shuō),有一個(gè)?React.memo?方法,可以用來(lái)決定是否需要 re-render,如下我們將?Hello?組件 memo 化,這樣點(diǎn)擊更新數(shù)字的時(shí)候,?Hello?組件是不會(huì) re-render 的。除非?Hello?組件的 props 更新:
const Hello = React.memo(({ name }) => {
console.log("hello render");
return<div>hello {name}div>;
});
const App = () => {
console.log("app render");
const [count, addCount] = useState(0);
return (
<div className="app">
<Hello name="react" />
<div className="counter-num">{count}div>
<button
onClick={() => {
addCount(count + 1);
}}
>
add
button>
div>
);
};
memo 方法的源碼定義簡(jiǎn)略如下:
exportfunction memo<Props>(
type: React$ElementType, // react 自定義組件
compare?: (oldProps: Props, newProps: Props) => boolean, // 可選的比對(duì)函數(shù),決定是否 re-render
) {
...
const elementType = {
$$typeof: REACT_MEMO_TYPE,
type,
compare: compare === undefined ? null : compare,
};
...
return elementType;
}
memo 的關(guān)鍵比對(duì)邏輯如下,如果有傳入 compare 函數(shù)則使用 compare 函數(shù)決定是否需要 re-render,否則使用淺比較?shallowEqual?決定是否需要 re-render:
var compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
既然默認(rèn)不傳 compare 時(shí),用的是淺對(duì)比,那么對(duì)于引用類(lèi)的 props,就要注意了,尤其是事件處理的函數(shù),如下,我們給?Hello?組件添加一個(gè)點(diǎn)擊事件,這時(shí)我們發(fā)現(xiàn)每次點(diǎn)擊計(jì)數(shù),Hello?組件又開(kāi)始 re-render 了:
// 新增 onClick 處理函數(shù)
const Hello = memo(({ name, onClick }) => {
console.log("hello render");
return<div onClick={onClick}>hello {name}div>;
});
const App = ({ children }) => {
console.log("counter render");
const [count, addCount] = useState(0);
// 新增處理函數(shù)
const clickHandler = () => {
console.log("hello click");
};
return (
<div className="counter">
<Hello name="react" onClick={clickHandler} />
<div className="counter-num">{count}div>
<button
onClick={() => {
addCount(count + 1);
}}
>
add
button>
div>
);
};
這是因?yàn)槊看吸c(diǎn)擊計(jì)數(shù),都會(huì)重新定義?clickHandler?處理函數(shù),這樣?shallowEqual?淺比較發(fā)現(xiàn)?onClick?屬性值不同,于是將會(huì)進(jìn)行 re-render。
2.3.1、useCallback
這個(gè)時(shí)候我們可以使用 useCallback 將定義的函數(shù)緩存起來(lái),如下就不會(huì)引起 re-render 了
// 新增處理函數(shù),使用 useCallback 緩存起來(lái)
const clickHandler = useCallback(() => {
console.log("hello click");
}, []);
useCallback?的原理主要是在掛載的時(shí)候,將定義的 callback 函數(shù)及 deps 依賴(lài)掛載該 hook 的 memoizedState,當(dāng)更新時(shí),將依賴(lài)進(jìn)行對(duì)比,如果依賴(lài)沒(méi)變,則直接返回老的 callback 函數(shù),否則則更新新的 callback 函數(shù)及依賴(lài):
// 掛載時(shí)
function mountCallback(callback, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
// 更新時(shí)
function updateCallback(callback, deps) {
var hook = updateWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
var prevDeps = prevState[1];
// 如果依賴(lài)未變,則直接返回老的函數(shù)
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
// 否則更新新的 callback 函數(shù)
hook.memoizedState = [callback, nextDeps];
return callback;
}
看起來(lái)好像是沒(méi)問(wèn)題了,但是如果我們?cè)趧偛?callback 函數(shù)中使用了 count 這個(gè) state 值呢?
// 新增處理函數(shù),使用 useCallback 緩存起來(lái)
// 在 callback 函數(shù)中使用 count
const clickHandler = useCallback(() => {
console.log("count: ", count);
}, []);
當(dāng)我們點(diǎn)擊了幾次計(jì)數(shù),然后再點(diǎn)擊?Hello?組件時(shí),會(huì)發(fā)現(xiàn)我們打印的 count 還是掛載時(shí)候的值,而不是最新的 count 值。其實(shí),這都是是閉包惹得禍(具體解釋可參考:Be Aware of Stale Closures when Using React Hooks)。所以為了讓 callback 函數(shù)中可以使用最新的 state,我們還要將該 state 放入 deps 依賴(lài),但是這樣依賴(lài)更新了,callback 函數(shù)也將會(huì)更新,于是?Hello?組件又將會(huì) re-render,這又回到了從前。
// 新增處理函數(shù),使用 useCallback 緩存起來(lái)
// 在 callback 函數(shù)中使用 count
// 并將 count 添加進(jìn)依賴(lài)
// 只要 count 更新,callback 函數(shù)又將更新,useCallback 就沒(méi)什么用了
const clickHandler = useCallback(() => {
console.log("count: ", count);
}, [count]);
這樣我們得出了一個(gè)結(jié)論:當(dāng) callback 函數(shù)需要使用 state 值時(shí),如果是 state 值更新引起的更新,useCallback 其實(shí)是沒(méi)有任何效果的。
2.3.2、useRef & useEffect
為了解決剛才的?useCallback?的閉包問(wèn)題,我們換一個(gè)方式,引入?useRef?和?useEffect?來(lái)解決該問(wèn)題:
const App = ({ children }) => {
console.log("counter render");
const [count, addCount] = useState(0);
// 1、創(chuàng)建一個(gè) countRef
const countRef = useRef(count);
// 2、依賴(lài)改成 countRef
// 淺對(duì)比 countRef 時(shí),將不會(huì)引起 callback 函數(shù)更新
// callback 函數(shù)又中可以讀取到 countRef.current 值,即 count 的最新值
const clickHandler = useCallback(() => {
console.log("count: ", countRef.current);
}, [countRef]);
// 3、當(dāng) count 更新時(shí),更新 countRef 的值
useEffect(() => {
countRef.current = count;
}, [count]);
return (
<div className="counter">
<Hello name="react" onClick={clickHandler} />
<div className="counter-num">{count}div>
<button
onClick={() => {
addCount(count + 1);
}}
>
add
button>
div>
);
};
該方案總結(jié)如下:
通過(guò)?
useRef?來(lái)保存變化的值;通過(guò)?
useEffect?來(lái)更新變化的值;通過(guò)?
useCallback?來(lái)返回固定的 callback。
useRef?保存值的原理如下:
// 掛載 ref
function mountRef(initialValue) {
var hook = mountWorkInProgressHook();
// 創(chuàng)建一個(gè) ref 對(duì)象,將值掛在 current 屬性上
var ref = {
current: initialValue
};
{
Object.seal(ref);
}
// 將 ref 掛到 hook 的 memoizedState 屬性上,并返回
hook.memoizedState = ref;
return ref;
}
// 更新 ref
function updateRef(initialValue) {
var hook = updateWorkInProgressHook();
return hook.memoizedState; // 直接返回 ref
}
PS:注意不要跟 hooks API 中的 React.useMemo 搞混,這是兩個(gè)完全不一樣的東西。
3、context 更新,引起的 re-render
其實(shí)關(guān)于 context,我們平時(shí)都有在用,如 react-redux,react-router 都運(yùn)用了 context 來(lái)進(jìn)行狀態(tài)管理。
其涉及內(nèi)容比較多也比較復(fù)雜,一時(shí)半會(huì)也說(shuō)不清,這里就直接推薦一篇文章吧:React Context 源碼淺析。
參考資料
react 最新文檔:?managing state

緊追技術(shù)前沿,深挖專(zhuān)業(yè)領(lǐng)域
掃碼關(guān)注我們吧!

