如果你玩了幾小時的 React Hooks,你可能會陷入一個煩人的問題:在用 setInterval
時總會偏離自己想要的效果。
這是 Ryan Florence 的原話:
我已經碰到許多人提到帶有 setInterval 的 hooks 時常會打 React 的臉,但因為 stale state 引發的問題我還是頭一次見。 如果在 hooks 中這個問題極其困難,那麼相比於 class component,我們遇到了不同級別複雜度的問題。
老實說,我覺得這些人是有一套的,至少為此困惑了。
然而我發現這不是 Hooks 的問題,而是 React程式設計模型 和 setInterval
不匹配造成的。Hooks 比 class 更貼近 React 程式設計模型,使這種不匹配更明顯。
在這篇文章裡,我們會看到 intervals 和 Hooks 是如何玩在一起的、為什麼這個方案有意義和可以提供哪些新的功能。
免責宣告:這篇文章的重點是一個 問題樣例。即使 API 可以簡化上百種情況,議論始終指向更難的問題上。
如果你剛入手 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 直到你把它換掉 —— 不重置時間你是無法做到的。
或者等等,你可以做到?
Refs 可以做到!
這個問題歸結為下面這樣:
- 我們在第一次渲染時執行帶
callback1
的setInterval(callback1, delay)
。 - 我們在下一次渲染時得到攜帶新的 props 和 state 的
callbaxk2
。 - 我們無法在不重置時間的情況下替換掉已經存在的 interval。
那麼如果我們根本不替換 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。)
提取一個 Hook
不可否認,上面的程式碼令人困惑,混合相反的正規化令人費解,還可能弄亂可變 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()
的實現過程,在任意元件中使用它。
福利:暫停 Interval
假設我們希望能夠通過傳遞 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 要求我們花費更多的前期工作來描述建立和清除 —— 但新增新案例很容易。
福利:有趣的 Demo
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)