本文由雲+社群發表
作者:Dan Abramov
接觸 React Hooks 一定時間的你,也許會碰到一個神奇的問題: setInterval
用起來沒你想的簡單。
Ryan Florence 在他的推文裡面說到:
不少朋友跟我提起,setInterval 和 hooks 一起用的時候,有種蛋蛋的憂傷。
老實說,這些朋友也不是胡扯。剛開始接觸 Hooks 的時候,確實還挺讓人疑惑的。
但我認為談不上 Hooks 的毛病,而是 React 程式設計模型和 setInterval
之間的一種模式差異。相比類(Class),Hooks 更貼近 React 程式設計模型,使得這種差異更加突出。
雖然有點繞,但是讓兩者和諧相處的方法,還是有的。
本文就來探索一下,如何讓 setInterval 和 Hooks 和諧地玩耍,為什麼是這種方式,以及這種方式給你帶來了什麼新能力。
宣告:本文采用循序漸進的示例來解釋問題。所以有一些示例雖然看起來可以有捷徑可走,但是我們還是一步步來。
如果你是 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,除非把它換了。但是隻要把它換了,就沒法不重新設定時間了。
等會,真的不能嗎?
Refs 是救星!
先把問題整理下:
- 第一次渲染的時候,使用
callback1
進行setInterval(callback1, delay)
- 下一次渲染的時候,使用
callback2
可以訪問到新的 props 和 state - 我們無法用 callback2 替換掉 callback1 但是又不重設計時器
如果我們壓根不替換計時器,而是傳入一個 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 線上示例)
提取為自定義 Hook
不得不承認,上面的程式碼有點迷。各種花裡胡哨的操作讓人費解不說,還有可能讓 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()
了。
Bonus: 暫停計時器
我們希望在給 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 需要我們前期花更多的精力進行設定和清理工作,新增新能力卻是輕鬆了。
Bonus: 有趣的 Demo
這個 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,享受其帶來的好處。
此文已由騰訊雲+社群在各渠道釋出
獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號