若是你玩了幾小時的 React Hooks,你可能會陷入一個煩人的問題:在用 setInterval
時總會偏離本身想要的效果。html
這是 Ryan Florence 的原話:react
我已經碰到許多人提到帶有 setInterval 的 hooks 時常會打 React 的臉,但由於 stale state 引起的問題我仍是頭一次見。 若是在 hooks 中這個問題極其困難,那麼相比於 class component,咱們遇到了不一樣級別複雜度的問題。git
老實說,我以爲這些人是有一套的,至少爲此困惑了。github
然而我發現這不是 Hooks 的問題,而是 React編程模型 和 setInterval
不匹配形成的。Hooks 比 class 更貼近 React 編程模型,使這種不匹配更明顯。spring
在這篇文章裏,咱們會看到 intervals 和 Hooks 是如何玩在一塊兒的、爲何這個方案有意義和能夠提供哪些新的功能。數據庫
免責聲明:這篇文章的重點是一個 問題樣例。即便 API 能夠簡化上百種狀況,議論始終指向更難的問題上。npm
若是你剛入手 Hooks 且不知道這兒在說什麼,先查看 這個介紹 和 文檔。這篇文章假設你已經使用 Hooks 超過一個小時。編程
不用多說,這是一個每秒遞增的計數器:數組
import React, { useState, useEffect, useRef } from 'react';
function Counter() {
let [count, setCount] = useState(0);
useInterval(() => {
// 你本身的代碼
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
複製代碼
(這是 CodeSandbox demo)。閉包
demo裏面的 useInterval
不是一個內置 React Hook,而是一個我寫的 custom Hook。
import React, { useState, useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef();
// 保存新回調
useEffect(() => {
savedCallback.current = callback;
});
// 創建 interval
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
複製代碼
(這是前面的demo中,你可能錯過的 CodeSandbox demo。)
個人 useInterval
Hook 內置了一個 interval 並在 unmounting 的時候清除,它是一個做用在組件生命週期裏的 setInterval
和 clearInterval
的組合。
你能夠隨意將它複製粘貼到項目中或者用 npm 導入。
若是你不在意它是怎麼實現的,你能夠中止閱讀了!接下來的部分是給想深度挖掘 React Hooks 的鄉親們準備的。
我知道你在想什麼:
Dan,這段代碼根本沒什麼意思,「單單是 JavaScript」能有什麼?認可 React 用 Hooks 釣到了 「鯊魚」 吧!
一開始我也是這樣想的,但後來我改變想法了,我也要改變你的。在解釋這段代碼爲何有意義以前,我想展現下它能作什麼。
useInterval()
是更好的API提醒你下,個人 useInterval
Hook 接收 一個 function 和 一個 delay 參數:
useInterval(() => {
// ...
}, 1000);
複製代碼
這樣看起很像 setInterval
:
setInterval(() => {
// ...
}, 1000);
複製代碼
因此爲何不直接用 setInterval
呢?
一開始可能不明顯,但你發現個人 useInterval
與 setInterval
之間的不一樣後,你會看出 它的參數是「動態地」。
我將用具體的例子來講明這一點。
假設咱們但願 delay 可調:
雖然你不必定要用到輸入控制 delay,但動態調整可能頗有用 —— 例如,用戶切換到其餘選項卡時,要減小 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} />
</>
);
}
}
複製代碼
(這是 CodeSandbox demo。)
這樣也不錯!
Hook 版本看起來是什麼樣子的?
🥁🥁🥁
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} />
</>
);
}
複製代碼
(這是 CodeSandbox demo。)
是的,這就是所有了。
不像 class 的版本,useInterval
Hook 例子中,「更新」成動態調整 delay 很簡單:
// 固定 delay
useInterval(() => {
setCount(count + 1);
}, 1000);
// 可調整 delay
useInterval(() => {
setCount(count + 1);
}, delay);
複製代碼
當 useInterval
Hook 接收到不一樣 delay,它會重設 interval。
聲明一個帶有動態調整 delay 的 interval,來替代寫 添加和清除 interval 的代碼 —— useInterval
Hook 幫咱們作到了。
若是我想暫時 暫停 interval 要怎麼作?我能夠用一個 state 來作到:
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
複製代碼
(這是 demo!)
這讓我對 React 和 Hooks 再次感到興奮。咱們能夠包裝現有的命令式 APIs 和建立更貼近表達咱們意圖的聲明式 APIs。就拿渲染來講,咱們能夠同時準確地描述每一個時間點過程,而不用當心地用指令來操做它。
我但願到這裏大家開始以爲 useInterval()
Hook 是一個更好的 API 了 —— 至少和組件比。
但爲何在 Hooks 中使用 setInterval()
和 clearInterval()
讓人心煩呢?讓咱們回到計數器例子並試着手動實現它。
我會從一個只渲染初始狀態的簡單例子開始:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
複製代碼
如今我想要一個每秒增長的 interval,它是一個須要清理反作用的,因此我將用到 useEffect()
並返回清理函數:
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
複製代碼
(查看 CodeSandbox demo.)
這種工做看起來很簡單對吧?
可是,這代碼有一個奇怪的行爲。
默認狀況下,React 會在每次渲染後重執行 effects,這是有目的的,這有助於避免 React class 組件的某種 bugs。
這一般是好的,由於須要許多訂閱 API 能夠隨時順手移除老的監聽者和加個新的。可是,setInterval
和它們不同。當咱們執行 clearInterval
和 setInterval
時,它們會進入時間隊列裏,若是咱們頻繁重渲染和重執行 effects,interval 有可能沒有機會被執行!
咱們能夠經過以更短間隔重渲染咱們的組件,來發現這個 bug:
setInterval(() => {
// 重渲染和重執行 Counter 的 effects
// 這裏會發生 clearInterval()
// 在 interval 被執行前 setInterval()
ReactDOM.render(<Counter />, rootElement); }, 100); 複製代碼
(看這個 bug 的 demo)
你可能知道 useEffect()
容許咱們選擇性地進行重執行 effects,你能夠設定一個依賴數組做爲第二個參數,React 只會在數組裏的某個發生變化時重運行:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
複製代碼
當咱們 只 想在 mount 時執行 effect 和 unmount 時清理它,咱們能夠傳空 []
的依賴數組。
可是,若是你不熟悉 JavaScript 的閉包,會碰到一個常見的錯誤。咱們如今就來製造這個錯誤!(咱們還創建了一個儘早反饋這個錯誤的 lint 規則,但還沒準備好。)
在第一次嘗試中,咱們的問題是重運行 effects 時使得 timer 過早被清除,咱們能夠嘗試不重運行去修復它們:
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
在第一次渲染時獲取值爲 0 的 count
,咱們再也不重執行 effect,因此 setInterval
一直引用第一次渲染時的閉包 count
,以致於 count + 1
一直是 1
。哎呀呀!
我能夠聽見你咬牙切齒了,Hooks 真煩人對吧?
修復它的一種方法是用像 setCount(c => c + 1)
這樣的 「updater」替換 setCount(count + 1)
,這樣能夠讀到新 state 變量。但這個沒法幫助你獲取到新的 props。
另外一個方法是用 useReducer()
。這種方法爲你提供了更大的靈活性。在 reducer 中,你能夠訪問到當前 state 和新的 props。dispatch
方法自己永遠不會改變,因此你能夠從任何閉包中將數據放入其中。useReducer()
有個約束是你不能夠用它執行反作用。(可是,你能夠返回新狀態 —— 觸發一些 effect。)
但爲何要變得這麼複雜?
這個術語有時會被提到,Phil Haack 解釋以下:
有人說數據庫來自火星而對象來自金星,數據庫不會天然地映射到對象模型。這很像試圖將磁鐵的兩極推到一塊兒。
咱們的「阻抗匹配」不在數據庫和對象之間,它在 React 編程模型和命令式 setInterval
API 之間。
一個 React 組件可能在 mounted 以前流經許多不一樣的 state,但它的渲染結果將一次性所有描述出來。
// 描述每次渲染
return <h1>{count}</h1>
複製代碼
Hooks 使咱們把相同的聲明方法用在 effects 上:
// 描述每一個間隔狀態
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
複製代碼
咱們不設置 interval,但指定它是否設置延遲或延遲多少,咱們的 Hooks 作到了,用離散術語描述連續過程
相反,setInterval
沒有及時地描述過程 —— 一旦設定了 interval,除了清除它,你沒法對它作任何改變。
這就是 React 模型和 setInterval
API 之間的不匹配。
React 組件中的 props 和 state 是能夠改變的, React 會重渲染它們且「丟棄」任何關於上一次渲染的結果,它們之間再也不有相關性。
useEffect()
Hook 也「丟棄」上一次渲染結果,它會清除上一次 effect 再創建下一個 effect,下一個 effect 鎖住新的 props 和 state,這也是咱們第一次嘗試簡單示例能夠正確工做的緣由。
但 setInterval
不會「丟棄」。 它會一直引用老的 props 和 state 直到你把它換掉 —— 不重置時間你是沒法作到的。
或者等等,你能夠作到?
這個問題歸結爲下面這樣:
callback1
的 setInterval(callback1, delay)
。callbaxk2
。那麼若是咱們根本不替換 interval,而是引入一個指向新 interval 回調的可變 savedCallback
會怎麼樣?
如今咱們來看看這個方案:
setInterval(fn, delay)
,其中 fn
調用 savedCallback
。savedCallback
設爲 callback1
。savedCallback
設爲 callback2
。這個可變的 savedCallback
須要在從新渲染時「可持續(persist)」,因此不能夠是一個常規變量,咱們想要一個相似實例的字段。
正如咱們從 Hooks FAQ 中學到的,useRef()
給出了咱們想要的結果:
const savedCallback = useRef();
// { current: null }
複製代碼
(你可能熟悉 React 中的 DOM refs)。Hooks 使用相同的概念來保存任意可變值。ref 就像一個「盒子」,你能夠聽任何東西
useRef()
返回一個有帶有 current
可變屬性的普通對象在 renders 間共享,咱們能夠保存新的 interval 回掉給它:
function callback() {
// 能夠讀到新 props,state等。
setCount(count + 1);
}
// 每次渲染後,保存新的回調到咱們的 ref 裏。
useEffect(() => {
savedCallback.current = callback;
});
複製代碼
以後咱們即可以從咱們的 interval 中讀取和調用它:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
複製代碼
感謝 []
,不重執行咱們的 effect,interval 就不會被重置。同時,感謝 savedCallback
ref,讓咱們能夠一直在新渲染以後讀取到回調,並在 interval tick 裏調用它。
這是完整的解決方案:
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 demo。)
不能否認,上面的代碼使人困惑,混合相反的範式使人費解,還可能弄亂可變 refs。
我以爲 Hooks 提供了比 class 更低級的原語 —— 但它們的美麗在於它們使咱們可以創做並創造出更好的陳述性抽象。
理想狀況下,我只想這樣寫:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
複製代碼
我將我 ref 機制的代碼複製粘貼到一個 custom 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
delay 是寫死的,我想把它變成一個參數:
function useInterval(callback, delay) {
複製代碼
我會在建立好 interval 後使用它:
let id = setInterval(tick, delay);
複製代碼
如今 delay
能夠在 renders 之間改變,我須要在個人 interval effect 依賴部分聲明它:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
複製代碼
等等,咱們不是要避免重置 interval effect,並專門經過 []
來避免它嗎?不徹底是,咱們只想在回調改變時避免重置它,但當 delay
改變時,咱們想要重啓 timer!
讓咱們檢查下咱們的代碼是否有效:
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()
的實現過程,在任意組件中使用它。
假設咱們但願可以經過傳遞 null
做爲 delay
來暫停咱們的 interval:
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
複製代碼
如何實現這個?答案時:不建立 interval。
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
複製代碼
(看 CodeSandbox demo。)
就是這樣。此代碼處理了全部可能的變化:改變 delay、暫停、或者恢復 interval。useEffect()
API 要求咱們花費更多的前期工做來描述創建和清除 —— 但添加新案例很容易。
useInterval()
Hook 真的很好玩,當反作用是陳述性的,將複雜的行爲編排在一塊兒要容易得多。
例如:咱們 interval 中 delay
能夠受控於另一個:
function Counter() {
const [delay, setDelay] = useState(1000);
const [count, setCount] = useState(0);
// 增長計數器
useInterval(() => {
setCount(count + 1);
}, delay);
// 每秒加速
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>
</>
);
}
複製代碼
(看 CodeSandbox demo!)
Hooks 須要花時間去習慣 —— 特別是在跨越命令式和聲明式的代碼上。你能夠建立像 React Spring 同樣的抽象,但有時它們會讓你不安。
Hooks 還處於前期階段,無疑此模式仍須要修煉和比較。若是你習慣跟隨衆所周知的「最佳實踐」,不要急於採用 Hooks,它須要不少的嘗試和探索。
我但願這篇文章能夠幫助你理解帶有 setInterval()
等 API 的 Hooks 的相關常見問題、能夠幫助你克服它們的模式、及享用創建在它們之上更具表達力的聲明式 APIs 的甜蜜果實。
翻譯原文Making setInterval Declarative with React Hooks(2019-02-04)