前言
在 React 的世界中,有容器組件和 UI 組件之分,在 React Hooks 出現之前,UI 組件我們可以使用函數,無狀態組件來展示 UI,而對於容器組件,函數組件就顯得無能為力,我們依賴於類組件來獲取數據,處理數據,並向下傳遞參數給 UI 組件進行渲染。在我看來,使用 React Hooks 相比於從前的類組件有以下幾點好處:
- 代碼可讀性更強,原本同一塊功能的代碼邏輯被拆分在了不同的生命周期函數中,容易使開發者不利於維護和迭代,通過 React Hooks 可以將功能代碼聚合,方便閱讀維護
- 組件樹層級變淺,在原本的代碼中,我們經常使用 HOC/render props 等方式來復用組件的狀態,增強功能等,無疑增加了組件樹層數及渲染,而在 React Hooks 中,這些功能都可以通過強大的自定義的 Hooks 來實現
React 在 v16.8 的版本中推出了 React Hooks 新特性,雖然社區還沒有最佳實踐如何基於 React Hooks 來打造復雜應用(至少我還沒有),憑借着閱讀社區中大量的關於這方面的文章,下面我將通過十個案例來幫助你認識理解並可以熟練運用 React Hooks 大部分特性。
useState 保存組件狀態
在類組件中,我們使用 this.state
來保存組件狀態,並對其修改觸發組件重新渲染。比如下面這個簡單的計數器組件,很好詮釋了類組件如何運行:
import React from "react"; class App extends React.Component { constructor(props) { super(props); this.state = { count: 0, name: "alife" }; } render() { const { count } = this.state; return ( <div> Count: {count} <button onClick={() => this.setState({ count: count + 1 })}>+</button> <button onClick={() => this.setState({ count: count - 1 })}>-</button> </div> ); } }
一個簡單的計數器組件就完成了,而在函數組件中,由於沒有 this 這個黑魔法,React 通過 useState 來幫我們保存組件的狀態。
import React, { useState } from "react"; function App() { const [obj, setObject] = useState({ count: 0, name: "alife" }); return ( <div className="App"> Count: {obj.count} <button onClick={() => setObject({ ...obj, count: obj.count + 1 })}>+</button> <button onClick={() => setObject({ ...obj, count: obj.count - 1 })}>-</button> </div> ); }
通過傳入 useState 參數后返回一個帶有默認狀態和改變狀態函數的數組。通過傳入新狀態給函數來改變原本的狀態值。值得注意的是 useState 不幫助你處理狀態,相較於 setState 非覆蓋式更新狀態,useState 覆蓋式更新狀態,需要開發者自己處理邏輯。(代碼如上)
似乎有個 useState 后,函數組件也可以擁有自己的狀態了,但僅僅是這樣完全不夠。
useEffect 處理副作用
函數組件能保存狀態,但是對於異步請求,副作用的操作還是無能為力,所以 React 提供了 useEffect 來幫助開發者處理函數組件的副作用,在介紹新 API 之前,我們先來看看類組件是怎么做的:
import React, { Component } from "react"; class App extends Component { state = { count: 1 }; componentDidMount() { const { count } = this.state; document.title = "componentDidMount" + count; this.timer = setInterval(() => { this.setState(({ count }) => ({ count: count + 1 })); }, 1000); } componentDidUpdate() { const { count } = this.state; document.title = "componentDidMount" + count; } componentWillUnmount() { document.title = "componentWillUnmount"; clearInterval(this.timer); } render() { const { count } = this.state; return ( <div> Count:{count} <button onClick={() => clearInterval(this.timer)}>clear</button> </div> ); } }
在例子中,組件每隔一秒更新組件狀態,並且每次觸發更新都會觸發 document.title 的更新(副作用),而在組件卸載時修改 document.title(類似於清除)
從例子中可以看到,一些重復的功能開發者需要在 componentDidMount 和 componentDidUpdate 重復編寫,而如果使用 useEffect 則完全不一樣。
import React, { useState, useEffect } from "react"; let timer = null; function App() { const [count, setCount] = useState(0); useEffect(() => { document.title = "componentDidMount" + count; },[count]); useEffect(() => { timer = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); // 一定注意下這個順序: // 告訴react在下次重新渲染組件之后,同時是下次執行上面setInterval之前調用 return () => { document.title = "componentWillUnmount"; clearInterval(timer); }; }, []); return ( <div> Count: {count} <button onClick={() => clearInterval(timer)}>clear</button> </div> ); }
我們使用 useEffect 重寫了上面的例子,useEffect 第一個參數接收一個函數,可以用來做一些副作用比如異步請求,修改外部參數等行為,而第二個參數稱之為dependencies,是一個數組,如果數組中的值變化才會觸發 執行useEffect 第一個參數中的函數。返回值(如果有)則在組件銷毀或者調用函數前調用。
- 1.比如第一個 useEffect 中,理解起來就是一旦 count 值發生改變,則修改 documen.title 值;
- 2.而第二個 useEffect 中傳遞了一個空數組[],這種情況下只有在組件初始化或銷毀的時候才會觸發,用來代替 componentDidMount 和 componentWillUnmount,慎用;
-
- 還有另外一個情況,就是不傳遞第二個參數,也就是useEffect只接收了第一個函數參數,代表不監聽任何參數變化。每次渲染DOM之后,都會執行useEffect中的函數。
基於這個強大 Hooks,我們可以模擬封裝出其他生命周期函數,比如 componentDidUpdate 代碼十分簡單
function useUpdate(fn) { // useRef 創建一個引用 const mounting = useRef(true); useEffect(() => { if (mounting.current) { mounting.current = false; } else { fn(); } }); }
現在我們有了 useState 管理狀態,useEffect 處理副作用,異步邏輯,學會這兩招足以應對大部分類組件的使用場景。
useContext 減少組件層級
上面介紹了 useState、useEffect 這兩個最基本的 API,接下來介紹的 useContext 是 React 幫你封裝好的,用來處理多層級傳遞數據的方式,在以前組件樹種,跨層級祖先組件想要給孫子組件傳遞數據的時候,除了一層層 props 往下透傳之外,我們還可以使用 React Context API 來幫我們做這件事,舉個簡單的例子:
const { Provider, Consumer } = React.createContext(null); function Bar() { return <Consumer>{color => <div>{color}</div>}</Consumer>; } function Foo() { return <Bar />; } function App() { return ( <Provider value={"grey"}> <Foo /> </Provider> ); }
通過 React createContext 的語法,在 APP 組件中可以跨過 Foo 組件給 Bar 傳遞數據。而在 React Hooks 中,我們可以使用 useContext 進行改造。
const colorContext = React.createContext("gray"); function Bar() { const color = useContext(colorContext); return <div>{color}</div>; } function Foo() { return <Bar />; } function App() { return ( <colorContext.Provider value={"red"}> <Foo /> </colorContext.Provider> ); }
傳遞給 useContext 的是 context 而不是 consumer,返回值即是想要透傳的數據了。用法很簡單,使用 useContext 可以解決 Consumer 多狀態嵌套的問題。
function HeaderBar() { return ( <CurrentUser.Consumer> {user => <Notifications.Consumer> {notifications => <header> Welcome back, {user.name}! You have {notifications.length} notifications. </header> } } </CurrentUser.Consumer> ); }
而使用 useContext 則變得十分簡潔,可讀性更強且不會增加組件樹深度。
function HeaderBar() { const user = useContext(CurrentUser); const notifications = useContext(Notifications); return ( <header> Welcome back, {user.name}! You have {notifications.length} notifications. </header> ); }
useReducer
useReducer 這個 Hooks 在使用上幾乎跟 Redux/React-Redux 一模一樣,唯一缺少的就是無法使用 redux 提供的中間件。我們將上述的計時器組件改寫為 useReducer,
import React, { useReducer } from "react"; const initialState = { count: 0 }; function reducer(state, action) { switch (action.type) { case "increment": return { count: state.count + action.payload }; case "decrement": return { count: state.count - action.payload }; default: throw new Error(); } } function App() { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} <button onClick={() => dispatch({ type: "increment", payload: 5 })}> + </button> <button onClick={() => dispatch({ type: "decrement", payload: 5 })}> - </button> </> ); }
用法跟 Redux 基本上是一致的,用法也很簡單,算是提供一個 mini 的 Redux 版本。
useCallback 記憶函數
在類組件中,我們經常犯下面這樣的錯誤:
class App { render() { return <div> <SomeComponent style={{ fontSize: 14 }} doSomething={ () => { console.log('do something'); }} /> </div>; } }
這樣寫有什么壞處呢?一旦 App 組件的 props 或者狀態改變了就會觸發重渲染,即使跟 SomeComponent 組件不相關,由於每次 render 都會產生新的 style 和 doSomething(因為重新render前后, style 和 doSomething分別指向了不同的引用),所以會導致 SomeComponent 重新渲染,倘若 SomeComponent 是一個大型的組件樹,這樣的 Virtual Dom 的比較顯然是很浪費的,解決的辦法也很簡單,將參數抽離成變量。
const fontSizeStyle = { fontSize: 14 }; class App { doSomething = () => { console.log('do something'); } render() { return <div> <SomeComponent style={fontSizeStyle} doSomething={ this.doSomething } /> </div>; } }
在類組件中,我們還可以通過 this 這個對象來存儲函數,而在函數組件中沒辦法進行掛載了。所以函數組件在每次渲染的時候如果有傳遞函數的話都會重渲染子組件。
function App() { const handleClick = () => { console.log('Click happened'); } return <SomeComponent onClick={handleClick}>Click Me</SomeComponent>; }
這里多說一句,一版把函數式組件理解為class組件render函數的語法糖,所以每次重新渲染的時候,函數式組件內部所有的代碼都會重新執行一遍。所以上述代碼中每次render,handleClick都會是一個新的引用,所以也就是說傳遞給SomeComponent組件的props.onClick一直在變(因為每次都是一個新的引用),所以才會說這種情況下,函數組件在每次渲染的時候如果有傳遞函數的話都會重渲染子組件。
而有了 useCallback 就不一樣了,你可以通過 useCallback 獲得一個記憶后的函數。
function App() { const memoizedHandleClick = useCallback(() => { console.log('Click happened') }, []); // 空數組代表無論什么情況下該函數都不會發生改變 return <SomeComponent onClick={memoizedHandleClick}>Click Me</SomeComponent>; }
老規矩,第二個參數傳入一個數組,數組中的每一項一旦值或者引用發生改變,useCallback 就會重新返回一個新的記憶函數提供給后面進行渲染。
這樣只要子組件繼承了 PureComponent 或者使用 React.memo 就可以有效避免不必要的 VDOM 渲染。
useMemo 記憶組件
useCallback 的功能完全可以由 useMemo 所取代,如果你想通過使用 useMemo 返回一個記憶函數也是完全可以的。
useCallback(fn, inputs) is equivalent to useMemo(() => fn, inputs).
所以前面使用 useCallback 的例子可以使用 useMemo 進行改寫:
function App() { const memoizedHandleClick = useMemo(() => () => { console.log('Click happened') }, []); // 空數組代表無論什么情況下該函數都不會發生改變 return <SomeComponent onClick={memoizedHandleClick}>Click Me</SomeComponent>; }
唯一的區別是:useCallback 不會執行第一個參數函數,而是將它返回給你,而 useMemo 會執行第一個函數並且將函數執行結果返回給你。所以在前面的例子中,可以返回 handleClick 來達到存儲函數的目的。
所以 useCallback 常用記憶事件函數,生成記憶后的事件函數並傳遞給子組件使用。而 useMemo 更適合經過函數計算得到一個確定的值,比如記憶組件。
function Parent({ a, b }) { // Only re-rendered if `a` changes: const child1 = useMemo(() => <Child1 a={a} />, [a]); // Only re-rendered if `b` changes: const child2 = useMemo(() => <Child2 b={b} />, [b]); return ( <> {child1} {child2} </> ) }
當 a/b 改變時,child1/child2 才會重新渲染。從例子可以看出來,只有在第二個參數數組的值發生變化時,才會觸發子組件的更新。
useRef 保存引用值
useRef 跟 createRef 類似,都可以用來生成對 DOM 對象的引用,看個簡單的例子:
import React, { useState, useRef } from "react"; function App() { let [name, setName] = useState("Nate"); let nameRef = useRef(); const submitButton = () => { setName(nameRef.current.value); }; return ( <div className="App"> <p>{name}</p> <div> <input ref={nameRef} type="text" /> <button type="button" onClick={submitButton}> Submit </button> </div> </div> ); }
useRef 返回的值傳遞給組件或者 DOM 的 ref 屬性,就可以通過 ref.current 值訪問組件或真實的 DOM 節點,重點是組件也是可以訪問到的,從而可以對 DOM 進行一些操作,比如監聽事件等等。
當然 useRef 遠比你想象中的功能更加強大,useRef 的功能有點像類屬性,或者說您想要在組件中記錄一些值,並且這些值在稍后可以更改。
利用 useRef 就可以繞過 Capture Value 的特性。可以認為 ref 在所有 Render 過程中保持着唯一引用,因此所有對 ref 的賦值或取值,拿到的都只有一個最終狀態,而不會在每個 Render 間存在隔離。參考例子:精讀《Function VS Class 組件》
React Hooks 中存在 Capture Value 的特性:
function App() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { alert("count: " + count); }, 3000); }, [count]); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>增加 count</button> <button onClick={() => setCount(count - 1)}>減少 count</button> </div> ); }
先點擊增加button,后點擊減少button,3秒后先alert 1,后alert 0,而不是alert兩次0。這就是所謂的 capture value 的特性。而在類組件中 3 秒后輸出的就是修改后的值,因為這時候** message 是掛載在 this 變量上,它保留的是一個引用值**,對 this 屬性的訪問都會獲取到最新的值,類組件舉例,在線Demo。講到這里你應該就明白了,useRef 創建一個引用,就可以有效規避 React Hooks 中 Capture Value 特性。useRef避免 Capture Value
function App() { const count = useRef(0); const showCount = () => { alert("count: " + count.current); }; const handleClick = number => { count.current = count.current + number; setTimeout(showCount, 3000); }; return ( <div> <p>You clicked {count.current} times</p> <button onClick={() => handleClick(1)}>增加 count</button> <button onClick={() => handleClick(-1)}>減少 count</button> </div> ); }
只要將賦值與取值的對象變成 useRef,而不是 useState,就可以躲過 capture value 特性,在 3 秒后得到最新的值。
useImperativeHandle 透傳 Ref
通過 useImperativeHandle 用於讓父組件獲取子組件內的索引
import React, { useRef, useEffect, useImperativeHandle, forwardRef } from "react"; function ChildInputComponent(props, ref) { const inputRef = useRef(null); useImperativeHandle(ref, () => inputRef.current); return <input type="text" name="child input" ref={inputRef} />; } const ChildInput = forwardRef(ChildInputComponent); function App() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); }, []); return ( <div> <ChildInput ref={inputRef} /> </div> ); }
通過這種方式,App 組件可以獲得子組件的 input 的 DOM 節點。
useLayoutEffect 同步執行副作用
大部分情況下,使用 useEffect 就可以幫我們處理組件的副作用,但是如果想要同步調用一些副作用,比如對 DOM 的操作,就需要使用 useLayoutEffect,useLayoutEffect 中的副作用會在 DOM 更新之后同步執行。
function App() { const [width, setWidth] = useState(0); useLayoutEffect(() => { const title = document.querySelector("#title"); const titleWidth = title.getBoundingClientRect().width; console.log("useLayoutEffect"); if (width !== titleWidth) { setWidth(titleWidth); } }); useEffect(() => { console.log("useEffect"); }); return ( <div> <h1 id="title">hello</h1> <h2>{width}</h2> </div> ); }
在上面的例子中,useLayoutEffect 會在 render,DOM 更新之后同步觸發函數,會優於 useEffect 異步觸發函數。
useEffect和useLayoutEffect有什么區別?
簡單來說就是調用時機不同,useLayoutEffect
和原來componentDidMount
&componentDidUpdate
一致,在react完成DOM更新后馬上同步調用的代碼,會阻塞頁面渲染。而useEffect
是會在整個頁面渲染完才會調用的代碼。
官方建議優先使用useEffect
However, we recommend starting with useEffect first and only trying useLayoutEffect if that causes a problem.
在實際使用時如果想避免頁面抖動(在useEffect
里修改DOM很有可能出現)的話,可以把需要操作DOM的代碼放在useLayoutEffect
里。關於使用useEffect
導致頁面抖動,參考git倉庫git倉庫示例
不過useLayoutEffect
在服務端渲染時會出現一個warning,要消除的話得用useEffect
代替或者推遲渲染時機。
React Hooks 不足
盡管我們通過上面的例子看到 React Hooks 的強大之處,似乎類組件完全都可以使用 React Hooks 重寫。但是當下 v16.8 的版本中,還無法實現 getSnapshotBeforeUpdate 和 componentDidCatch 這兩個在類組件中的生命周期函數。官方也計划在不久的將來在 React Hooks 進行實現。
父組件
import Child from 'component/Child' const dataobj = { pdata1: 1, pdate2:2 } const [dataobj ,setDataobj ] = useState({}); const pchildref = useRef(); function parantHandler() { // 子組件調用的父組件方法 } function parentDivClick() { // 父組件調用子組件方法 pchildref .current._childFn(); } return ( <div> <Child ref={pchildref} params1={dataobj.pdata1} params2={dataobj.pdata2} handlerClick={parantHandler}> </Child> <div onClick={parentDivClick}></div> </div> )
子組件
const Child = (props,ref) => { // 接收父組件的傳值 const { params1,params2,handlerClick} = props; const childRef = useRef(); // 暴露的子組件方法,給父組件調用 useImperativeHandle(ref,() => { return { _childFn() { // something…. } } }) // handlerClick子組件調用父組件方法 return <div ref={childRef} onClick={handlerClick}> </div> } // forwardRef 配合useRef 父組件調用子組件方法使用 export default forwardRef(Child);