likes
comments
collection

组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~

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

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

这篇文章主要探讨和组件重渲染有关的种种问题,以解决日常开发中的疑问,做到心中有🌲。文章中包含了日常开发中常用的state,props,context,react-redux以及umi usemodel等和组件状态有关的实践,并浅述了一些原理。(只针对函数式组件哦~)

目录

  1. 什么是组件重渲染?
  2. 引起组件重渲染的原因有哪些?
  3. 如何避免不必要的重渲染?
  4. 使用 react-redux 要注意什么?
  5. 使用 umi useModel 要注意什么?

什么是组件重渲染?

const component = () => {
  const [state, setState] = useState(0);
  console.log('component render');
  return <div>{state}</div>;
};

如代码所示,不管是第一次渲染或是state改变,都会触发这个组件函数的执行,控制台将打印“component render”。函数组件执行后返回一个JSX,后面react将执行一系列操作以更新页面UI。除去第一次渲染,后面每次组件函数的执行我们都叫做这个组件的重渲染。

引起组件重渲染的原因有哪些?

原因可以归为以下三点:

  1. 组件本身的state改变
  2. 父组件重渲染
  3. context改变

1. 组件本身的state改变

组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 如图,调用 setState 改变 state 值时将触发组件的重渲染。不过,如果调用 setState 时传入了一个相同的值,如setState(state => state),则不会触发重渲染。useReducer同理,当调用 dispatch修改state值时也将触发组件重渲染。

const [state, dispatch] = useReducer(reducer, initialArg, init);

另外,还有一些隐藏起来的state,比如自定义hook。以官方给的自定义hook为例:

// 自定义hook
function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

// 组件中使用
function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);
  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

组件中使用useFriendStatus也可能引起重渲染,不过其根本原因也是因为useState中的状态改变了。

2. 父组件重渲染

组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 如图,父组件的重渲染也将引起所有子组件的重渲染。但反过来,子组件的重渲染不会影响父组件。父组件可以通过props传值给子组件,但这和重渲染没有直接关系。在下图中,即使value值没有变化,父组件重渲染仍然会引起子组件的重渲染。 组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~

3. context改变

当一个Context Provider中的值改变时,所有使用了这个Context的组件都将重渲染,即使它们没有直接用到Context中改变的那部分值。如下代码所示:

const App = () => {
  return (
    <AppContext.Provider
      value={contextValue}
    >
      <Component1 />
      <Component2 />
    </AppContext.Provider>
  );
};

const Component1 = () => {
  const { value1 } = useContext(AppContext); // AppContextValue.value2变化也将导致这个组件的重渲染
  return null;
};

const Component1 = () => {
  const { value2 } = useContext(AppContext);// AppContextValue.value1变化也将导致这个组件的重渲染
  return null;
};

在上面的代码示例中,当 contextValue 改变时,Component1和Component2都会重渲染,即使它们用到的value1value2并没有改变。

🌟如何避免不必要的重渲染?

一个组件的重渲染可能做两件事情,一是更新页面内容(DOM),二是执行hook,如下代码所示:

const Component = (props) => {
  // 执行 hook
  useEffect(() => {
    document.title = props.title;
  }, [props.title]);

  return <h1>{props.title}</h1>; // 更新页面内容
};

而如果一个组件触发了重渲染,却一没有页面内容更新,二没有执行hook逻辑,那么这次重渲染就是没有任何作用的,也就是不必要的重渲染。但实际开发中,这种情况常常发生,比如你在一个input框中输入内容,结果整个页面的组件都在重渲染(但由于React做了优化,这种看起来“高代价”的重渲染实际上对用户体验影响不大)。

那如何避免不必要的重渲染呢?我们可以分别从上面三个引起组件重渲染的原因入手。

1. 组件内部状态变化🤔

组件内部的state改变会使组件重渲染,所以我们只要记住只在需要的时候使用state即可。如果一些值的变化对页面渲染无影响,那么我们可以将这些值维护在函数组件外,或使用useRef

const value = {}; // 这里的值改变不会影响组件渲染

const App = () => {
  const valueRef = useRef(); // 这里的值改变不会影响组件渲染
  
  //...
};

2. 父组件重渲染😨

这一部分需要注意的就比较多了,而且经常是容易忽略的部分。

state影响最小化原则

方法一:抽离子组件组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 如图所示,父组件中的状态变化将导致所有子组件的重渲染,但如果将这个state抽离到一个子组件中,那它影响的范围就变小了。所以抽离组件是一个好习惯,这样state变化只会影响到和它相关的组件。

方法二:将组件作为props组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 这种方法同样也是抽离组件,只不过方式和第一种不同。这里将原组件变成了一个高阶组件,并将没有用到state的组件抽离出去,通过props传入。其实同理,也是将state的影响最小化。

使用缓存

相比抽离组件,一种更简单的方法是使用React.memo。 组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 在上图中,使用React.memo包裹子组件后,父组件的重渲染将不会影响子组件。然而,当子组件接收props时,我们却需要额外注意了。

如果props为基本类型,如字符串或数字,则不需要做任何处理。但如果props为对象、数组或函数等类型时,则需要引入useMemo或useCallback。因为这类变量在每次组件函数执行时都会更新,就像{} !== {},所以需要对这类变量进行缓存。如下图所示: 组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 不过,使用useMemo,useCallback以及React.memo本身也是有开销的,在一些简单场景下使用它们可能并不会有性能上的提升,所以切记不要滥用。

在一个组件内我们也可以单独使用useMemo来对开销大的子组件做缓存优化,如下图所示:

组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~

3. 使用context😣

前面有提到,当Context Provider中的值改变时,所有使用了这个Context的组件都将重渲染。由于这个特性,使用context时很容易就产生不必要的重渲染。但下面有一些方法可以帮助我们缓解这个问题。

缓存context值

组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~ 如上图所示,如果Context Provider的value值不是基本类型,我们可以将这个值进行缓存,这样可以避免组件重渲染时改变context值。

分离Context

前面有提到state影响最小化,使用context时也是一样。我们可以将一个context进行拆分来避免不必要的重渲染。

在下图右侧的示例中,通过拆分conetext,firstsecond就不会相互影响了。 组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~

同理,我们也可以将data与setData的context分开。如下图,右侧示例中使用ApiContext的组件不会因为DataContext的值改变而重渲染。 组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~

在context消费者中使用缓存

如果不能拆分context,我们也可以通过缓存来避免重渲染。如下面两个示例所示:

function Button() {
  const appContextValue = useContext(AppContext);
  const theme = appContextValue.theme; // Your "selector"
  return <ThemedButton theme={theme} />
}

const ThemedButton = memo(({ theme }) => {
  // ...
  return <ExpensiveTree className={theme} />;
});

function Button() {
  const appContextValue = useContext(AppContext);
  const theme = appContextValue.theme; // Your "selector"

  return useMemo(() => {
    // ...
    return <ExpensiveTree className={theme} />;
  }, [theme])
}

使用 react-redux

redux是一个管理和更新应用状态的模式和工具库,它可以搭配react一起使用。

import { Provider } from 'react-redux';

// redux的数据store
<Provider store={store} >
   <App />
</Provider>

如上代码所示,官方建议一个应用中所有的数据都维护在一个公共的store中。那我们不禁疑问,它有没有类似context的问题呢?会不会部分数据的改变也会导致所有数据的消费者都受影响呢?

这里我们就需要了解一个重要的概念了——selector(选择器)。

使用useSelector读取state

const selectStatus = (state) => state.counter.status; // 一个selector
const status = useSelector(selectStatus);

使用 useSelector 可以将组件更新仅与选择的这部分数据绑定。如果 selector 返回的值与上次运行时相比发生了变化,useSelector 将强制组件使用新值重新渲染,反之则没有任何影响。这样就避免了context的问题。

这里state影响最小化原则同样适用,即selector返回的值是组件需要用到的最小范围值。下面有一些好的做法🙂️和一些不好的做法🙁️:

// bad 🙁️ 多选择了
const selectCounter = (state) => state.counter;
const arr = useSelector(selectCounter).arr; // 只用到 counter.arr!

// good 🙂️ 选择的刚刚好
const selectArr = (state) => state.counter.arr;
const arr = useSelector(selectArr);

