最近在總結 React Flight Rules, 正好業務需求裡遇到了一個時間選擇器,比較適合用來做一個案例。
實現一個初版的時間選擇器#
選擇器的 UI 如下:
這個選擇器分為兩個部分:
- 第一部分是默認的一組時間按鈕,點擊時選擇器直接設置為對應的值
- 第二部分是一個輸入框,用戶可以輸入自定義的時間
看起來功能比較簡單,我們來實現一個初版的選擇器:
import { type Dayjs } from 'dayjs'
const FORMAT = 'HH:mm'
const defaultOptions = [
dayjs('08:00', FORMAT),
dayjs('10:00', FORMAT),
dayjs('16:00', FORMAT),
]
const MyTimeRadio: React.FC<{
value: Dayjs;
onChange: (value: Dayjs) => void;
}> = ({ value, onChange }) => {
const [timeInputValue, timeInputValue] = useState<Dayjs>()
const isCustomTime = !defaultOptions.includes(value)
return (
<div>
{defaultOptions.map((option) => (
<button
key={option.format(FORMAT)}
onClick={() => onChange(option)}
>
{option.format(FORMAT)}
</button>
))}
<div>
<span>自定義時間</span>
<TimeInput
value={timePickerValue}
onChange={(time) => {
setTimePickerValue(time)
onChange(time)
}}
/>
</div>
</div>
)
}
這裡我們加了一個 timeInputValue
的狀態,用來保存用戶輸入的時間,為什麼不直接使用 value 呢?因為 value 是整個組件共用的 prop, 當用戶點擊 10 點的按鈕時,value 會變為 10 點,如果 TimeInput 組件直接使用 value 的話,就會導致輸入框的值也變為 10 點,這顯然不是我們想要的效果。
所以我們使用了一個 timeInputValue
來保存用戶輸入的值,當用戶點擊按鈕時,timeInputValue
不會改變,這樣就可以保證輸入框的值不會被覆蓋。
看起來挺好使,也挺簡單,但是這個組件有一個回顯的問題:
假設我們將組件放在一個 form 表單中,而表單初始化的數據需要從接口獲取,代碼如下:
const MyForm: React.FC = () => {
const [time, setTime] = useState<Dayjs>()
useEffect(() => {
fetch('/api/time').then((res) => {
// 假如 res.time 是 12:00
setTime(res.time)
})
}, [])
return (
<form>
<MyTimeRadio value={time} onChange={setTime} />
</form>
)
}
我們發現,當 MyTimeRadio 第一次渲染時,取得的 value 是 undefined, 獲取到接口返回的時間後,value 才會變為 12:00, 這時候 MyTimeRadio 會重新渲染,但 timeInputValue
仍然是 undefined 。
所以我們的組件無法正常回顯自定義時間,需要一點小小的改造。
完整功能第一版#
show case 發現這個問題後,我的第一反應是我們想讓 value 正確同步到 timeInputValue
中,那麼加一個監聽不就行了?
// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()
useEffect(() => {
setTimeInputValue(value)
}, [value])
// ...
<TimeInput
value={timeInputValue}
/>
嗯,看起來可以,但是我們不能讓 timeInputValue
一直跟著 value
走,因為當用戶點擊 10 點按鈕觸發 onChange, 最終導致 value 改變時,我們不希望 timeInputValue
被覆蓋。所以要再過濾掉一下:
// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()
useEffect(() => {
if (isCustomTime) setTimeInputValue(value)
}, [value, isCustomTime])
// ...
<TimeInput
value={timeInputValue}
/>
很完美,我們監聽了 value 的變化,把值同步給了 timeInputValue
, 然後為了防止用戶輸入的值被覆蓋,我們加了一個 isCustomTime
的判斷。
測試一下組件,回顯的 bug 解決了。
但是等等,我們在 React Flight Rules 裡明確過,useEffect 的作用並不是提供渲染完成後的回調鉤子,也不是要在函數式組件中實現生命週期。從他的名字我們可以知道他的出現是為了解決副作用。正確的做法是 == 使用 useEffect 讓 React 應用與外界狀態同步 ==。
在我們的 useEffect 代碼中,value
isCustomTime
都是 React 的內部狀態,所以在這裡使用 useEffect 是不符合其設計意圖的。
那麼如何不使用 useEffect 來解決這個問題呢?
完整功能第二版#
如果我們使用 useRef 來替代 timeInputValue 這個 state 的話,我們就可以在任意地方修改他的值,這樣就不需要 useEffect 了。
// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
if (isCustomTime) timeInputValueBackRef.current = value
const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current
// ...
<TimeInput
value={timeInputValue}
/>
改完跑一下代碼,功能正常,但是又發現了一個問題,因為我們的飛行規則裡還說過,== 我們應當確保 React 的 rendering 代碼是個純函數 ==, 但是當我們 rendering 邏輯裡加入了 ref 的讀取與修改,它就不再是純函數了。
為什麼這麼說呢,因為 ref 是在每次組件渲染時都會公用的一個值,相當於函數的外部變量,而我們的 rendering 邏輯是一個純函數,它不應該依賴 (讀取) 外部變量,更不能修改它。
我們舉一個簡單的例子:
const add = (a: number) => ++a
add(1) // 2
add(1) // 2
add(1) // 2
無論代碼執行多少次,add(1)
的返回值都是固定的,這就是純函數的特性。
但是如果我們把 add 函數改成這樣:
let a = 1
const add = () => ++a
add(1) // 2
add(1) // 3
add(1) // 4
由於 a 是一個外部變量,所以 add(1)
的返回值就不再是固定的,這就不是純函數了。
那麼回到問題,如何在 rendering 階段去除 ref 呢?
完整功能第三版#
我們回顧一下上邊的代碼,為了避免 useEffect 的使用,我們把 timeInputValue
的值放到了 ref 中,然後我們利用 props.value 變化引起 rerender 的特性,把 value 的值賦給了 timeInputValue
。
但我們的初衷應該是解決 TimeInput 組件值被 value 覆蓋的問題,所以回到這一點,我們的 ref 應該是用來保存 TimeInput 組件的值的一個副本,那麼它應該在 TimeInput 的 onChange 中來賦值才更加合理。
// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
const isCustomTime = !defaultOptions.includes(value)
const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current
// ...
<TimeInput
value={timeInputValue}
onChange={(time) => {
timeInputValueBackRef.current = time
onChange(time)
}}
/>
好了,現在我們的 ref 只在 TimeInput 的 onChange 中被修改,也就是說 ref 這個外部變量的修改被移到 rendering 邏輯之外了,這增加了 rendering 的純度。
但是 rendering 仍然讀取了 ref, 它依然是個非純函數。
到了這個份上,除了 useEffect 我也想不到什麼辦法能把 ref 的讀取與修改都移到 rendering 之外了。直到今天又翻了下 react useState 和 useRef 的文檔,我有了新的想法。
完整功能第四版#
const [timePickerValue, setTimePickerValue] = useState<Dayjs>()
if (isCustomTime && timePickerValue !== value) setTimePickerValue(value)
// ...
<TimeInput
value={timeInputValue}
/>
只需要在我們的初版代碼裡加上一行代碼就行: if (isCustomTime && timePickerValue !== value) setTimePickerValue(value)
。
為什麼我又放棄了 useRef 改用 useState 了呢?
因為在 useRef 的文檔中,React 明確說了 == 不要在 rendering 中讀取或修改 ref 的值 ==, 如果需要在 rendering 中讀取或修改,則使用 state 代替。
我之前對 ref 的理解是套用了類組件的實例屬性,把它當作一個在多次渲染中公用的變量來使用,所以想當然地把它放到了 rendering 邏輯中,也就出現了方案二和三。
文檔中推薦了三類應該適合 ref 的場景:
- 保存 timeout ID
- 保存 DOM 節點
- 保存可變值,但他們不需要參與 JSX 的計算,也就是說不會影響生成的 JSX
多讀文檔還是有好處的,coding 的時候也需要多思考,不能一味地套用已有的知識。函數式組件與類組件的 mental model 是不一樣的,我們需要把它們的區別理解透徹,才能寫出更好的代碼。
回到第四版代碼上,為什麼我一開始就沒有想到這個方案呢?因為我下意識排斥在 rendering 中使用 setState, 因為這樣會導致無限循環 (在本例中,我們使用 if 避免了這種情況)。
React 文檔也是這麼講的, 應當避免這樣的行為,在大多數情況下應該在事件回調中修改 state, 只有少數情況需要修改 state 來適應 rendering (也就是我們遇到的情況)。
那這麼做相比第一版使用 useEffect 的好處是什麼呢?
- 更符合 React 的 mental model
- 性能更好
當我們使用 useEffect 時,組件是這樣的:
因為 useEffect 的觸發時機是 render 完成後,所以組件會經歷兩次完整的渲染,包括其子組件也會一起執行。
而使用 setState 的話,組件是這樣的:
render 1 我用了灰色背景,這是因為當組件走到 return JSX 時,會立即觸發 rerender, 跳過其子組件的執行。
總結#
在這篇文章中,我們嘗試討論了 useState, useRef, useEffect 三個 hook 的使用場景,並且嘗試了一些方案來解決一個實際的問題。
總結一下比較重要的幾個點:
- useEffect 的名字是 effect, 它的作用是處理副作用,使 React 與外部世界進行同步。
- 應當保證 rendering 是個純函數。
- 不應該在 rendering 邏輯中讀取或修改 ref 的值,應該使用 state 代替。
- 不能套用以往的知識,要理解 React 函數式組件的 mental model。Thinking in React。