在 Hooks 正式推出不久時我寫了一篇 Hooks 隨談 ,主要是從概念介紹上對它的簡單的分析理解,實習時開發的新控制台則完全使用了這個特性,因此想結合目前的實踐經驗對 Hooks 做一個最佳實踐與對比的分析。
Capture Value#
從 Hooks 出現後,函數式組件也具有了狀態的特性,因此避免 Stateless Component 而統一叫 Function Component 更為恰當。這裡先從 props 的不可變性上對 Function Component 與 Class Component 做一個對比:
Function Component:
function ProfilePage(props) {
setTimeout(() => {
// 父組件 Rerender 了,props 也是初始的
console.log(props)
}, 3000)
}
Class Component:
class ProfilePage extends React.Component {
render() {
setTimeout(() => {
// 父組件 Rerender 了,this.props 也會改變,因為 this 變了
console.log(this.props)
}, 3000)
}
}
如果想在 Class Component 中捕獲初始的 props,可以 const props = this.props
,但是這樣有點 hack,所以想拿到穩定的 props
推薦使用 Function Component。
Hooks:
function MessageThread() {
const [message, setMessage] = useState("")
const showMessage = () => {
alert("你說: " + message)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
setMessage(e.target.value)
}
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>發送</button>
</>
)
}
點擊 send
後修改輸入框的值,3 秒後輸出還是點擊前輸入框的值,說明 Hooks 也具有 Capture Value 的特性。
可以認為每次 Render 的內容都會形成一個快照,每個 Render 狀態都有自己固定不變的 Props 和 State。
不僅僅是對象,函數在每次渲染時也是獨立的,這就是 Capture Value 特性。
實際開發中就被這個特性坑過,避免 capture value 可以利用 useRef
:
function MessageThread() {
const latestMessage = useRef("")
const showMessage = () => {
alert("你說: " + latestMessage.current)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
latestMessage.current = e.target.value
}
}
可以認為
ref
在所有 Render 過程中保持著唯一引用,所以ref
的取值或賦值拿到的都是一個最終狀態,也可以簡潔地認為ref
是 Mutable 而state
的 Immutable 的。
生命週期方法的替代#
- constructor:Function Component 不需要初始構造函數,可以初始化狀態通過調用
useState
,如果初始值計算很消耗時間,可以傳入函數,這樣只會執行一次。 - getDerivedStateFromProps:當渲染時合理調度更新。
- shouldComponentUpdate:見
[React.memo](https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-shouldcomponentupdate)
。 - render:就是 Function Component 本身。
- componentDidMount、componentDidUpdate、componentWillUnmount:它們的集合對應
useEffect
。 - componentDidCatch、getDerivedStateFromError:近期會增加對應的 Hook 方法。
最佳實踐#
組件定義#
const App: React.FC<{ title: string }> = ({ title }) => {
return React.useMemo(() => <div>{title}</div>, [title])
}
App.defaultProps = {
title: 'Function Component'
}
- 用
React.FC
申明 Function Component 組件類型與定義 Props 參數類型。 - 用
React.useMemo
優化渲染性能。 - 用
App.defaultProps
定義 Props 默認值。
為什麼不用 React.memo?
因為組件通信時存在 React.useContext
的用法,會使所有用到的組件重渲染,只有 React.useMemo
可以按需渲染,同時考慮到未來維護,隨時可能通過 useContext
等注入數據,即使沒有性能問題的組件也建議使用 useMemo
。
為什麼不用解構方式代替 defaultProps?
雖然書寫上解構方式更優雅,但是存在一個性能問題:對於對象類型每次 Rerender 時引用都會變化。
局部狀態#
按常用程度排列:useState
、useRef
、useReducer
。
useState
const [hide, setHide] = React.useState(false)
const [name, setName] = React.useState("jeremy")
狀態和函數名要見名知意,推薦都放在頂部聲明,方便查閱。
useRef
const dom = React.useRef(null)
useRef
儘量少用,因為大量 Mutable 的數據會影響代碼的可維護性,對於不需要重複初始化的對象推薦使用。
useReducer
局部狀態不推薦使用 useReducer
,容易導致內部狀態過於複雜,建議在多組件間通信時結合 useContext
使用。
在函數內直接聲明普通常量或普通函數合適嗎?
因為 Function Component 每次渲染都會重新執行,常量推薦放到函數外層避免性能問題,函數推薦使用 useCallback
聲明以保證準確性與性能,useCallback
第二個參數必須填寫,eslint-plugin-react-hooks 會自動填寫依賴項。
組件通信#
簡單的組件通信使用 Props 透傳的方式,頻繁組件間通信使用 useContext
。
useEffect 指南#
Function Component 沒有生命週期,僅描述 UI 狀態,然後 React 將其同步到 DOM。每次渲染的狀態都會固化下來,包括 state
props
useEffect
和內部的所有函數。
捨棄了生命週期的同步會帶來一些性能問題,我們需要告訴 React 如何對比 Effects。
useEffect 的依賴項#
React 在 DOM 渲染時會 diff 內容,只修改改變了的部分,但是做不到對 Effect 的增量修改識別,需要開發者通過 useEffect
的第二個參數告訴 React 用到了哪些外部變量:
useEffect(() => {
document.title = '你好, ' + name
}, [name])
直到 name
改變的 Rerender,useEffect
才會再次執行。手動維護比較麻煩,可以利用 eslint 自動提示 fix。
這裡需要關注的是依賴項的設置很重要:
由於 useEffect 符合 Capture Value 的特性,必須處理好依賴項才能保證獲取值的準確性:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count])
如果這裡不傳入 count
作為依賴項,拿到的 count
值就永遠是初始化的 0
,這樣之後的 setCount
就沒有作用了。
傳入了 count
可以獲取到最新的 count
,但是導致了兩個問題:
- 計時器不準確了,因為每次
count
變化都會銷毀重新計時。 - 頻繁生成和銷毀定時器帶來了一定性能負擔。
這裡設不設依賴都有問題,本質上是因為我們在一個只想執行一次的 Effect 裡依賴了外部變量。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
setCount
還有一種函數回調模式,不需要關心當前值是什麼,只要對舊的值進行修改即可,這樣雖然代碼永遠運行在第一次 Render 中,但總是可以訪問到最新的 state
。
上面的解法並沒有徹底解決所有場景的問題,比如同時依賴了兩個 state
的情況:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step)
}, 1000)
return () => clearInterval(id)
}, [step])
這裡就不得不依賴 step
這個變量,那麼現在該怎麼處理呢?
利用 useReducer
函數將更新與動作解耦:
const [state, dispatch] = useReducer(reducer, initialState)
const { count, step } = state
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" })
}, 1000)
return () => clearInterval(id)
}, [dispatch])
這樣形成了一個局部 Redux,不管更新時需要依賴多少變量,在實際更新時都不需要依賴任何變量,具體更新操作寫在 reducer
函數裡即可。
Function 與 Effect#
如果函數定義不在 useEffect
中,不僅可能遺漏依賴,而且 eslint 插件也無法幫助自動收集依賴。
只要不依賴 Function Component 內變量的函數都可以直接抽出去,但是依賴了變量的函數怎麼辦?
useCallback
:
function Parent() {
const [query, setQuery] = useState("react");
// ✅ Preserves identity until query changes
const fetchData = useCallback(() => {
const url = "https://hn.algolia.com/api/v1/search?query=" + query;
// ... Fetch data and return it ...
}, [query]); // ✅ Callback deps are OK
return <Child fetchData={fetchData} />;
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect deps are OK
// ...
}
因為函數也具有 Capture Value 特性,經過 useCallback
包裝的函數可以當成普通變量來作為 useEffect
的依賴。
useCallback
就是在它的依賴變化時返回一個新的函數引用,從而觸發 useEffect
的依賴變化並激活它重新執行。
在 Class Component 中,如果希望參數變化就重新取數,我們不能直接對比取數函數的 diff 而是要對比取數參數是否變化,這樣的代碼不內聚難維護;對比 Function Component 中利用 useCallback
封裝的取數函數,useEffect
只需關心這個依賴是否變化,參數的變化在 useCallback
內關係,再配合 eslint 插件掃描就能做到依賴不丟、邏輯內聚易維護。
回收機制#
在組件被銷毀時,通過 useEffect
註冊的監聽需要被銷毀,可以通過它的返回值處理:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
在組件銷毀時會執行返回值函數內回調函數,由於 Capture Value 特性,每次註冊與回收拿到的都是成對的固定值。
如果沒有合理的返回回收,很容易造成內存洩漏,如果直接傳了個 async
這樣的異步函數 useEffect 也會警告,那如何做到銷毀時取消異步函數呢?
如果使用的異步方式支持取消可以直接在清除函數中取消異步請求,更簡單的一個方式是借助一個布爾值:
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) {
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
// ...
}
這篇文章 討論了更多關於如何處理錯誤和加載狀態的場景。
其它優勢#
useEffect
在渲染結束時執行,也就不會阻塞瀏覽器渲染進程,符合 React Fiber 的理念,因為 Fiber 是會根據情況暫停或插隊執行不同組件的 Render,遵循 Capture Value 特性的代碼可以保證值的安全訪問,弱化生命週期也能解決中斷執行帶來的問題。
React Hooks 目前還在完善發展中,官方與社區的實踐方案與輪子都有值得參考學習的地方,同時 Vue 3.0 也借鑒了 React Hooks 的思想,產生了 Vue Hooks,兩者各有優劣,對這一部分深入了解後會再補充到這裡。
參考: