文章目錄
源碼分析:react hook 最佳實踐(上篇)
前言
本文從 mini React
—— Preact
源碼的角度,分析 React Hook
各個 API
的優點缺點。
從而理解爲什麼要用 hook
,以及如何最佳使用。
2條規則
爲什麼?
- ✅只在最頂層使用 Hook,不要在循環,條件或嵌套函數中調用 Hook;
- ✅只在 React 函數中調用 Hook,不要在普通的 JavaScript 函數中調用 Hook。
源碼分析
let currentIndex; // 全局索引
let currentComponent; // 當前 hook 所在的組件
function getHookState(index) {
const hooks =
currentComponent.__hooks ||
(currentComponent.__hooks = {_list: [], _pendingEffects: []});
if (index >= hooks._list.length) {
hooks._list.push({});
}
return hooks._list[index];
}
// 獲取註冊的 hook
const hookState = getHookState(currentIndex++);
- hook 狀態都維護在數組結構中,執行
hook api
時,索引currentIndex + 1
依次存入數組。
當組件render
之前,會先調用hook render
,重置索引和設置當前組件,hook 注入在options
內。
options._render = vnode => {
currentComponent = vnode._component;
currentIndex = 0;
// ...
};
-
首先需要知道一點的是,
函數組件
在每次diff
時,整個函數都會重新執行,
而class組件
只會執行this.render
,因此hook
在性能上會有些損耗,
考慮到這一點hook
爲那些聲明開銷很大的數據結構和函數,提供了useMemo
和useCallback
優化。 -
hook
在每次render
時,取上一次hook state
時,
如果在循環,條件或嵌套函數不確定的分支裏執行,就有可能取錯數據,導致混亂。
function Todo(props) {
const [a] = useState(1);
if(props.flag) {
const [b] = useState(2);
}
const [c] = useState(3);
// ...
}
<Todo flag={true} />
- 此時
a = 1, b = 2, c = 3
;
<Todo flag={false} />
- 當條件被改變時,
a = 1, c = 2
。c
取錯了狀態!
第二條嘛,就顯而易見了,hook
寄生於 react
組件和生命週期。
Preact hook
在options
對象上聲明瞭_render
->diffed
->_commit
->unmount
四個鉤子,
分別會在對象組件的生命週期前執行,這樣侵入性較小。
useState
使用方式
// 聲明 hook
const [state, setState] = useState(initialState);
// 更新 state
setState(newState);
// 也可以函數式更新
setState(prevState => { // 可以拿到上一次的 state 值
// 也可以使用 Object.assign
return {...prevState, ...updatedValues};
});
- 惰性初始 state。如果初始化
state
值開銷很大,可以傳入函數,初始化只會執行一次。
const [state, setState] = useState(() => {
const initialState = someExpensiveComputation(props);
return initialState;
});
- 跳過 state 更新。設置相同的值(
Object.is
判斷),不會觸發組件更新。
const [state, setState] = useState(0);
// ...
// 更新 state 不會觸發組件重新渲染
setState(0);
setState(0);
爲什麼?
- 坑:依賴
props.state === 1
初始化hook
,爲什麼props.state === 2
時,hook state
不會變化?
function Component(props) {
const [state, setState] = useState(props.state);
// ...
}
- 惰性初始的原理是什麼?
hook state
變更是怎麼驅動組件渲染的,爲什麼說可以當class state
使用?
源碼分析
Preact
中useState
是使用useReducer
實現的,便於行文,代碼會略加修改。
function useState(initialState) {
const hookState = getHookState(currentIndex++);
if (!hookState._component) {
hookState._component = currentComponent;
hookState._value = [
invokeOrReturn(undefined, initialState),
action => {
const nextValue = invokeOrReturn(hookState._value[0], action);
if (hookState._value[0] !== nextValue) {
hookState._value[0] = nextValue;
hookState._component.setState({});
}
}
];
}
return hookState._value;
}
// 工具函數,用來支持函數式初始和更新
function invokeOrReturn(arg, f) {
return typeof f === 'function' ? f(arg) : f;
}
- 可以看出
useState
只會在組件首次render
時初始化一次,以後由返回的函數來更新狀態。
- 坑:初始化(包括傳入的函數)只會執行一次,所有不應該依賴
props
的值來初始化useState
; - 優化:可以利用傳入函數來性能優化開銷較大的初始化操作。
hookState._value[0] !== nextValue
比較新舊值避免不必要的渲染。- 可以看出,更新操作利用了組件實例的
this.setState
函數。這就是爲什麼hook
可以代替class
的this.state
使用。
useEffect
使用方式
- 例如,常用根據
query
參數,首次加載組件只發一次請求內容。
function Component(props) {
const [state, setState] = useState({});
useEffect(() => {
ajax.then(data => setState(data));
}, []); // 依賴項
// ...
}
useState
有說到,props
初始state
有坑,可以用useEffect
實現。
function Component(props) {
const [state, setState] = useState(props.state);
useEffect(() => {
setState(props.state);
}, [props.state]); // props.state 變動賦值給 state
// ...
}
- 清除副作用,例如監聽改變瀏覽器窗口大小,之後清除副作用
function WindowWidth(props) {
const [width, setWidth] = useState(0);
function onResize() {
setWidth(window.innerWidth);
}
// 只執行一次副作用,組件 unmount 時會被清除
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <div>Window width: {width}</div>;
}
- 注意:在
useEffect
在使用state
時最好把它作爲依賴,不然容易產生bug
function Component() {
const [a, setA] = useState(0);
useEffect(() => {
const timer = setInterval(() => console.log(a), 100);
return () => clearInterval(timer)
}, []);
return <button onClick={() => setA(a+1)}>{a}</button>
}
當你點擊按鈕 a+=1
時,此時 console.log
依舊打印 0
。
這是因爲 useEffect
的副作用只會在組件首次加載時入 _pendingEffects
數組,形成閉包。
修改如下:
function Component() {
const [a, setA] = useState(0);
useEffect(() => {
const timer = setInterval(() => console.log(a), 100);
return () => clearInterval(timer)
- }, []);
+ }, [a]);
return <button onClick={() => setA(a+1)}>{a}</button>
}
這段代碼在 React
裏運行,輸出會隨點擊按鈕而變化,而在 preact
中,之前定時器未被清除,
說明有 bug
。-_-||
爲什麼?
useEffect
解決了什麼問題
一般發送數據請求 componentDidMount
中,之後 componentWillUnmount
在相關清理。
這就導致相互無關的邏輯夾雜在 componentDidMount
,而對應的掃尾工作卻分配在 componentWillUnmount
中。
有了 useEffect
,你可以把相互獨立的邏輯寫在不同的 useEffect
中,他人擔心維護時,也不用擔心其他代碼塊裏還有清理代碼。
- 在組件函數體內執行副作用(改變 DOM、添加訂閱、設置定時器、記錄日誌等)是不被允許的?
每次 diff
函數組件會被當做class
組件的 this.render
函數類似使用,
整體會被執行,在主體裏操作副作用是致命的。
useEffect
的機制?
源碼分析
function useEffect(callback, args) {
const state = getHookState(currentIndex++);
if (argsChanged(state._args, args)) {
state._value = callback;
state._args = args;
currentComponent.__hooks._pendingEffects.push(state);
}
}
- 工具函數,依賴項爲
undefined
或依賴項數組中一個值變動,則true
function argsChanged(oldArgs, newArgs) {
return !oldArgs || newArgs.some((arg, index) => arg !== oldArgs[index]);
}
- 可以看出副作用的回調函數會在
_pendingEffects
數組中維護,代碼有兩處執行
options._render = vnode => {
currentComponent = vnode._component;
currentIndex = 0;
if (currentComponent.__hooks) { // 這裏爲什麼要清理了再執行!!!
currentComponent.__hooks._pendingEffects.forEach(invokeCleanup);
currentComponent.__hooks._pendingEffects.forEach(invokeEffect);
currentComponent.__hooks._pendingEffects = [];
}
};
function invokeCleanup(hook) {
if (hook._cleanup) hook._cleanup();
}
function invokeEffect(hook) {
const result = hook._value(); // 如果副作用函數有返回函數的,會被當成清理函數保存。
if (typeof result === 'function') hook._cleanup = result;
}
options.diffed = vnode => {
if (oldAfterDiff) oldAfterDiff(vnode);
const c = vnode._component;
if (!c) return;
const hooks = c.__hooks;
if (hooks) {
if (hooks._pendingEffects.length) {
afterPaint(afterPaintEffects.push(c));
}
}
};
function afterPaint(newQueueLength) {
if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
prevRaf = options.requestAnimationFrame;
(prevRaf || afterNextFrame)(flushAfterPaintEffects);
}
}
function flushAfterPaintEffects() {
afterPaintEffects.some(component => {
if (component._parentDom) {
try {
component.__hooks._pendingEffects.forEach(invokeCleanup);
component.__hooks._pendingEffects.forEach(invokeEffect);
component.__hooks._pendingEffects = [];
} catch (e) {
options._catchError(e, component._vnode);
return true;
}
}
});
afterPaintEffects = [];
}
-
我很懷疑,
options._render
的代碼是從flushAfterPaintEffects
不假思索的拷過去。
導致上面講到的一個bug
。 -
afterPaint
利用requestAnimationFrame
或setTimeout
來達到以下目的
與 componentDidMount
、componentDidUpdate
不同的是,在瀏覽器完成佈局與繪製之後,傳給 useEffect 的函數會延遲調用,不會在函數中執行阻塞瀏覽器更新屏幕的操作。
useMemo
使用方式
const memoized = useMemo(
() => expensive(a, b),
[a, b]
);
爲什麼?
useMemo
解決了什麼問題
上面反覆強調了,函數組件體會被反覆執行,如果進行大的開銷的會吃性能。
所以 react
提供了 useMemo
來緩存函數執行返回結果,useCallback
來緩存函數。
源碼分析
function useMemo(factory, args) {
const state = getHookState(currentIndex++);
if (argsChanged(state._args, args)) {
state._args = args;
state._factory = factory;
return (state._value = factory());
}
return state._value;
}
-
可以看出,只是把傳入的函數根據依賴性執行了一遍把結果保存在內部的
hook state
中。 -
記住,所有的
hook aoi
都一樣,不要在沒有傳入state
作爲依賴項的情況下,在傳入的函數體中
使用state
。
useCallback
使用方式
const onClick = useCallback(
() => console.log(a, b),
[a, b]
);
爲什麼?
useCallback
解決了什麼問題
上面提到了,用來緩存函數的
- 例如,上面優化監聽窗口的例子。
function WindowWidth(props) {
const [width, setWidth] = useState(0);
- function onResize() {
- setWidth(window.innerWidth);
- }
+ const onResize = useCallback(() => {
+ setWidth(window.innerWidth);
+ }, []);
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <div>Window width: {width}</div>;
}
上面說過,沒有依賴的時,不使要用 width
,但可以使用 setWidth
,
函數是引用,閉包變量 setWidth
是同一個地址。
源碼分析
useMemo
的封裝
function useCallback(callback, args) {
return useMemo(() => callback, args);
}
上篇完。
下篇介紹
- useReducer
- useContext
- useRef
- useLayoutEffect
- useImperativeHandle
- 自定義 hook
- 總結函數組件 hook 與 class 組件的對比