React Hooks 完全使用指南
Hook 是什麼
Hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。
Hook 是 React 團隊在 React 16.8 版本中提出的新特性,在遵循函數式組件的前提下,爲已知的 React 概念提供了更直接的 API:props,state,context,refs 以及聲明週期,目的在於解決常年以來在 class 組件中存在的各種問題,實現更高效的編寫 react 組件
class 組件的不足
-
難以複用組件間狀態邏輯:組件狀態邏輯的複用,需要 props render 和高階組件等解決方案,但是此類解決方案的抽象封裝將會導致層級冗餘,形成 “嵌套地獄”
-
難以維護複雜組件:
-
許多不相干的邏輯代碼被混雜在同一個生命週期中,相關聯的邏輯代碼被拆分到不同聲明週期當中,容易遺忘導致產生 bug
-
組件常常充斥着狀態邏輯的訪問和處理,不能拆分爲更小的粒度,可通過狀態管理庫集中管理狀態,但耦合了狀態管理庫又會導致組件複用性降低
-
this 指向問題:在 JavaScript 中,class 的方法默認不會綁定 this,當調用 class 的方法時 this 的值爲 undefined,爲了在方法中訪問 this 則必須在構造器中綁定或使用 class fields 語法(實驗性語法)
class Example extends React.Component { constructor(props) { ... // 方式1: 在構造函數中綁定 this this.handleClick = this.handleClick.bind(this); } handleClick() { this.setState({...}) } // 方式2: 使用 class fields 語法 handleClick = () => { this.setState({...}) } }
-
難以對 class 進行編譯優化:由於 JavaScript 歷史設計原因,使用 class 組件會讓組件預編譯過程中變得難以進行優化,如 class 不能很好的壓縮,並且會使熱重載出現不穩定的情況
Hook 的優勢
-
Hook 使你在無需改變組件結構的情況下複用狀態邏輯(自定義 Hook)
-
Hook 將組件中互相關聯的部分拆分成更小的函數(比如設置訂閱或請求數據)
-
Hook 使你在非 class 的情況下可以使用更多的 React 特性
Hook 使用規則
Hook 就是 Javascript 函數,使用它們時有兩個額外的規則:
-
只能在函數外層調用 Hook,不要在循環、條件判斷或者子函數中調用
-
只能在 React 的函數組件和自定義 Hook 中調用 Hook。不要在其他 JavaScript 函數中調用
在組件中 React 是通過判斷 Hook 調用的順序來判斷某個 state 對應的 useState
的,所以必須保證 Hook 的調用順序在多次渲染之間保持一致,React 才能正確地將內部 state 和對應的 Hook 進行關聯
useState
useState
用於在函數組件中調用給組件添加一些內部狀態 state,正常情況下純函數不能存在狀態副作用,通過調用該 Hook 函數可以給函數組件注入狀態 state
useState
唯一的參數就是初始 state,會返回當前狀態和一個狀態更新函數,並且 useState
返回的狀態更新函數不會把新的 state 和舊的 state 進行合併,如需合併可使用 ES6 的對象結構語法進行手動合併
const [state, setState] = useState(initialState);
方法使用
import React, { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<input type="text" value={count} onChange={(e) => setCount(e.target.value)} />
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
等價 class 示例
useState
返回的狀態類似於 class 組件在構造函數中定義 this.state
,返回的狀態更新函數類似於 class 組件的 this.setState
import React from 'react';
export default class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
<div>
<button onClick={() => this.setState({ count: this.state.count - 1 })}>-</button>
<input type="text" value={this.state.count} onChange={(e) => this.setState({ count: e.target.value })} />
<button onClick={() => this.setState({ count: this.state.count + 1 })}>+</button>
</div>
);
}
}
函數式更新
如果新的 state 需要通過使用先前的 state 計算得出,可以往 setState
傳遞函數,該函數將接收先前的 state,並返回一個更新後的值
import React, { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0);
const lazyAdd = () => {
setTimeout(() => {
// 每次執行都會最新的state,而不是使用事件觸發時的state
setCount(count => count + 1);
}, 3000);
}
return (
<div>
<p>the count now is {count}</p>
<button onClick={() => setCount(count + 1)}>add</button>
<button onClick={lazyAdd}>lazyAdd</button>
</div>
);
}
惰性初始 state
如果初始 state 需要通過複雜計算獲得,則可以傳入一個函數,在函數中計算並返回初始的 state,此函數只會在初始渲染時被調用
import React, { useState } from 'react'
export default function Counter(props) {
// 函數只在初始渲染時執行一次,組件重新渲染時該函數不會重新執行
const initCounter = () => {
console.log('initCounter');
return { number: props.number };
};
const [counter, setCounter] = useState(initCounter);
return (
<div>
<button onClick={() => setCounter({ number: counter.number - 1 })}>-</button>
<input type="text" value={counter.number} onChange={(e) => setCounter({ number: e.target.value})} />
<button onClick={() => setCounter({ number: counter.number + 1 })}>+</button>
</div>
);
}
跳過 state 更新
調用 State Hook 的更新函數時,React 將使用 Object.is
來比較前後兩次 state,如果返回結果爲 true,React 將跳過子組件的渲染及 effect 的執行
import React, { useState } from 'react';
export default function Counter() {
console.log('render Counter');
const [counter, setCounter] = useState({
name: '計時器',
number: 0
});
// 修改狀態時傳的狀態值沒有變化,則不重新渲染
return (
<div>
<p>{counter.name}: {counter.number}</p>
<button onClick={() => setCounter({ ...counter, number: counter.number + 1})}>+</button>
<button onClick={() => setCounter(counter)}>++</button>
</div>
);
}
useEffect
在函數組件主體內(React 渲染階段)改變 DOM、添加訂閱、設置定時器、記錄日誌以及執行其他包含副作用的操作都是不被允許的,因爲這可能會產生莫名其妙的 bug 並破壞 UI 的一致性
useEffect
Hook 的使用則是用於完成此類副作用操作。useEffect
接收一個包含命令式、且可能有副作用代碼的函數
useEffect
函數會在瀏覽器完成佈局和繪製之後,下一次重新渲染之前執行,保證不會阻塞瀏覽器對屏幕的更新
useEffect(didUpdate);
方法使用
import React, { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
// useEffect 內的回調函數會在初次渲染後和更新完成後執行
// 相當於 componentDidMount 和 componentDidUpdate
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>count now is {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
等價 class 示例
useEffect
Hook 函數執行時機類似於 class 組件的 componentDidMount
、componentDidUpdate
生命週期,不同的是傳給 useEffect
的函數會在瀏覽器完成佈局和繪製之後進行異步執行
import React from 'react';
export default class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
}
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() {
document.title = `You clicked ${this.state.count} times`;
}
render() {
return (
<div>
<p>count now is {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>+</button>
</div>
);
}
}
清除 effect
通常情況下,組件卸載時需要清除 effect 創建的副作用操作,useEffect
Hook 函數可以返回一個清除函數,清除函數會在組件卸載前執行。組件在多次渲染中都會在執行下一個 effect 之前,執行該函數進行清除上一個 effect
清除函數的執行時機類似於 class 組件componentDidUnmount
生命週期,這的話使用 useEffect
函數可以將組件中互相關聯的部分拆分成更小的函數,防止遺忘導致不必要的內存泄漏
import React, { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('start an interval timer')
const timer = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
// 返回一個清除函數,在組件卸載前和下一個effect執行前執行
return () => {
console.log('destroy effect');
clearInterval(timer);
};
}, []);
return (
<div>
<p>count now is {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
優化 effect 執行
默認情況下,effect 會在每一次組件渲染完成後執行。useEffect
可以接收第二個參數,它是 effect 所依賴的值數組,這樣就只有當數組值發生變化纔會重新創建訂閱。但需要注意的是:
-
確保數組中包含了所有外部作用域中會發生變化且在 effect 中使用的變量
-
傳遞一個空數組作爲第二個參數可以使 effect 只會在初始渲染完成後執行一次
import React, { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 僅在 count 更改時更新
return (
<div>
<p>count now is {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
useContext
Context 提供了一個無需爲每層組件手動添加 props ,就能在組件樹間進行數據傳遞的方法,useContext
用於函數組件中訂閱上層 context 的變更,可以獲取上層 context 傳遞的 value
prop 值
useContext
接收一個 context 對象(React.createContext
的返回值)並返回 context 的當前值,當前的 context 值由上層組件中距離當前組件最近的 <MyContext.Provider>
的 value
prop 決定
const value = useContext(MyContext);
方法使用
import React, { useContext, useState } from 'react';
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
// 爲當前 theme 創建一個 context
const ThemeContext = React.createContext();
export default function Toolbar(props) {
const [theme, setTheme] = useState(themes.dark);
const toggleTheme = () => {
setTheme(currentTheme => (
currentTheme === themes.dark
? themes.light
: themes.dark
));
};
return (
// 使用 Provider 將當前 props.value 傳遞給內部組件
<ThemeContext.Provider value={{theme, toggleTheme}}>
<ThemeButton />
</ThemeContext.Provider>
);
}
function ThemeButton() {
// 通過 useContext 獲取當前 context 值
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button style={{background: theme.background, color: theme.foreground }} onClick={toggleTheme}>
Change the button's theme
</button>
);
}
等價 class 示例
useContext(MyContext)
相當於 class 組件中的 static contextType = MyContext
或者 <MyContext.Consumer>
useContext
並沒有改變消費 context 的方式,它只爲我們提供了一種額外的、更漂亮的、更漂亮的方法來消費上層 context。在將其應用於使用多 context 的組件時將會非常有用
import React from 'react';
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
const ThemeContext = React.createContext(themes.light);
function ThemeButton() {
return (
<ThemeContext.Consumer>
{
({theme, toggleTheme}) => (
<button style={{background: theme.background, color: theme.foreground }} onClick={toggleTheme}>
Change the button's theme
</button>
)
}
</ThemeContext.Consumer>
);
}
export default class Toolbar extends React.Component {
constructor(props) {
super(props);
this.state = {
theme: themes.light
};
this.toggleTheme = this.toggleTheme.bind(this);
}
toggleTheme() {
this.setState(state => ({
theme:
state.theme === themes.dark
? themes.light
: themes.dark
}));
}
render() {
return (
<ThemeContext.Provider value={{ theme: this.state.theme, toggleTheme: this.toggleTheme }}>
<ThemeButton />
</ThemeContext.Provider>
)
}
}
優化消費 context 組件
調用了 useContext
的組件都會在 context 值變化時重新渲染,爲了減少重新渲染組件的較大開銷,可以通過使用 memoization 來優化
假設由於某種原因,您有 AppContext
,其值具有 theme
屬性,並且您只想在 appContextValue.theme
更改上重新渲染一些 ExpensiveTree
- 方式 1: 拆分不會一起更改的 context
function Button() {
// 把 theme context 拆分出來,其他 context 變化時不會導致 ExpensiveTree 重新渲染
let theme = useContext(ThemeContext);
return <ExpensiveTree className={theme} />;
}
- 當不能拆分 context 時,將組件一分爲二,給中間組件加上
React.memo
function Button() {
let appContextValue = useContext(AppContext);
let theme = appContextValue.theme; // 獲取 theme 屬性
return <ThemedButton theme={theme} />
}
const ThemedButton = memo(({ theme }) => {
// 使用 memo 儘量複用上一次渲染結果
return <ExpensiveTree className={theme} />;
});
- 返回一個內置
useMemo
的組件
function Button() {
let appContextValue = useContext(AppContext);
let theme = appContextValue.theme; // 獲取 theme 屬性
return useMemo(() => {
// The rest of your rendering logic
return <ExpensiveTree className={theme} />;
}, [theme])
}
useReducer
useReducer
作爲 useState
的代替方案,在某些場景下使用更加適合,例如 state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於之前的 state 等。
使用 useReducer
還能給那些會觸發深更新的組件做性能優化,因爲父組件可以向自組件傳遞 dispatch 而不是回調函數
const [state, dispatch] = useReducer(reducer, initialArg, init);
方法使用
import React, { useReducer } from 'react'
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
初始化 state
useReducer 初始化 sate 的方式有兩種
// 方式1
const [state, dispatch] = useReducer(
reducer,
{count: initialCount}
);
// 方式2
function init(initialClunt) {
return {count: initialClunt};
}
const [state, dispatch] = useReducer(reducer, initialCount, init);
useRef
useRef
用於返回一個可變的 ref 對象,其 .current
屬性被初始化爲傳入的參數(initialValue
)
useRef
創建的 ref 對象就是一個普通的 JavaScript 對象,而 useRef()
和自建一個 {current: ...}
對象的唯一區別是,useRef
會在每次渲染時返回同一個 ref 對象
const refContainer = useRef(initialValue);
綁定 DOM 元素
使用 useRef
創建的 ref 對象可以作爲訪問 DOM 的方式,將 ref 對象以 <div ref={myRef} />
形式傳入組件,React 會在組件創建完成後會將 ref 對象的 .current
屬性設置爲相應的 DOM 節點
import React, { useRef } from 'react'
export default function FocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
綁定可變值
useRef
創建的 ref 對象同時可以用於綁定任何可變值,通過手動給該對象的.current
屬性設置對應的值即可
import React, { useState, useRef, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const currentCount = useRef();
// 使用 useEffect 獲取當前 count
useEffect(() => {
currentCount.current = count;
}, [count]);
const alertCount = () => {
setTimeout(() => {
alert(`Current count is: ${currentCount.current}, Real count is: ${count}`);
}, 3000);
}
return (
<>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>Count add</button>
<button onClick={alertCount}>Alert current Count</button>
</>
);
}
性能優化(useCallback & useMemo)
useCallback
和 useMemo
結合 React.Memo
方法的使用是常見的性能優化方式,可以避免由於父組件狀態變更導致不必要的子組件進行重新渲染
useCallback
useCallback
用於創建返回一個回調函數,該回調函數只會在某個依賴項發生改變時纔會更新,可以把回調函數傳遞給經過優化的並使用引用相等性去避免非必要渲染的子組件,在 props 屬性相同情況下,React 將跳過渲染組件的操作並直接複用最近一次渲染的結果
import React, { useState, useCallback } from 'react';
function SubmitButton(props) {
const { onButtonClick, children } = props;
console.log(`${children} updated`);
return (
<button onClick={onButtonClick}>{children}</button>
);
}
// 使用 React.memo 檢查 props 變更,複用最近一次渲染結果
SubmitButton = React.memo(submitButton);
export default function CallbackForm() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleAdd1 = () => {
setCount1(count1 + 1);
}
// 調用 useCallback 返回一個 memoized 回調,該回調在依賴項更新時纔會更新
const handleAdd2 = useCallback(() => {
setCount2(count2 + 1);
}, [count2]);
return (
<>
<div>
<p>count1: {count1}</p>
<SubmitButton onButtonClick={handleAdd1}>button1</SubmitButton>
</div>
<div>
<p>count2: {count2}</p>
<SubmitButton onButtonClick={handleAdd2}>button2</SubmitButton>
</div>
</>
)
}
useCallback(fn, deps)
相當於 useMemo(() => fn, deps)
,以上 useCallback
可替換成 useMemo
結果如下:
const handleAdd2 = useMemo(() => {
return () => setCount2(count2 + 1);
}, [count2]);
useMemo
把 “創建” 函數和依賴項數組作爲參數傳入 useMemo
,它僅會在某個依賴項改變時才重新計算 memoized 值。這種優化有助於避免在每次渲染時都進行高開銷的計算
使用注意:
-
傳入
useMemo
的函數會在渲染期間執行,不要在這個函數內部執行與渲染無關的操作 -
如果沒有提供依賴項數組,
useMemo
在每次渲染時都會計算新的值
import React, { useState, useMemo } from 'react';
function counterText({ countInfo }) {
console.log(`${countInfo.name} updated`);
return (
<p>{countInfo.name}: {countInfo.number}</p>
);
}
// // 使用 React.memo 檢查 props 變更,複用最近一次渲染結果
const CounterText = React.memo(counterText);
export default function Counter() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const countInfo1 = {
name: 'count1',
number: count1
};
// 使用 useMemo 緩存最近一次計算結果,會在依賴項改變時才重新計算
const countInfo2 = useMemo(() => ({
name: 'count2',
number: count2
}), [count2]);
return (
<>
<div>
<CounterText countInfo={countInfo1} />
<button onClick={() => setCount1(count1 + 1)}>Add count1</button>
</div>
<div>
<CounterText countInfo={countInfo2} />
<button onClick={() => setCount2(count2 + 1)}>Add count2</button>
</div>
</>
);
}
其他 Hook
useImperativeHandle
useImperativeHandle
可以讓你在使用 ref
時自定義暴露給父組件的實例值。在大多數情況下,應當避免使用 ref 這樣的命令式代碼。useImperativeHandle
應當與 React.forwardRef
一起使用:
import React, { useRef, useImperativeHandle, useState } from 'react'
function FancyInput(props, ref) {
const inputRef = useRef();
// 自定義暴露給父組件的 ref 實例值
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} type="text" {...props} />;
}
// 通過 forwardRef 向父組件傳遞暴露的 ref
const ForwardFancyInput = React.forwardRef(FancyInput);
export default function Counter() {
const [text, setText] = useState('');
const inputRef = useRef();
const onInputFocus = () => {
inputRef.current.focus();
};
return (
<>
<ForwardFancyInput ref={inputRef} value={text} onChange={e => setText(e.target.value)} />
<button onClick={onInputFocus}>Input focus</button>
</>
);
}
useLayoutEffect
useLayoutEffect
與 useEffect
類似,與 useEffect
在瀏覽器 layout 和 painting 完成後異步執行 effect 不同的是,它會在瀏覽器佈局 layout 之後,painting 之前同步執行 effect
useLayoutEffect
的執行時機對比如下:
import React, { useState, useEffect, useLayoutEffect } from 'react';
export default function LayoutEffect() {
const [width, setWidth] = useState('100px');
// useEffect 會在所有 DOM 渲染完成後執行 effect 回調
useEffect(() => {
console.log('effect width: ', width);
});
// useLayoutEffect 會在所有的 DOM 變更之後同步執行 effect 回調
useLayoutEffect(() => {
console.log('layoutEffect width: ', width);
});
return (
<>
<div id='content' style={{ width, background: 'red' }}>內容</div>
<button onClick={() => setWidth('100px')}>100px</button>
<button onClick={() => setWidth('200px')}>200px</button>
<button onClick={() => setWidth('300px')}>300px</button>
</>
);
}
// 使用 setTimeout 保證在組件第一次渲染完成後執行,獲取到對應的 DOM
setTimeout(() => {
const contentEl = document.getElementById('content');
// 監視目標 DOM 結構變更,會在 useLayoutEffect 回調執行後,useEffect 回調執行前調用
const observer = new MutationObserver(() => {
console.log('content element layout updated');
});
observer.observe(contentEl, {
attributes: true
});
}, 1000);
自定義 Hook
通過自定義 Hook,可以將組件邏輯提取到可重用的函數中,在 Hook 特性之前,React 中有兩種流行的方式來共享組件之間的狀態邏輯:render props 和高階組件,但此類解決方案會導致組件樹的層級冗餘等問題。而自定義 Hook 的使用可以很好的解決此類問題
創建自定義 Hook
自定義 Hook 是一個函數,其名稱以 “use
” 開頭,函數內部可以調用其他的 Hook。以下就是實時獲取鼠標位置的自定義 Hook 實現:
import { useEffect, useState } from "react"
export const useMouse = () => {
const [position, setPosition] = useState({
x: null,
y: null
});
useEffect(() => {
const moveHandler = (e) => {
setPosition({
x: e.screenX,
y: e.screenY
});
};
document.addEventListener('mousemove', moveHandler);
return () => {
document.removeEventListener('mousemove', moveHandler);
};
}, []);
return position;
}
使用自定義 Hook
自定義 Hook 的使用規則與 Hook 使用規則基本一致,以下是 useMouse
自定義 Hook 的使用過程:
import React from 'react';
import { useMouse } from '../hooks/useMouse';
export default function MouseMove() {
const { x, y } = useMouse();
return (
<>
<p>Move mouse to see changes</p>
<p>x position: {x}</p>
<p>y position: {y}</p>
</>
);
}
每次使用自定義 Hook 時,React 都會執行該函數來獲取獨立的 state 和執行獨立的副作用函數,所有 state 和副作用都是完全隔離的
參考文獻
詳解 React useCallback & useMemo
Preventing rerenders with React.memo and useContext hook
useLayoutEffect 和 useEffect 的區別
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/qmX6u-Q_TdQcmFsd_cVPXA