使用 React Hooks 聲明 setInterval

若是你玩了幾小時的 React Hooks,你可能會陷入一個煩人的問題:在用 setInterval 時總會偏離本身想要的效果。html

這是 Ryan Florence 的原話react

我已經碰到許多人提到帶有 setInterval 的 hooks 時常會打 React 的臉,但由於 stale state 引起的問題我仍是頭一次見。 若是在 hooks 中這個問題極其困難,那麼相比於 class component,咱們遇到了不一樣級別複雜度的問題。git

老實說,我以爲這些人是有一套的,至少爲此困惑了。github

然而我發現這不是 Hooks 的問題,而是 React編程模型setInterval 不匹配形成的。Hooks 比 class 更貼近 React 編程模型,使這種不匹配更明顯。spring

在這篇文章裏,咱們會看到 intervals 和 Hooks 是如何玩在一塊兒的、爲何這個方案有意義和能夠提供哪些新的功能。數據庫


免責聲明:這篇文章的重點是一個 問題樣例。即便 API 能夠簡化上百種狀況,議論始終指向更難的問題上npm

若是你剛入手 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)

相關文章
相關標籤/搜索