由於工作的原因我已經很長時間沒接觸過React了。前段時間圈子裡都在討論React Hooks,出於好奇也學習了一番,特此整理以加深理解。
緣由
在web應用無所不能的9012年,組成應用的Components也越來越複雜,冗長而難以複用的程式碼給開發者們造成了很多麻煩。比如:
- 難以複用stateful的程式碼,render props及HOC雖然解決了問題,但對元件的包裹改變了元件樹的層級,存在冗餘;
- 在ComponentDidMount、ComponentDidUpdate、ComponentWillUnmount等生命週期中做獲取資料,訂閱/取消事件,操作ref等相互之間無關聯的操作,而把訂閱/取消這種相關聯的操作分開,降低了程式碼的可讀性;
- 與其他語言中的class概念差異較大,需要對事件處理函式做bind操作,令人困擾。另外class也不利於元件的AOT compile,minify及hot loading。 在這種背景下,React在16.8.0引入了React Hooks。
特性
主要介紹state hook,effect hook及custom hook
State Hook
最基本的應用如下:
import React, { useState } from 'react'
function counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>You have clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click
</button>
</div>
)
}
複製程式碼
呼叫useState,傳入初始值,通過陣列的結構賦值得到獨立的local state count,及setCount。count可以理解為class component中的state,可見這裡的state不侷限於物件,可以為number,string,當然也可以是一個物件。而setCount可以理解為class component中的setState,不同的是setState會merge新老state,而hook中的set函式會直接替換,這就意味著如果state是物件時,每次set應該傳入所有屬性,而不能像class component那樣僅傳入變化的值。所以在使用useState時,儘量將相關聯的,會共同變化的值放入一個object。
再看看有多個“local state”的情況:
import React, { useState } from 'react'
function person() {
const [name, setName] = useState('simon')
const [age, setAge] = useState(24)
return (
<div>
<p>name: {name}</p>
<p>age: {age}</p>
</div>
)
}
複製程式碼
我們知道當函式執行完畢,函式作用域內的變數都會銷燬,hooks中的state在component首次render後被React保留下來了。那麼在下一次render時,React如何將這些保留的state與component中的local state對應起來呢。這裡給出一個簡單版本的實現:
const stateArr = []
const setterArr = []
let cursor = 0
let isFirstRender = true
function createStateSetter(cursor) {
return state => {
stateArr[cursor] = state
}
}
function useState(initState) {
if (isFirstRender) {
stateArr.push(initState)
setterArr.push(createStateSetter(cursor))
isFirstRender = false
}
const state = stateArr[cursor]
const setter = setterArr[cursor]
cursor++
return [state, setter]
}
複製程式碼
可以看出React需要保證多個hooks在component每次render的時候的執行順序都保持一致,否則就會出現錯誤。這也是React hooks rule中必須在top level使用hooks的由來————條件,遍歷等語句都有可能會改變hooks執行的順序。
Effect Hook
import React, { useState, useEffect } from 'react'
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null)
// 基本寫法
useEffect(() => {
document.title = 'Dom is ready'
})
// 需要取消操作的寫法
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的函式(effect)中做了一些"side effect",在class component中我們通常會在componentDidMount,componentDidUpdate中去做這些事情。另外在class component中,需要在componentDidMount中訂閱,在componentWillUnmount中取消訂閱,這樣將一件事拆成兩件事做,不僅可讀性低,還容易產生bug:
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';
}
}
複製程式碼
如上程式碼,如果props中的friend.id發生變化,則會導致訂閱和取消的id不一致,如需解決需要在componentDidUpdate中先取消訂閱舊的再訂閱新的,程式碼非常冗餘。而useEffect hook在這一點上是渾然天成的。另外effect函式在每次render時都是新建立的,這其實是有意而為之,因為這樣才能取得最新的state值。
有同學可能會想,每次render後都會執行effect,這樣會不會對效能造成影響。其實effect是在頁面渲染完成之後執行的,不會阻塞,而在effect中執行的操作往往不要求同步完成,除了少數如要獲取寬度或高度,這種情況需要使用其他的hook(useLayoutEffect),此處不做詳解。即使這樣,React也提供了控制的方法,及useEffect的第二個引數————一個陣列,如果陣列中的值不發生變化的話就跳過effect的執行:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
}
}, [props.friend.id])
複製程式碼
Custom Hook
A custom Hook is a JavaScript function whose name starts with ”use” and that may call other Hooks. Custom Hook的使命是解決stateful logic複用的問題,如上面例子中的FriendStatus,在一個聊天應用中可能多個元件都需要知道好友的線上狀態,將FriendStatus抽象成這樣的hook:
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});
return isOnline;
}
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id)
if (isOnline === null) {
return 'Loading...'
}
return isOnline ? 'Online' : 'Offline'
}
function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id)
return (
<li style={{ color: isOnline ? 'green' : 'black' }}>
{props.friend.name}
</li>
)
}
複製程式碼
FriendStatus和FriendListItem中的isOnline是獨立的,因custom hook複用的是stateful logic,而不是state本身。另外custom hook必須以use開頭來命名,這樣linter工具才能正確檢測其是否符合規範。
除了以上三種hook,React還提供了useContext, useReducer, useCallback, useMemo, useRef, useImperativeHandle, useLayoutEffect, useDebugValue內建hook,它們的用途可以參考官方文件,這裡我想單獨講講useRef。 顧名思義,這個hook應該跟ref相關的:
function TextInputWithFocusButton() {
const inputEl = useRef(null)
const onButtonClick = () => {
inputEl.current.focus()
}
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
)
}
複製程式碼
來看看官方文件上的說明:
useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component. 這句話告訴我們在元件的整個生命週期裡,inputEl.current都是存在的,這擴充套件了useRef本身的用途,可以使用useRef維護類似於class component中例項屬性的變數:
function Timer() {
const intervalRef = useRef()
useEffect(() => {
const id = setInterval(() => {
// ...
})
intervalRef.current = id
return () => {
clearInterval(intervalRef.current)
}
})
// ...
}
複製程式碼
這在class component中是理所當然的,但不要忘記Timer僅僅是一個函式,函式執行完畢後函式作用域內的變數將會銷燬,所以這裡需要使用useRef來保持這個timerId。類似的useRef還可以用來獲取preState:
function Counter() {
const [count, setCount] = useState(0)
const prevCountRef = useRef()
useEffect(() => {
prevCountRef.current = count // 由於useEffect中的函式是在render完成之後非同步執行的,所以在每次render時prevCountRef.current的值為上一次的count值
})
const prevCount = prevCountRef.current
return <h1>Now: {count}, before: {prevCount}</h1>
}
複製程式碼