React 最佳實踐之 “你可能不需要 Effect”

前言

本文思想來自 React 官方文檔 You Might Not Need an Effect,保熟,是我近幾天讀了 n 遍之後自己的理解,感覺受益匪淺,這裏小記一下跟大家分享。

曾經本小白 R 的水平一直停留在會用 React 寫業務,講究能跑就行的程度,最近嘗試學習一些關於 React 的最佳實踐,感興趣的朋友一起上車吧!!

useEffect 痛點概述

useEffect的回調是異步宏任務,在 React 根據當前狀態更新視圖之後,下一輪事件循環裏纔會執行useEffect的回調,一旦useEffect回調的邏輯中存在狀態修改等操作,就會觸發渲染的重新執行(FC 函數體重新運行,渲染視圖),不光存在一定的性能損耗,而且因爲前後兩次渲染的數據不同,可能造成用戶視角下視圖的閃動,所以在開發過程中應該避免濫用useEffect

如何移除不必要的 Effect

根據propsstate來更新state(類似於 vue 中的計算屬性)

如下Form組件中fullNamefirstNamelastName計算(簡單拼接)而來,錯誤使用Effect

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 避免:多餘的 state 和不必要的 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() ={
    setFullName(firstName + ' ' + lastName);
  }[firstName, lastName]);
  // ...
}

分析一下,按照上面的寫法,如果firstName或者lastName改變之後,首先根據新的firstNamelastName與舊的fullName進行渲染,然後纔是useEffect回調的執行,最後根據最新的fullName再次渲染視圖。

我們要做的是儘可能把渲染的效果進行統一(同步fullName與兩個組成 state 的新舊),並且減少渲染的次數:

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ 非常好:在渲染期間進行計算
  const fullName = firstName + ' ' + lastName;
  // ...
}

緩存昂貴的計算

基於上面的經驗,我們如果遇到比較複雜的計算邏輯,把它放在 FC 函數體中可能性能消耗較大,可以使用useMemo進行緩存,如下,visibleTodos這個數據由todosfilter兩個props數據計算而得,並且計算消耗較大:

import { useMemo } from 'react';

function TodoList({ todos, filter }) {
    
  // ✅ 除非 todos 或 filter 發生變化,否則不會重新執行 getFilteredTodos()
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter)[todos, filter]);
  // ...
}

當 props 變化時重置所有 state

比如一個ProfilePage組件,它接收一個userId代表當前正在操作的用戶,裏面有一個評論輸入框,用一個 state 來記錄輸入框中的內容。我們爲了防止切換用戶後,原用戶輸入的內容被當前的用戶發出這種誤操作,有必要在userId改變時置空 state,包括ProfilePage組件的所有子組件中的評論state

錯誤操作:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 避免:當 prop 變化時,在 Effect 中重置 state
  useEffect(() ={
    setComment('');
  }[userId]);
  // ...
}

爲什麼避免上訴情況,本質還是避免Effect的痛點,我們可以利用組件 **key不同將會完全重新渲染 ** 的特點解決這個問題,只需要在父組件中給這個組件傳遞一個與props同步的key值即可:

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 當 key 變化時,該組件內的 comment 或其他 state 會自動被重置
  const [comment, setComment] = useState('');
  // ...
}

當 prop 變化時調整部分 state

其實說白了還是上面的基於propsstate來計算其它所需state的邏輯,如下List組件,當傳入的items改變時希望同步selection(被選中的數據),那麼我們直接在渲染階段計算所需內容就好了:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 非常好:在渲染期間計算所需內容
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

在事件處理函數中共享邏輯

比如兩種用戶操作都可以修改某個數據,然後針對數據修改有相應的邏輯處理,這時候有一種錯誤(不好)的代碼邏輯:事件回調——> 修改 state——>state 修改觸發 Effect——>Effect 中執行後續邏輯。

我們不應該多此一舉的添加一個 Effect,這個 Effect 就類似於數據改變的監聽器一樣,完全是多餘的,我們只需要在數據改變之後接着寫後續的邏輯就好了!!

如下,用戶的購買與檢查兩種行爲都可以觸發addToCart的邏輯,進而修改product這個數據,然後可能觸發後續邏輯showNotification

function ProductPage({ product, addToCart }) {
  // 🔴 避免:在 Effect 中處理屬於事件特定的邏輯
  useEffect(() ={
    if (product.isInCart) {
      showNotification(`已添加 ${product.name} 進購物車!`);
    }
  }[product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

我們把Effect中的邏輯提取出來放到事件處理函數中就好了:

function ProductPage({ product, addToCart }) {
  // ✅ 非常好:事件特定的邏輯在事件處理函數中處理
  function buyProduct() {
    addToCart(product);
    showNotification(`已添加 ${product.name} 進購物車!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

發送 POST 請求

也有一些典型的需要使用Effect的情景,比如有些數據、邏輯是頁面初次渲染,因爲組件的呈現而需要的,而不是後續交互觸發的,比如異步數據的獲取,我們就可以寫一個依賴數組爲[]Effect

如下Form組件,頁面加載之際就需要發送一個分析請求,這個行爲與後續交互無關,是因爲頁面的呈現就需要執行的邏輯,所以放在Effect中,而表單提交的行爲觸發的網絡請求,我們直接放在事件回調中即可。

切忌再多寫一個state和一個Effect,然後把一部分邏輯寫在Effect裏面,比如下面handleSubmit中修改firstNamelastName,然後多寫一個Effect監聽這兩個數據發送網絡請求,這就是上面我們一直糾正的問題,我就不放代碼了。

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ 非常好:這個邏輯應該在組件顯示時執行
  useEffect(() ={
    post('/analytics/event'{ eventName: 'visit_form' });
  }[]);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ 非常好:事件特定的邏輯在事件處理函數中處理
    post('/api/register'{ firstName, lastName });
  }
  // ...
}

鏈式計算

避免通過 state 將Effect變成鏈式調用,如下Game組件中,類似於一個卡牌合成遊戲,card改變可能觸發goldCardCount的改變,goldCardCount的改變可能觸發round的改變,最終round的改變可能觸發isGameOver的改變,試想如果某次card改變,從而正好所有條件都依次滿足,最後isGameOver改變,setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染,有三次不必要的重新渲染!!

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 避免:鏈接多個 Effect 僅僅爲了相互觸發調整 state
  useEffect(() ={
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }[card]);

  useEffect(() ={
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }[goldCardCount]);

  useEffect(() ={
    if (round > 5) {
      setIsGameOver(true);
    }
  }[round]);

  useEffect(() ={
    alert('遊戲結束!');
  }[isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('遊戲已經結束了。');
    } else {
      setCard(nextCard);
    }
  }

  // ...

因爲Game中所有state改變之後的行爲都是可以預測的,也就是說某個卡牌數據變了,後續要不要繼續合成更高級的卡牌,或者遊戲結束等等這些邏輯都是完全明確的,所以直接把數據修改的邏輯放在同一個事件回調中即可,然後根據入參判斷是哪種卡牌然後進行後續的操作即可:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ 儘可能在渲染期間進行計算
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('遊戲已經結束了。');
    }

    // ✅ 在事件處理函數中計算剩下的所有 state
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('遊戲結束!');
        }
      }
    }
  }

  // ...

初始化應用

因爲 React 嚴格模式 & 開發模式下:

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

組件的渲染會執行兩次(掛載 + 卸載 + 掛載),包括依賴爲[]Effect同樣會執行兩次,這是 React 作者爲了提醒開發者 cleanup 有意而設計之的(比如一些需要手動清除的原生事件如果沒寫清除邏輯,事件觸發時就會執行兩次回調從而引起注意),所以執行兩次的邏輯可能會造成一些邏輯問題,我們可以用一個全局變量來保證即使在 React 嚴格模式 & 開發模式下也只執行一次Effect的回調:

let didInit = false;

function App() {
  useEffect(() ={
    if (!didInit) {
      didInit = true;
      // ✅ 只在每次應用加載時執行一次
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }[]);
  // ...
}

通知父組件有關 state 變化的信息

最佳實踐的本質還是我們剛剛一直強調的:減少 Effect 的使用,可以歸併到回調函數中的邏輯就不要放在Effect中。

如下,假設我們正在編寫一個有具有內部 state isOnToggle 組件,該 state 可以是 truefalse,希望在 Toggle 的 state 變化時通知父組件。

錯誤示範:

(事件回調只負責修改 state, Effect 中執行通知父組件的邏輯)

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 避免:onChange 處理函數執行的時間太晚了
  useEffect(() ={
    onChange(isOn);
  }[isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

刪除Effect

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ 事件回調中直接通知父組件即可
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

將數據傳遞給父組件

避免在 Effect 中傳遞數據給父組件,這樣會造成數據流的混亂。我們應該考慮把獲取數據的邏輯提取到父組件中,然後通過props將數據傳遞給子組件:

錯誤示範:

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 避免:在 Effect 中傳遞數據給父組件
  useEffect(() ={
    if (data) {
      onFetched(data);
    }
  }[onFetched, data]);
  // ...
}

理想情況:

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 非常好:向子組件傳遞數據
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

訂閱外部 store

說白了就是 React 給我們提供了一個專門的 hook 用來綁定外部數據(所謂外部數據,就是一些環境運行環境裏的數據,比如window.xxx

我們曾經常用的做法是在Effect中編寫事件監聽的邏輯:

function useOnlineStatus() {
  // 不理想:在 Effect 中手動訂閱 store
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() ={
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () ={
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }[]);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

這裏可以換成useSyncExternalStore這個 hook,關於這個 hook,還是有一點理解成本的,我的基於 useSyncExternalStore 封裝一個自己的 React 狀態管理模型吧這篇文章裏有詳細的解釋,下面直接放綁定外部數據最佳實踐的代碼了:

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () ={
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ 非常好:用內置的 Hook 訂閱外部 store
  return useSyncExternalStore(
    subscribe, // 只要傳遞的是同一個函數,React 不會重新訂閱
    () => navigator.onLine, // 如何在客戶端獲取值
    () =true // 如何在服務端獲取值
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

獲取異步數據

比如組件內根據props參數query與一個組件內狀態page來實時獲取異步數據,下面組件獲取異步數據的邏輯之所以沒有寫在事件回調中,是因爲首屏即使用戶沒有觸發數據修改,我們也需要主動發出數據請求(類似於首屏數據獲取),總之因爲業務場景需求吧,我們把請求邏輯放在一個Effect中:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() ={
    // 🔴 避免:沒有清除邏輯的獲取數據
    fetchResults(query, page).then(json ={
      setResults(json);
    });
  }[query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

上面代碼的問題在於,由於每次網絡請求的不可預測性,我們不能保證請求結果是根據當前最新的組件狀態獲取的,也即是所謂的**「競態條件:兩個不同的請求 “相互競爭”,並以與你預期不符的順序返回。」**

「所以可以給我們的Effect添加一個清理函數,來忽略較早的返回結果,」 如下,說白了用一個變量ignore來控制這個Effect回調的 "有效性",只要是執行了下一個Effect回調,上一個Effect裏的ignore置反,也就是讓回調的核心邏輯失效,保證了只有最後執行的Effect回調是 “有效” 的:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() ={
    // 說白了用一個ignore變量來控制這個Effect回調的"有效性",
    let ignore = false;
    fetchResults(query, page).then(json ={
      if (!ignore) {
        setResults(json);
      }
    });
    return () ={
      ignore = true;
    };
  }[query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/brQYKlQGoSRU8rvvGL1siA