相信不少React开发者都有过这样的经历:父组件里一个很普通的状态更新,比如拖动一下侧边栏、改一次筛选条件、刷新一下某个计数,屏幕上的整个列表、每个卡片、每行数据全都跟着重新渲染了一遍。数据量小的时候还能忍,一旦列表几百上千行、每行又是一个嵌套了好几层的重组件,卡顿感立马就上来了。React性能优化里最常被拿出来讲的shouldComponentUpdate,就是处理这类问题的第一把钥匙。
这期内容从实际项目场景出发,把shouldComponentUpdate的原理、写法、适用边界、常见坑位一口气理清楚。不管你是刚入门React、准备跳槽刷面试题,还是正在为某个页面优化焦头烂额,看完应该都会有自己的思路。
1. shouldComponentUpdate到底在解决什么问题:先看React的渲染流程
1.1 为什么React会选择“无脑”重渲染
很多新手对React渲染有一个误解:觉得React足够聪明,props没变的话组件就不会重新渲染。实际上默认情况下完全不是这样。React的re-render触发条件主要有几个:组件自身调用了setState、收到的props引用发生了变化、父组件重新渲染、context值发生变化。其中最容易被忽略的就是“父组件重新渲染”这一条——只要父组件render了,它生成的所有子组件React元素都会被重新创建,无论子组件接收的props看起来有没有变。
这是React刻意做出的设计取舍。React核心团队的原则是“宁可多渲染,也不要漏渲染”,因为一旦漏掉某个更新,界面残留的就是脏数据,这种bug远比性能问题难查得多。所以React把“props是否真的发生了值级别的变化”这个判断交给开发者自己决定,而不是框架默认帮你做。shouldComponentUpdate就是类组件里手动控制这个决策的入口。
可以拿装修打个比方:默认情况相当于装修队进场后不问青红皂白先把整套房子的墙都铲了重刷,而shouldComponentUpdate就像工头在每户门口问一句“这间屋子到底要不要动工”。问的人越多,判断开销越大,但能省下大把粉刷时间。
1.2 SCU在组件生命周期中的位置
类组件生命周期里,shouldComponentUpdate的执行时机非常明确:它在props或state发生变化、即将触发render之前被调用。首次挂载时不会调用它,因为首次挂载无论如何都必须渲染。调用的时候React会传入两个参数:nextProps和nextState,分别代表接下来要应用的新props和新state。在方法内部通过this.props和this.state拿到的还是当前旧值。
返回值决定后续行为:返回true,React继续走render流程;返回false,React跳过当前组件的render,并且连带跳过它整个后代子树的渲染。注意这个连带机制很关键,后面讲性能收益时全指望它了。但有一点要特别强调,SCU返回false不能阻止子组件自身通过setState触发的更新,它只管“由父级变化导致的渲染”。
1.3 SCU不是银弹,而是一笔交易
一个经常被忽略的事实是:SCU判断本身是有成本的。每次render之前都要额外执行一次方法调用,方法内部如果做了复杂的比较逻辑,这个成本可能比省下来的渲染成本还高。所以SCU的精髓并不在于“给每个组件都加上”,而在于判断成本明显低于渲染成本时,把它加在渲染代价较大的组件上。
比如一个纯展示型组件,props里就一个字符串,渲染出来就是一行文字,那渲染成本很低,加了SCU反而多了一次函数调用。而一个列表项组件内部有图片加载、有多个子组件嵌套、有复杂的样式计算,渲染一次可能要3到5毫秒,这时候用几微秒做一次浅比较就是非常划算的买卖。整篇内容后面所有的实操和策略,都是在围绕这笔“交易”怎么做得划算。
2. 如何正确写shouldComponentUpdate:从基础语法到三类常见策略
2.1 基础写法:比较目标字段,而不是偷懒全量比较
最笨的写法是把所有props都用循环比较一遍,但实际工程里我几乎不这么干。更常见的是针对业务字段做精准比较。假设你有一个列表项组件OrderItem,props里包含order对象(里面有id、status、price、items)和一个onAdd回调,那么SCU通常写成这样:
class OrderItem extends React.Component { shouldComponentUpdate(nextProps) { return ( nextProps.order.id !== this.props.order.id || nextProps.order.status !== this.props.order.status || nextProps.order.price !== this.props.order.price ); } render() { // 渲染图片、价格、状态标签等 return <div className="order-item">{/* ... */}</div>; } }这里有个细节容易踩坑:列表项的key对应的id字段,如果id没变但order内部其他字段变了,而你的SCU里只比较了id,就会漏掉这次更新,界面上出现脏数据。反过来也一样,如果你把items这样的复杂数组整个纳入了比较,又容易因为父组件生成了新数组引用而误触发渲染。所以字段选择本质上是“把真正影响渲染结果的字段挑出来”,要覆盖全,又不能过度覆盖。
在SCU里使用JSON.stringify做全量比较是我见过最典型的反面教材:
shouldComponentUpdate(nextProps) { return JSON.stringify(this.props) !== JSON.stringify(nextProps); }这个写法的问题很明显:序列化两个对象的时间往往比渲染组件本身还贵,而且props里字段顺序一旦变了就会误判为不同,无条件触发渲染。除非你的组件渲染成本高到离谱,否则千万别这么干。
2.2 PureComponent:帮你完成浅比较的现成方案
React早就想到了大多数人写SCU就是比较props和state,所以直接提供了一个内置实现:React.PureComponent。它继承自Component,然后在shouldComponentUpdate里自动做了props和state的浅比较。
浅比较的意思是:对每个key调用Object.is进行比较,只比较第一层引用。如果你给PureComponent传入的props里有一个对象,对象内部属性变了但引用没变,PureComponent会认为props没有变化:
class OrderItem extends React.PureComponent { render() { // ... } }这个例子中,如果父组件拿到的order对象是同一个引用,仅改了order.status字段,PureComponent的浅比较会直接跳过这次渲染,UI就不更新了。这正是后面要讲到的“浅比较陷阱”。所以使用PureComponent有一个前提:所有props和state都要遵循不可变数据模式,每次修改都生成新的对象或数组引用。
2.3 函数组件里的React.memo:SCU的现代继任者
React 16.6之后推出的React.memo,是PureComponent在函数组件世界里的等价物。它的用法很简单:
const OrderItem = React.memo(function OrderItem(props) { // 渲染逻辑 });React.memo同样默认做浅比较。它还可以接收第二个参数,传入自定义比较函数,这是它比PureComponent更灵活的地方:
const OrderItem = React.memo( function OrderItem(props) { // 渲染逻辑 }, (prevProps, nextProps) => { // 返回 true 表示 props 相等,不需要重新渲染 // 返回 false 表示 props 不相等,需要重新渲染 return prevProps.order.id === nextProps.order.id; } );这里有个非常容易记反的点:SCU返回true表示“需要渲染”,而React.memo的第二个参数返回true表示“不需要渲染”。两者的语义正好相反。我刚接触React.memo时在这个地方栽过跟头,写反之后发现页面完全不更新,排查了半天才反应过来。
2.4 三种方案怎么选
| 优化方案 | 适用组件类型 | 默认比较逻辑 | 自定义能力 |
|---|---|---|---|
| shouldComponentUpdate | 类组件 | 无,完全由开发者实现 | 最强,可任意比较 |
| PureComponent | 类组件 | props和state浅比较 | 弱,没有自定义入口 |
| React.memo | 函数组件 | props浅比较 | 中,第二参数可传比较函数 |
个人经验:类组件项目里,如果组件比较简单且props结构扁平,直接用PureComponent最省事;如果比较逻辑复杂,老老实实写SCU;函数组件一律用React.memo,需要纠偏时传自定义比较函数。三种方案的底层思想完全一致,本质上都是“在render之前拦截一次”。
3. 实测:用shouldComponentUpdate给商品列表砍掉80%多余渲染
3.1 搭一个“渲染成本很高”的列表场景
光讲原理不过瘾,直接看一个完整案例。假设你在做一个订单列表页,OrderList组件维护筛选状态、排序状态和购物车状态。列表里每一项都是一个OrderItem组件,内部要渲染商品图片、价格、物流状态、营销标签,还嵌套了一个PriceTag子组件,整体属于典型的重组件。
伪代码如下:
function OrderList() { const [filter, setFilter] = useState('all'); const [cart, setCart] = useState([]); const orders = getOrders(filter); return ( <div> <FilterBar onChange={setFilter} /> {orders.map((order) => ( <OrderItem key={order.id} order={order} /> ))} </div> ); }现在问题来了:用户每点一次FilterBar上的按钮,setFilter会触发OrderList整个函数重新执行,紧接着getOrders重新生成一份订单数组,所有OrderItem的props都会被重新赋值,然后每个OrderItem都走一遍render流程。哪怕这次筛选操作只影响了其中两三个订单的展示状态,剩余几十个订单也会全部跟着重渲染一遍。
我在调试这个问题时,习惯先在render里挂一个计数器,直观看到渲染次数:
window.__renderLog = window.__renderLog || {}; class OrderItem extends React.Component { render() { const key = this.props.order.id; window.__renderLog[key] = (window.__renderLog[key] || 0) + 1; console.log(`OrderItem ${key} 渲染次数: ${window.__renderLog[key]}`); // 实际渲染逻辑 } }打开控制台点击一次筛选按钮,会刷出一整屏的日志,几乎每个订单项都被渲染了一遍。这个现象如果出现在生产环境的移动端页面里,卡顿几乎是一定的。
3.2 给OrderItem接上SCU:精准跳过无关订单
接下来对OrderItem做改造。这个组件只关心order.id、order.status、order.price这三个字段是否真正变化,其他字段变化时完全不需要重新渲染。因此SCU写成这样:
class OrderItem extends React.Component { shouldComponentUpdate(nextProps) { return ( nextProps.order.id !== this.props.order.id || nextProps.order.status !== this.props.order.status || nextProps.order.price !== this.props.order.price ); } render() { // 原渲染逻辑保持不变 } }这一步做完之后,再点一次筛选按钮,你会发现渲染日志明显变少:只有那些status或price真正发生变化的订单项还在渲染,其他订单项直接跳过了整个render流程。
如果这个项目用的是函数组件,也可以用React.memo实现同样效果:
const OrderItem = React.memo( function OrderItem({ order }) { // 渲染逻辑 }, (prevProps, nextProps) => { return ( prevProps.order.id === nextProps.order.id && prevProps.order.status === nextProps.order.status && prevProps.order.price === nextProps.order.price ); } );注意这里的返回语义:返回true代表“相等,不需要渲染”,和SCU的返回语义正好相反。
3.3 函数props的引用稳定问题:useCallback不能省
如果OrderItem还需要接收一个点击加购的回调函数,比如onAddToCart,情况会变得微妙。假设父组件这样写:
{orders.map((order) => ( <OrderItem key={order.id} order={order} onAddToCart={(id) => setCart((list) => list.concat(id))} /> ))}这里每次OrderList重新render都会创建一个全新的箭头函数,即使React.memo的自定义比较函数忽略了onAddToCart,浅比较模式的默认memo也会因为函数引用变化而认为props不同,导致SCU/memo的拦截失效。所以在函数组件里必须配合useCallback稳定函数引用:
const handleAddToCart = useCallback((id) => { setCart((list) => list.concat(id)); }, []);这个细节是很多“我明明加了memo为什么没用”的终极原因,面试里也经常拿这个当陷阱题。
3.4 用React DevTools Profiler量化收益
代码改完,最好还是用数据说话。打开React DevTools的Profiler面板,点击一次筛选操作,会看到这次交互涉及的组件渲染耗时分布。优化前,OrderList里的几十个OrderItem全部被点亮,单次交互的总渲染耗时可能就在十几毫秒甚至更高;优化后,只有少数几个组件点亮,总耗时可以压到一两毫秒以内。
如果页面有Perfomance面板,也可以直接用浏览器录制一段筛选操作的性能profile,对比优化前后的Scripting和Rendering耗时。我实操下来,列表项越多、每个项渲染越重,SCU带来的收益越明显。几十个轻量组件的场景收益不大,但几百上千个中重组件就非常值得做。
4. 踩过的坑与排查技巧:SCU不是“加了就完事”
4.1 坑1:数据是可变的,PureComponent直接“失灵”
这是使用PureComponent或SCU后最常见的bug,没有之一。场景一般是这样的:state里有一个对象,代码里写push、直接赋值修改属性,而不是生成新对象:
handleUpdate = () => { const newItem = this.state.item; newItem.name = 'new name'; this.setState({ item: newItem }); };这种写法下,引用没有变化,PureComponent的浅比较一看新旧props引用相同,直接跳过渲染。于是界面上永远是旧数据。排查方法很简单:在render里打印this.props,复制一份到控制台对比旧值,会发现对象引用完全一致。
解决办法只有一条路:改成不可变更新:
handleUpdate = () => { this.setState((prev) => ({ item: { ...prev.item, name: 'new name' }, })); };展开运算符生成全新对象,引用变了,浅比较才能感知到变化。这也是为什么每次聊SCU都要把不可变数据放在一起讲,两者是配套关系。
4.2 坑2:浅比较挡不住深层次变化
浅比较只比较第一层引用。如果props里有一个嵌套对象,比如cart数组里的商品数量变了,但数组引用没变,PureComponent或默认memo都会认为没有变化。这种情况下比较粒度已经超出了浅比较的能力范围。
我不太推荐为了处理这种情况去写深比较,成本高、容易误判。更健康的思路是拆分组件:把深层数据变化影响的小区域抽成独立组件,让它的props尽可能扁平和简单。比如把价格展示抽成PriceTag组件,价格变化只影响它一个组件,父级不参与比较,这样既绕开了深嵌套比较,又提高了更新触发的精准度。
4.3 坑3:SCU返回false反而破坏了组件自身状态
SCU的return false影响面比很多人想象的大,它不只是拦住“来自父组件的渲染”,也会拦住“自身state变化引起的渲染”。如果一个组件内部有展开、选中等本地状态,在SCU里又只比较了props字段,就可能出现用户点开弹窗没反应、展开某个区域瞬间被收起这种诡异问题。
避免方式:SCU的比较逻辑里务必把this.state和nextState也纳入考量。更省心的做法是,把内部状态相关的组件拆出来,主组件只负责纯展示,这样SCU就能专注比较props,不用操心state。
4.4 坑4:不要在SCU里做随机、异步、时间相关的操作
SCU是一个被React高频调用的方法,每次渲染前都可能执行。如果在这个方法里读取Math.random()或者new Date().getTime(),会导致渲染结果不可预测,有时跳过渲染,有时不跳过,产生难以复现的闪烁问题。SCU必须是纯粹的函数,同样的props和state输入,必须产生相同的判断结果,不依赖任何外部可变状态。
另外,不要在SCU里发起异步请求。这个方法不是做副作用的地方,它没有任何生命周期语义保证,React完全可能在某些情况下调用它而不继续render。
4.5 坑5:盲目给所有组件加SCU,反而拖累性能
SCU判断本身有开销,如果给一个渲染成本极低的组件强行套上比较逻辑,省下来的渲染时间可能还不及判断消耗的时间。更重要的是,每个自定义SCU都是一份业务逻辑,维护成本不低,稍有不慎就引入一个不更新的bug。
我的原则是“先度量,再优化”:先在Profiler里看哪些组件的渲染耗时排在最前面,再针对热点组件做SCU/memo。不要一上来就给一个系统里所有组件加memo,那只是自欺欺人的“看起来很努力”。
5. 面试与实战:SCU、PureComponent、React.memo的性能优化组合拳
5.1 面试高频题:React性能优化有哪些手段,SCU处于什么位置
这类问题最好分层回答,让面试官看到你有完整的知识框架。通常我会按数据层、组件层、引用层、渲染调度层来展开。数据层包括不可变数据、合理拆分state、避免大范围context更新;组件层就是今天聊的SCU、PureComponent、React.memo;引用层是useCallback和useMemo,负责稳定传给子组件的函数和对象;渲染调度层则对应useTransition、useDeferredValue这类并发特性,用于处理高频输入和重型更新的场景。
SCU在这套体系里的定位非常清晰:它是开发者第一次有机会告诉React“这个组件不用渲染”。其他方案都是在源头上减少渲染次数,SCU是在渲染前设置一道闸门。
5.2 面试高频题:为什么父组件re-render,子组件默认也会re-render
这条背后的原因是React默认不做props值的深比较,它只关心“React元素是否被重新创建”。父组件render后,所有子组件的React元素都会被重新创建,哪怕props值一模一样。React为了保证更新不被漏掉,选择了“全部重新走一遍”的保守策略,把精准判断交给开发者。
这个设计在架构上有它的合理性:React不需要追踪每个props字段是否变化,也就不需要做大规模脏检查。代价就是渲染次数偏多,需要SCU/memo来做增量式兜底。
5.3 面试高频题:SCU、PureComponent、React.memo的区别
回答要点可以归结为三条。第一,SCU是类组件完全自定义的控制方法,你写什么React就执行什么;第二,PureComponent是内置了浅比较的Component子类,省去自己写SCU的麻烦;第三,React.memo是函数组件版的PureComponent,但支持传入第二个参数做自定义比较。还需要额外说明React.memo比较函数返回true表示“相等不需要渲染”,和SCU的语义相反。
这类题面试官真正想考察的是你是不是只背了API名字,还是能把每个方案的边界说清楚。能讲出浅比较的局限性和不可变数据配合的必要性,基本就能过关了。
5.4 面试高频题:为什么加了React.memo页面还是很卡
最常见的答案集中在引用稳定性上。React.memo默认做浅比较,如果父组件每次渲染都重新生成对象字面量或函数,memo就会认为props变了,拦截完全失效。比如这样的代码:
<OrderItem info={{ name: 'foo' }} />每次父组件render都会创建一个新对象,memo浅比较发现引用不同,无论如何都会渲染。解决办法要么保证对象引用稳定,要么写自定义比较函数按字段判断。
除了引用问题,还得确认是否真的存在“多余渲染”。如果组件本身确实需要频繁更新,那memo本来就不该起到拦截作用,卡顿的根源可能另有他处,需要继续往下挖,比如图片加载、layout抖动、大数据渲染,等等。
5.5 实战决策表
| 场景 | 推荐做法 |
|---|---|
| 类组件、渲染较重、条件简单 | PureComponent |
| 类组件、比较逻辑复杂 | 自定义shouldComponentUpdate |
| 函数组件、props结构扁平稳定 | React.memo默认浅比较 |
| 函数组件、需跳过特定字段变化 | React.memo自定义比较函数 |
| 子组件接收函数props | useCallback稳定函数引用 |
| 深嵌套数据、高频局部更新 | 拆组件,细化状态粒度 |
结尾:一点个人体会
我在实际项目中有一个雷打不动的习惯:任何SCU或memo改造之前,先在render里挂一个计数器或者用Profiler录一段操作,改完之后再录一段,前后对比数量级差异。没有数据支撑的优化很容易变成自我安慰。有一次生产环境出了个奇怪的同步问题,列表里的价格怎么点都不更新,排查了半天才发现同事在自定义比较函数里漏了price字段。这个案例让我后来一直坚持把SCU的比较函数当成业务逻辑来review,而不是当成“加了就变快”的黑盒。shouldComponentUpdate确实是一把性能优化的关键钥匙,但它需要理解React的渲染时机、配合不可变数据、并结合引用稳定性一起使用。想清楚“判断成本”和“渲染成本”这笔账,你才能真正用好它。