React系列二十一 - Hook(二)高級使用
一. Hook高級使用
1.1. useReducer
很多人看到useReducer的第一反應應該是redux的某個替代品,其實並不是。
useReducer僅僅是useState的一種替代方案:
-
在某些場景下,如果state的處理邏輯比較複雜,我們可以通過useReducer來對其進行拆分;
-
或者這次修改的state需要依賴之前的state時,也可以使用;
單獨創建一個reducer/counter.js文件:
export function counterReducer(state, action) { switch(action.type) { case "increment": return {...state, counter: state.counter + 1} case "decrement": return {...state, counter: state.counter - 1} default: return state; } }
home.js
import React, { useReducer } from 'react' import { counterReducer } from '../reducer/counter' export default function Home() { const [state, dispatch] = useReducer(counterReducer, {counter: 100}); return ( <div> <h2>當前計數: {state.counter}</h2> <button onClick={e => dispatch({type: "increment"})}>+1</button> <button onClick={e => dispatch({type: "decrement"})}>-1</button> </div> ) }
我們來看一下,如果我們創建另外一個profile.js也使用這個reducer函數,是否會進行數據的共享:
import React, { useReducer } from 'react' import { counterReducer } from '../reducer/counter' export default function Profile() { const [state, dispatch] = useReducer(counterReducer, {counter: 0}); return ( <div> <h2>當前計數: {state.counter}</h2> <button onClick={e => dispatch({type: "increment"})}>+1</button> <button onClick={e => dispatch({type: "decrement"})}>-1</button> </div> ) }
數據是不會共享的,它們只是使用了相同的counterReducer的函數而已。
所以,useReducer只是useState的一種替代品,並不能替代Redux。
1.2. useCallback
useCallback實際的目的是爲了進行性能的優化。
如何進行性能的優化呢?
-
useCallback會返回一個函數的 memoized(記憶的) 值;
-
在依賴不變的情況下,多次定義的時候,返回的值是相同的;
const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b] );
我們來看下面一段很有趣的代碼:
-
increment1在每次函數組件重新渲染時,會返回相同的值;
-
increment2每次定義的都是不同的值;
-
問題:是否increment1會比increment2更加節省性能呢?
-
事實上,經過一些測試,並沒有更加節省內存,因爲useCallback中還是會傳入一個函數作爲參數;
-
所以並不存在increment2每次創建新的函數,而increment1不需要創建新的函數這種性能優化;
-
那麼,爲什麼說useCallback是爲了進行性能優化呢?
import React, { memo, useState, useCallback } from 'react' export default function CallbackHookDemo() { const [count, setCount] = useState(0); const increment1 = useCallback(function increment() { setCount(count + 1); }, []); const increment2 = function() { setCount(count + 1); } return ( <div> <h2>當前計數: {count}</h2> <button onClick={increment1}>+1</button> <button onClick={increment2}>+1</button> </div> ) }
我們來對上面的代碼進行改進:
-
在下面的代碼中,我們將回調函數傳遞給了子組件,在子組件中會進行調用;
-
在發生點擊時,我們會發現接受increment1的子組件不會重新渲染,但是接受increment2的子組件會重新渲染;
-
所以useCallback最主要用於性能渲染的地方應該是和memo結合起來,決定子組件是否需要重新渲染;
import React, { memo, useState, useCallback } from 'react'; const CounterIncrement = memo((props) => { console.log("CounterIncrment被渲染:", props.name); return <button onClick={props.increment}>+1</button> }) export default function CallbackHookDemo() { const [count, setCount] = useState(0); const increment1 = useCallback(function increment() { setCount(count + 1); }, []); const increment2 = function() { setCount(count + 1); } return ( <div> <h2>當前計數: {count}</h2> {/* <button onClick={increment1}>+1</button> <button onClick={increment2}>+1</button> */} <CounterIncrement increment={increment1} name="increment1"/> <CounterIncrement increment={increment2} name="increment2"/> </div> ) }
1.3. useMemo
useMemo實際的目的也是爲了進行性能的優化。
如何進行性能的優化呢?
-
useMemo返回的也是一個 memoized(記憶的) 值;
-
在依賴不變的情況下,多次定義的時候,返回的值是相同的;
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
我們來看一個案例:
-
無論我們點擊了是
+1
還是切換
案例都會重新計算一次; -
事實上,我們只是希望在count發生變化時重新計算;
import React, { useState, useMemo } from 'react'; function calcNum(count) { let total = 0; for (let i = 0; i < count; i++) { total += i; } console.log("計算一遍"); return total } export default function MemoHookDemo() { const [count, setCount] = useState(10); const [isLogin, setIsLogin] = useState(true); const total = calcNum(count); return ( <div> <h2>數字和: {total}</h2> <button onClick={e => setCount(count + 1)}>+1</button> {isLogin && <h2>Coderwhy</h2>} <button onClick={e => setIsLogin(!isLogin)}>切換</button> </div> ) }
這個時候,我們可以使用useMemo來進行性能的優化:
import React, { useState, useMemo } from 'react'; function calcNum(count) { let total = 0; for (let i = 0; i < count; i++) { total += i; } console.log("計算一遍"); return total } export default function MemoHookDemo() { const [count, setCount] = useState(10); const [isLogin, setIsLogin] = useState(true); const total = useMemo(() => { return calcNum(count); }, [count]); return ( <div> <h2>數字和: {total}</h2> <button onClick={e => setCount(count + 1)}>+1</button> {isLogin && <h2>Coderwhy</h2>} <button onClick={e => setIsLogin(!isLogin)}>切換</button> </div> ) }
當然,useMemo也可以用於子組件的性能優化:
-
ShowCounter子組件依賴的是一個基本數據類型,所以在比較的時候只要值不變,那麼就不會重新渲染;
-
ShowInfo接收的是一個對象,每次都會定義一個新的對象,所以我們需要通過useMemo來對其進行優化;
import React, { useState, useMemo, memo } from 'react'; function calcNum(count) { let total = 0; for (let i = 0; i < count; i++) { total += i; } console.log("計算一遍"); return total } const ShowCounter = memo((props) => { console.log("重新渲染"); return <h1>Counter: {props.total}</h1> }) const ShowInfo = memo((props) => { console.log("ShowInfo重新渲染"); return <h1>信息: {props.info.name}</h1> }) export default function MemoHookDemo() { const [count, setCount] = useState(10); const [isLogin, setIsLogin] = useState(true); const total = useMemo(() => { return calcNum(count); }, [count]); const info = useMemo(() => { return {name: "why"} }, []) return ( <div> <h2>數字和: {total}</h2> <ShowCounter total={total} /> <ShowInfo info={info}/> <button onClick={e => setCount(count + 1)}>+1</button> {isLogin && <h2>Coderwhy</h2>} <button onClick={e => setIsLogin(!isLogin)}>切換</button> </div> ) }
1.4. useRef
useRef返回一個ref對象,返回的ref對象在組件的整個生命週期保持不變。
最常用的ref是兩種用法:
-
用法一:引入DOM(或者組件,但是需要是class組件)元素;
-
用法二:保存一個數據,這個對象在整個生命週期中可以保存不變;
用法一:引用DOM
import React, { useRef } from 'react'; export default function RefHookDemo() { const inputRef = useRef(); const titleRef = useRef(); const handleOperating = () => { titleRef.current.innerHTML = "我是coderwhy"; inputRef.current.focus(); } return ( <div> <input type="text" ref={inputRef}/> <h2 ref={titleRef}>默認內容</h2> <button onClick={e => handleOperating()}>操作</button> </div> ) }
用法二:使用ref保存上一次的某一個值
-
useRef可以想象成在ref對象中保存了一個.current的可變盒子;
-
useRef在組件重新渲染時,返回的依然是之前的ref對象,但是current是可以修改的;
import React, { useState, useEffect, useRef } from 'react'; let preValue = 0; export default function RefHookDemo02() { const [count, setCount] = useState(0); const countRef = useRef(count); useEffect(() => { countRef.current = count; }, [count]); return ( <div> <h2>前一次的值: {countRef.current}</h2> <h2>這一次的值: {count}</h2> <button onClick={e => setCount(count + 1)}>+1</button> </div> ) }
1.5. useImperativeHandle
useImperativeHandle並不是特別好理解,我們一點點來學習。
我們先來回顧一下ref和forwardRef結合使用:
-
通過forwardRef可以將ref轉發到子組件;
-
子組件拿到父組件中創建的ref,綁定到自己的某一個元素中;
import React, { useRef, forwardRef } from 'react'; const HYInput = forwardRef(function (props, ref) { return <input type="text" ref={ref}/> }) export default function ForwardDemo() { const inputRef = useRef(); return ( <div> <HYInput ref={inputRef}/> <button onClick={e => inputRef.current.focus()}>聚焦</button> </div> ) }
上面的做法本身沒有什麼問題,但是我們是將子組件的DOM直接暴露給了父組件:
-
直接暴露給父組件帶來的問題是某些情況的不可控;
-
父組件可以拿到DOM後進行任意的操作;
-
但是,事實上在上面的案例中,我們只是希望父組件可以操作的focus,其他並不希望它隨意操作;
通過useImperativeHandle可以只暴露固定的操作:
-
通過useImperativeHandle的Hook,將
傳入的ref
和useImperativeHandle第二個參數返回的對象
綁定到了一起; -
所以在父組件中,使用
inputRef.current
時,實際上使用的是返回的對象
; -
比如我調用了
focus函數
,甚至可以調用printHello函數
;
import React, { useRef, forwardRef, useImperativeHandle } from 'react'; const HYInput = forwardRef(function (props, ref) { // 創建組件內部的ref const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); }, printHello: () => { console.log("Hello World") } })) // 這裏綁定的是組件內部的inputRef return <input type="text" ref={inputRef}/> }) export default function ImperativeHandleHookForwardDemo() { const inputRef = useRef(); return ( <div> <HYInput ref={inputRef}/> <button onClick={e => inputRef.current.focus()}>聚焦</button> <button onClick={e => inputRef.current.printHello()}>Hello World</button> </div> ) }
1.6. useLayoutEffect
useLayoutEffect看起來和useEffect非常的相似,事實上他們也只有一點區別而已:
-
useEffect會在渲染的內容更新到DOM上後執行,不會阻塞DOM的更新;
-
useLayoutEffect會在渲染的內容更新到DOM上之前執行,會阻塞DOM的更新;
如果我們希望在某些操作發生之後再更新DOM,那麼應該將這個操作放到useLayoutEffect。
我們來看下面的一段代碼:
-
這段代碼在開發中會發生閃爍的現象;
-
因爲我們先將count設置爲了0,那麼DOM會被更新,並且會執行一次useEffect中的回調函數;
-
在useEffect中我們發現count爲0,又執行一次setCount操作,那麼DOM會再次被更新,並且useEffect又會被執行一次;
import React, { useEffect, useState, useLayoutEffect } from 'react'; export default function EffectHookDemo() { const [count, setCount] = useState(0); useEffect(() => { if (count === 0) { setCount(Math.random()*200) } }, [count]); return ( <div> <h2>當前數字: {count}</h2> <button onClick={e => setCount(0)}>隨機數</button> </div> ) }
事實上,我們上面的操作的目的是在count被設置爲0時,隨機另外一個數字:
-
如果我們使用useLayoutEffect,那麼會等到useLayoutEffect代碼執行完畢後,再進行DOM的更新;
import React, { useEffect, useState, useLayoutEffect } from 'react'; export default function EffectHookDemo() { const [count, setCount] = useState(0); useLayoutEffect(() => { if (count === 0) { setCount(Math.random()*200) } }, [count]); return ( <div> <h2>當前數字: {count}</h2> <button onClick={e => setCount(0)}>隨機數</button> </div> ) }
二. 自定義Hook
2.1. 認識自定義hook
自定義Hook本質上只是一種函數代碼邏輯的抽取,嚴格意義上來說,它本身並不算React的特性。
需求:所有的組件在創建和銷燬時都進行打印
-
組件被創建:打印
組件被創建了
; -
組件被銷燬:打印
組件被銷燬了
;
export default function CustomHookDemo() { useEffect(() => { console.log("組件被創建了"); return () => { console.log("組件被銷燬了"); } }, []) return ( <div> <h2>CustomHookDemo</h2> </div> ) }
但是這樣來做意味着所有的組件都需要有對應的邏輯:
function Home(props) { useEffect(() => { console.log("組件被創建了"); return () => { console.log("組件被銷燬了"); } }, []) return <h2>Home</h2> } function Profile(props) { useEffect(() => { console.log("組件被創建了"); return () => { console.log("組件被銷燬了"); } }, []) return <h2>Profile</h2> }
如何可以對它們的邏輯進行抽取呢?
-
我們可能希望抽取到一個函數中;
function loggingLife() { useEffect(() => { console.log("組件被創建了"); return () => { console.log("組件被銷燬了"); } }, []) }
但是,抽取到這裏調用之後,代碼是報錯的:
-
原因是普通的函數中不能使用hook
那麼,我們應該如何操作呢?
-
非常簡單,函數以特殊的方式命名,以
use
開頭即可;
function useLoggingLife() { useEffect(() => { console.log("組件被創建了"); return () => { console.log("組件被銷燬了"); } }, []) }
當然,自定義Hook可以有參數,也可以有返回值:
function useLoggingLife(name) { useEffect(() => { console.log(`${name}組件被創建了`); return () => { console.log(`${name}組件被銷燬了`); } }, []) }
2.2. 自定義Hook練習
我們通過一些案例來練習一下自定義Hook。
使用User、Token的Context
比如多個組件都需要使用User和Token的Context:
-
這段代碼我們在每次使用user和token時都需要導入對應的Context,並且需要使用兩次useContext;
import React, { useContext } from 'react' import { UserContext, TokenContext } from '../App' export default function CustomHookContextDemo() { const user = useContext(UserContext); const token = useContext(TokenContext); console.log(user, token); return ( <div> <h2>CustomHookContextDemo</h2> </div> ) }
我們可以抽取到一個自定義Hook中:
function useUserToken() { const user = useContext(UserContext); const token = useContext(TokenContext); return [user, token]; }
獲取窗口滾動的位置
在開發中,某些場景我們可能總是希望獲取創建滾動的位置:
import React, { useEffect, useState } from 'react' export default function CustomScrollPositionHook() { const [scrollPosition, setScrollPosition] = useState(0); useEffect(() => { const handleScroll = () => { setScrollPosition(window.scrollY); } document.addEventListener("scroll", handleScroll); return () => { document.removeEventListener("scroll", handleScroll); } }, []) return ( <div style={{padding: "1000px 0"}}> <h2 style={{position: "fixed", top: 0, left: 0}}>CustomScrollPositionHook: {scrollPosition}</h2> </div> ) }
但是如果每一個組件都有對應這樣的一個邏輯,那麼就會存在很多的冗餘代碼:
function useScrollPosition() { const [scrollPosition, setScrollPosition] = useState(0); useEffect(() => { const handleScroll = () => { setScrollPosition(window.scrollY); } document.addEventListener("scroll", handleScroll); return () => { document.removeEventListener("scroll", handleScroll); } }, []) return scrollPosition; }
數據存儲的localStorage
在開發中,我們會有一些數據希望通過localStorage進行存儲(當然,你可以根據自己的情況選擇sessionStorage)
import React, { useState, useEffect } from 'react' export default function CustomDataStoreHook() { const [name, setName] = useState(() => { return JSON.parse(window.localStorage.getItem("name")) }); useEffect(() => { window.localStorage.setItem("name", JSON.stringify(name)); }, [name]) return ( <div> <h2>CustomDataStoreHook: {name}</h2> <button onClick={e => setName("coderwhy")}>設置name</button> </div> ) }
如果每一個裏面都有這樣的邏輯,那麼代碼就會變得非常冗餘:
function useLocalStorange(key) { const [data, setData] = useState(() => { return JSON.parse(window.localStorage.getItem(key)) }); useEffect(() => { window.localStorage.setItem(key, JSON.stringify(data)); }, [data]); return [data, setData]; }
三. Hook原理分析
這裏我主要分析一下useState的原理,因爲本次教程是穿插講解源碼的,所以不會所有源碼一一講解。
3.1. useState代碼位置
useState還是從React中導入的,所以我們可以先查看:
點到useState的源碼中:
-
useState本質上,是使用的dispatcher的useState;
dispatcher來自另外的一個函數 resolveDispatcher
:
運行的過程中,會賦值一個current的值是一個Dispatcher類型:
-
Dispatcher來自於
react-reconciler/src/ReactFiberHooks
Dispatch類型的定義:
這裏的Dispatch僅僅是一個類型而已,我們賦值具體的值在不同的階段是不同的:
-
在掛載階段:HooksDispatcherOnMount
-
在更新階段:HooksDispatcherOnUpdate
掛載哪一個取決於renderWithHook函數:
3.2. HooksDispatcherOnMount
HooksDispatcherOnMount對應的useState是mountState
mountState的源碼:
綁定的dispatchAction函數,事實上是將所有的action放到了queue的隊列中:
3.3. HooksDispatcherOnUpdate
HooksDispatcherOnUpdate對應的useState是updateState
updateState本質上會執行updateReducer:
-
所有其實就更新階段而言,useState本質上用的是updateReducer
updateReducer的源碼如下:
四. Redux Hooks
在之前的redux開發中,爲了讓組件和redux結合起來,我們使用了react-redux中的connect:
-
但是這種方式必須使用
高階函數
結合返回的高階組件
; -
並且必須編寫:
mapStateToProps
和mapDispatchToProps
映射的函數;
在Redux7.1開始,提供了Hook的方式,我們再也不需要編寫connect以及對應的映射函數了
4.1. useSelector使用
useSelector的作用是將state映射到組件中:
-
參數一:將state映射到需要的數據中;
-
參數二:可以進行比較來決定是否組件重新渲染;(後續講解)
const result: any = useSelector(selector: Function, equalityFn?: Function)
現在,我可以改進一下之前的Profile中使用redux的代碼:
function Profile(props) { const {banners, recommends, counter} = useSelector(state => ({ banners: state.homeInfo.banners, recommends: state.homeInfo.recommends })); console.log("Profile重新渲染"); return ( <div> <h2>數字: {counter}</h2> <h1>Banners</h1> <ul> { banners.map((item, index) => { return <li key={item.acm}>{item.title}</li> }) } </ul> <h1>Recommends</h1> <ul> { recommends.map((item, index) => { return <li key={item.acm}>{item.title}</li> }) } </ul> </div> ) }
但是這段代碼會有一個問題:
-
當前我們的組件並不依賴counter,但是counter發生改變時,依然會引起Profile的重新渲染;
原因是什麼呢?
-
useSelector默認會比較我們返回的兩個對象是否相等;
-
如何比較呢?
const refEquality = (a, b) => a === b
; -
也就是我們必須返回兩個完全相等的對象纔可以不引起重新渲染;
這個時候,我們可以使用react-redux中給我們提供的 shallowEqual:
-
這段代碼的作用是避免不必要的重新渲染;
const {banners, recommends, counter} = useSelector(state => ({ banners: state.homeInfo.banners, recommends: state.homeInfo.recommends }), shallowEqual);
當然,你也可以編寫自己的比較函數,來決定是否重新渲染。
4.2. useDispatch
useDispatch非常簡單,就是直接獲取dispatch函數,之後在組件中直接使用即可:
const dispatch = useDispatch()
直接使用dispatch:
<button onClick={e => dispatch(subAction(1))}>-1</button> <button onClick={e => dispatch(subAction(5))}>-5</button>
我們還可以通過useStore來獲取當前的store對象:
const store = useStore()
在組件中可以使用store:
const store = useStore(); console.log(store.getState());