// bad 🙁️ 多选择了
const selectArr = (state) => state.counter.arr;
const arrlength = useSelector(selectArr).length; // 只用到 counter.arr.length!

// good 🙂️ 选择的刚刚好
const selectArrLength = (state) => state.counter.arr.length;
const arrlength = useSelector(selectArrLength);

在selector中使用自定义比较

需要注意,useSelector 使用严格的 === 来比较结果,因此只要 selector 函数返回的结果是新地址引用,组件就会重新渲染!

// bad 🙁️
const selectTodoDescriptions = state => {
  // 这会创建一个新的数组引用!
  return state.todos.map(todo => todo.text)
}

我们可以使用useSelector的第二个参数来自定义比较函数,从而避免不必要的重渲染。

// react-redux中有一个shallowEqual函数可以对数组和对象进行浅层比较
const todoIds = useSelector(selectTodoIds, shallowEqual);

另外,还可以使用一种称为"记忆(memoized)selector" 的特殊 selector 函数来改进组件渲染。

【多说一句】 为什么react-redux没有context的问题呢?原因是虽然react-redux里用了context来传递store的值,但并没有通过更新store值来更新UI,而是实现了一套发布订阅模式,根据seletor的值变化来更新,这样就避免了context的问题。这里可以参考一个简单版本的实现代码。而在最新版本的react-redux 8中,使用了react18的use-sync-external-storeAPI来替换这套逻辑,其原因还稍微复杂一些,这里就不细究了(赶紧去学习🫢)。

使用 umi useModel

umiuseModel同样是一个全局状态的管理方案,不过它相比redux更轻量,使用也简单很多。即然是全局状态,那也得扣一扣问题了。下面是一个useModel的使用示例:

// 一个自定义的useModel
export default function useValueModel() {
  const [value1, setValue1] = useState(0);
  const [value2, setValue2] = useState(0);
  return {
    value1,
    setValue1,
    value2,
    setValue2,
  };
}

在组件中使用:

const Component1 = () => {
  const { value1, setValue1 } = useModel('useValueModel');
};

const Component2 = () => {
  const { value2, setValue2 } = useModel('useValueModel');
};

经过前面的铺垫,相信大家对这里也会有疑问,value1变化会不会影响使用value2的组件呢?答案是,会的!解决方法和前面类似,使用selector。

  const { value1, setValue1 } = useModel('useValueModel', (model) => ({
    value1: model.value1,
    setValue1: model.setValue1,
  })); // 第二个参数是selector,只在选择的状态变化时才更新

【多说一句】 useModel是怎么实现的呢?seletor的原理又是什么呢?看了下源码,useModel中也使用了context传递数据,但传入的Context Provider的值是一个不变的对象(又是这一招😄)。

const dispatcher = new Dispatcher!();
//...
<UmiContext.Provider value={dispatcher}>...</UmiContext.Provider>;

那么数据更新怎么触发视图更新呢,方法还是发布订阅。订阅就在useModel里实现了(源码 .umi/plugin-model/useModel.tsx),它将回调绑定在了特定的namespace上,比如下面的useValueModel

const { value1, setValue1 } = useModel('useValueModel');

而发布靠的是react原生的hook(源码 .umi/plugin-model/helpers/executor.tsx),当setValue1执行时,将执行useValueModel这个namespace的回调函数,从而触发视图更新。回调函数的核心逻辑如下所示:

// 如果存在updater(即selector),则对比前后的state
if(updater && updaterRef.current){
  const currentState = updaterRef.current(e);
  const previousState = stateRef.current
  if(!isEqual(currentState, previousState)){
    setState(currentState); // 通过react setState更新视图
  }
} else {
  setState(e); // 通过react setState更新视图
}

总结

虽然上面有那么多示例,但也可以用几句话总结。

  • 将state影响最小化,抽离组件或拆分状态
  • 合理使用缓存
  • 全局状态使用注意添加selector

其实很多项目中过早的使用优化手段并没有必要,但了解一些优秀的实践和内部的原理还是大有裨益的,做到心中有🌲,写起代码来也能更自信!

参考