寫在前面
React Hooks 是 React 團隊在兩年前的 16.8 版本推出的一套全新的機制。作為最主流的前端框架,React 的 API 十分穩定,這次更新的釋出,讓眾多恐懼新輪子的前端大佬們虎軀一震,畢竟每一次更新又是高成本的學習,這玩意到底好使麼?
答案是好用的,對於 React 的開發者而言,只是多了一個選擇。過去的開發方式是基於Class元件的,而hooks是基於函式元件,這意味著 這兩種開發方式可以並存 ,而新的程式碼可以根據具體情況採用 Hooks 的方式來實現就行了。這篇文章主要就來介紹一下 Hooks 的優勢 和 常用的幾個鉤子函式 。
Hooks的優勢
1.類元件的不足
程式碼量多 :
相較於函式元件的寫法,使用類元件程式碼量要略多一點,這個是最直觀的感受。
this指向:
類元件中總是需要考慮this的指向問題,而函式元件則可以忽略。
趨向複雜難以維護 :
在高版本的React中,又更新了一些生命週期函式,因為這些函式互相解耦,很容易造成分散不集中的寫法,漏掉關鍵邏輯和多了冗餘邏輯,導致後期debug困難。相反,hooks可以把關鍵邏輯都放在一起,不顯得那麼割裂,除錯起來也易懂一點。
狀態邏輯難複用 :
在元件之間複用狀態邏輯很難,可能要用到 render props (渲染屬性)或者 HOC (高階元件),但無論是渲染屬性,還是高階元件,都會在原先的元件外包裹一層父容器(一般都是 div 元素),導致層級冗餘。
2. Hooks帶來的好處
邏輯複用
在元件之前複用狀態邏輯,往往需要藉助高階元件等複雜的設計模式,這些高階元件會產生冗餘的元件節點,讓除錯變得困難,下面用一個demo來對比一下兩種實現方式。
Class
在class元件場景下,定義了一個高階元件,負責監聽視窗大小變化,並將變化後的值作為 props 傳給下一個元件。
const useWindowSize = Component => {
// 產生一個高階元件 HOC,只包含監聽視窗大小的邏輯
class HOC extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
size: this.getSize()
};
}
componentDidMount() {
window.addEventListener("resize", this.handleResize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.handleResize);
}
getSize() {
return window.innerWidth > 1000 ? "large" :"small";
}
handleResize = ()=> {
const currentSize = this.getSize();
this.setState({
size: this.getSize()
});
}
render() {
// 將視窗大小傳遞給真正的業務邏輯元件
return <Component size={this.state.size} />;
}
}
return HOC;
};
接下來可以在自定義元件中可以呼叫 useWindowSize 這樣的函式來產生一個新元件,並自帶 size 屬性,例如:
class MyComponent extends React.Component{
render() {
const { size } = this.props;
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
}
}
// 使用 useWindowSize 產生高階元件,用於產生 size 屬性傳遞給真正的業務元件
export default useWindowSize(MyComponent);
下面看下Hooks的實現方式
Hooks
const getSize = () => {
return window.innerWidth > 1000 ? "large" : "small";
}
const useWindowSize = () => {
const [size, setSize] = useState(getSize());
useEffect(() => {
const handler = () => {
setSize(getSize())
};
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}, []);
return size;
};
使用:
const Demo = () => {
const size = useWindowSize();
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
};
從上面的例子中通過 Hooks 的方式對視窗大小進行了封裝,從而將其變成一個可繫結的資料來源。這樣當視窗大小發生變化時,使用這個 Hook 的元件就都會重新渲染。而且程式碼也更加簡潔和直觀,不會產生額外的元件節點,也不顯得那麼冗餘了。
- 業務程式碼更加聚合
下面舉一個最常見的計時器的例子。
class
let timer = null
componentDidMount() {
timer = setInterval(() => {
// ...
}, 1000)
}
// ...
componentWillUnmount() {
if (timer) clearInterval(timer)
}
Hooks
useEffect(() => {
let timer = setInterval(() => {
// ...
}, 1000)
return () => {
if (timer) clearInterval(timer)
}
}, [//...])
Hooks的實現方式能讓程式碼更加集中,邏輯也更清晰。
- 寫法簡潔
這個就不舉例了,可以從字面意思理解,使用函式元件確實能少些很多程式碼,懂得都懂,嘻嘻~
幾個內建Hooks的作用以及使用思考
useState :讓函式元件具有維持狀態的能力
const[count, setCount]=useState(0);
優點:
讓函式元件具有維持狀態的能力,即:在一個函式元件的多次渲染之間,這個 state 是共享的。便於維護狀態。
缺點:
一旦元件有自己狀態,意味著元件如果重新建立,就需要有恢復狀態的過程,這通常會讓元件變得更復雜。
用法:
- useState(initialState) 的引數 initialState 是建立 state 的初始值。
它可以是任意型別,比如數字、物件、陣列等等。
- useState() 的返回值是一個有著兩個元素的陣列。第一個陣列元素用來讀取 state 的值,第二個則是用來設定這個 state 的值。
在這裡要注意的是,state 的變數(例子中的 count)是隻讀的,所以我們必須通過第二個陣列元素 setCount 來設定它的值。
- 如果要建立多個 state ,那麼我們就需要多次呼叫 useState。
什麼樣的值應該儲存在 state 中?
通常來說,我們要遵循的一個原則就是:state 中不要儲存可以通過計算得到的值 。
- 從 props 傳遞過來的值。有時候 props 傳遞過來的值無法直接使用,而是要通過一定的計算後再在 UI 上展示,比如說排序。那麼我們要做的就是每次用的時候,都重新排序一下,或者利用某些 cache 機制,而不是將結果直接放到 state 裡。
- 從 URL 中讀到的值。比如有時需要讀取 URL 中的引數,把它作為元件的一部分狀態。那麼我們可以在每次需要用的時候從 URL 中讀取,而不是讀出來直接放到 state 裡。
- 從 cookie、localStorage 中讀取的值。通常來說,也是每次要用的時候直接去讀取,而不是讀出來後放到 state 裡。
useEffect:執行副作用
useEffect(fn, deps);
useEffect ,顧名思義,用於執行一段副作用。
什麼是副作用?
通常來說,副作用是指一段和當前執行結果無關的程式碼。比如說要修改函式外部的某個變數,要發起一個請求,等等。
也就是說,在函式元件的當次執行過程中, useEffect 中程式碼的執行是不影響渲染出來的 UI 的。
對應到 Class 元件,那麼 useEffect 就涵蓋了 ComponentDidMount、componentDidUpdate 和 componentWillUnmount 三個生命週期方法。不過如果你習慣了使用 Class 元件,那千萬不要按照把 useEffect 對應到某個或者某幾個生命週期的方法。你只要記住,useEffect 是每次元件 render 完後判斷依賴並執行就可以了。
useEffect 還有兩個特殊的用法:沒有依賴項,以及依賴項作為空陣列。我們來具體分析下。
- 沒有依賴項,則每次 render 後都會重新執行。例如:
useEffect(() => {
// 每次 render 完一定執行
console.log('渲染...........');
});
- 空陣列作為依賴項,則只在首次執行時觸發,對應到 Class 元件就是 componentDidMount。例如:
useEffect(() => {
// 元件首次渲染時執行,等價於 class 元件中的 componentDidMount
console.log('did mount........');
}, []);
小結用法:
總結一下,useEffect 讓我們能夠在下面四種時機去執行一個回撥函式產生副作用:
- 每次 render 後執行:不提供第二個依賴項引數。
比如useEffect(() => {})。
- 僅第一次 render 後執行:提供一個空陣列作為依賴項。
比如useEffect(() => {}, [])。
- 第一次以及依賴項發生變化後執行:提供依賴項陣列。
比如useEffect(() => {}, [deps])。
- 元件 unmount 後執行:返回一個回撥函式。
比如useEffect() => { return () => {} }, [])。
useCallback:快取回撥函式
useCallback(fn, deps)
為什麼要使用useCallback?
在 React 函式元件中, 每一次 UI 的變化,都是通過重新執行整個函式來完成的 ,這和傳統的 Class 元件有很大區別:函式元件中並沒有一個直接的方式在多次渲染之間維持一個狀態。
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = () => setCount(count+1);
return <button onClick={handleIncrement}>+</button>
}
思考下這個過程。 每次元件狀態發生變化的時候,函式元件實際上都會重新執行一遍 。在每次執行的時候,實際上都會建立一個新的事件處理函式 handleIncrement 。
這也意味著,即使 count 沒有發生變化,但是函式元件因為其它狀態發生變化而重新渲染時(函式元件重新被執行),這種寫法也會每次建立一個新的函式。建立一個新的事件處理函式,雖然不影響結果的正確性,但其實是沒必要的。因為這樣做不僅增加了系統的開銷,更重要的是: 每次建立新函式的方式會讓接收事件處理函式的元件,需要重新渲染 。
比如這個例子中的 button 元件,接收了 handleIncrement ,並作為一個屬性。如果每次都是一個新的,那麼這個 React 就會認為這個元件的 props 發生了變化,從而必須重新渲染。因此,我們需要做到的是: 只有當 count 發生變化時,我們才需要重新定一個回撥函式 。而這正是 useCallback 這個 Hook 的作用。
import React, { useState, useCallback } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = useCallback(
() => setCount(count + 1),
[count], // 只有當 count 發生變化時,才會重新建立回撥函式
);
return <button onClick={handleIncrement}>+</button>
}
useMemo:快取計算的結果
useMemo(fn, deps);
useCallback(fn, deps) 相當於 useMemo(() => fn, deps)。
這裡的 fn 是產生所需資料的一個 計算函式 。通常來說, fn 會使用 deps 中宣告的一些變數來生成一個結果,用來渲染出最終的 UI 。
這個場景應該很容易理解:如果某個 資料 是通過其它資料計算得到的,那麼只有當用到的資料,也就是依賴的資料發生變化的時候,才應該需要重新計算。
避免重複計算
通過 useMemo 這個 Hook,可以避免在用到的資料沒發生變化時進行的重複計算。雖然例子展示的是一個很簡單的場景,但如果是一個複雜的計算,那麼對於 提升效能 會有很大的幫助。
舉個例子:
const calc = (a, b) => {
// 假設這裡做了複雜的計算,暫時用次冪模擬
return a ** b;
}
const MyComponent = (props) => {
const {a, b} = props;
const c = calc(a, b);
return <div>c: {c}</div>;
}
如果 calc 計算耗時 1000ms,那麼每次渲染都要等待這麼久,怎麼優化呢?
a, b 值不變的情況下,得出的 c 定是相同的。
所以我們可以用 useMemo 把值給快取起來,避免重複計算相同的結果。
const calc = (a, b) => {
// 假設這裡做了複雜的計算,暫時用次冪模擬
return a ** b;
}
const MyComponent = (props) => {
const {a, b} = props;
// 快取
const c = React.useMemo(() => calc(a, b), [a, b]);
return <div>c: {c}</div>;
}
useCallback 的功能其實是可以用 useMemo 來實現的:
const myEventHandler = useMemo(() => {
// 返回一個函式作為快取結果
return () => {
// 在這裡進行事件處理
}
}, [dep1, dep2]);
小結一下:
感覺到這有這種感覺,其實 hook 就是建立了一個繫結某個結果到依賴資料的關係。只有當依賴變了,這個結果才需要被重新得到。
useRef:在多次渲染之間共享資料
const myRefContainer =useRef(initialValue);
我們可以把 useRef 看作是在函式元件之外建立的一個容器空間。在這個容器上,我們可以通過唯一的 current 屬設定一個值,從而在函式元件的多次渲染之間共享這個值。
useRef 的重要的功能
1. 儲存跨渲染的資料
使用 useRef 儲存的資料一般是和 UI 的渲染無關的,因此當 ref 的值發生變化時,是不會觸發元件的重新渲染的,這也是 useRef 區別於 useState 的地方。
舉例:
const [time, setTime] = useState(0);
// 定義 timer 這樣一個容器用於在跨元件渲染之間儲存一個變數
const timer = useRef(null);
const handleStart = useCallback(() => {
// 使用 current 屬性設定 ref 的值
timer.current = window.setInterval(() => { setTime((time) => time + 1); }, 100);
}, []);
2. 儲存某個 DOM 節點的引用
是在某些場景中,我們必須要獲得真實 DOM 節點的引用,所以結合 React 的 ref 屬性和 useRef 這個 Hook,我們就可以獲得真實的 DOM 節點,並對這個節點進行操作。
React 官方例子:
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// current 屬性指向了真實的 input 這個 DOM 節點,從而可以呼叫 focus 方法
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
理解:
可以看到ref 這個屬性提供了獲得 DOM 節點的能力,並利用 useRef 儲存了這個節點的應用。這樣的話,一旦 input 節點被渲染到介面上,那我們通過 inputEl.current 就能訪問到真實的 DOM 節點的例項了
useContext:定義全域性狀態
為什麼要使用 useContext?
React 元件之間的狀態傳遞只有一種方式,那就是通過 props。缺點: 這種傳遞關係只能在父子元件之間進行。
那麼問題出現:跨層次,或者同層的元件之間要如何進行資料的共享?這就涉及到一個新的命題: 全域性狀態管理 。
react提供的解決方案: Context 機制。
具體原理:
React 提供了 Context 這樣一個機制, 能夠讓所有在某個元件開始的元件樹上建立一個 Context 。這樣這個元件樹上的所有元件,就都能訪問和修改這個 Context 了。
那麼在函式元件裡,我們就可以使用 useContext 這樣一個 Hook 來管理 Context。
使用:(這兒用了官方例子)
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
// 建立一個 Theme 的 Context
const ThemeContext = React.createContext(themes.light);
function App() {
// 整個應用使用 ThemeContext.Provider 作為根元件
return (
// 使用 themes.dark 作為當前 Context
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}
// 在 Toolbar 元件中使用一個會使用 Theme 的 Button
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}
// 在 Theme Button 中使用 useContext 來獲取當前的主題
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button style={{
background: theme.background,
color: theme.foreground
}}>
I am styled by theme context!
</button>
);
}
優點:
Context 提供了一個方便在多個元件之間共享資料的機制。
缺點:
Context 相當於提供了一個定義 React 世界中全域性變數的機制,而全域性變數則意味著兩點:
1. 會讓除錯變得困難,因為你很難跟蹤某個 Context 的變化究竟是如何產生的。
2. 讓元件的複用變得困難,因為一個元件如果使用了某個 Context ,它就必須確保被用到的地方一定有這個 Context 的 Provider 在其父元件的路徑上。
實際應用場景
由於以上缺點,所以在 React 的開發中,除了像 Theme、Language 等一目瞭然的需要全域性設定的變數外),我們很少會使用 Context 來做太多資料的共享。需要再三強調的是,Context 更多的是提供了一個強大的機制,讓 React 應用具備定義全域性的響應式資料的能力。
此外,很多狀態管理框架,比如 Redux,正是利用了 Context 的機制來提供一種更加可控的元件之間的狀態管理機制。因此,理解 Context 的機制,也可以讓我們更好地去理解 Redux 這樣的框架實現的原理。
最後
感覺這次的內容不多不少。其實瞭解學會了useState 和 useEffect 這兩個 核心 Hooks,基本能完成絕大多數 React 功能的開發了。
useCallback、useMemo、useRef 和 useContext。這幾個 Hook 都是為了解決函式元件中遇到的特定問題。
還有幾個比較邊緣的hook這裡就不再寫了,有興趣的大佬可以移步到官方文件上看看。
碼字不易,也辛苦大佬們指導交流~
團隊
TNTWeb - 騰訊新聞前端團隊,TNTWeb 致力於行業前沿技術探索和團隊成員個人能力提升。為前端開發人員整理出了小程式以及 web 前端技術領域的最新優質內容,每週更新 ✨,歡迎 star,github 地址:https://github.com/tnfe/TNT-Weekly