Haydenull

Haydenull

A front-end developer with a passion for using technology to increase personal efficiency and productivity 💡.
twitter

如何實現一個符合 React 設計的時間選擇器

最近在總結 React Flight Rules, 正好業務需求裡遇到了一個時間選擇器,比較適合用來做一個案例。

實現一個初版的時間選擇器#

選擇器的 UI 如下:

image

這個選擇器分為兩個部分:

  1. 第一部分是默認的一組時間按鈕,點擊時選擇器直接設置為對應的值
  2. 第二部分是一個輸入框,用戶可以輸入自定義的時間

看起來功能比較簡單,我們來實現一個初版的選擇器:

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 點,這顯然不是我們想要的效果。

image

所以我們使用了一個 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 。

image

所以我們的組件無法正常回顯自定義時間,需要一點小小的改造。

完整功能第一版#

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 時,組件是這樣的:

image

因為 useEffect 的觸發時機是 render 完成後,所以組件會經歷兩次完整的渲染,包括其子組件也會一起執行。

而使用 setState 的話,組件是這樣的:

image

render 1 我用了灰色背景,這是因為當組件走到 return JSX 時,會立即觸發 rerender, 跳過其子組件的執行。

總結#

在這篇文章中,我們嘗試討論了 useState, useRef, useEffect 三個 hook 的使用場景,並且嘗試了一些方案來解決一個實際的問題。

總結一下比較重要的幾個點:

  • useEffect 的名字是 effect, 它的作用是處理副作用,使 React 與外部世界進行同步。
  • 應當保證 rendering 是個純函數。
  • 不應該在 rendering 邏輯中讀取或修改 ref 的值,應該使用 state 代替。
  • 不能套用以往的知識,要理解 React 函數式組件的 mental model。Thinking in React。
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。