组件又双叒叕重渲染了?!来看看这篇React组件重渲染总结吧~
“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”
这篇文章主要探讨和组件重渲染有关的种种问题,以解决日常开发中的疑问,做到心中有🌲。文章中包含了日常开发中常用的state,props,context,react-redux以及umi usemodel等和组件状态有关的实践,并浅述了一些原理。(只针对函数式组件哦~)
目录
- 什么是组件重渲染?
- 引起组件重渲染的原因有哪些?
- 如何避免不必要的重渲染?
- 使用 react-redux 要注意什么?
- 使用 umi useModel 要注意什么?
什么是组件重渲染?
const component = () => {
const [state, setState] = useState(0);
console.log('component render');
return <div>{state}</div>;
};
如代码所示,不管是第一次渲染或是state改变,都会触发这个组件函数的执行,控制台将打印“component render”。函数组件执行后返回一个JSX,后面react将执行一系列操作以更新页面UI。除去第一次渲染,后面每次组件函数的执行我们都叫做这个组件的重渲染。
引起组件重渲染的原因有哪些?
原因可以归为以下三点:
- 组件本身的state改变
- 父组件重渲染
- context改变
1. 组件本身的state改变
如图,调用
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. 父组件重渲染
如图,父组件的重渲染也将引起所有子组件的重渲染。但反过来,子组件的重渲染不会影响父组件。父组件可以通过props传值给子组件,但这和重渲染没有直接关系。在下图中,即使
value
值没有变化,父组件重渲染仍然会引起子组件的重渲染。
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都会重渲染,即使它们用到的value1
或value2
并没有改变。
🌟如何避免不必要的重渲染?
一个组件的重渲染可能做两件事情,一是更新页面内容(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影响最小化原则
方法一:抽离子组件。
如图所示,父组件中的状态变化将导致所有子组件的重渲染,但如果将这个state抽离到一个子组件中,那它影响的范围就变小了。所以抽离组件是一个好习惯,这样state变化只会影响到和它相关的组件。
方法二:将组件作为props。
这种方法同样也是抽离组件,只不过方式和第一种不同。这里将原组件变成了一个高阶组件,并将没有用到state的组件抽离出去,通过props传入。其实同理,也是将state的影响最小化。
使用缓存
相比抽离组件,一种更简单的方法是使用React.memo。
在上图中,使用
React.memo
包裹子组件后,父组件的重渲染将不会影响子组件。然而,当子组件接收props时,我们却需要额外注意了。
如果props为基本类型,如字符串或数字,则不需要做任何处理。但如果props为对象、数组或函数等类型时,则需要引入useMemo或useCallback。因为这类变量在每次组件函数执行时都会更新,就像{} !== {}
,所以需要对这类变量进行缓存。如下图所示:
不过,使用useMemo,useCallback以及React.memo本身也是有开销的,在一些简单场景下使用它们可能并不会有性能上的提升,所以切记不要滥用。
在一个组件内我们也可以单独使用useMemo来对开销大的子组件做缓存优化,如下图所示:
3. 使用context😣
前面有提到,当Context Provider中的值改变时,所有使用了这个Context的组件都将重渲染。由于这个特性,使用context时很容易就产生不必要的重渲染。但下面有一些方法可以帮助我们缓解这个问题。
缓存context值
如上图所示,如果Context Provider的value值不是基本类型,我们可以将这个值进行缓存,这样可以避免组件重渲染时改变context值。
分离Context
前面有提到state影响最小化,使用context时也是一样。我们可以将一个context进行拆分来避免不必要的重渲染。
在下图右侧的示例中,通过拆分conetext,first
和second
就不会相互影响了。
同理,我们也可以将data与setData的context分开。如下图,右侧示例中使用ApiContext的组件不会因为DataContext的值改变而重渲染。
在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-store
API来替换这套逻辑,其原因还稍微复杂一些,这里就不细究了(赶紧去学习🫢)。
使用 umi useModel
umi的useModel同样是一个全局状态的管理方案,不过它相比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
其实很多项目中过早的使用优化手段并没有必要,但了解一些优秀的实践和内部的原理还是大有裨益的,做到心中有🌲,写起代码来也能更自信!
参考
转载自:https://juejin.cn/post/7144652366736785416