首頁 > 軟體

React Hook之使用Effect Hook的方法

2022-03-16 13:00:31

Effect Hook 可以讓你在函陣列件中執行副作用操作

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

這段程式碼基於上一章節中的計數器範例進行修改,我們為計數器增加了一個小功能:將 document 的 title 設定為包含了點選次數的訊息。

資料獲取,設定訂閱以及手動更改 React 元件中的 DOM 都屬於副作用。不管你知不知道這些操作,或是“副作用”這個名字,應該都在元件中使用過它們。

提示

如果你熟悉 React class 的生命週期函數,你可以把 useEffect Hook 看做 componentDidMountcomponentDidUpdate 和 componentWillUnmount 這三個函數的組合。

在 React 元件中有兩種常見副作用操作:需要清除的和不需要清除的。我們來更仔細地看一下他們之間的區別。

無需清除的 effect

有時候,我們只想在 React 更新 DOM 之後執行一些額外的程式碼。比如傳送網路請求,手動變更 DOM,記錄紀錄檔,這些都是常見的無需清除的操作。因為我們在執行完這些操作之後,就可以忽略他們了。讓我們對比一下使用 class 和 Hook 都是怎麼實現這些副作用的。

使用 class 的範例

在 React 的 class 元件中,render 函數是不應該有任何副作用的。一般來說,在這裡執行操作太早了,我們基本上都希望在 React 更新 DOM 之後才執行我們的操作。

這就是為什麼在 React class 中,我們把副作用操作放到 componentDidMount 和 componentDidUpdate 函數中。回到範例中,這是一個 React 計數器的 class 元件。它在 React 對 DOM 進行操作之後,立即更新了 document 的 title 屬性

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }
  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

注意,在這個 class 中,我們需要在兩個生命週期函數中編寫重複的程式碼。

這是因為很多情況下,我們希望在元件載入和更新時執行同樣的操作。從概念上說,我們希望它在每次渲染之後執行 —— 但 React 的 class 元件沒有提供這樣的方法。即使我們提取出一個方法,我們還是要在兩個地方呼叫它。

現在讓我們來看看如何使用 useEffect 執行相同的操作。

使用 Hook 的範例

我們在本章節開始時已經看到了這個範例,但讓我們再仔細觀察它:

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
  • useEffect 做了什麼?通過使用這個 Hook,你可以告訴 React 元件需要在渲染後執行某些操作。React 會儲存你傳遞的函數(我們將它稱之為 effect),並且在執行 DOM 更新之後呼叫它。在這個 effect 中,我們設定了 document 的 title 屬性,不過我們也可以執行資料獲取或呼叫其他命令式的 API。
  • 為什麼在元件內部呼叫 useEffect? 將 useEffect 放在元件內部讓我們可以在 effect 中直接存取 count state 變數(或其他 props)。我們不需要特殊的 API 來讀取它 —— 它已經儲存在函數作用域中。Hook 使用了 JavaScript 的閉包機制,而不用在 JavaScript 已經提供瞭解決方案的情況下,還引入特定的 React API。
  • useEffect 會在每次渲染後都執行嗎? 是的,預設情況下,它在第一次渲染之後和每次更新之後都會執行。(我們稍後會談到如何控制它。)你可能會更容易接受 effect 發生在“渲染之後”這種概念,不用再去考慮“掛載”還是“更新”。React 保證了每次執行 effect 的同時,DOM 都已經更新完畢。

詳細說明

現在我們已經對 effect 有了大致瞭解,下面這些程式碼應該不難看懂了:

function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
}

我們宣告了 count state 變數,並告訴 React 我們需要使用 effect。緊接著傳遞函數給 useEffect Hook。此函數就是我們的 effect。然後使用 document.title 瀏覽器 API 設定 document 的 title。我們可以在 effect 中獲取到最新的 count 值,因為他在函數的作用域內。當 React 渲染元件時,會儲存已使用的 effect,並在更新完 DOM 後執行它。這個過程在每次渲染時都會發生,包括首次渲染。

經驗豐富的 JavaScript 開發人員可能會注意到,傳遞給 useEffect 的函數在每次渲染中都會有所不同,這是刻意為之的。事實上這正是我們可以在 effect 中獲取最新的 count 的值,而不用擔心其過期的原因。每次我們重新渲染,都會生成新的 effect,替換掉之前的。某種意義上講,effect 更像是渲染結果的一部分 —— 每個 effect “屬於”一次特定的渲染。我們將在本章節後續部分更清楚地瞭解這樣做的意義。

提示

與 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 排程的 effect 不會阻塞瀏覽器更新螢幕,這讓你的應用看起來響應更快。大多數情況下,effect 不需要同步地執行。在個別情況下(例如測量佈局),有單獨的 useLayoutEffect Hook 供你使用,其 API 與 useEffect 相同。

需要清除的 effect

之前,我們研究瞭如何使用不需要清除的副作用,還有一些副作用是需要清除的。例如訂閱外部資料來源。這種情況下,清除工作是非常重要的,可以防止引起記憶體洩露!現在讓我們來比較一下如何用 Class 和 Hook 來實現。

使用 Class 的範例

在 React class 中,你通常會在 componentDidMount 中設定訂閱,並在 componentWillUnmount 中清除它。例如,假設我們有一個 ChatAPI 模組,它允許我們訂閱好友的線上狀態。以下是我們如何使用 class 訂閱和顯示該狀態:

class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }
  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}

你會注意到 componentDidMount 和 componentWillUnmount 之間相互對應。使用生命週期函數迫使我們拆分這些邏輯程式碼,即使這兩部分程式碼都作用於相同的副作用。

注意眼尖的讀者可能已經注意到了,這個範例還需要編寫 componentDidUpdate 方法才能保證完全正確。我們先暫時忽略這一點,本章節中後續部分會介紹它。

使用 Hook 的範例

如何使用 Hook 編寫這個元件。

你可能認為需要單獨的 effect 來執行清除操作。但由於新增和刪除訂閱的程式碼的緊密性,所以 useEffect 的設計是在同一個地方執行。如果你的 effect 返回一個函數,React 將會在執行清除操作時呼叫它:

import React, { useState, useEffect } from 'react';
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

為什麼要在 effect 中返回一個函數? 這是 effect 可選的清除機制。每個 effect 都可以返回一個清除函數。如此可以將新增和移除訂閱的邏輯放在一起。它們都屬於 effect 的一部分。

React 何時清除 effect? React 會在元件解除安裝的時候執行清除操作。正如之前學到的,effect 在每次渲染的時候都會執行。這就是為什麼 React 會在執行當前 effect 之前對上一個 effect 進行清除。

注意

並不是必須為 effect 中返回的函數命名。這裡我們將其命名為 cleanup 是為了表明此函數的目的,但其實也可以返回一個箭頭函數或者給起一個別的名字。

小結

瞭解了 useEffect 可以在元件渲染後實現各種不同的副作用。有些副作用可能需要清除,所以需要返回一個函數:

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

其他的 effect 可能不必清除,所以不需要返回。

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

effect Hook 使用同一個 API 來滿足這兩種情況。

使用 Effect 的提示

在本節中將繼續深入瞭解 useEffect 的某些特性,有經驗的 React 使用者可能會對此感興趣。你不一定要在現在瞭解他們,你可以隨時檢視此頁面以瞭解有關 Effect Hook 的更多詳細資訊。

提示:使用多個 Effect 實現關注點分離

使用 Hook 其中一個目的就是要解決 class 中生命週期函數經常包含不相關的邏輯,但又把相關邏輯分離到了幾個不同方法中的問題。下述程式碼是將前述範例中的計數器和好友線上狀態指示器邏輯組合在一起的元件:

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }
  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  // ...

可以發現設定 document.title 的邏輯是如何被分割到 componentDidMount 和 componentDidUpdate 中的,訂閱邏輯又是如何被分割到 componentDidMount 和 componentWillUnmount 中的。而且 componentDidMount 中同時包含了兩個不同功能的程式碼。

那麼 Hook 如何解決這個問題呢?就像你可以使用多個 state 的 Hook 一樣,你也可以使用多個 effect。這會將不相關邏輯分離到不同的 effect 中:

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  // ...
}

Hook 允許我們按照程式碼的用途分離他們, 而不是像生命週期函數那樣。React 將按照 effect 宣告的順序依次呼叫元件中的每一個 effect

解釋:為什麼每次更新的時候都要執行 Effect

如果你已經習慣了使用 class,那麼你或許會疑惑為什麼 effect 的清除階段在每次重新渲染時都會執行,而不是隻在解除安裝元件的時候執行一次。讓我們看一個實際的例子,看看為什麼這個設計可以幫助我們建立 bug 更少的元件。

在本章節開始時,我們介紹了一個用於顯示好友是否線上的 FriendStatus 元件。從 class 中 props 讀取 friend.id,然後在元件掛載後訂閱好友的狀態,並在解除安裝元件的時候取消訂閱:

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

但是當元件已經顯示在螢幕上時,friend prop 發生變化時會發生什麼? 我們的元件將繼續展示原來的好友狀態。這是一個 bug。而且我們還會因為取消訂閱時使用錯誤的好友 ID 導致記憶體洩露或崩潰的問題。

在 class 元件中,我們需要新增 componentDidUpdate 來解決這個問題:

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentDidUpdate(prevProps) {
    // 取消訂閱之前的 friend.id
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange
    );
    // 訂閱新的 friend.id
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

忘記正確地處理 componentDidUpdate 是 React 應用中常見的 bug 來源。

現在看一下使用 Hook 的版本:

function FriendStatus(props) {
  // ...
  useEffect(() => {
    // ...
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

它並不會受到此 bug 影響。(雖然我們沒有對它做任何改動。)

並不需要特定的程式碼來處理更新邏輯,因為 useEffect 預設就會處理。它會在呼叫一個新的 effect 之前對前一個 effect 進行清理。為了說明這一點,下面按時間列出一個可能會產生的訂閱和取消訂閱操作呼叫序列:

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // 執行第一個 effect
// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除上一個 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // 執行下一個 effect
// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除上一個 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // 執行下一個 effect
// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最後一個 effect

此預設行為保證了一致性,避免了在 class 元件中因為沒有處理更新邏輯而導致常見的 bug。

提示:通過跳過 Effect 進行效能優化

在某些情況下,每次渲染後都執行清理或者執行 effect 可能會導致效能問題。在 class 元件中,我們可以通過在 componentDidUpdate 中新增對 prevProps 或 prevState 的比較邏輯解決:

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

這是很常見的需求,所以它被內建到了 useEffect 的 Hook API 中。如果某些特定值在兩次重渲染之間沒有發生變化,你可以通知 React 跳過對 effect 的呼叫,只要傳遞陣列作為 useEffect 的第二個可選引數即可:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 僅在 count 更改時更新

上面這個範例中,我們傳入 [count] 作為第二個引數。這個引數是什麼作用呢?如果 count 的值是 5,而且我們的元件重渲染的時候 count 還是等於 5React 將對前一次渲染的 [5] 和後一次渲染的 [5] 進行比較。因為陣列中的所有元素都是相等的 (5 === 5),React 會跳過這個 effect,這就實現了效能的優化。

當渲染時,如果 count 的值更新成了 6,React 將會把前一次渲染時的陣列 [5] 和這次渲染的陣列 [6] 中的元素進行對比。這次因為 5 !== 6,React 就會再次呼叫 effect。如果陣列中有多個元素,即使只有一個元素髮生變化,React 也會執行 effect

對於有清除操作的 effect 同樣適用:

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 僅在 props.friend.id 發生變化時,重新訂閱

未來版本,可能會在構建時自動新增第二個引數。

注意:

如果你要使用此優化方式,請確保陣列中包含了所有外部作用域中會隨時間變化並且在 effect 中使用的變數,否則你的程式碼會參照到先前渲染中的舊變數。參閱檔案,瞭解更多關於如何處理常式以及陣列頻繁變化時的措施內容。

如果想執行只執行一次的 effect(僅在元件掛載和解除安裝時執行),可以傳遞一個空陣列([])作為第二個引數。這就告訴 React 你的 effect 不依賴於 props 或 state 中的任何值,所以它永遠都不需要重複執行。這並不屬於特殊情況 —— 它依然遵循依賴陣列的工作方式。

如果你傳入了一個空陣列([]),effect 內部的 props 和 state 就會一直擁有其初始值。儘管傳入 [] 作為第二個引數更接近大家更熟悉的 componentDidMount 和 componentWillUnmount 思維模式,但我們有更好的方式來避免過於頻繁的重複呼叫 effect。除此之外,請記得 React 會等待瀏覽器完成畫面渲染之後才會延遲呼叫 useEffect,因此會使得額外操作很方便。

我們推薦啟用 eslint-plugin-react-hooks 中的 exhaustive-deps 規則。此規則會在新增錯誤依賴時發出警告並給出修復建議。

總結

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!   


IT145.com E-mail:sddin#qq.com