解析 useEffect 和 useLayoutEffect

目錄

函數參數詳解

兩個 effect hook 是 React 提供給用戶處理反作用邏輯的一個窗口,好比改變 DOM、添加訂閱、設置定時器、記錄日誌以及執行其餘各類渲染過程當中不容許出現的操做。前端

在使用上,兩個 hook 的函數簽名是同樣的:react

useEffect(() => {
  // 執行一些反作用
  // ...
  return () => {
    // 清理函數
  }
})
複製代碼

這樣會每次組件更新後都會執行,有點相似於 componentDidUpdate,但請不要用 class 組件的生命週期思惟方式來看待 hooks,只是看起來能夠先這麼理解。若是想要像 componentDidMount 那樣只執行一次的話,第二個參數傳入空數組:jquery

useEffect(() => {
  // 執行一些反作用
  // ...
  return () => {
    // 清理函數
  }
}, [])
複製代碼

但有的時候須要根據 props 的變化來條件執行 effect 函數,要實現這一點,能夠給 useEffect 傳遞第二個參數,它是 effect 所依賴的值數組:編程

useEffect(
  () => {
    const subscription = props.source.subscribe();
    return () => {
      subscription.unsubscribe();
    };
  },
  [props.source],
);
複製代碼

此時,只有當 props.source 改變後纔會從新建立訂閱。數組

兩個 hook 的區別

這裏就要說到 useEffect 和 useLayoutEffect 區別了。瀏覽器

官網中的提示,絕大部分場景只用到 useEffect 就能夠,只有當它出問題的時候再嘗試使用 useLayoutEffect。markdown

但什麼樣的狀況下 useLayoutEffect 才能體現不一樣之處呢?閉包

首先咱們知道,瀏覽器中 JS 線程和渲染線程(注意是線程)是互斥的,對於 React 的函數組件來講,其更新過程大體分爲如下步驟:app

(這裏假設 React 組件已初次渲染成功)異步

  1. 用戶點擊事件,改變了某一個state
  2. React 內部更新 state 變量
  3. React 處理更新組件中 return 出來的 DOM 節點(進行一系列 diff 調度等流程)
  4. 將更新事後的 DOM 數據繪製到瀏覽器中
  5. 用戶看到新的頁面

前三步都是 React 在處理,也就是 JS 線程執行咱們所寫的代碼,都是在內存中進行一系列操做,而第四步纔是真正將更新後數據交給渲染線程進行處理。

那這時候的 useEffect 只會在第四步後纔會調用,也就是在瀏覽器繪製完後才調用,並且 useEffect 仍是異步執行的,所謂的異步就是被 React 使用 requestIdleCallback 封裝的,只在瀏覽器空閒時候纔會執行,這就保證了不會阻塞瀏覽器的渲染過程。

而 useLayoutEffect 就不同,它會在第三第四步之間執行,並且是同步阻塞後面的流程。

這二者的差距會在某些 DOM 變化的場景下體現出來:

如下面的代碼舉例:

export default function FuncCom () {
    const [counter, setCounter] = useState(0);

    useEffect(() => {
        if (counter === 12) {
            // 爲了演示,這裏同步設置一個延時函數 500ms
            delay()
            setCounter(2)
        }
    });
    return (
        <div style={{ fontSize: '100px' }}> <div onClick={() => setCounter(12)}>{counter}</div> </div>
    )
}
複製代碼

能夠觀察到,初始屏幕上是 0,當點擊觸發 setCounter 後,屏幕上先是出現了 12,最後變爲了 2:

想象一下,這就是有些動畫場景會出現的閃屏現象,緣由在於 useEffect 執行的時候 setCounter(12) 已經觸發一次渲染了。這在體驗上很很差。

換成了 useLayoutEffect 後,屏幕上只會出現 0 和 2,這是由於 useLayoutEffect 的同步特性,會在瀏覽器渲染以前同步更新 DOM 數據,哪怕是屢次的操做,也會在渲染前一次性處理完,再交給瀏覽器繪製。這樣不會致使閃屏現象發生。

這裏簡單總結一下:

  • useEffect 是異步非阻塞調用
  • useLayoutEffect 是同步阻塞調用
  • useEffect 瀏覽器繪製後
  • useLayoutEffect 在 DOM 變動(React 的更新)後,瀏覽器繪製前完成全部操做

替代 class 的生命週期函數

進一步分析,咱們但願在函數組件中使用 hook 函數替換 class 組件中的生命週期,那麼這裏是如何對應的?

一樣舉一個 class 組件的例子:

class ClassCom extends React.Component {
    state = {
        value: 'a'
    }
    componentDidMount() {
        // 延時觸發
        delay()
        this.setState({
            value: 'fasd'
        })
    }
    componentDidUpdate() {
        if (this.state.value === 'b') {
            // 延時觸發
            delay()
            this.setState({
                value: 'c'
            })
        }
    }
    render() {
        return (
            <div onClick={() => this.setState({ value: 'b' })} > Class Components {`${this.state.value}`} </div>
        )
    }
}
複製代碼

