從 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 應該拆分呢?我總結了下面兩點:瀏覽器
size
和 position
。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
,而是提供一些修改它的方法。
說了這麼多,歸根到底都是爲了寫出更加清晰、易於維護的代碼。若是發現依賴數組依賴過多,咱們就須要從新審視本身的代碼:
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
的場景
useMemo
。useMemo
。以確保當值相同時,引用不發生變化。Context
時,若是 Provider
的 value 中定義的值(第一層)發生了變化,即使用了 Pure Component 或者 React.memo
,仍然會致使子組件 re-render。這種狀況下,仍然建議使用 useMemo
保持引用的一致性。cloneDeep
一個很大而且層級很深的數據2、無需使用 useMemo 的場景
string
, boolean
, null
, undefined
, number
, symbol
(不包括動態聲明的 Symbol),通常不須要使用 useMemo
。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 和高階組件來講,它們都有各自的使用場景:
getSnapshotBeforeUpdate
和 componentDidCatch
還不支持。不過,能使用 Hooks 的場景仍是應該優先使用 Hooks,其次纔是 Render Props 和 HOC。固然,Hooks、Render Props 和 HOC 不是對立的關係。咱們既能夠用 Hook 來寫 Render Props 和 HOC,也能夠在 HOC 中使用 Render Props 和 Hooks。
一、若 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]);
複製代碼
二、參考原生 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();
複製代碼
三、ref
不要直接暴露給外部使用,而是提供一個修改值的方法。 四、在使用 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
屢次執行,不能知足需求。如何解決這些問題呢?
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];
};
複製代碼
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
的場景:
useMemo
的場景:
string
, boolean
, null
, undefined
, number
, symbol
(不包括動態聲明的 Symbol),通常不須要使用 useMemo
。useMemo
。ref
不要直接暴露給外部使用,而是提供一個修改值的方法。useMemo
或者 useCallback
時,能夠藉助 ref
或者 setState
callback,確保返回的函數只建立一次。也就是說,函數不會根據依賴數組的變化而二次建立。參考文章: