使用 React Hooks 宣告 setInterval

boomyao發表於2019-02-16

如果你玩了幾小時的 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 的時候清除,它是一個作用在元件生命週期裡的 setIntervalclearInterval 的組合。

你可以隨意將它複製貼上到專案中或者用 npm 匯入。

如果你不在乎它是怎麼實現的,你可以停止閱讀了!接下來的部分是給想深度挖掘 React Hooks 的鄉親們準備的


等什麼?! ?

我知道你在想什麼:

Dan,這段程式碼根本沒什麼意思,「單單是 JavaScript」能有什麼?承認 React 用 Hooks 釣到了 「鯊魚」 吧!

一開始我也是這樣想的,但後來我改變想法了,我也要改變你的。在解釋這段程式碼為什麼有意義之前,我想展示下它能做什麼。


為什麼 useInterval() 是更好的API

提醒你下,我的 useInterval Hook 接收 一個 function 和 一個 delay 引數:

  useInterval(() => {
    // ...
  }, 1000);
複製程式碼

這樣看起很像 setInterval

  setInterval(() => {
    // ...
  }, 1000);
複製程式碼

所以為什麼不直接用 setInterval

一開始可能不明顯,但你發現我的 useIntervalsetInterval 之間的不同後,你會看出 它的引數是「動態地」

我將用具體的例子來說明這一點。


假設我們希望 delay 可調:

Counter with an input that adjusts the interval 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 和它們不一樣。當我們執行 clearIntervalsetInterval 時,它們會進入時間佇列裡,如果我們頻繁重渲染和重執行 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 可以做到!

這個問題歸結為下面這樣:

  • 我們在第一次渲染時執行帶 callback1setInterval(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 可以受控於另外一個:

Counter that automatically speeds up

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)

相關文章