在瀏覽器中,初次渲染用戶不會看到 Class Components a 這個值,而是直接出現 mount 狀態以後的值 Class Components fasd,當觸發點擊事件後,只會顯示 didupdate 以後的值 Class Components c

這說明了 componentDidMount 和 componentDidUpdate 都是同步阻塞的,並且是在 React 提交給瀏覽器渲染步驟以前。

因此從表現(以及源碼中的流程)來看,useLayoutEffect 和 componentDidMount,componentDidUpdate 調用時機是一致的,且都是被 React 同步調用,都會阻塞瀏覽器渲染。

同上,useLayoutEffect 返回的 clean 函數的調用位置、時機與 componentWillUnmount 一致,且都是同步調用。useEffect 的 clean 函數從調用時機上來看,更像是 componentDidUnmount (儘管 React 中並無這個生命週期函數)。

雖然 useLayoutEffect 更像 class 中的生命週期函數,但官方的建議是大多數正常狀況下,並不須要使用它,而是使用 useEffect,由於 useEffect 不會阻塞渲染,只有在涉及到修改 DOM、動畫等場景下考慮使用 useLayoutEffect,全部的修改會一次性更新到瀏覽器中,減小用戶體驗上的不適。

effect 中使用 props 和 state 的陷阱

在使用 effect 的過程當中,有一個隱形的 bug 要注意。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}
複製代碼

這段代碼的意圖很簡單,每隔 1000ms 更新 count,但事實上,count 永遠只會增長到 1!

一樣的代碼用 class 組件來實現,就不會有這個問題:

class Counter extends Components {
  state = {
    count: 0
  }
  id = null;
  componentDidMount() {
    this.id = setInterval(() => {
      this.setState(({
        count: this.state.count + 1
      }));
    }, 1000);
  }
  componentWillUnmount() {
    clearInterval(this.id)
  }
  render() {
    return <h1>{this.state.count}</h1>
  }
}
複製代碼

上面 class 組件和函數組件的代碼的差別在於,class 組件中的 this.state 是可變的!每一次的更新都是對 state 對象的一個更新,一次又一次的 setInterval 中引用的都會是新 state 中的值。這在使用 class 組件中很常見,咱們對於 state 對象也是這麼期待的。

然而在函數組件中狀況就不同了。函數組件因爲每次更新都會經歷從新調用的過程,useEffect(callback) 中的回調函數都是全新的,這樣其中引用到的 state 值將只跟當次渲染綁定。這是很神奇嗎?不,這就是閉包!這只是 JavaScript 的語言特性而已。

useEffect(() => {
    // 回調函數只運行一次,這裏的 count 只記住初次渲染的那個值
    // 因此致使每一次的 setInterval 中用到的永遠都不會變!
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
}, []);
複製代碼

這點在使用函數組件要當心,寫慣了 class 組件後,咱們對於變量的一些使用上很容易產生誤解。把函數組件當成純粹的函數,每一次的組件更新渲染當前的頁面,也會記住當前環境下的變量值。這就是 React Hooks 所推崇的邏輯和狀態的同步,這跟 class 組件以生命週期爲劃分的思惟有着使人迷惑的差距,雖然同是 React,但這是全新的一個思惟方式,甚至我以爲更接近 JavaScript 語言的本質,更有函數式的氣質。

要想解決這個 setInterval 帶來的困惑,能夠深刻看一下這篇 post: Making setInterval Declarative with React Hooks

解決方案很簡單,但解決思考的過程很驚奇。

關於 Hooks 的一點思考

React 從剛推出來的時候就宣揚單向數據流的特色,根據 stateprops 對象的變化來更新組件,這帶來了前端的一次革命,讓開發者擺脫了 jquery 這樣命令式的思惟編程方式,擁抱聲明式編程。

但經典的 calss 組件也不是沒有問題,複雜難懂的生命週期 API將咱們的狀態邏輯拆分到各個階段,這就給咱們設計組件多了一個時間維度思考。

而 Hooks 是進一步的革命,完全拋棄時間這一思考負重,從思考「個人狀態邏輯應該放在組件哪些生命週期中」到思考「隨着狀態變化,個人頁面應該展現成什麼樣」 和 「隨着狀態變化,什麼樣的反作用應該被觸發」。

這種「邏輯狀態和與頁面的同步」纔是真正的 React 數據流思惟方式,這是一種巨大的思惟減負。

useEffect 和 useLayoutEffect 相對於 componentDidMount 這樣的 API 來講,儘管能夠替代模仿,但本質上是不一樣的。 對於 effect hook API,咱們思考的是* UI 狀態完成後,咱們須要作一些什麼的反作用操做* ?而在 componentMount API 中咱們思考的是這個時間階段中咱們能夠作些什麼反作用操做

componentMount API 思考的是各個時間階段中的操做,effect hook API 不須要考慮時間這一因素,只須要考慮組件狀態變化後的處理。

參考

相關文章
相關標籤/搜索