本文由雲+社區發表html
做者:Dan Abramovreact
接觸 React Hooks 必定時間的你,也許會碰到一個神奇的問題: setInterval
用起來沒你想的簡單。git
Ryan Florence 在他的推文裏面說到:github
很多朋友跟我提起,setInterval 和 hooks 一塊兒用的時候,有種蛋蛋的憂傷。spring
老實說,這些朋友也不是胡扯。剛開始接觸 Hooks 的時候,確實還挺讓人疑惑的。數據庫
但我認爲談不上 Hooks 的毛病,而是 React 編程模型和 setInterval
之間的一種模式差別。相比類(Class),Hooks 更貼近 React 編程模型,使得這種差別更加突出。編程
雖然有點繞,可是讓二者和諧相處的方法,仍是有的。數組
本文就來探索一下,如何讓 setInterval 和 Hooks 和諧地玩耍,爲何是這種方式,以及這種方式給你帶來了什麼新能力。閉包
聲明:本文采用按部就班的示例來解釋問題。因此有一些示例雖然看起來能夠有捷徑可走,可是咱們仍是一步步來。dom
若是你是 Hooks 新手,不太明白我在糾結啥,不妨讀一下 React Hooks 的介紹和官方文檔。本文假設讀者已經使用 Hooks 超過一個小時。
經過下面的方式,咱們能夠輕鬆地實現一個每秒自增的計數器:
import React, { useState, useEffect, useRef } from 'react'; function Counter() { let [count, setCount] = useState(0); useInterval(() => { // Your custom logic here setCount(count + 1); }, 1000); return <h1>{count}</h1>; }
上述 useInterval
並非內置的 React Hook,而是我實現的一個自定義 Hook:
import React, { useState, useEffect, useRef } from 'react'; function useInterval(callback, delay) { const savedCallback = useRef(); // Remember the latest callback. useEffect(() => { savedCallback.current = callback; }); // Set up the interval. useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); }
(若是你在錯過了,這裏也有一個同樣的 CodeSandbox 線上示例)
我實現的 useInterval Hook 設置了一個計時器,而且在組件 unmount 的時候清理掉了。 這是經過組件生命週期上綁定 setInterval
與 clearInterval
的組合完成的。
這是一份能夠在項目中隨意複製粘貼的實現,你甚至能夠發佈到 NPM 上。
不關心爲何這樣實現的讀者,就不用繼續閱讀了。下面的內容是爲但願深刻理解 React Hooks 的讀者而準備的。
我知道你想什麼:
Dan,這代碼不對勁。說好的「純粹 JavaScript」呢?React Hooks 打了 React 哲學的臉?
哈,我一開始也是這麼想的,可是後來我改觀了,如今,我準備也改變你的想法。開始以前,我先介紹下這份實現的能力。
useInterval()
是一個更合理的 API?注意下,useInterval
Hook 接收一個函數和一個延時做爲參數:
useInterval(() => { // ... }, 1000);
這個跟原生的 setInterval
很是的類似:
setInterval(() => { // ... }, 1000);
那爲啥不乾脆使用 setInterval 呢?
setInterval
和 useInterval
Hook 最大的區別在於,useInterval
Hook 的參數是「動態的」。乍眼一看,可能不是那麼明顯。
我將經過一個實際的例子來講明這個問題:
若是咱們但願 interval 的間隔是可調的:
一個延時可輸入的計時器
此時無需手動控制延時,直接動態調整 Hooks 參數就好了。比方說,咱們能夠在用戶切換到另外一個選項卡時,下降 AJAX 更新數據的頻率。
若是按照類(Class)的方式,怎麼經過 setInterval
實現上述需求呢?我折騰出這個:
class Counter extends React.Component { state = { count: 0, delay: 1000, }; componentDidMount() { this.interval = setInterval(this.tick, this.state.delay); } componentDidUpdate(prevProps, prevState) { if (prevState.delay !== this.state.delay) { clearInterval(this.interval); this.interval = setInterval(this.tick, this.state.delay); } } componentWillUnmount() { clearInterval(this.interval); } tick = () => { this.setState({ count: this.state.count + 1 }); } handleDelayChange = (e) => { this.setState({ delay: Number(e.target.value) }); } render() { return ( <> <h1>{this.state.count}</h1> <input value={this.state.delay} onChange={this.handleDelayChange} /> </> ); } }
太熟悉了!
那改爲使用 Hooks 怎麼實現呢?
🥁🥁🥁表演開始了!
function Counter() { let [count, setCount] = useState(0); let [delay, setDelay] = useState(1000); useInterval(() => { // Your custom logic here setCount(count + 1); }, delay); function handleDelayChange(e) { setDelay(Number(e.target.value)); } return ( <> <h1>{count}</h1> <input value={delay} onChange={handleDelayChange} /> </> ); }
沒了,就這麼多!
不用於 class 實現的版本,useInterval
Hook 「升級到」支持到支持動態調整延時的版本,沒有增長任何複雜度。
使用 useInterval
新增動態延時能力,幾乎沒有增長任何複雜度。這個優點是使用 class 沒法比擬的。
// 固定延時 useInterval(() => { setCount(count + 1); }, 1000); // 動態延時 useInterval(() => { setCount(count + 1); }, delay);
當 useInterval
接收到另外一個 delay 的時候,它就會從新設置計時器。
咱們並無經過執行代碼來設置或者清理計時器,而是聲明瞭具備特定延時的計時器 - 這是咱們實現的 useInterval 的根本緣由。
若是想臨時暫停計時器呢?我能夠這樣來:
const [delay, setDelay] = useState(1000); const [isRunning, setIsRunning] = useState(true); useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
(線上示例)
這就是 Hooks 和 React 再一次讓我興奮的緣由。咱們能夠把原有的調用式 API,包裝成聲明式 API,從而更加貼切地表達咱們的意圖。就跟渲染同樣,咱們能夠描述當前時間每一個點的狀態,而無需當心翼翼地經過具體的命令來操做它們。
到這裏,我但願你已經確信 useInterval
Hook 是一個更好的 API - 至少在組件層面使用的時候是這樣。
但是爲何在 Hooks 裏使用 setInterval 和 clearInterval 這麼讓人惱火? 回到剛開始的計時器例子,咱們嘗試手動去實現它。
最簡單的,渲染初始狀態:
function Counter() { const [count, setCount] = useState(0); return <h1>{count}</h1>; }
如今我但願它每秒定時更新。我準備使用 useEffect()
而且返回一個清理方法,由於它是一個須要清理的 Side Effect:
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }); return <h1>{count}</h1>; }
(查看 CodeSandbox 線上示例)
看起來很簡單?
然而,這段代碼有個詭異的行爲。
React 默認會在每次渲染時,都從新執行 effects。這是符合預期的,這機制規避了早期在 React Class 組件中存在的一系列問題。
一般來講,這是一個好特性,由於大部分的訂閱 API 都容許移除舊的訂閱並添加一個新的訂閱來替換。可是,這不包括 setInterval
。調用了 clearInterval
後從新 setInterval
的時候,計時會被重置。若是咱們頻繁從新渲染,致使 effects 頻繁執行,計時器可能根本沒有機會被觸發!
經過使用在一個更小的時間間隔從新渲染咱們的組件,能夠重現這個 BUG:
setInterval(() => { // 從新渲染致使的 effect 從新執行會讓計時器在調用以前, // 就被 clearInterval() 清理掉,以後 setInterval() // 從新設置的計時器,會從新開始計時 ReactDOM.render(<Counter />, rootElement); }, 100);
(查看這個 BUG 的線上示例)
部分讀者可能知道,useEffect
容許咱們控制從新執行的實際。經過在第二個參數指定依賴數組,React 就會只在這個依賴數組變動的時候從新執行 effect。
useEffect(() => { document.title = `You clicked ${count} times`; }, [count]);
若是咱們但願 effect 只在組件 mount 的時候執行,而且在 unmount 的時候清理,咱們能夠傳遞空數組 []
做爲依賴。
可是!不是特別熟悉 JavaScript 閉包的讀者,極可能會犯一個共性錯誤。我來示範一下!(咱們在設計 lint 規則來幫助定位此類錯誤,不過如今尚未準備好。)
第一次的問題在於,effect 的從新執行致使計時器太早被清理掉了。若是不從新執行它們,也許能夠解決這個問題:
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
若是這樣實現,計時器更新到 1 以後,就中止不動了。(查看這個 BUG 的線上示例)
發生了啥?
問題在於,useEffect 使用的 count 是在第一次渲染的時候獲取的。 獲取的時候,它就是 0
。因爲一直沒有從新執行 effect,因此 setInterval
在閉包中使用的 count
始終是從第一次渲染時來的,因此就有了 count + 1
始終是 1
的現象。呵呵噠!
我感受你已經開始懟天懟地了。Hooks 是什麼鬼嘛!
解決這個問題的一個方案,是把 setCount(count + 1)
替換成「更新回調」的方式 setCount(c => c + 1)
。從回調參數中,能夠獲取到最新的狀態。此非萬全之策,新的 props 就沒法讀取到。
另外一個解決方案是使用 useReducer()
。此方案更爲靈活。在 reducer 內部,能夠訪問當前的狀態,以及最新的 props。dispatch
方法自己不會改變,因此你能夠在閉包裏往裏面灌任何數據。使用 useReducer()
的一個限制是,你不能在內部觸發 effects。(不過,你是能夠經過返回一個新 state 來觸發一些 effect)。
爲什麼如此艱難?
這個術語(譯者注:術語原文爲 "Impedance Mismatch")在不少地方被你們使用,Phil Haack 是這樣解釋的:
有人說數據庫來自火星,對象來自金星。數據庫不能自然的和對象模型創建映射關係。這就像嘗試將兩塊磁鐵的 N 極擠在一塊兒同樣。
咱們此處的「阻抗不匹配」,說的不是數據庫和對象。而是 React 編程模型,與命令式的 setInterval
API 之間的不匹配。
一個 React 組件可能會被 mount 一段時間,而且經歷多個不一樣的狀態,不過它的 render 結果一次性地描述了全部這些狀態
// 描述了每一次渲染的狀態 return <h1>{count}</h1>
同理,Hooks 讓咱們聲明式地使用一些 effect:
// 描述每個計數器的狀態 useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
咱們不須要去設置計時器,可是指明瞭它是否應該被設置,以及設置的間隔是多少。咱們事先的 Hook 就是這麼作的。經過離散的聲明,咱們描述了一個連續的過程。
相對應的,setInterval 卻沒有描述到整個過程 - 一旦你設置了計時器,它就沒法改變了,只能清除它。
這就是 React 模型和 setInterval
API 之間的「阻抗不匹配」。
React 組件的 props 和 state 會變化時,都會被從新渲染,而且把以前的渲染結果「忘記」的一乾二淨。兩次渲染之間,是互不相干的。
useEffect()
Hook 一樣會「遺忘」以前的結果。它清理上一個 effect 而且設置新的 effect。新的 effect 獲取到了新的 props 和 state。因此咱們第一次的事先在某些簡單的狀況下,是能夠執行的。
可是 setInterval() 不會 「忘記」。 它會一直引用着舊的 props 和 state,除非把它換了。可是隻要把它換了,就無法不從新設置時間了。
等會,真的不能嗎?
先把問題整理下:
callback1
進行 setInterval(callback1, delay)
callback2
能夠訪問到新的 props 和 state若是咱們壓根不替換計時器,而是傳入一個 savedCallback 變量,始終指向最新的計時器回調呢??
如今咱們的方案看起來是這樣的:
setInterval(fn, delay)
,其中 fn
調用 savedCallback
。savedCallback
爲 callback1
savedCallback
爲 callback2
可變的 savedCallback
須要在屢次渲染之間「持久化」,因此不能使用常規變量。咱們須要像相似實例字段的手段。
從 Hooks 的 FAQ 中,咱們得知 useRef()
能夠幫咱們作到這點:
const savedCallback = useRef(); // { current: null }
(你可能已經對 React 的 DOM refs 比較熟悉了。Hooks 引用了相同的概念,用於持有任意可變的值。一個 ref 就行一個「盒子」,能夠放東西進去。)
useRef()
返回了一個字面量,持有一個可變的 current
屬性,在每一次渲染之間共享。咱們能夠把最新的計時器回調保存進去。
function callback() { // 能夠讀取到最新的 state 和 props setCount(count + 1); } // 每次渲染,保存最新的回調到 ref 中 useEffect(() => { savedCallback.current = callback; });
後續就能夠在計時器回調中調用它了:
useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []);
因爲傳入了 []
,咱們的 effect 不會從新執行,因此計時器不會被重置。另外一方面,因爲設置了 savedCallback
ref,咱們能夠獲取到最後一次渲染時設置的回調,而後在計時器觸發時調用。
再看一遍完整的實現:
function Counter() { const [count, setCount] = useState(0); const savedCallback = useRef(); function callback() { setCount(count + 1); } useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
(查看 CodeSandbox 線上示例)
不得不認可,上面的代碼有點迷。各類花裏胡哨的操做讓人費解不說,還有可能讓 state 和 refs 與其它邏輯裏的搞混。
我認爲,雖然 Hooks 相比 Class 提供了更底層的能力 - 不過 Hooks 的牛逼在於容許咱們重組、抽象後創造出聲明語意更優的 Hooks
事實上,我就想這樣來寫:
function Counter() { const [count, setCount] = useState(0); useInterval(() => { setCount(count + 1); }, 1000); return <h1>{count}</h1>; }
因而我把個人實現核心拷貝到自定義 Hook 中:
function useInterval(callback) { const savedCallback = useRef(); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []); }
延時值 1000
是硬編碼的,把它參數化:
function useInterval(callback, delay) {
在設置計時器的時候使用:
let id = setInterval(tick, delay);
如今 delay
可能在屢次渲染之間變動,我須要把它聲明爲計時器 effect 的依賴:
useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, delay); return () => clearInterval(id); }, [delay]);
慢着,咱們以前不是爲了不計時器重設,才傳入了一個 []
的嗎?不徹底是。咱們只是但願 Hooks 不要在 callback 變動的從新執行。若是 delay
變動了,咱們是想要從新啓動計時器的。
如今來看下咱們的代碼是否是能跑:
function Counter() { const [count, setCount] = useState(0); useInterval(() => { setCount(count + 1); }, 1000); return <h1>{count}</h1>; } function useInterval(callback, delay) { const savedCallback = useRef(); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, delay); return () => clearInterval(id); }, [delay]); }
(讀者能夠在 CodeSandbox 上試一下)
棒棒的!如今,咱們能夠無需關注實現細節,在任何組件裏面須要的時候,直接使用 useInterval()
了。
咱們但願在給 delay
傳 null
的時候暫停計時器:
const [delay, setDelay] = useState(1000); const [isRunning, setIsRunning] = useState(true); useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
怎麼實現?簡單:不設置計時器就能夠了。
useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]);
就這樣了。這段代碼能夠處理各類可能的變動了:延時值改變、暫停和繼續。雖然 useEffect()
API 須要咱們前期花更多的精力進行設置和清理工做,添加新能力倒是輕鬆了。
這個 useInterval()
Hook 其實很好玩。如今 side effects 是聲明式的,因此組合使用變得輕鬆多了。
比方說,咱們可使用一個計時器來控制另外一個計時器的 delay:
自動加速的計時器
function Counter() { const [delay, setDelay] = useState(1000); const [count, setCount] = useState(0); // Increment the counter. useInterval(() => { setCount(count + 1); }, delay); // Make it faster every second! useInterval(() => { if (delay > 10) { setDelay(delay / 2); } }, 1000); function handleReset() { setDelay(1000); } return ( <> <h1>Counter: {count}</h1> <h4>Delay: {delay}</h4> <button onClick={handleReset}> Reset delay </button> </> ); }
Hooks 須要咱們慢慢適應 - 尤爲是在面對命令式和聲明式代碼的區別時。你能夠創造出像 React Spring 同樣強大的聲明式抽象,可是他們複雜的用法偶爾會讓你緊張。
Hooks 還很年輕,還有不少咱們能夠研究和對比的模式。若是你習慣於按照「最佳實踐」來的話,大可沒必要着急使用 Hooks。社區還需時間來嘗試和挖掘更多的內容。
使用 Hooks 的時候,涉及到相似 setInterval()
的 API,會碰到一些問題。閱讀本文後,但願讀者可以理解而且解決它們,同時,經過建立更加語義化的聲明式 API,享受其帶來的好處。
此文已由騰訊雲+社區在各渠道發佈
獲取更多新鮮技術乾貨,能夠關注咱們騰訊雲技術社區-雲加社區官方號及知乎機構號