從 React Hooks 正式發佈到如今,我一直在項目使用它。可是,在使用 Hooks 的過程當中,我也進入了一些誤區,致使寫出來的代碼隱藏 bug 而且難以維護。這篇文章中,我會具體分析這些問題,並總結一些好的實踐,以供你們參考。javascript
useState
的出現,讓咱們可使用多個 state 變量來保存 state,好比:html
const [width, setWidth] = useState(100); const [height, setHeight] = useState(100); const [left, setLeft] = useState(0); const [top, setTop] = useState(0);
但同時,咱們也能夠像 Class 組件的 this.state
同樣,將全部的 state 放到一個 object
中,這樣只需一個 state 變量便可:java
const [state, setState] = useState({ width: 100, height: 100, left: 0, top: 0 });
那麼問題來了,到底該用單個 state 變量仍是多個 state 變量呢?react
若是使用單個 state 變量,每次更新 state 時須要合併以前的 state。由於 useState
返回的 setState
會替換原來的值。這一點和 Class 組件的 this.setState
不一樣。this.setState
會把更新的字段自動合併到 this.state
對象中。typescript
const handleMouseMove = (e) => { setState((prevState) => ({ ...prevState, left: e.pageX, top: e.pageY, })) };
使用多個 state 變量可讓 state 的粒度更細,更易於邏輯的拆分和組合。好比,咱們能夠將關聯的邏輯提取到自定義 Hook 中:編程
function usePosition() { const [left, setLeft] = useState(0); const [top, setTop] = useState(0); useEffect(() => { // ... }, []); return [left, top, setLeft, setTop]; }
咱們發現,每次更新 left
時 top
也會隨之更新。所以,把 top
和 left
拆分爲兩個 state 變量顯得有點多餘。數組
在使用 state 以前,咱們須要考慮狀態拆分的「粒度」問題。若是粒度過細,代碼就會變得比較冗餘。若是粒度過粗,代碼的可複用性就會下降。那麼,到底哪些 state 應該合併,哪些 state 應該拆分呢?我總結了下面兩點:瀏覽器
- 將徹底不相關的 state 拆分爲多組 state。好比
size
和position
。- 若是某些 state 是相互關聯的,或者須要一塊兒發生改變,就能夠把它們合併爲一組 state。好比
left
和top
。
function Box() { const [position, setPosition] = usePosition(); const [size, setSize] = useState({width: 100, height: 100}); // ... } function usePosition() { const [position, setPosition] = useState({left: 0, top: 0}); useEffect(() => { // ... }, []); return [position, setPosition]; }
使用 useEffect
hook 時,爲了不每次 render 都去執行它的 callback,咱們一般會傳入第二個參數「dependency array」(下面統稱爲依賴數組)。這樣,只有當依賴數組發生變化時,纔會執行 useEffect
的回調函數。閉包
function Example({id, name}) { useEffect(() => { console.log(id, name); }, [id, name]); }
在上面的例子中,只有當 id
或 name
發生變化時,纔會打印日誌。依賴數組中必須包含在 callback 內部用到的全部參與 React 數據流的值,好比 state
、props
以及它們的衍生物。若是有遺漏,可能會形成 bug。這其實就是 JS 閉包問題,對閉包不清楚的同窗能夠自行 google,這裏就不展開了。app
function Example({id, name}) { useEffect(() => { // 因爲依賴數組中不包含 name,因此當 name 發生變化時,沒法打印日誌 console.log(id, name); }, [id]); }
在 React 中,除了 useEffect 外,接收依賴數組做爲參數的 Hook 還有 useMemo
、useCallback
和 useImperativeHandle
。咱們剛剛也提到了,依賴數組中千萬不要遺漏回調函數內部依賴的值。可是,若是依賴數組依賴了過多東西,可能致使代碼難以維護。我在項目中就看到了這樣一段代碼:
const refresh = useCallback(() => { // ... }, [name, searchState, address, status, personA, personB, progress, page, size]);
不要說內部邏輯了,光是看到這一堆依賴就使人頭大!若是項目中處處都是這樣的代碼,可想而知維護起來多麼痛苦。如何才能避免寫出這樣的代碼呢?
首先,你須要從新思考一下,這些 deps 是否真的都須要?看下面這個例子:
function Example({id}) { const requestParams = useRef({}); requestParams.current = {page: 1, size: 20, id}; const refresh = useCallback(() => { doRefresh(requestParams.current); }, []); useEffect(() => { id && refresh(); }, [id, refresh]); // 思考這裏的 deps list 是否合理? }
雖然 useEffect
的回調函數依賴了 id
和 refresh
方法,可是觀察 refresh
方法能夠發現,它在首次 render 被建立以後,永遠不會發生改變了。所以,把它做爲 useEffect
的 deps 是多餘的。
其次,若是這些依賴真的都是須要的,那麼這些邏輯是否應該放到同一個 hook 中?
function Example({id, name, address, status, personA, personB, progress}) { const [page, setPage] = useState(); const [size, setSize] = useState(); const doSearch = useCallback(() => { // ... }, []); const doRefresh = useCallback(() => { // ... }, []); useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); page && doRefresh({name, page, size}); }, [id, name, address, status, personA, personB, progress, page, size]); }
能夠看出,在 useEffect
中有兩段邏輯,這兩段邏輯是相互獨立的,所以咱們能夠將這兩段邏輯放到不一樣 useEffect
中:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]); useEffect(() => { page && doRefresh({name, page, size}); }, [name, page, size]);
若是邏輯沒法繼續拆分,可是依賴數組仍是依賴了過多東西,該怎麼辦呢?就好比咱們上面的代碼:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]);
這段代碼中的 useEffect
依賴了七個值,仍是偏多了。仔細觀察上面的代碼,能夠發現這些值都是「過濾條件」的一部分,經過這些條件能夠過濾頁面上的數據。所以,咱們能夠將它們看作一個總體,也就是咱們前面講過的合併 state:
const [filters, setFilters] = useState({ name: "", address: "", status: "", personA: "", personB: "", progress: "" }); useEffect(() => { id && doSearch(filters); }, [id, filters]);
若是 state 不能合併,在 callback 內部又使用了 setState
方法,那麼能夠考慮使用 setState
callback 來減小一些依賴。好比:
const useValues = () => { const [values, setValues] = useState({ data: {}, count: 0 }); const [updateData] = useCallback( (nextData) => { setValues({ data: nextData, count: values.count + 1 // 由於 callback 內部依賴了外部的 values 變量,因此必須在依賴數組中指定它 }); }, [values], ); return [values, updateData]; };
上面的代碼中,咱們必須在 useCallback
的依賴數組中指定 values
,不然咱們沒法在 callback 中獲取到最新的 values
狀態。可是,經過 setState
回調函數,咱們不用再依賴外部的 values
變量,所以也無需在依賴數組中指定它。就像下面這樣:
const useValues = () => { const [values, setValues] = useState({}); const [updateData] = useCallback((nextData) => { setValues((prevValues) => ({ data: nextData, count: prevValues.count + 1, // 經過 setState 回調函數獲取最新的 values 狀態,這時 callback 再也不依賴於外部的 values 變量了,所以依賴數組中不須要指定任何值 })); }, []); // 這個 callback 永遠不會從新建立 return [values, updateData]; };
最後,還能夠經過 ref
來保存可變變量。之前咱們只把 ref
用做保持 DOM 節點引用的工具,可 useRef
Hook 能作的事情遠不止如此。咱們能夠用它來保存一些值的引用,並對它進行讀寫。舉個例子:
const useValues = () => { const [values, setValues] = useState({}); const latestValues = useRef(values); latestValues.current = values; const [updateData] = useCallback((nextData) => { setValues({ data: nextData, count: latestValues.current.count + 1, }); }, []); return [values, updateData]; };
在使用 ref
時要特別當心,由於它能夠隨意賦值,因此必定要控制好修改它的方法。特別是一些底層模塊,在封裝的時候千萬不要直接暴露 ref
,而是提供一些修改它的方法。
說了這麼多,歸根到底都是爲了寫出更加清晰、易於維護的代碼。若是發現依賴數組依賴過多,咱們就須要從新審視本身的代碼。
- 依賴數組依賴的值最好不要超過 3 個,不然會致使代碼會難以維護。
若是發現依賴數組依賴的值過多,咱們應該採起一些方法來減小它。
- 去掉沒必要要的依賴。
- 將 Hook 拆分爲更小的單元,每一個 Hook 依賴於各自的依賴數組。
- 經過合併相關的 state,將多個依賴值聚合爲一個。
- 經過
setState
回調函數獲取最新的 state,以減小外部依賴。- 經過
ref
來讀取可變變量的值,不過須要注意控制修改它的途徑。
useMemo
?該不應使用 useMemo
?對於這個問題,有的人歷來沒有思考過,有的人甚至不以爲這是個問題。無論什麼狀況,只要用 useMemo
或者 useCallback
「包裹一下」,彷佛就能使應用遠離性能的問題。但真的是這樣嗎?有的時候 useMemo
沒有任何做用,甚至還會影響應用的性能。
爲何這麼說呢?首先,咱們須要知道 useMemo
自己也有開銷。useMemo
會「記住」一些值,同時在後續 render 時,將依賴數組中的值取出來和上一次記錄的值進行比較,若是不相等纔會從新執行回調函數,不然直接返回「記住」的值。這個過程自己就會消耗必定的內存和計算資源。所以,過分使用 useMemo
可能會影響程序的性能。
要想合理使用 useMemo
,咱們須要搞清楚 useMemo
適用的場景:
讓咱們來看個例子:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = useMemo(() => { return getResolvedValue(page, type); }, [page, type]); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
注:ExpensiveComponent
組件包裹了React.memo
。
在上面的例子中,渲染 ExpensiveComponent
的開銷很大。因此,當 resolvedValue
的引用發生變化時,做者不想從新渲染這個組件。所以,做者使用了 useMemo
,避免每次 render 從新計算 resolvedValue
,致使它的引用發生改變,從而使下游組件 re-render。
這個擔心是正確的,可是使用 useMemo
以前,咱們應該先思考兩個問題:
useMemo
的函數開銷大不大?在上面的例子中,就是考慮 getResolvedValue
函數的開銷大不大。JS 中大多數方法都是優化過的,好比 Array.map
、Array.forEach
等。若是你執行的操做開銷不大,那麼就不須要記住返回值。不然,使用 useMemo
自己的開銷就可能超太重新計算這個值的開銷。所以,對於一些簡單的 JS 運算來講,咱們不須要使用 useMemo
來「記住」它的返回值。page
和 type
相同時,resolvedValue
的引用是否會發生改變?這裏咱們就須要考慮 resolvedValue
的類型了。若是 resolvedValue
是一個對象,因爲咱們項目上使用「函數式編程」,每次函數調用都會產生一個新的引用。可是,若是 resolvedValue
是一個原始值(string
, boolean
, null
, undefined
, number
, symbol
),也就不存在「引用」的概念了,每次計算出來的這個值必定是相等的。也就是說,ExpensiveComponent
組件不會被從新渲染。所以,若是 getResolvedValue
的開銷不大,而且 resolvedValue
返回一個字符串之類的原始值,那咱們徹底能夠去掉 useMemo
,就像下面這樣:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = getResolvedValue(page, type); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
還有一個誤區就是對建立函數開銷的評估。有的人以爲在 render 中建立函數可能會開銷比較大,爲了不函數屢次建立,使用了 useMemo
或者 useCallback
。可是對於現代瀏覽器來講,建立函數的成本微乎其微。所以,咱們沒有必要使用 useMemo
或者 useCallback
去節省這部分性能開銷。固然,若是是爲了保證每次 render 時回調的引用相等,你能夠放心使用 useMemo
或者 useCallback
。
const Example = () => { const onSubmit = useCallback(() => { // 考慮這裏的 useCallback 是否必要? doSomething(); }, []); return <form onSubmit={onSubmit}></form>; };
我以前看過一篇文章(連接在文章的最後),這篇文章中提到,若是隻是想在從新渲染時保持值的引用不變,更好的方法是使用 useRef
,而不是 useMemo
。我並不一樣意這個觀點。讓咱們來看個例子:
// 使用 useMemo function Example() { const users = useMemo(() => [1, 2, 3], []); return <ExpensiveComponent users={users} /> } // 使用 useRef function Example() { const {current: users} = useRef([1, 2, 3]); return <ExpensiveComponent users={users} /> }
在上面的例子中,咱們用 useMemo
來「記住」users
數組,不是由於數組自己的開銷大,而是由於 users
的引用在每次 render 時都會發生改變,從而致使子組件 ExpensiveComponent
從新渲染(可能會帶來較大開銷)。
做者認爲從語義上不該該使用 useMemo
,而是應該使用 useRef
,不然會消耗更多的內存和計算資源。雖然在 React 中 useRef
和 useMemo
的實現有一點差異,可是當 useMemo
的依賴數組爲空數組時,它和 useRef
的開銷能夠說相差無幾。useRef
甚至能夠直接用 useMemo
來實現,就像下面這樣:
const useRef = (v) => { return useMemo(() => ({current: v}), []); };
所以,我認爲使用 useMemo
來保持值的引用一致沒有太大問題。
在編寫自定義 Hook 時,返回值必定要保持引用的一致性。由於你沒法肯定外部要如何使用它的返回值。若是返回值被用作其餘 Hook 的依賴,而且每次 re-render 時引用不一致(當值相等的狀況),就可能會產生 bug。好比:
function Example() { const data = useData(); const [dataChanged, setDataChanged] = useState(false); useEffect(() => { setDataChanged((prevDataChanged) => !prevDataChanged); // 當 data 發生變化時,調用 setState。若是 data 值相同而引用不一樣,就可能會產生非預期的結果。 }, [data]); console.log(dataChanged); return <ExpensiveComponent data={data} />; } const useData = () => { // 獲取異步數據 const resp = getAsyncData([]); // 處理獲取到的異步數據,這裏使用了 Array.map。所以,即便 data 相同,每次調用獲得的引用也是不一樣的。 const mapper = (data) => data.map((item) => ({...item, selected: false})); return resp ? mapper(resp) : resp; };
在上面的例子中,咱們經過 useData
Hook 獲取了 data
。每次 render 時 data
的值沒有發生變化,可是引用卻不一致。若是把 data
用到 useEffect
的依賴數組中,就可能產生非預期的結果。另外,因爲引用的不一樣,也會致使 ExpensiveComponent
組件 re-render,產生性能問題。
若是由於 prop 的值相同而引用不一樣,從而致使子組件發生 re-render,不必定會形成性能問題。由於 Virtual DOM re-render ≠ DOM re-render。可是當子組件特別大時,Virtual DOM 的 Diff 開銷也很大。所以,仍是應該儘可能避免子組件 re-render。
所以,在使用 useMemo
以前,咱們不妨先問本身幾個問題:
回答出上面這幾個問題,判斷是否應該使用 useMemo
也就再也不困難了。不過在實際項目中,仍是最好定義出一套統一的規範,方便團隊中多人協做。好比第一個問題,開銷很大如何定義?若是沒有明確的標準,執行起來會很是困難。所以,我總結了下面一些規則:
1、應該使用
useMemo
的場景
保持引用相等:
- 對於組件內部用到的 object、array、函數等,若是用在了其餘 Hook 的依賴數組中,或者做爲 props 傳遞給了下游組件,應該使用
useMemo
。- 自定義 Hook 中暴露出來的 object、array、函數等,都應該使用
useMemo
。以確保當值相同時,引用不發生變化。- 使用
Context
時,若是Provider
的 value 中定義的值(第一層)發生了變化,即使用了 Pure Component 或者React.memo
,仍然會致使子組件 re-render。這種狀況下,仍然建議使用useMemo
保持引用的一致性。計算成本很高
- 好比
cloneDeep
一個很大而且層級很深的數據2、無需使用 useMemo 的場景
- 若是返回的值是原始值:
string
,boolean
,null
,undefined
,number
,symbol
(不包括動態聲明的 Symbol),通常不須要使用useMemo
。- 僅在組件內部用到的 object、array、函數等(沒有做爲 props 傳遞給子組件),且沒有用到其餘 Hook 的依賴數組中,通常不須要使用
useMemo
。
在 Hooks 出現以前,咱們有兩種方法能夠複用組件邏輯:Render Props 和高階組件。可是這兩種方法均可能會形成 JSX「嵌套地獄」的問題。Hooks 的出現,讓組件邏輯的複用變得更簡單,同時解決了「嵌套地獄」的問題。Hooks 之於 React 就像 async / await 之於 Promise 同樣。
那 Hooks 能替代高階組件和 Render Props 嗎?官方給出的回答是,在高階組件或者 Render Props 只渲染一個子組件時,Hook 提供了一種更簡單的方式。不過在我看來,Hooks 並不能徹底替代 Render Props 和高階組件。接下來,咱們會詳細分析這個問題。
高階組件是一個函數,它接受一個組件做爲參數,返回一個新的組件。
function enhance(Comp) { // 增長一些其餘的功能 return class extends Component { // ... render() { return <Comp />; } }; }
高階組件採用了裝飾器模式,讓咱們能夠加強原有組件的功能,而且不破壞它原有的特性。例如:
const RedButton = withStyles({ root: { background: "red", }, })(Button);
在上面的代碼中,咱們但願保留 Button
組件的邏輯,但同時咱們又想使用它原有的樣式。所以,咱們經過 withStyles
這個高階組件注入了自定義的樣式,而且生成了一個新的組件 RedButton
。
Render Props 經過父組件將可複用邏輯封裝起來,並把數據提供給子組件。至於子組件拿到數據以後要怎麼渲染,徹底由子組件本身決定,靈活性很是高。而高階組件中,渲染結果是由父組件決定的。Render Props 不會產生新的組件,並且更加直觀的體現了「父子關係」。
<Parent> {(data) => { // 你父親已經把江山給你打好了,並給你留下了一堆金幣,至於怎麼花就看你本身了 return <Child data={data} />; }} </Parent>
Render Props 做爲 JSX 的一部分,能夠很方便地利用 React 生命週期和 Props、State 來進行渲染,在渲染上有着很是高的自由度。同時,它不像 Hooks 須要遵照一些規則,你能夠放心大膽的在它裏面使用 if / else、map 等各種操做。
在大部分狀況下,高階組件和 Render Props 是能夠相互轉換的,也就是說用高階組件能實現的,用 Render Props 也能實現。只不過在不一樣的場景下,哪一種方式使用起來簡單一點罷了。
將上面 HOC 的例子改爲 Render Props,使用起來確實要「麻煩」一點:
<RedButton> {(styles)=>( <Button styles={styles}/> )} </RedButton>
沒有 Hooks 以前,高階組件和 Render Props 本質上都是將複用邏輯提高到父組件中。而 Hooks 出現以後,咱們將複用邏輯提取到組件頂層,而不是強行提高到父組件中。這樣就可以避免 HOC 和 Render Props 帶來的「嵌套地獄」。可是,像 Context 的 <Provider/>
和 <Consumer/>
這樣有父子層級關係(樹狀結構關係)的,仍是隻能使用 Render Props 或者 HOC。
對於 Hooks、Render Props 和高階組件來講,它們都有各自的使用場景:
Hooks:
getSnapshotBeforeUpdate
和 componentDidCatch
還不支持。不過,能使用 Hooks 的場景仍是應該優先使用 Hooks,其次纔是 Render Props 和 HOC。固然,Hooks、Render Props 和 HOC 不是對立的關係。咱們既能夠用 Hook 來寫 Render Props 和 HOC,也能夠在 HOC 中使用 Render Props 和 Hooks。
1.若 Hook 類型相同,且依賴數組一致時,應該合併成一個 Hook。不然會產生更多開銷。
const dataA = useMemo(() => { return getDataA(); }, [A, B]); const dataB = useMemo(() => { return getDataB(); }, [A, B]); // 應該合併爲 const [dataA, dataB] = useMemo(() => { return [getDataA(), getDataB()] }, [A, B]);
2.參考原生 Hooks 的設計,自定義 Hooks 的返回值可使用 Tuple 類型,更易於在外部重命名。但若是返回值的數量超過三個,仍是建議返回一個對象。
export const useToggle = (defaultVisible: boolean = false) => { const [visible, setVisible] = useState(defaultVisible); const show = () => setVisible(true); const hide = () => setVisible(false); return [visible, show, hide] as [typeof visible, typeof show, typeof hide]; }; const [isOpen, open, close] = useToggle(); // 在外部能夠更方便地修更名字 const [visible, show, hide] = useToggle();
3.ref
不要直接暴露給外部使用,而是提供一個修改值的方法。
4.在使用 useMemo
或者 useCallback
時,確保返回的函數只建立一次。也就是說,函數不會根據依賴數組的變化而二次建立。舉個例子:
export const useCount = () => { const [count, setCount] = useState(0); const [increase, decrease] = useMemo(() => { const increase = () => { setCount(count + 1); }; const decrease = () => { setCount(count - 1); }; return [increase, decrease]; }, [count]); return [count, increase, decrease]; };
在 useCount
Hook 中, count
狀態的改變會讓 useMemo
中的 increase
和 decrease
函數被從新建立。因爲閉包特性,若是這兩個函數被其餘 Hook 用到了,咱們應該將這兩個函數也添加到相應 Hook 的依賴數組中,不然就會產生 bug。好比:
function Counter() { const [count, increase] = useCount(); useEffect(() => { const handleClick = () => { increase(); // 執行後 count 的值永遠都是 1 }; document.body.addEventListener("click", handleClick); return () => { document.body.removeEventListener("click", handleClick); }; }, []); return <h1>{count}</h1>; }
在 useCount
中,increase
會隨着 count
的變化而被從新建立。可是 increase
被從新建立以後, useEffect
並不會再次執行,因此 useEffect
中取到的 increase
永遠都是首次建立時的 increase
。而首次建立時 count
的值爲 0,所以不管點擊多少次, count
的值永遠都是 1。
那把 increase
函數放到 useEffect
的依賴數組中不就行了嗎?事實上,這會帶來更多問題:
increase
的變化會致使頻繁地綁定事件監聽,以及解除事件監聽。useEffect
,可是 increase
的變化會致使 useEffect
屢次執行,不能知足需求。如何解決這些問題呢?
1、經過 setState
回調,讓函數不依賴外部變量。例如:
export const useCount = () => { const [count, setCount] = useState(0); const [increase, decrease] = useMemo(() => { const increase = () => { setCount((latestCount) => latestCount + 1); }; const decrease = () => { setCount((latestCount) => latestCount - 1); }; return [increase, decrease]; }, []); // 保持依賴數組爲空,這樣 increase 和 decrease 方法都只會被建立一次 return [count, increase, decrease]; };
2、經過 ref
來保存可變變量。例如:
export const useCount = () => { const [count, setCount] = useState(0); const countRef = useRef(count); countRef.current = count; const [increase, decrease] = useMemo(() => { const increase = () => { setCount(countRef.current + 1); }; const decrease = () => { setCount(countRef.current - 1); }; return [increase, decrease]; }, []); // 保持依賴數組爲空,這樣 increase 和 decrease 方法都只會被建立一次 return [count, increase, decrease]; };
咱們總結了在實踐中一些常見的問題,並提出了一些解決方案。最後讓咱們再來回顧一下:
若是發現依賴數組依賴的值過多,咱們應該採起一些方法來減小它。
setState
回調函數獲取最新的 state,以減小外部依賴。ref
來讀取可變變量的值,不過須要注意控制修改它的途徑。爲了確保不濫用 useMemo
,咱們定義了下面幾條規則:
string
, boolean
, null
, undefined
, number
, symbol
(不包括動態聲明的 Symbol),則不須要使用 useMemo
。useMemo
。useMemo
。以確保當值相同時,引用不發生變化。ref
不要直接暴露給外部使用,而是提供一個修改值的方法。useMemo
或者 useCallback
時,能夠藉助 ref
或者 setState
callback,確保返回的函數只建立一次。也就是說,函數不會根據依賴數組的變化而二次建立。參考文章: