likes
comments
collection
share

React最佳实践之“你可能不需要 Effect”

作者站长头像
站长
· 阅读数 24

前言

本文思想来自React官方文档You Might Not Need an Effect,保熟,是我近几天读了n遍之后自己的理解,感觉受益匪浅,这里小记一下跟大家分享。

曾经本小白R的水平一直停留在会用React写业务,讲究能跑就行的程度,最近尝试学习一些关于React的最佳实践,感兴趣的朋友一起上车吧!!

useEffect痛点概述

useEffect的回调是异步宏任务,在React根据当前状态更新视图之后,下一轮事件循环里才会执行useEffect的回调,一旦useEffect回调的逻辑中存在状态修改等操作,就会触发渲染的重新执行(FC函数体重新运行,渲染视图),不光存在一定的性能损耗,而且因为前后两次渲染的数据不同,可能造成用户视角下视图的闪动,所以在开发过程中应该避免滥用useEffect

如何移除不必要的 Effect

  • 对于渲染所需的数据,如果可以用组件内状态(propsstate)转换而来,转换操作避免放在Effect中,而应该直接放在FC函数体中。

    如果转换计算的消耗比较大,可以用useMemo进行缓存。

  • 对于一些用户行为引起数据变化,其后续的逻辑不应该放在Effect中,而是在事件处理函数中执行逻辑即可。

    比如点击按钮会使组件内count加一,我们希望count变化后执行某些逻辑,那么就没必要把代码写成:

    function Counter() {
        const [count, setCount] = useState(0);
        
        function handleClick() {
            setCount(prev => prev + 1);
        }
        
        useEffect(() => {
            // count改变后的逻辑...
        }, [count])
        
        // ...
    }
    

    上面的demo大家肯定也看出来了,直接把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();
  // ...
}
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);
  }
  // ...
}