前言
首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵,希望大家多多關注呀!React 16.8中新增了Hooks特性,並且在React官方文件中新增加了Hooks模組介紹新特性,可見React對Hooks的重視程度,如果你還不清楚Hooks是什麼,強烈建議你瞭解一下,畢竟這可能真的是React未來的發展方向。
起源
React一直以來有兩種建立元件的方式: Function Components(函式元件)與Class Components(類元件)。函式元件只是一個普通的JavaScript函式,接受props
物件並返回React Element。在我看來,函式元件更符合React的思想,資料驅動檢視,不含有任何的副作用和狀態。在應用程式中,一般只有非常基礎的元件才會使用函式元件,並且你會發現隨著業務的增長和變化,元件內部可能必須要包含狀態和其他副作用,因此你不得不將之前的函式元件改寫為類元件。但事情往往並沒有這麼簡單,類元件也沒有我們想象的那麼美好,除了徒增工作量之外,還存在其他種種的問題。
首先類元件共用狀態邏輯非常麻煩。比如我們借用官方文件中的一個場景,FriendStatus元件用來顯示朋友列表中該使用者是否線上。
class FriendStatus extends React.Component {
constructor(props) {
super(props);
this.state = { isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
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';
}
}
複製程式碼
上面FriendStatus元件會在建立時主動訂閱使用者狀態,並在解除安裝時會退訂狀態防止造成記憶體洩露。假設又出現了一個元件也需要去訂閱使用者線上狀態,如果想用複用該邏輯,我們一般會使用render props
和高階元件來實現狀態邏輯的複用。
// 採用render props的方式複用狀態邏輯
class OnlineStatus extends React.Component {
constructor(props) {
super(props);
this.state = { isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
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() {
const {isOnline } = this.state;
return this.props.children({isOnline})
}
}
class FriendStatus extends React.Component{
render(){
return (
<OnlineStatus friend={this.props.friend}>
{
({isOnline}) => {
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
}
</OnlineStatus>
);
}
}
複製程式碼
// 採用高階元件的方式複用狀態邏輯
function withSubscription(WrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = { isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
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() {
return <WrappedComponent isOnline={this.state.isOnline}/>
}
}
}
const FriendStatus = withSubscription(({isOnline}) => {
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
})
複製程式碼
上面兩種複用狀態邏輯的方式不僅需要費時費力地重構元件,而且Devtools檢視元件的層次結構時,會發現元件層級結構變深,當複用的狀態邏輯過多時,也會陷入元件巢狀地獄(wrapper hell)的情況。可見上述兩種方式並不能完美解決狀態邏輯複用的問題。
不僅如此,隨著類元件中業務邏輯逐漸複雜,維護難度也會逐步提升,因為狀態邏輯會被分割到不同的生命週期函式中,例如訂閱狀態邏輯位於componentDidMount
,取消訂閱邏輯位於componentWillUnmount
中,相關邏輯的程式碼相互割裂,而邏輯不相關的程式碼反而有可能集中在一起,整體都是不利於維護的。並且相比如函式式元件,類元件學習更為複雜,你需要時刻提防this
在元件中的陷阱,永遠不能忘了為事件處理程式繫結this
。如此種種,看來函式元件還是有特有的優勢的。
Hooks
函式式元件一直以來都缺乏類元件諸如狀態、生命週期等種種特性,而Hooks的出現就是讓函式式元件擁有類元件的特性。官方定義:
Hooks are functions that let you “hook into” React state and lifecycle features from function components.
要讓函式元件擁有類元件的特性,首先就要實現狀態state
的邏輯。
State: useState useReducer
useState
就是React提供最基礎、最常用的Hook,主要用來定義本地狀態,我們以一個最簡單的計數器為例:
import React, { useState } from 'react'
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={()=> setCount(count + 1)}>+</button>
<button onClick={() => setCount((count) => count - 1)}>-</button>
</div>
);
}
複製程式碼
useState
可以用來定義一個狀態,與state不同的是,狀態不僅僅可以是物件,而且可以是基礎型別值,例如上面的Number型別的變數。useState
返回的是一個陣列,第一個是當前狀態的實際值,第二個用於更改該狀態的函式,類似於setState
。更新函式與setState
相同的是都可以接受值和函式兩種型別的引數,與useState
不同的是,更新函式會將狀態替換(replace)而不是合併(merge)。
函式元件中如果存在多個狀態,既可以通過一個useState
宣告物件型別的狀態,也可以通過useState
多次宣告狀態。
// 宣告物件型別的狀態
const [count, setCount] = useState({
count1: 0,
count2: 0
});
// 多次宣告
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
複製程式碼
相比於宣告物件型別的狀態,明顯多次宣告狀態的方式更加方便,主要是因為更新函式是採用的替換的方式,因此你必須給引數中新增未變化的屬性,非常的麻煩。需要注意的是,React是通過Hook呼叫的次序來記錄各個內部狀態的,因此Hook不能在條件語句(如if)或者迴圈語句中呼叫,並在需要注意的是,我們僅可以在函式元件中呼叫Hook,不能在元件和普通函式中(除自定義Hook)呼叫Hook。
當我們要在函式元件中處理複雜多層資料邏輯時,使用useState就開始力不從心,值得慶幸的是,React為我們提供了useReducer來處理函式元件中複雜狀態邏輯。如果你使用過Redux,那麼useReducer可謂是非常的親切,讓我們用useReducer重寫之前的計數器例子:
import React, { useReducer } from 'react'
const reducer = function (state, action) {
switch (action.type) {
case "increment":
return { count : state.count + 1};
case "decrement":
return { count: state.count - 1};
default:
return { count: state.count }
}
}
function Example() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
const {count} = state;
return (
<div>
<span>{count}</span>
<button onClick={()=> dispatch({ type: "increment"})}>+</button>
<button onClick={() => dispatch({ type: "decrement"})}>-</button>
</div>
);
}
複製程式碼
useReducer接受兩個引數: reducer函式和預設值,並返回當前狀態state和dispatch函式的陣列,其邏輯與Redux基本一致。useReducer和Redux的區別在於預設值,Redux的預設值是通過給reducer函式賦值預設引數的方式給定,例如:
// Redux的預設值邏輯
const reducer = function (state = { count: 0 }, action) {
switch (action.type) {
case "increment":
return { count : state.count + 1};
case "decrement":
return { count: state.count - 1};
default:
return { count: state.count }
}
}
複製程式碼
useReducer之所以沒有采用Redux的邏輯是因為React認為state的預設值可能是來自於函式元件的props,例如:
function Example({initialState = 0}) {
const [state, dispatch] = useReducer(reducer, { count: initialState });
// 省略...
}
複製程式碼
這樣就能實現通過傳遞props來決定state的預設值,當然React雖然不推薦Redux的預設值方式,但也允許你類似Redux的方式去賦值預設值。這就要接觸useReducer的第三個引數: initialization。
顧名思義,第三個引數initialization是用來初始化狀態,當useReducer初始化狀態時,會將第二個引數initialState傳遞initialization函式,initialState函式返回的值就是state的初始狀態,這也就允許在reducer外抽象出一個函式專門負責計算state的初始狀態。例如:
const initialization = (initialState) => ({ count: initialState })
function Example({initialState = 0}) {
const [state, dispatch] = useReducer(reducer, initialState, initialization);
// 省略...
}
複製程式碼
所以藉助於initialization函式,我們就可以模擬Redux的初始值方式:
import React, { useReducer } from 'react'
const reducer = function (state = {count: 0}, action) {
// 省略...
}
function Example({initialState = 0}) {
const [state, dispatch] = useReducer(reducer, undefined, reducer());
// 省略...
}
複製程式碼
Side Effects: useEffect useLayoutEffect
解決了函式元件中內部狀態的定義,接下來亟待解決的函式元件中生命週期函式的問題。在函式式思想的React中,生命週期函式是溝通函式式和命令式的橋樑,你可以在生命週期中執行相關的副作用(Side Effects),例如: 請求資料、操作DOM等。React提供了useEffect來處理副作用。例如:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`
return () => {
console.log('clean up!')
}
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
複製程式碼
在上面的例子中我們給useEffect
傳入了一個函式,並在函式內根據count值更新網頁標題。我們會發現每次元件更新時,useEffect中的回撥函式都會被呼叫。因此我們可以認為useEffect是componentDidMount和componentDidUpdate結合體。當元件安裝(Mounted)和更新(Updated)時,回撥函式都會被呼叫。觀察上面的例中,回撥函式返回了一個函式,這個函式就是專門用來清除副作用,我們知道類似監聽事件的副作用在元件解除安裝時應該及時被清除,否則會造成記憶體洩露。清除函式會在每次元件重新渲染前呼叫,因此執行順序是:
render -> effect callback -> re-render -> clean callback -> effect callback
因此我們可以使用useEffect
模擬componentDidMount、componentDidUpdate、componentWillUnmount行為。之前我們提到過,正是因為生命週期函式,我們迫不得已將相關的程式碼拆分到不同的生命週期函式,反而將不相關的程式碼放置在同一個生命週期函式,之所以會出現這個情況,主要問題在於我們並不是依據於業務邏輯書寫程式碼,而是通過執行時間編碼。為了解決這個問題,我們可以通過建立多個Hook,將相關邏輯程式碼放置在同一個Hook來解決上述問題:
import React, { useState, useEffect } from 'react';
function Example() {
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
useEffect(() => {
otherAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return function cleanup() {
otherAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// 省略...
}
複製程式碼
我們通過多個Hook來集中邏輯關注點,避免不相關的程式碼糅雜而出現的邏輯混亂。但是隨之而來就遇到一個問題,假設我們的某個行為確定是要在區分componentDidUpdate
或者componentDidMount
時才執行,useEffect
是否能區分。好在useEffect
為我們提供了第二個引數,如果第二個引數傳入一個陣列,僅當重新渲染時陣列中的值發生改變時,useEffect
中的回撥函式才會執行。因此如果我們向其傳入一個空陣列,則可以模擬生命週期componentDidMount
。但是如果你想僅模擬componentDidUpdate
,目前暫時未發現什麼好的方法。
useEffect
與類元件生命週期不同的是,componentDidUpdate
和componentDidMount
都是在DOM更新後同步執行的,但useEffect
並不會在DOM更新後同步執行,也不會阻塞更新介面。如果需要模擬生命週期同步效果,則需要使用useLayoutEffect
,其使用方法和useEffect
相同,區域只在於執行時間上。
Context:useContext
藉助Hook:useContext
,我們也可以在函式元件中使用context
。相比於在類元件中需要通過render props的方式使用,useContext
的使用則相當方便。
import { createContext } from 'react'
const ThemeContext = createContext({ color: 'color', background: 'black'});
function Example() {
const theme = useContext(Conext);
return (
<p style={{color: theme.color}}>Hello World!</p>
);
}
class App extends Component {
state = {
color: "red",
background: "black"
};
render() {
return (
<Context.Provider value={{ color: this.state.color, background: this.state.background}}>
<Example/>
<button onClick={() => this.setState({color: 'blue'})}>color</button>
<button onClick={() => this.setState({background: 'blue'})}>backgroud</button>
</Context.Provider>
);
}
}
複製程式碼
useContext
接受函式React.createContext
返回的context物件作為引數,返回當前context中值。每當Provider
中的值發生改變時,函式元件就會重新渲染,需要注意的是,即使的context的未使用的值發生改變時,函式元件也會重新渲染,正如上面的例子,Example
元件中即使沒有使用過background
,但background
發生改變時,Example
也會重新渲染。因此必要時,如果Example
元件還含有子元件,你可能需要新增shouldComponentUpdate
防止不必要的渲染浪費效能。
Ref: useRef useImperativeHandle
useRef
常用在訪問子元素的例項:
function Example() {
const inputEl = useRef();
const onButtonClick = () => {
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
複製程式碼
上面我們說了useRef
常用在ref
屬性上,實際上useRef
的作用不止於此
const refContainer = useRef(initialValue)
useRef
可以接受一個預設值,並返回一個含有current
屬性的可變物件,該可變物件會將持續整個元件的生命週期。因此可以將其當做類元件的屬性一樣使用。
useImperativeHandle
用於自定義暴露給父元件的ref
屬性。需要配合forwardRef
一起使用。
function Example(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} />;
}
export default forwardRef(Example);
複製程式碼
class App extends Component {
constructor(props){
super(props);
this.inputRef = createRef()
}
render() {
return (
<>
<Example ref={this.inputRef}/>
<button onClick={() => {this.inputRef.current.focus()}}>Click</button>
</>
);
}
}
複製程式碼
New Feature: useCallback useMemo
熟悉React的同學見過類似的場景:
class Example extends React.PureComponent{
render(){
// ......
}
}
class App extends Component{
render(){
return <Example onChange={() => this.setState()}/>
}
}
複製程式碼
其實在這種場景下,雖然Example
繼承了PureComponent
,但實際上並不能夠優化效能,原因在於每次App
元件傳入的onChange
屬性都是一個新的函式例項,因此每次Example
都會重新渲染。一般我們為了解決這個情況,一般會採用下面的方法:
class App extends Component{
constructor(props){
super(props);
this.onChange = this.onChange.bind(this);
}
render(){
return <Example onChange={this.onChange}/>
}
onChange(){
// ...
}
}
複製程式碼
通過上面的方法一併解決了兩個問題,首先保證了每次渲染時傳給Example
元件的onChange
屬性都是同一個函式例項,並且解決了回撥函式this
的繫結。那麼如何解決函式元件中存在的該問題呢?React提供useCallback
函式,對事件控制程式碼進行快取。
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
複製程式碼
useCallback接受函式和一個陣列輸入,並返回的一個快取版本的回撥函式,僅當重新渲染時陣列中的值發生改變時,才會返回新的函式例項,這也就解決我們上面提到的優化子元件效能的問題,並且也不會有上面繁瑣的步驟。
與useCallback
類似的是,useMemo
返回的是一個快取的值。
const memoizedValue = useMemo(
() => complexComputed(),
[a, b],
);
複製程式碼
也就是僅當重新渲染時陣列中的值發生改變時,回撥函式才會重新計算快取資料,這可以使得我們避免在每次重新渲染時都進行復雜的資料計算。因此我們可以認為:
useCallback(fn, input)
等同於useMemo(() => fn, input)
如果沒有給useMemo
傳入第二個引數,則useMemo
僅會在收到新的函式例項時,才重新計算,需要注意的是,React官方文件提示我們,useMemo
僅可以作為一種優化效能的手段,不能當做語義上的保證,這就是說,也會React在某些情況下,即使陣列中的資料未發生改變,也會重新執行。
自定義Hook
我們前面講過,Hook只能在函式元件的頂部呼叫,不能再迴圈、條件、普通函式中使用。我們前面講過,類元件想要共享狀態邏輯非常麻煩,必須要藉助於render props和HOC,非常的繁瑣。相比於次,React允許我們建立自定義Hook來封裝共享狀態邏輯。所謂的自定義Hook是指以函式名以use
開頭並呼叫其他Hook的函式。我們用自定義Hook來重寫剛開始的訂閱使用者狀態的例子:
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
function handleStatusChange(isOnline) {
setIsOnline(isOnline);
}
useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});
return isOnline;
}
function FriendStatus() {
const isOnline = useFriendStatus();
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
複製程式碼
我們用自定義Hook重寫了之前的訂閱使用者線上狀態的例子,相比於render prop和HOC複雜的邏輯,自定義Hook更加的簡潔,不僅於此,自定義Hook也不會引起之前我們說提到過的元件巢狀地獄(wrapper hell)的情況。優雅的解決了之前類元件複用狀態邏輯困難的情況。
總結
藉助於Hooks,函式元件已經能基本實現絕大部分的類元件的功能,不僅於此,Hooks在共享狀態邏輯、提高元件可維護性上有具有一定的優勢。可以預見的是,Hooks很有可能是React可預見未來大的方向。React官方對Hook採用的是逐步採用策略(Gradual Adoption Strategy),並表示目前沒有計劃會將class從React中剔除,可見Hooks會很長時間內和我們的現有程式碼並行工作,React並不建議我們全部用Hooks重寫之前的類元件,而是建議我們在新的元件或者非關鍵性元件中使用Hooks。 如有表述不周之處,虛心接受批評指教。願大家一同進步!