在過去的幾個月裏,React Hooks 在咱們的項目中獲得了充分利用。在實際使用過程當中,我發現 React Hooks 除了帶來簡潔的代碼外,也存在對其使用不當的狀況。html
在這篇文章中,我想總結我過去幾個月來對 React Hooks 使用,分享我對它的見解以及我認爲的最佳實踐,供你們參考。前端
本文假定讀者已經對 React-Hooks 及其使用方式有了初步的瞭解。您能夠經過 官方文檔 進行學習。react
簡而言之,就是在一個函數中返回 React Element。git
const App = (props) => { const { title } = props; return ( <h1>{title}</h1> ); };
通常的,該函數接收惟一的參數:props 對象。從該對象中,咱們能夠讀取到數據,並經過計算產生新的數據,最後返回 React Elements 以交給 React 進行渲染。此外也能夠選擇在函數中執行反作用。github
在本文中,咱們給函數式組件的函數起個簡單一點的名字:render 函數。數組
const appElement = App({ title: "XXX" }); ReactDOM.render( appElement, document.getElementById('app') );
在上方的代碼中,咱們自行調用了 render 函數以期執行渲染。然而這在 React 中不是正常的操做。瀏覽器
正常操做是像下方這樣的代碼:緩存
// React.createElement(App, { // title: "XXX" // }); const appElement = <App title="XXX" />; ReactDOM.render( appElement, document.getElementById('app') );
在 React 內部,它會決定在什麼時候調用 render 函數,並對返回的 React Elements 進行遍歷,若是遇到函數組件,React 便會繼續調用這個函數組件。在這個過程當中,能夠由父組件經過 props 將數據傳遞到該子組件中。最終 React 會調用完全部的組件,從而知曉如何進行渲染。app
這種把 render 函數交給 React 內部處理的機制,爲引入狀態帶來了可能。less
在本文中,爲了方便描述,對於 render 函數的每次調用,我想稱它爲一幀。
在引入狀態以前,咱們須要明白這一點。
咱們經過 例一 進行觀察:
function Example(props) { const { count } = props; const handleClick = () => { setTimeout(() => { alert(count); }, 3000); }; return ( <div> <p>{count}</p> <button onClick={handleClick}>Alert Count</button> </div> ); }
重點關注 <Example>
函數組件的代碼,其中的 count
屬性由父組件傳入,初始值爲 0,每隔一秒增長 1。點擊 "Alert Count" 按鈕,將延遲 3 秒鐘彈出 count
的值。操做後發現,彈窗中出現的值,與頁面中文本展現的值不一樣,而是等於點擊 "alert Count" 按鈕時 count
的值。
若是更換爲 class 組件,它的實現是 <Example2>
這樣的:
class Example2 extends Component { handleClick = () => { setTimeout(() => { alert(this.props.count); }, 3000); }; render() { return ( <div> <h2>Example2</h2> <p>{this.props.count}</p> <button onClick={this.handleClick}>Alert Count</button> </div> ); } }
此時,點擊 "Alert Count" 按鈕,延遲 3 秒鐘彈出 count
的值,與頁面中文本展現的值是同樣的。
在某些狀況下,<Example>
函數組件中的行爲才符合預期。若是將 setTimeout
類比到一次 Fetch 請求,在請求成功時,我要獲取的是發起 Fetch 請求前相關的數據,並對其進行修改。
如何理解其中的差別呢?
在 <Example2>
class 組件中,咱們是從 this
中獲取到的 props.count
。this
是固定指向同一個組件實例的。在 3 秒的延時器生效後,組件從新進行了渲染,this.props
也發生了改變。當延時的回調函數執行時,讀取到的 this.props
是當前組件最新的屬性值。
而在 <Example>
函數組件中,每一次執行 render 函數時,props
做爲該函數的參數傳入,它是函數做用域下的變量。
當 <Example>
組件被建立,將運行相似這樣的代碼來完成第一幀:
const props_0 = { count: 0 }; const handleClick_0 = () => { setTimeout(() => { alert(props_0.count); }, 3000); }; return ( <div> <h2>Example</h2> <p>{props_0.count}</p> <button onClick={handleClick_0}>alert Count</button> </div> );
當父組件傳入的 count 變爲 1,React 會再次調用 Example
函數,執行第二幀,此時 count
是 1
。
const props_1 = { count: 1 }; const handleClick_1 = () => { setTimeout(() => { alert(props_1.count); }, 3000); }; return ( <div> <h2>Example</h2> <p>{props_1.count}</p> <button onClick={handleClick_1}>alert Count</button> </div> );
因爲 props
是 Example
函數做用域下的變量,能夠說對於這個函數的每一次調用中,都產生了新的 props
變量,它在聲明時被賦予了當前的屬性,他們相互間互不影響。
換一種說法,對於其中任一個 props
,其值在聲明時便已經決定,不會隨着時間產生變化。handleClick
函數亦是如此。例如定時器的回調函數是在將來發生的,但 props.count
的值是在聲明 handleClick
函數時就已經決定好的。
若是咱們在函數開頭使用解構賦值,const { count } = props
,以後直接使用 count
,和上面的狀況沒有區別。
能夠簡單的認爲,在某個組件中,對於返回的 React Elements 樹形結構,某個位置的 element ,其類型與 key 屬性均不變,React 便會選擇重用該組件實例;不然,好比從 <A/>
組件切換到了 <B/>
組件,會銷燬 A,而後重建 B,B 此時會執行第一幀。
在實例中,能夠經過 useState
等方式擁有局部狀態。在重用的過程當中,這些狀態會獲得保留。而若是沒法重用,狀態會被銷燬。
例如 useState
,爲當前的函數組件建立了一個狀態,這個狀態的值獨立於函數存放。 useState
會返回一個數組,在該數組中,獲得該狀態的值和更新該狀態的方法。經過解構,該狀態的值會賦值到當前 render 函數做用域下的一個常量 state
中。
const [state, setState] = useState(initialState);
當組件被建立而不是重用時,即在組件的第一幀中,該狀態將被賦予初始值 initialState
,而以後的重用過程當中,不會被重複賦予初始值。
經過調用 setState
,能夠更新狀態的值。
須要明確的是,state
做爲函數中的一個常量,就是普通的數據,並不存在諸如數據綁定這樣的操做來驅使 DOM 發生更新。在調用 setState
後,React 將從新執行 render 函數,僅此而已。
所以,狀態也是函數做用域下的普通變量。咱們能夠說每次函數執行擁有獨立的狀態。
爲了加深印象,咱們來看 例二,它是 React 官網某個例子的複雜化:
function Example2() { const [count, setCount] = useState(0); const handleClick = () => { setTimeout(() => { setCount(count + 1); }, 3000); }; return ( <div> <p>{count}</p> <button onClick={() => setCount(count + 1)}> setCount </button> <button onClick={handleClick}> Delay setCount </button> </div> ); }
在第一幀中,p
標籤中的文本爲 0。點擊 "Delay setCount",文本依然爲 0。隨後在 3 秒內連續點擊 "setCount" 兩次,將會分別執行第二幀和第三幀。你將看到 p
標籤中的文本由 0 變化爲 1, 2。但在點擊 "Delay setCount" 3 秒後,文本從新變爲 1。
// 第一幀 const count_1 = 0; const handleClick_1 = () => { const delayAction_1 = () => { setCount(count_1 + 1); }; setTimeout(delayAction_1, 3000); }; //... <button onClick={handleClick_1}> //... // 點擊 "setCount" 後第二幀 const count_2 = 1; const handleClick_2 = () => { const delayAction_2 = () => { setCount(count_2 + 1); }; setTimeout(delayAction_2, 3000); }; //... <button onClick={handleClick_2}> //... // 再次點擊 "setCount" 後第三幀 const count_3 = 2; const handleClick_3 = () => { const delayAction_3 = () => { setCount(count_3 + 1); }; setTimeout(delayAction_3, 3000); }; //... <button onClick={handleClick_3}> //...
count
,handleClick
都是 Example2
函數做用域中的常量。在點擊 "Delay setCount" 時,定時器設置 3000ms 到期後的執行函數爲 delayAction_1
,函數中讀取 count_1
常量的值是 0,這和第二幀的 count_2
無關。
對於 state,若是想要在第一幀時點擊 "Delay setCount" ,在一個異步回調函數的執行中,獲取到 count
最新一幀中的值,不妨向 setCount
傳入函數做爲參數。
其餘狀況下,例如須要讀取到 state 及其衍生的某個常量,相對於變量聲明時所在幀過去或將來的值,就須要使用 useRef
,經過它來擁有一個在全部幀中共享的變量。
若是要與 class 組件進行比較,useRef
的做用相對於讓你在 class 組件的 this
上追加屬性。
const refContainer = useRef(initialValue);
在組件的第一幀中,refContainer.current
將被賦予初始值 initialValue
,以後便再也不發生變化。但你能夠本身去設置它的值。設置它的值不會從新觸發 render 函數。
例如,咱們把第 n 幀的某個 props 或者 state 經過 useRef
進行保存,在第 n + 1 幀能夠讀取到過去的,第 n 幀中的值。咱們也能夠在第 n + 1 幀使用 ref 保存某個 props 或者 state,而後在第 n 幀中聲明的異步回調函數中讀取到它。
對 例二 進行修改,獲得 例三,看看具體的效果:
[![Edit 獲取過去或將來幀中的值
](https://codesandbox.io/static...](https://codesandbox.io/s/recu...
function Example() { const [count, setCount] = useState(0); const currentCount = useRef(count); currentCount.current = count; const handleClick = () => { setTimeout(() => { setCount(currentCount.current + 1); }, 3000); }; return ( <div> <p>{count}</p> <button onClick={() => setCount(count + 1)}> setCount </button> <button onClick={handleClick}> Delay setCount </button> </div> ); }
在 setCount
後便會執行下一幀,在函數的開頭,currentCount
始終與最新的 count
state 保持同步。所以,在 setTimeout
中能夠經過此方法獲取到回調函數執行時當前的 count 值。
接下來再經過 例四 瞭解如何獲取過去幀中的值:
function Example4() { const [count, setCount] = useState(1); const prevCountRef = useRef(1); const prevCount = prevCountRef.current; prevCountRef.current = count; const handleClick = () => { setCount(prevCount + count); }; return ( <div> <p>{count}</p> <button onClick={handleClick}>SetCount</button> </div> ); }
這段代碼實現的功能是,count 初始值爲 1,點擊按鈕後累加到 2,隨後點擊按鈕,老是用當前 count 的值和前一個 count 的值進行累加,獲得新的 count 的值。
prevCountRef
在 render 函數執行的過程當中,與最新的 count
state 進行了同步。因爲在同步前,咱們將該 ref 保存到函數做用域下的另外一個變量 prevCount
中,所以咱們老是可以獲取到前一個 count 的值。
一樣的方法,咱們能夠用於保存任何值:某個 prop,某個 state 變量,甚至一個函數等。在後面的 Effects 部分,咱們會繼續使用 refs 爲咱們帶來好處。
若是弄清了前面的『每一幀擁有獨立的變量』的概念,你會發現,若某個 useEffect/useLayoutEffect 有且僅有一個函數做爲參數,那麼每次 render 函數執行時該 Effects 也是獨立的。由於它是在 render 函數中選擇適當時機的執行。
對於 useEffect
來講,執行的時機是完成全部的 DOM 變動並讓瀏覽器渲染頁面後,而 useLayoutEffect
和 class 組件中 componentDidMount
, componentDidUpdate
一致——在 React 完成 DOM 更新後立刻同步調用,會阻塞頁面渲染。
若是 useEffect 沒有傳入第二個參數,那麼第一個參數傳入的 effect 函數在每次 render 函數執行是都是獨立的。每一個 effect 函數中捕獲的 props 或 state 都來自於那一次的 render 函數。
咱們能夠再觀察一個例子:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { console.log(`You clicked ${count} times`); }, 3000); }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
在這個例子中,每一次對 count
進行改變,從新執行 render 函數後,延遲 3 秒打印 count
的值。
若是咱們不停地點擊按鈕,打印的結果是什麼呢?
咱們發現通過延時後,每一個 count 的值被依次打印了,他們從 0 開始依次遞增,且不重複。
若是換成 class 組件,嘗試使用 componentDidUpdate
去實現,會獲得不同的結果:
componentDidUpdate() { setTimeout(() => { console.log(`You clicked ${this.state.count} times`); }, 3000); }
this.state.count
老是指向最新的 count
值,而不是屬於某次調用 render 函數時的值。
所以,在使用 useEffect 時,應當拋開在 class 組件中關於生命週期的思惟。他們並不相同。在 useEffect 中刻意尋找那幾個生命週期函數的替代寫法,將會陷入僵局,沒法充分發揮 useEffect 的能力。
React 針對 React Elements 先後值進行對比,只去更新 DOM 真正發生改變的部分。對於 Effects,可否有相似這樣的理念呢?
某個 Effects 函數一旦執行,函數內的反作用已經發生,React 沒法猜想到函數相比於上一次作了哪些變化。但咱們能夠給 useEffect 傳入第二個參數,做爲依賴數組 (deps),避免 Effects 沒必要要的重複調用。
這個 deps 的含義是:當前 Effect 依賴了哪些變量。
但有時問題不必定能解決。好比官網就有 這樣的例子:
const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]);
若是咱們頻繁修改 count
,每次執行 Effect,上一次的計時器被清除,須要調用 setInterval
從新進入時間隊列,實際的按期時間被延後,甚至有可能根本沒有機會被執行。
可是下面這樣的實踐方式也不宜採用:
在 Effect 函數中尋找一些變量添加到 deps 中,須要知足條件:其變化時,須要從新觸發 effect。
按照這種實踐方式,count
變化時,咱們並不但願從新 setInterval
,故 deps 爲空數組。這意味着該 hook 只在組件掛載時運行一次。Effect 中明明依賴了 count
,但咱們撒謊說它沒有依賴,那麼當 setInterval
回調函數執行時,獲取到的 count
值永遠爲 0。
遇到這種問題,直接從 deps 移除是不可行的。靜下來分析一下,此處爲何要用到 count
?可否避免對其直接使用?
能夠看到,在 setCount
中用到了 count
,爲的是把 count
轉換爲 count + 1
,而後返回給 React。React 其實已經知道當前的 count
,咱們須要告知 React 的僅僅是去遞增狀態,無論它如今具體是什麼值。
因此有一個最佳實踐:狀態變動時,應該經過 setState 的函數形式來代替直接獲取當前狀態。
setCount(c => c + 1);
另一種場景是:
const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(id); }, []);
在這裏,一樣的,當count
變化時,咱們並不但願從新 setInterval
。但咱們能夠把 count
經過 ref 保存起來。
const [count, setCount] = useState(0); const countRef = useRef(); countRef.current = count; useEffect(() => { const id = setInterval(() => { console.log(countRef.current); }, 1000); return () => clearInterval(id); }, []);
這樣,count
的確再也不被使用,而是用 ref 存儲了一個在全部幀中共享的變量。
另外的狀況是,Effects 依賴了函數或者其餘引用類型。與原始數據類型不一樣的是,在未優化的狀況下,每次 render 函數調用時,由於對這些內容的從新建立,其值老是發生了變化,致使 Effects 在使用 deps 的狀況下依然會頻繁被調用。
對於這個問題,官網的 FAQ 已經給出了答案:對於函數,使用 useCallback 避免重複建立;對於對象或者數組,則可使用 useMemo。從而減小 deps 的變化。
使用 ESLint 插件 eslint-plugin-react-hooks@>=2.4.0
,頗有必要。
該插件除了幫你檢查使用 Hook 須要遵循的兩條規則外,還會向你提示在使用 useEffect 或者 useMemo 時,deps 應該填入的內容。
若是你正在使用 VSCode,而且安裝了 ESLint 擴展。當你編寫 useEffect 或者 useMemo ,且 deps 中的內容並不完整時,deps 所在的那一行便會給出警告或者錯誤的提示,而且會有一個快速修復的功能,該功能會爲你自動填入缺失的 deps。
對於這些提示,不要暴力地經過 eslint-disable
禁用。將來,你可能再次修改該 useEffect 或者 useMemo,若是使用了新的依賴而且在 deps 中漏掉了它,便會引起新的問題。有一些場景,好比 useEffect 依賴一個函數,而且填入 deps 了。可是這個函數使用了 useCallback 且 deps 出現了遺漏,這種狀況下一旦出現問題,排查的難度會很大,因此爲何要讓 ESLint 沉默呢?
嘗試用上一節的方法進行分析,對於一些變量不但願引發 effect 從新更新的,使用 ref 解決。對於獲取狀態用於計算新的狀態的,嘗試 setState 的函數入參,或者使用 useReducer 整合多個類型的狀態。
useMemo 的含義是,經過一些變量計算獲得新的值。經過把這些變量加入依賴 deps,當 deps 中的值均未發生變化時,跳過此次計算。useMemo 中傳入的函數,將在 render 函數調用過程被同步調用。
可使用 useMemo 緩存一些相對耗時的計算。
除此之外,useMemo 也很是適合用於存儲引用類型的數據,能夠傳入對象字面量,匿名函數等,甚至是 React Elements。
const data = useMemo(() => ({ a, b, c, d: 'xxx' }), [a, b, c]); // 能夠用 useCallback 代替 const fn = useMemo(() => () => { // do something }, [a, b]); const memoComponentsA = useMemo(() => ( <ComponentsA {...someProps} /> ), [someProps]);
在這些例子中,useMemo 的目的實際上是儘可能使用緩存的值。
對於函數,其做爲另一個 useEffect 的 deps 時,減小函數的從新生成,就能減小該 Effect 的調用,甚至避免一些死循環的產生;
對於對象和數組,若是某個子組件使用了它做爲 props,減小它的從新生成,就能避免子組件沒必要要的重複渲染,提高性能。
未優化的代碼以下:
const data = { id }; return <Child data={data}>;
此時,每當父組件須要 render 時,子組件也會執行 render。若是使用 useMemo
對 data 進行優化:
const data = useMemo(() => ({ id }), [id]); return <Child data={data}>;
當父組件 render 時,只要知足 id 不變,data 的值也不會發生變化,子組件也將避免 render。
對於組件返回的 React Elements,咱們能夠選擇性地提取其中一部分 elements,經過 useMemo 進行緩存,也能避免這一部分的重複渲染。
在過去的 class 組件中,咱們經過 shouldComponentUpdate
判斷當前屬性和狀態是否和上一次的相同,來避免組件沒必要要的更新。其中的比較是對於本組件的全部屬性和狀態而言的,沒法根據 shouldComponentUpdate
的返回值來使該組件一部分 elements 更新,另外一部分不更新。
爲了進一步優化性能,咱們會對大組件進行拆分,拆分出的小組件只關心其中一部分屬性,從而有更多的機會不去更新。
而函數組件中的 useMemo 其實就能夠代替這一部分工做。爲了方便理解,咱們來看 例五:
[![Edit 使用 useMemo 緩存 React Elements
](https://codesandbox.io/static...](https://codesandbox.io/s/goof...
function Example(props) { const [count, setCount] = useState(0); const [foo] = useState("foo"); const main = ( <div> <Item key={1} x={1} foo={foo} /> <Item key={2} x={2} foo={foo} /> <Item key={3} x={3} foo={foo} /> <Item key={4} x={4} foo={foo} /> <Item key={5} x={5} foo={foo} /> </div> ); return ( <div> <p>{count}</p> <button onClick={() => setCount(count + 1)}>setCount</button> {main} </div> ); }
假設 <Item>
組件,其自身的 render 消耗較多的時間。默認狀況下,每次 setCount 改變 count 的值,便會從新對 <Example>
進行 render,其返回的 React Elements 中3個 <Item>
也從新 render,其耗時的操做阻塞了 UI 的渲染。致使按下 "setCount" 按鈕後出現了明顯的卡頓。
爲了優化性能,咱們能夠將 main
變量這一部分單獨做爲一個組件 <Main>
,拆分出去,並對 <Main>
使用諸如 React.memo
, shouldComponentUpdate
的方式,使 count
屬性變化時,<Main>
不重複 render。
const Main = React.memo((props) => { const { foo }= props; return ( <div> <Item key={1} x={1} foo={foo} /> <Item key={2} x={2} foo={foo} /> <Item key={3} x={3} foo={foo} /> <Item key={4} x={4} foo={foo} /> <Item key={5} x={5} foo={foo} /> </div> ); });
而如今,咱們可使用 useMemo
,避免了組件拆分,代碼也更簡潔易懂:
function Example(props) { const [count, setCount] = useState(0); const [foo] = useState("foo"); const main = useMemo(() => ( <div> <Item key={1} x={1} foo={foo} /> <Item key={2} x={2} foo={foo} /> <Item key={3} x={3} foo={foo} /> <Item key={4} x={4} foo={foo} /> <Item key={5} x={5} foo={foo} /> </div> ), [foo]); return ( <div> <p>{count}</p> <button onClick={() => setCount(count + 1)}>setCount</button> {main} </div> ); }
對於 state,其擁有 惰性初始化的方法。可能有人不明白它的做用。
someExpensiveComputation
是一個相對耗時的操做。若是咱們直接採用
const initialState = someExpensiveComputation(props); const [state, setState] = useState(initialState);
注意,雖然 initialState
只在初始化時有其存在的價值,可是 someExpensiveComputation
在每一幀都被調用了。只有當使用惰性初始化的方法:
const [state, setState] = useState(() => { const initialState = someExpensiveComputation(props); return initialState; });
因 someExpensiveComputation
運行在一個匿名函數下,該函數當且僅當初始化時被調用,從而優化性能。
咱們甚至能夠跳出計算 state 這一規定,來完成任何昂貴的初始化操做。
useState(() => { someExpensiveComputation(props); return null; });
當 useEffect
的依賴頻繁變化,你可能想到把頻繁變化的值用 ref 保存起來。然而,useReducer 多是更好的解決方式:使用 dispatch 消除對一些狀態的依賴。官網的 FAQ 有詳細的解釋。
最終能夠總結出這樣的實踐:
useEffect 對於函數依賴,嘗試將該函數放置在 effect 內,或者使用 useCallback 包裹;useEffect/useCallback/useMemo,對於 state 或者其餘屬性的依賴,根據 eslint 的提示填入 deps;若是不直接使用 state,只是想修改 state,用 setState 的函數入參方式(setState(c => c + 1)
)代替;若是修改 state 的過程依賴了其餘屬性,嘗試將 state 和屬性聚合,改寫成 useReducer 的形式。當這些方法都不奏效,使用 ref,可是依然要謹慎操做。
使用 useMemo 當 deps 不變時,直接返回上一次計算的結果,從而使子組件跳過渲染。
可是當返回的是原始數據類型(如字符串、數字、布爾值)。即便參與了計算,只要 deps 依賴的內容不變,返回結果也極可能是不變的。此時就須要權衡這個計算的時間成本和 useMemo 額外帶來的空間成本(緩存上一次的結果)了。
此外,若是 useMemo 的 deps 依賴數組爲空,這樣作說明你只是但願存儲一個值,這個值在從新 render 時永遠不會變。
好比:
const Comp = () => { const data = useMemo(() => ({ type: 'xxx' }), []); return <Child data={data}>; }
能夠被替換爲:
const Comp = () => { const { current: data } = useRef({ type: 'xxx' }); return <Child data={data}>; }
甚至:
const data = { type: 'xxx' }; const Comp = () => { return <Child data={data}>; }
此外,若是 deps 頻繁變更,咱們也要思考,使用 useMemo 是否有必要。由於 useMemo 佔用了額外的空間,還須要在每次 render 時檢查 deps 是否變更,反而比不使用 useMemo 開銷更大。
在一個自定義 Hooks,咱們可能有這樣一段邏輯:
useSomething = (inputCount) => { const [ count, setCount ] = setState(inputCount); };
這裏有一個問題,外部傳入的 inputCount
屬性發生了變化,使其與 useSomething
Hook 內的 count
state 不一致時,是否想要更新這個 count
?
默認不會更新,由於 useState 參數表明的是初始值,僅在 useSomething
初始時賦值給了 count
state。後續 count
的狀態將與 inputCount
無關。這種外部沒法直接控制 state 的方式,咱們稱爲非受控。
若是想被外部傳入的 props 始終控制,好比在這個例子中,useSomething
內部,count
這一 state 的值須要從 inputCount
進行同步,須要這樣寫:
useSomething = (inputCount) => { const [ count, setCount ] = setState(inputCount); setCount(inputCount); };
setCount
後,React 會當即退出當前的 render 並用更新後的 state 從新運行 render 函數。這一點,官網文檔 是有說明的。
在這種的機制下,state 由外界同步的同時,內部又有可能經過 setState 來修改 state,可能引起新的問題。例如 useSomething
初始時,count 爲 0,後續內部經過 setCount
修改了 count
爲 1。當外部函數組件的 render 函數從新調用,也會再一次調用 useSomething
,此時傳入的 inputCount
依然是 0,就會把 count
變回 0。這極可能不符合預期。
遇到這樣的問題,建議將 inputCount
的當前值與上一次的值進行比較,只有肯定發生變化時執行 setCount(inputCount)
。
固然,在特殊的場景下,這樣的設定也不必定符合需求。官網的這篇文章 有提出相似的問題。
經過一個滑動選擇器自定義 hook userSlider
的實現,咱們能夠回答上面的這個問題,順便對本文作一個總結。
userSlider
須要實現的邏輯是:按住滑動選擇器的圓形手柄區域並拖動能夠調節數值大小,數值範圍爲 0 到 1。
userSlider
只負責邏輯的實現,UI 樣式由組件自行完成。爲了模擬真實業務,另外經過文本展現了當前的數值。並有幾個按鈕用於切換數值的初始值,這是爲了切換分類後,當前的滑動選擇器須要重置到某個數值。
按照常規的邏輯,咱們實現瞭如下代碼:
當前的問題是,useEffect
涉及到多個 state 的獲取與計算。致使鼠標按下、移動、彈起的幾個操做中由於對 stata 的修改,useEffect
頻繁刷新,且涉及到了鼠標按下、移動、彈起事件監聽的取消與從新綁定,這帶來了性能問題以及較難觀察到的 BUG。
和前面的 setInterval
例子類似,咱們不但願在狀態變更時,刷新 useEffect
。因爲此處涉及到多個狀態:是否滑動中、鼠標位置、上一次鼠標的問題、選擇器的可滑動寬度,若是整合到一個 state
中,會面臨代碼不清晰,缺乏內聚性的問題,咱們嘗試用 useReducer
作一次替換。
const reducer = (state, action) => { switch (action.type) { case "start": return { ...state, lastPos: action.x, slideRange: action.slideWidth, sliding: true }; case "move": { if (!state.sliding) { return state; } const pos = action.x; const delta = pos - state.lastPos; return { ...state, lastPos: pos, ratio: fixRatio(state.ratio + delta / state.slideRange) }; } case "end": { if (!state.sliding) { return state; } const pos = action.x; const delta = pos - state.lastPos; return { ...state, lastPos: pos, ratio: fixRatio(state.ratio + delta / state.slideRange), sliding: false }; } default: return state; } }; //... const handleThumbMouseDown = useCallback(ev => { const hotArea = hotAreaRef.current; dispatch({ type: "start", x: ev.pageX slideWidth: hotArea.clientWidth }); }, []); useEffect(() => { const onSliding = ev => { dispatch({ type: "move", x: ev.pageX }); }; const onSlideEnd = ev => { dispatch({ type: "end", x: ev.pageX }); }; document.addEventListener("mousemove", onSliding); document.addEventListener("mouseup", onSlideEnd); return () => { document.removeEventListener("mousemove", onSliding); document.removeEventListener("mouseup", onSlideEnd); }; }, []);
這樣處理後,effect 只要執行一次便可。
接下來還有一個問題沒有處理,目前 initRatio
是做爲初始值傳入的,useSlider
內部的 ratio 是不受外部控制的。
以一個音樂均衡器的設置爲例:當前滑動選擇器表明的是低頻端(31)的增益值,用戶經過拖動滑塊能夠設置這個值的大小(-12 到 12 dB 範圍,咱們設置到了 3 dB)。同時咱們提供了一些預設選項,一旦選擇預設選項,如『流行』風格,當前滑塊須要重置到特定值 -1 dB。爲此, useSlider
須要提供控制狀態的方法。
根據前一節的介紹,在 useSlider
的開頭,咱們能夠將屬性 initRatio
的當前值與上一次的值進行比較,若發生變化,則執行 setRatio
。但仍然有場景沒法知足:用戶選擇了『流行』這一預設,而後拖動滑塊進行了調節,以後又從新選擇『流行』這一預設,此時 initRatio
沒有任何變化,但咱們指望 ratio 從新變爲 initRatio
。
解決這個問題的辦法是,在 useSlider
內部添加一個 setRatio
方法。
const setRatio = useCallback( ratio => dispatch({ type: "setRatio", ratio }), [] );
將該方法輸出供外部用於對 ratio 控制。initRatio
再也不控制 ratio 的狀態,僅用於設置初始值。
能夠看下最終的實現方案:
該方案中,除了完成以上需求,還支持在選擇器的其餘區域點擊直接跳轉到對應的數值;支持設定選擇器爲垂直仍是水平方向。供你們參考。
忘掉 class 組件的生命週期,從新審視函數式組件的意義,是用好 React Hooks 的關鍵一步。但願這篇文章能幫助你們進一步理解並獲取到一些最佳實踐。固然,不一樣的 React Hooks 使用姿式可能帶來不一樣的最佳實踐,歡迎你們交流。
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!