【React】1009- 換個角度思考 React Hooks
從 Vue 遷移到 React ,不太習(xí)慣 React Hooks 的使用?也許換個角度思考 Hooks 出現(xiàn)的意義會對你有所幫助。
1 什么是 Hooks
簡而言之, Hooks 是個函數(shù),通過使用 Hooks 可以讓函數(shù)組件功能更加豐富。
在某些場景下,使用 Hooks 是一個比使用類組件更好的主意。
1.1 Hooks 出現(xiàn)的背景
在 Hooks 出現(xiàn)之前,函數(shù)組件對比類組件(class)形式有很多局限,例如:
不能使用 state、ref 等屬性,只能通過函數(shù)傳參的方式使用 props 沒有生命周期鉤子
同時在類組件的使用中,也存在著不少難以解決的問題:
在復(fù)雜組件中,耦合的邏輯代碼很難分離
組件化講究的是分離邏輯與 UI,但是對于平常所寫的業(yè)務(wù)代碼,較難做到分離和組合。尤其是在生命周期鉤子中,多個不相關(guān)的業(yè)務(wù)代碼被迫放在一個生命周期鉤子中,需要把相互關(guān)聯(lián)的部分拆封更小的函數(shù)。
監(jiān)聽清理和資源釋放問題
當組件要銷毀時,很多情況下都需要清除注冊的監(jiān)聽事件、釋放申請的資源。
事件監(jiān)聽、資源申請需要在 Mount 鉤子中申請,當組件銷毀時還必須在 Unmount 勾子中進行清理,這樣寫使得同一資源的生成和銷毀邏輯不在一起,因為生命周期被迫劃分成兩個部分。
組件間邏輯復(fù)用困難
在 React 中實現(xiàn)邏輯復(fù)用是比較困難的。雖然有例如 render props、高階組件等方案,但仍然需要重新組織組件結(jié)構(gòu),不算真正意義上的復(fù)用。抽象復(fù)用一個復(fù)雜組件更是不小的挑戰(zhàn),大量抽象層代碼帶來的嵌套地獄會給開發(fā)者帶來巨大的維護成本。
class 學(xué)習(xí)成本
與 Vue 的易于上手不同,開發(fā) React 的類組件需要比較扎實的 JavaScript 基礎(chǔ),尤其是關(guān)于 this 、閉包、綁定事件處理器等相關(guān)概念的理解。
Hooks 的出現(xiàn),使得上述問題得到了不同程度的解決。
我認為了解 Hooks 出現(xiàn)的背景十分重要。只有知道了為什么要使用 Hooks,知道其所能解決而 class 不能解決的問題時,才能真正理解 Hooks 的思想,真正享受 Hooks 帶來的便利,真正優(yōu)雅地使用 Hooks。
2 Hooks 基礎(chǔ)
讓我們從最簡單的 Hooks 使用開始。
2.1 useState
這里貼上 React 文檔中的示例:
import React, { useState } from 'react';
function Example() {
// 聲明一個 "count" 的 state 變量
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
useState 就是一個 Hooks,以前的函數(shù)組件是無狀態(tài)的,但是有了 Hooks 后我們可以在函數(shù)中通過 useState 來獲取 state 屬性(count)以及修改 state 屬性的方法(setCount)。
整個 Hooks 運作過程:
函數(shù)組件 Example第一次執(zhí)行函數(shù)時useState進行初始化,其傳入的參數(shù)0就是count的初始值;返回的 VDOM 中使用到了 count屬性,其值為0;通過點擊按鈕,觸發(fā) setCount函數(shù),傳入修改count的值,然后重新執(zhí)行函數(shù)(就像類組件中重新執(zhí)行 render 函數(shù)一樣);第二次及以后執(zhí)行函數(shù)時,依舊通過 useState來獲取count及修改count的方法setCount,只不過不會執(zhí)行count的初始化,而是使用其上一次setCount傳入的值。
從使用最簡單的 Hooks 我們可以知道。
存儲 “狀態(tài)” 不再使用一個
state屬性。以往都是把所有狀態(tài)全部放到
state屬性中,而現(xiàn)在有了 Hooks 我們可以按照需求通過調(diào)用多個useState來創(chuàng)建多個state,這更有助于分離和修改變量。const [count, setCount] = useState(0);
const [visible, setVisible] = useState(false);
const [dataList, setDataList] = useState([]);setCount傳入的參數(shù)是直接覆蓋,而setState執(zhí)行的是對象的合并處理。
總之 useState 使用簡單,它為函數(shù)組件帶來了使用 state 的能力。
2.2 useEffect
在 Hooks 出現(xiàn)之前函數(shù)組件是不能訪問生命周期鉤子的,所以提供了 useEffect Hooks 來解決鉤子問題,以往的所有生命周期鉤子都被合并成了 useEffect,并且其解決了之前所提的關(guān)于生命周期鉤子的問題。
2.2.1 實現(xiàn)生命周期鉤子組合
先舉一個關(guān)于 class 生命周期鉤子問題的例子,這里貼上 React 文檔的示例:
// Count 計數(shù)組件
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
componentDidMount() {
document.title = `你點擊了 ${this.state.count} 次`;
}
componentDidUpdate() {
document.title = `你點擊了 ${this.state.count} 次`;
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
可以看到當我們在第一次組件掛載(初始化)后以及之后每次更新都需要該操作,一個是初始化一個是更新后,這種情況在平時經(jīng)常會遇到,有時候遇到初始化問題,就避免不了會寫兩次,哪怕是抽離成單獨的函數(shù),也必須要在兩個地方調(diào)用,當這種寫法多了起來后將會變得冗余且容易出 bug 。
useEffect 是怎么解決的?一個簡單示例:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// 效果如同 componentDidMount 和 componentDidUpdate:
useEffect(() => {
// 更新 title
document.title = `你點擊了 ${count} 次`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
它把兩個生命周期鉤子合并在了一起。
整個 Hooks 過程:
Example組件第一次執(zhí)行時,返回 VDOM,渲染;渲染后從上至下按順序執(zhí)行 useEffect;Example組件更新后,返回 VDOM,渲染;渲染后從上至下按順序執(zhí)行 useEffect。
可以看到無論是初始化渲染還是更新渲染,useEffect 總是會確保在組件渲染完畢后再執(zhí)行,這就相當于組合了初始化和更新渲染時的生命周期鉤子。并且由于閉包的特性,useEffect 可以訪問到函數(shù)組件中的各種屬性和方法。
useEffect 里面可以進行 “副作用” 操作,例如:
更變 DOM(調(diào)用 setCount) 發(fā)送網(wǎng)絡(luò)請求 掛載監(jiān)聽
不應(yīng)該把 “副作用” 操作放到函數(shù)組件主體中,就像不應(yīng)該把 “副作用” 操作放到 render 函數(shù)中一樣,否則很可能會導(dǎo)致函數(shù)執(zhí)行死循環(huán)或資源浪費等問題。
2.2.2 實現(xiàn)銷毀鉤子
這就完了嗎?沒有,對于組件來說,有些其內(nèi)部是有訂閱外部數(shù)據(jù)源的,這些訂閱的 “副作用” 如果在組件卸載時沒有進行清除,將會容易導(dǎo)致內(nèi)存泄漏。React 類組件中還有個非常重要的生命周期鉤子 componentWillUnmount,其在組件將要銷毀時執(zhí)行。
下面演示類組件是如何清除訂閱的:
// 一個訂閱好友的在線狀態(tài)的組件
class FriendStatus extends React.Component {
constructor(props) {
super(props);
this.state = { isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
// 初始化:訂閱好友在線狀態(tài)
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange,
);
}
// 更新:好友訂閱更改
componentDidUpdate(prevProps) {
// 如果 id 相同則忽略
if (prevProps.friend.id === this.props.friend.id) {
return;
}
// 否則清除訂閱并添加新的訂閱
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange,
);
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange,
);
}
// 銷毀:清除好友訂閱
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange,
);
}
// 訂閱方法
handleStatusChange(status) {
this.setState({
isOnline: status.isOnline,
});
}
render() {
if (this.state.isOnline === null) {
return 'Loading...';
}
return this.state.isOnline ? 'Online' : 'Offline';
}
}
可以看到,一個好友狀態(tài)訂閱使用了三個生命周期鉤子。
那么使用 useEffect 該如何實現(xiàn)?
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// 清除好友訂閱
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
useEffect 把好友訂閱相關(guān)的邏輯代碼組合到了一起,而不像類組件那樣把同一類型的邏輯代碼按照生命周期來劃分。
其中 return 的函數(shù)是在 useEffect 再次執(zhí)行前或是組件要銷毀時執(zhí)行,由于閉包,useEffect 中的返回函數(shù)可以很容易地獲取對象并清除訂閱。
整個 Hooks 過程:
初始化函數(shù)組件 FriendStatus,掛載 VDOM;按順序執(zhí)行 useEffect中傳入的函數(shù);更新:函數(shù) FriendStatus重新執(zhí)行,重新掛載 VDOM;執(zhí)行上一次 useEffect傳入函數(shù)的返回值:清除好友訂閱的函數(shù);執(zhí)行本次 useEffect中傳入的函數(shù)。
2.2.3 實現(xiàn)不同邏輯分離
剛才講的都是在一個場景下使用 Hooks 。
現(xiàn)在將計數(shù)組件和好友在線狀態(tài)組件結(jié)合并作對比。
class FriendStatusWithCounter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0, isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
document.title = `你點擊了 ${count} 次`;
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentDidUpdate() {
document.title = `你點擊了 ${count} 次`;
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentDidUpdate(prevProps) {
// 如果 id 相同則忽略
if (prevProps.friend.id === this.props.friend.id) {
return;
}
// 否則清除訂閱并添加新的訂閱
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange,
);
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange,
);
}
handleStatusChange(status) {
this.setState({
isOnline: status.isOnline
});
}
可以很明顯地感受到,在多個生命周期鉤子中,計數(shù)和好友訂閱等邏輯代碼都混合在了同一個函數(shù)中。
接下來看看 useEffect 是怎么做的:
function FriendStatusWithCounter(props) {
// 計數(shù)相關(guān)代碼
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `你點擊了 ${count} 次`;
});
// 好友訂閱相關(guān)代碼
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// ...
}
useEffect 可以像使用多個 useState 那樣,把組件的邏輯代碼進行分離和組合,更有利于組件的開發(fā)和維護。
2.2.4 跳過 useEffect
有些時候并沒有必要每次在函數(shù)組件重新執(zhí)行時執(zhí)行 useEffect,這個時候就需要用到 useEffect 的第二個參數(shù)了。
第二個參數(shù)傳入一個數(shù)組,數(shù)組元素是要監(jiān)聽的變量,當函數(shù)再次執(zhí)行時,數(shù)組中只要有一個元素與上次函數(shù)執(zhí)行時傳入的數(shù)組元素不同,那么則執(zhí)行 useEffect 傳入的函數(shù),否則不執(zhí)行。
給個示例會更好理解:
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// 清除好友訂閱
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
// 加入 props.friend.id 作為依賴,當 id 改變時才會執(zhí)行該次 useEffect
}, [props.friend.id]);
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
給 useEffect 加入 id 的依賴,只有當 id 改變時,才會再次清除、添加訂閱,而不必每次函數(shù)重新執(zhí)行時都會清除并添加訂閱。
需要注意的是,對于傳入的對象類型,React 只是比較引用是否改變,而不會判斷對象的屬性是否改變,所以建議依賴數(shù)組中傳入的變量都采用基本類型。
3 真正的 Hooks
剛才只是 Hooks 的簡單使用,但是會使用并不能代表著真正理解到了 Hooks 的思想。
從類組件到函數(shù)組件不僅僅是使用 Hooks 的區(qū)別,更重要的是開發(fā)時根本上思維模式的變化。
讓我們換個角度思考。
3.1 useEffect —— 遠不止生命周期
很多人認為 useEffect 只是生命周期鉤子的更好替代品,這是不完全正確的。
試想一下這樣的場景:一個圖表組件 Chart 需要接收大量的數(shù)據(jù)然后對其進行大量計算處理(getDataWithinRange())并做展示。
類組件:
// 大量計算處理
function getDataWithinRange() {
//...
}
class Chart extends Component {
state = {
data: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
componentDidUpdate(prevProps) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
當使用生命周期鉤子時,我們需要手動去判斷哪些數(shù)據(jù)(dataRange)發(fā)生了變化,然后更新到對應(yīng)的數(shù)據(jù)(data)。
而在 Hooks 的使用中,我們只需關(guān)注哪些值(dataRange)需要進行同步。
使用 useEffect 的函數(shù)組件:
const Chart = ({ dateRange }) => {
const [data, setData] = useState()
useEffect(() => {
const newData = getDataWithinRange(dateRange)
setData(newData)
}, [dateRange])
return (
<svg className="Chart" />
)
}
useEffect 可以讓你有更簡單的想法實現(xiàn)保持變量同步。
不過這還不夠簡單,我們可以再看下一個例子。
3.2 強大的 useMemo
事實上,剛才 Hooks 中的例子還是有些類組件的思維模式,顯得有些復(fù)雜了。
使用 useEffect進行數(shù)據(jù)的處理;存儲變量到 state;在 JSX 中引用 state。
有沒有發(fā)現(xiàn)中間多了個 state 的環(huán)節(jié)?
我們不需要使用 state ,那是類組件的開發(fā)模式,因為在類組件中,render 函數(shù)和生命周期鉤子并不是在同一個函數(shù)作用域下執(zhí)行,所以需要 state 進行中間的存儲,同時執(zhí)行的 setState 讓 render 函數(shù)再次執(zhí)行,借此獲取最新的 state。
而在函數(shù)式組件中我們有時根本不會需要用到 state 這樣的狀態(tài)存儲,我們僅僅是想使用。
所以我們可以把剛才的圖表例子寫成這樣:
const Chart = ({ dateRange }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
return (
<svg className="Chart" />
)
}
useMemo 會返回一個“記憶化”的結(jié)果,執(zhí)行當前傳入的函數(shù)并返回結(jié)果值給聲明的變量,且當依賴沒變化時返回上一次計算的值。
為什么可以這樣寫?
因為函數(shù)組件中 render 和生命周期鉤子在同一個函數(shù)作用域中,這也就意味著不再需要 state 作中間數(shù)據(jù)橋梁,我們可以直接在函數(shù)執(zhí)行時獲取到處理的數(shù)據(jù),然后在 return 的 JSX 中使用,不必需要每次使用屬性都要在 state 中聲明和創(chuàng)建了,不再需要重新渲染執(zhí)行一次函數(shù)(setData)了,所以我們?nèi)コ袅?useState。這樣,我就減少了一個 state 的聲明以及一次重新渲染。
我們把變量定義在函數(shù)里面,而不是定義在 state 中,這是類組件由于其結(jié)構(gòu)和作用域上與函數(shù)組件相比的不足,是函數(shù)組件的優(yōu)越性。
當然,如果 getDataWithinRange 函數(shù)開銷不大的話,這樣寫也是可以的:
const Chart = ({ dateRange }) => {
const newData = getDataWithinRange(dateRange)
return (
<svg className="Chart" />
)
}
在函數(shù)上下文中進行數(shù)據(jù)的處理和使用,是類結(jié)構(gòu)組件所難以實現(xiàn)的。
如果還沒有體會到 Hooks 所帶來的變化,那么下面的例子可能會令你有所領(lǐng)悟。
3.3 多個數(shù)據(jù)依賴
上一個例子我們只要處理一個數(shù)據(jù)就可以了,這次我們嘗試處理多條數(shù)據(jù),并且數(shù)據(jù)間有依賴關(guān)系。
需求如下:
需要對傳入的 dataRange進行處理得到data當 margins改變后需要更新dimensions當 data改變后需要更新scales
類組件:
class Chart extends Component {
state = {
data: null,
dimensions: null,
xScale: null,
yScale: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
this.setState({dimensions: getDimensions()})
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
if (prevProps.margins != this.props.margins) {
this.setState({dimensions: getDimensions()})
}
if (prevState.data != this.state.data) {
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
函數(shù)組件:
const Chart = ({ dateRange, margins }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
const dimensions = useMemo(getDimensions, [margins])
const xScale = useMemo(getXScale, [data])
const yScale = useMemo(getYScale, [data])
return (
<svg className="Chart" />
)
}
為什么代碼那么少?因為在 Hooks 中我們依舊只需關(guān)注哪些值(data、dimensions、xScale、yScale)需要同步即可。
而觀察類組件的代碼,我們可以發(fā)現(xiàn)其使用了大量的陳述性代碼,例如判斷是否相等,同時還使用了 state 作為數(shù)據(jù)的存儲和使用,所以產(chǎn)生了很多 setState 代碼以及增加了多次重新渲染。
3.4 解放 State
還是剛才 3.3 的例子,不過把需求稍微改了一下:讓 scales 依賴于 dimensions。
看看類組件是如何做到的:
class Chart extends Component {
state = {
data: null,
dimensions: null,
xScale: null,
yScale: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
this.setState({dimensions: getDimensions()})
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
if (prevProps.margins != this.props.margins) {
this.setState({dimensions: getDimensions()})
}
if (
prevState.data != this.state.data
|| prevState.dimensions != this.state.dimensions
) {
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
由于依賴關(guān)系發(fā)生了變化,所以需要重新進行判斷,并且由于多個依賴關(guān)系,判斷的條件也變得更加復(fù)雜了,代碼的可讀性也大幅降低。
接著看 Hooks 是如何做到的:
const Chart = ({ dateRange, margins }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
const dimensions = useMemo(getDimensions, [margins])
const xScale = useMemo(getXScale, [data, dimensions])
const yScale = useMemo(getYScale, [data, dimensions])
return (
<svg className="Chart" />
)
}
使用 Hooks 所以不用再去關(guān)心誰是 props 誰是 state,不用關(guān)心該如何存儲變量,存儲什么變量等問題,也不必去關(guān)心如何進行判斷的依賴關(guān)系。在 Hooks 開發(fā)中,我們把這些瑣碎的負擔都清除了,只需關(guān)注要同步的變量。
所以當數(shù)據(jù)關(guān)系復(fù)雜起來的時候,類組件的這種寫法顯得比較笨重,使用 Hooks 的優(yōu)勢也就體現(xiàn)出來了。
再回顧一下之前一步步走過來的示例,可以看到 Hooks 幫我們精簡了非常多的代碼。
代碼越短并不意味著可讀性越好,但是更加精簡、輕巧的組件,更容易讓我們把關(guān)注點放在更有用的邏輯上,而不是把精力消耗在判斷依賴的冗余編碼中。
4 參考文章

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
