React最佳實踐
本文來分享 React 中的 16 種常見反模式和最佳實踐。
1、在組件外部聲明CSS
如果使用 CSS in JS 的解決方案,盡量避免在組件內聲明 CSS。
import makeCss from 'some/css/in/js/library'
const Component = () => {
// 不要這樣寫
return <div className={makeCss({ background: red, width: 100% })} />
}
因為在每次渲染時都會重新創建對象,可以將其從組件中提出來:
import cssLibrary from 'some/css/in/js/library'
const someCssClass = makeCss({
background: red,
width: 100%
})
const Component = () => {
return <div className={someCssClass} />
}
2、使用 useCallback 防止函數重新創建
每當重新渲染 React 組件時,都會重新創建組件中的所有函數。React 提供了一個 useCallback Hook,可以用來避免這種情況。只要其依賴項不改變,useCallback 就會在渲染之間保留函數的舊實例。
import { useCallback } from 'react'
const Component = () => {
const [value, setValue] = useState(false)
// 該函數將在每次渲染時重新創建
const handleClick = () => {
setValue(true)
}
return <button onClick={handleClick}>Click</button>
}
import { useCallback } from 'react'
const Component = () => {
const [value, setValue] = useState(false)
// 僅當變量值更新時才會重新創建此函數
const handleClick = useCallback(() => {
setValue(true)
}, [value])
return <button onClick={handleClick}>Click</button>
}
對于示例中的小函數,不能保證將函數包裝在useCallback中確實更好。所以還需要根據實際情況來判斷是否需要使用 useCallback 包裝。
在底層,React將在每次渲染時檢查依賴關系,以確定是否需要創建新函數,而且有時依賴關系經常發生變化。因此,useCallback提供的優化并不總是必需的。然而,如果函數的依賴項不經常更新,那么使用useCallback是一種很好的優化方法,以避免在每次渲染時重新創建函數。
3、使用 useCallback 防止依賴項更改
useCallback 不僅可以用于避免函數實例化,但它也可用于更重要的事情。由于 useCallback 在渲染之間為包裝函數保留相同的內存引用,因此它可用于優化其他 useCallback 和記憶的使用。
import { memo, useCallback, useMemo } from 'react'
const MemoizedChildComponent = memo({ onTriggerFn }) => {
// ...
})
const Component = ({ someProp }) => {
// 僅當 someProp 發生變化時,對 onTrigger 函數的引用才會發生變化
const onTrigger = useCallback(() => {
// ...
}, [someProp])
// 這個記憶值只會在 onTrigger 函數更新時更新
// 如果 onTrigger 不是 useCallback 中的包裝器,則將在每次渲染時重新計算該值
const memoizedValue = useMemo(() => {
// ...
}, [onTrigger])
// Memoize子組件只會在onTrigger函數更新時重新渲染
// 如果 onTrigger 未包裝在 useCallback 中,MemoizedChildComponent 將在每次渲染此組件時重新渲染
return (<>
<MemoizedChildComponent onTriggerFn={onTrigger} />
<button onClick={onTrigger}>Click me</button>
</>)
}
4、使用 useCallback 防止 useEffect 觸發
前面的示例展示了如何借助 useCallback 來優化渲染,同樣,也可以避免不必要的 useEffect 觸發。
import { useCallback, useEffect } from 'react'
const Component = ({ someProp }) => {
// 僅當 someProp 發生變化時,對 onTrigger 函數的引用才會發生變化
const onTrigger = useCallback(() => {
// ...
}, [someProp])
// useEffect 僅在 onTrigger 函數更新時運行
// 如果 onTrigger 未包裝在 useCallback 中,則 useEffect 將在每次此函數渲染時運行
useEffect(() => {
// ...
}, [onTrigger])
return <button onClick={onTrigger}>Click me</button>
}
5、當不需要依賴項時,向 useEffect 添加空依賴項
如果 effect 不依賴于任何變量,可以將空依賴項數組作為 useEffect 的第二個參數。否則,effect 將在每次渲染時運行。
import { useEffect } from 'react'
const Component = () => {
useEffect(() => {
// ...
}, [])
return <div>Example</div>
}
這個邏輯也適用于其他 React hook,例如 useCallback 和 useMemo。不過,如果沒有任何依賴項,可能根本不需要使用這些 Hooks。
6、始終將所有依賴項添加到 useEffect 和其他 React Hooks
在處理內置 React Hooks 的依賴項項(例如 useEffects 和 useCallback)時,請將所有依賴項添加到依賴項列表(Hooks 的第二個參數)。當省略依賴項時,effect 或回調可能會使用它的舊值,這通常會導致難以預測的錯誤。
import { useEffect } from 'react'
const Component = () => {
const [value, setValue] = useState()
useEffect(() => {
// 使用 value 變量的代碼
// 將變量添加到依賴項數組中,應在此處添加 value 變量
}, [])
return <div>{value}</div>
}
那當 useEffect 被觸發的次數比希望的次數多時,如何避免副作用?不幸的是,沒有完美的解決方案。不同的場景需要不同的解決方案。可以嘗試使用 hook 僅運行一次代碼,這有時很有用,但實際上并不是一個值得推薦的解決方案。
大多數情況下,可以使用 if-case 來解決問題。可以查看當前狀態并從邏輯上決定是否確實需要運行代碼。例如,如果不將 value 值添加為上述 effect 的依賴項的原因是僅在值未定義時運行代碼,則只需在 effect 內添加 if 語句即可。
import { useEffect } from 'react'
const Component = () => {
const [value, setValue] = useState()
useEffect(() => {
if (!value) {
// ...
}
}, [value])
return <div>{value}</div>
其他場景可能更復雜,也許使用 if 語句來防止 effect 多次發生不太可行。在這種情況下,首先應該確定,真的需要 effect 嗎?在很多情況下,開發人員在實際上不應該這樣做時卻使用了 effect。
7、不要將外部函數包裝在 useCallback 中
不需要 useCallback 來調用外部函數。只需按原樣調用外部函數即可。這使得 React 不必檢查 useCallback 是否需要重新創建,并且使代碼更簡潔。
import { useCallback } from 'react'
import externalFunction from '/services/externalFunction'
const Component = () => {
// ?
const handleClick = useCallback(() => {
externalFunction()
}, [])
return <button onClick={handleClick}>Click me</button>
}
import externalFunction from '/services/externalFunction'
const Component = () => {
// ?
return <button onClick={externalFunction}>Click me</button>
}
使用 useCallback 的一個用例是回調調用多個函數或讀取或更新內部狀態(例如 useState hook 中的值或組件傳入的 props 之一)時。
import { useCallback } from 'react'
import { externalFunction, anotherExternalFunction } from '/services'
const Component = ({ passedInProp }) => {
const [value, setValue] = useState()
const handleClick = useCallback(() => {
// 調用了多個函數
externalFunction()
anotherExternalFunction()
// 讀取和或設置內部值或屬性。
setValue(passedInProp)
}, [passedInProp, value])
return <button onClick={handleClick}>Click me</button>
}
8、不要將 useMemo 與空依賴數組一起使用
如果添加了帶有空依賴項數組的 useMemo,問問自己為什么要這樣做。因為它依賴于組件的狀態變量而不想添加它?在這種情況下,應該列出所有依賴變量!因為 useMemo 沒有任何依賴項?那就不需要使用 useMemo 了。
import { useMemo } from 'react'
const Component = () => {
// ?
const memoizedValue = useMemo(() => {
return 3 + 5
}, [])
return <div>{memoizedValue}</div>
}
// ?
const memoizedValue = 3 + 5
const Component = () => {
return <div>{memoizedValue}</div>
}
9、不要在其他組件中聲明組件
const Component = () => {
// ?
const ChildComponent = () => {
return <div>child</div>
}
return <div><ChildComponent /></div>
}
這樣寫的話,組件內聲明的變量將在每次組件呈現時重新聲明。在這種情況下,這意味著每次父級重新渲染時都必須重新創建功能子組件。就必須在每次渲染時實例化一個函數。React 將無法決定何時進行任何類型的組件優化。如果在 ChildComponent 中使用 hooks,它們將在每次渲染時重新啟動。
那該怎么辦呢?只需在父組件之外聲明子組件即可。
const ChildComponent = () => {
return <div>child</div>
}
const Component = () => {
return <div><ChildComponent /></div>
}
或者,更好的方式是:
import ChildComponent from 'components/ChildComponent'
const Component = () => {
return <div><ChildComponent /></div>
}
10、不要在 If 語句中使用 Hook
在React內部,Hook的調用順序是必須固定的,以確保正確地管理組件狀態和生命周期。如果在if語句內部使用Hook,會導致兩個問題:
違反Hook的調用規則:根據React的規定,Hook應該在每次渲染中按照相同的順序被調用。當條件發生變化時,Hook調用的順序可能會發生變化,這會破壞React對Hook調用順序的依賴,導致無法預料的行為和錯誤。
Hook的依賴關系無效:Hook的工作原理是基于依賴項列表,它可以檢測依賴項的變化,并在需要時重新運行。如果將Hook放在if語句中,它的依賴關系可能無法正確地捕捉到變化,從而導致狀態更新或副作用的錯誤。
import { useState } from 'react'
const Component = ({ propValue }) => {
if (!propValue) {
const [value, setValue] = useState(propValue)
}
return <div>{value}</div>
}
11、使用 useState 而不是變量
在React中,存儲狀態應該始終使用 React hooks(如useState或useReducer),不要直接將狀態聲明為組件中的變量。這樣做會導致在每次渲染時重新聲明變量,這意味著React無法像通常一樣對其進行記憶化處理。
import AnotherComponent from 'components/AnotherComponent'
const Component = () => {
const value = { someKey: 'someValue' }
return <AnotherComponent value={value} />
}
在上述情況下,依賴于value的AnotherComponent及其相關內容將在每次渲染時重新渲染,即使它們使用memo、useMemo或useCallback進行了記憶化處理。
如果將一個帶有value作為依賴的useEffect添加到組件中,它將在每次渲染時觸發。因為每次渲染時 value 的JavaScript引用都會不同。
通過使用 React 的useState,React會保留value的相同引用,直到使用setValue進行更新。然后,React 將能夠檢測何時觸發和何時不觸發 effect ,并重新計算記憶化處理。
import { useState } from 'react'
import AnotherComponent from 'components/AnotherComponent'
const Component = () => {
const [value, setValue] = useState({ someKey: 'someValue' })
return <AnotherComponent value={value} />
}
如果只需要一個狀態,在初始化后就不再更新,那么可以將變量聲明在組件外部。這樣 JavaScript 引用將不會改變。
// 如果不需要更新該值,就可以這樣聲明變量
const value = { someKey: 'someValue' }
const Component = () => {
return <AnotherComponent value={value} />
}
12、return 后不使用 Hook
根據定義,if語句是有條件執行的,“return”關鍵字也會導致條件 Hook 渲染。
import { useState } from 'react'
const Component = ({ propValue }) => {
if (!propValue) {
return null
}
// 這個 hook 是有條件的,因為只有當 propValue 存在時才會調用它
const [value, setValue] = useState(propValue)
return <div>{value}</div>
}
條件語句中的 return 語句會使后續的 Hook 成為有條件的。為了避免這種情況,將所有的 Hook 放在組件的第一個條件渲染之前。也就是說,始終將 Hook 放在組件的頂部。
import { useState } from 'react'
const Component = ({ propValue }) => {
// 在條件渲染之前放 hooks
const [value, setValue] = useState(propValue)
if (!propValue) {
return null
}
return <div>{value}</div>
}
13、讓子組件決定是否應該渲染
在許多情況下應該讓子組件決定是否應該渲染:
import { useState } from 'react'
const ChildComponent = ({ shouldRender }) => {
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <>
{ !!shouldRender && <ChildComponent shouldRender={shouldRender} /> }
</>
}
以上是有條件地渲染子組件的常見方法。代碼很好,除了在有很多子組件時有點冗長之外。但根據 ChildComponent 的作用,可能存在更好的解決方案。下面來稍微重寫一下代碼。
import { useState } from 'react'
const ChildComponent = ({ shouldRender }) => {
if (!shouldRender) {
return null
}
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <ChildComponent shouldRender={shouldRender} />
}
這里重寫了兩個組件,將條件渲染移至子組件中。那條件渲染移至子組件有什么好處?
最大的好處是 React 可以繼續渲染 ChildComponent,即使它不可見。這意味著,ChildComponent 可以在隱藏時保持其狀態,然后第二次渲染而不會丟失其狀態。它一直都在那里,只是不可見。
如果組件像第一個代碼那樣停止渲染,則 useState 中保存的狀態將被重置,并且一旦組件再次渲染,useEffect、useCallback 和 useMemo 都需要重新運行并重新計算新值。
如果代碼會觸發一些網絡請求或進行一些復雜的計算,那么當組件再次渲染時,這些請求也會運行。同樣,如果將一些表單數據存儲在組件的內部狀態中,則每次組件隱藏時都會重置。
14、使用 useReducer 而不是多個 useState
可以使用一個 useReducer 來代替使用多個 useState,這樣寫起來可能比較麻煩,但是這樣既可以避免不必要的渲染,又可以讓邏輯更容易理解。一旦有了 useReducer,向組件添加新邏輯和狀態就會容易得多。
import { useState } from 'react'
const Component = () => {
// ?
const [text, setText] = useState(false)
const [error, setError] = useState('')
const [touched, setTouched] = useState(false)
const handleChange = (event) => {
const value = event.target.value
setText(value)
if (value.length < 6) {
setError('Too short')
} else {
setError('')
}
}
return <>
{!touched && <div>Write something...</div> }
<input type="text" value={text} onChange={handleChange} />
<div>Error: {error}</div>
</>
}
import { useReducers } from 'react'
const UPDATE_TEXT_ACTION = 'UPDATE_TEXT_ACTION'
const RESET_FORM = 'RESET_FORM'
const getInitialFormState = () => ({
text: '',
error: '',
touched: false
})
const formReducer = (state, action) => {
const { data, type } = action || {}
switch (type) {
case UPDATE_TEXT_ACTION:
const text = data?.text ?? ''
return {
...state,
text: text,
error: text.length < 6,
touched: true
}
case RESET_FORM:
return getInitialFormState()
default:
return state
}
}
const Component = () => {
// ?
const [state, dispatch] = useReducer(formReducer, getInitialFormState());
const { text, error, touched } = state
const handleChange = (event) => {
const value = event.target.value
dispatch({ type: UPDATE_TEXT_ACTION, text: value})
}
return <>
{!touched && <div>Write something...</div> }
<input type="text" value={text} onChange={handleChange} />
<div>Error: {error}</div>
</>
}
15、將初始狀態寫為函數而不是對象
來看看下面的 getInitialFormState 函數:
// 初始狀態是一個函數
const getInitialFormState = () => ({
text: '',
error: '',
touched: false
})
const formReducer = (state, action) => {
// ...
}
const Component = () => {
const [state, dispatch] = useReducer(formReducer, getInitialFormState());
// ...
}
這里將將初始狀態寫成了一個函數,但其實直接使用一個對象也是可以的。
// 初始狀態是一個對象
const initialFormState = {
text: '',
error: '',
touched: false
}
const formReducer = (state, action) => {
// ...
}
const Component = () => {
const [state, dispatch] = useReducer(formReducer, getInitialFormState());
// ...
}
那為什么不直接寫成對象呢?答案很簡單,避免可變性。在上面的例子中,當initialFormState是一個對象時,我們可能會一不小心就在代碼中的某個地方改變了該對象。如果這樣,當再次使用該變量,例如在重置表單時,將無法恢復初始狀態。相反,會得到變異的對象。
因此,將初始狀態轉換為返回初始狀態對象的 getter 函數是一個很好的做法。或者更好的是,使用像 Immer 這樣的庫,它用于避免編寫可變代碼。
16、當組件不應重新渲染時,使用 useRef 而不是 useState
可以通過用 useRef 替換 useState 來優化組件渲染。來看下面的例子:
import { useEffect } from 'react'
const Component = () => {
const [triggered, setTriggered] = useState(false)
useEffect(() => {
if (!triggered) {
setTriggered(true)
// ...
}
}, [triggered])
}
當運行上面的代碼時,組件將在調用 setTriggered 時重新渲染。在這種情況下,觸發狀態變量可能是確保 effect 僅運行一次的一種方法。
由于在這種情況下觸發變量的唯一用途是跟蹤函數是否已被觸發,因此不需要組件渲染任何新狀態。因此,可以將 useState 替換為 useRef,這樣更新時就不會觸發組件重新渲染。
import { useRef } from 'react'
const Component = () => {
const triggeredRef = useRef(false)
useEffect(() => {
if (!triggeredRef.current) {
triggeredRef.current = true
// ...
}
}, [])
}
那為什么需要使用 useRef,而不簡單地使用組件外部的變量呢?
const triggered = false
const Component = () => {
useEffect(() => {
if (!triggered) {
triggered = true
// ...
}
}, [])
}
這里需要 useRef 的原因是因為上面的代碼不能以同樣的方式工作!上面的變量只會為 false 一次。如果組件卸載了,當組件再次掛載時,triggered變量仍然會被設置為true,因為triggered變量并沒有綁定到React的生命周期中。當使用 useRef 時,React 將在組件卸載并再次安裝時重置其值。在這種情況下,就可以要使用 useRef。