先看一个最常见的场景:一个列表页组件维护着一个输入框的state,用户每敲一个字,整个列表的几十行数据都跟着重新渲染,页面开始明显卡顿。你翻遍代码,发现列表的props明明没有任何变化,为什么children还是渲染了?要回答这个问题,得先回到React的渲染机制本身。

一、React渲染机制:父组件更新必然带动children
React中一个容易被忽视的事实是:组件函数被重新调用,并不代表DOM被更新。渲染指的是React调用你的函数组件获取新的虚拟DOM树,之后通过协调算法对比新旧树,最终只把必要的变更提交到真实DOM。所以children组件被重复调用,很多时候并不直接等于性能问题,React本身就是在多调用几次函数这个前提下设计的。
但关键在于默认行为:当父组件重新渲染时,无论props是否变化,React默认会递归渲染整棵子树。也就是说,只要父组件的函数体执行了一次,写在JSX里的所有子组件函数都会跟着执行一遍。这是因为React默认采用宁可多渲染、不能漏更新的保守策略,它不假设你的组件是纯函数,也就无法自动跳过任何一次渲染。
function Parent() {
const [count, setCount] = useState(0);
console.log('Parent 渲染');
return (
<div>
<button onClick={() => setCount(count + 1)}>加一</button>
<Child />
</div>
);
}
// Child 没有接收任何 props,但每次点击按钮它都会打印日志
function Child() {
console.log('Child 渲染');
return <div>我是子组件</div>;
}上面的代码里,点击按钮后控制台会同时输出两条日志。这就是children被重复渲染的根源:不是props变了,而是父组件重新执行,子组件作为父组件渲染结果的一部分被顺带重新调用了。理解这一点之后,优化才有明确的方向:要么阻断这条默认的递归渲染链路,要么减少父组件自身不必要的渲染。
二、React.memo失效的常见元凶:内联函数与内联对象
既然默认会递归渲染,那用React.memo包一层是不是就解决了?原理上是的:memo会对props做浅比较,只有props变化时才重新渲染。但在实际项目中,很多人加了memo之后发现毫无效果,问题几乎都出在props的引用相等性上。
JSX中直接写的内联函数和内联对象,每次父组件渲染时都会创建全新的引用。对浅比较来说,引用不同就等于值不同,memo的比较直接失效。下面是一个典型反面案例:
function Parent() {
const [count, setCount] = useState(0);
// 每次渲染都创建新对象,浅比较必然失败
const style = { color: 'red' };
return (
<div>
<button onClick={() => setCount(count + 1)}>加一</button>
<MemoChild onClick={() => console.log('点击')} style={style} />
</div>
);
}
const MemoChild = React.memo(function Child({ onClick, style }) {
console.log('Child 渲染');
return <div>子组件</div>;
});这段代码里memo完全不起作用:onClick是箭头函数内联写的,每次父组件渲染都产生新函数;style是渲染函数体内新建的对象字面量。两者在浅比较时都不等于上一次的值,于是memo每次都放行渲染。
修复手段是配合useCallback和useMemo把引用稳定下来:
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('点击');
}, []);
const style = useMemo(() => ({ color: 'red' }), []);
return (
<div>
<button onClick={() => setCount(count + 1)}>加一</button>
<MemoChild onClick={handleClick} style={style} />
</div>
);
}这样处理之后,memo的浅比较每次都能通过,子组件就真正跳过了渲染。需要提醒的是,如果回调依赖了变化的值,就要正确设置依赖数组,否则会拿到过期的闭包值,这是useCallback最常见的坑。
三、children作为属性:天然稳定的优化利器
除了memo,还有一个更优雅的思路:利用children的位置关系阻断渲染传播。当子组件内容以children的形式传入时,只要该内容的结构没有重新生成,就能有效控制渲染范围。
举个实际例子。假设页面有一个频繁更新的数据面板,旁边有一个不常变化的侧边栏。如果写成数据面板包裹侧边栏,侧边栏必然跟着渲染。换个思路,把布局组件提到外层,让易变内容和稳定内容各自独立:
const Layout = React.memo(function Layout({ children }) {
console.log('Layout 渲染');
return <div className="layout">{children}</div>;
});
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>加一</button>
<Layout>
<SlowSidebar />
</Layout>
<Counter value={count} />
</div>
);
}React官方文档中提到的把state下放到真正使用它的组件,以及把内容作为children传递这两种模式,本质都是缩短渲染的传播链条:state变了,只重新渲染依赖它的那一小块,而不是从顶到底整棵树刷新。当state被限制在底层组件内部时,上层组件不受影响;当易变部分被隔离在特定分支时,稳定分支的children元素引用保持不变,配合memo就能跳过整块子树的渲染。
另外值得一提的是Context。Context的value如果写成内联对象,所有消费该Context的组件都会在每次Provider渲染时重新渲染。给value配合useMemo稳定引用,是Context性能优化的标配操作。
四、优化策略的取舍:不要为了memo而memo
看到这里容易产生一个误区:给所有组件都包上memo、所有函数都套上useCallback。实际上memo本身有比较成本,useCallback和useMemo也有内存与依赖维护成本。如果组件本身很轻,渲染一次的开销还不如浅比较的开销大,那么这些优化反而是负收益。
比较务实的做法是:先用React DevTools的Profiler或者why-did-you-render这类工具定位真正的渲染热点,确认某个组件渲染耗时可观、且渲染来源是父组件传播,再针对性地加memo和引用稳定化。状态设计上优先遵循state就近原则,让数据变化的影响范围天然收窄,很多时候根本不需要走到memo这一步。
最后总结几个要点:父组件渲染默认会递归带动所有children,这是机制不是bug;内联函数和内联对象会让memo失效,需要useCallback和useMemo配合;children模式和状态下放可以从结构上阻断渲染传播;优化前先用工具测量,把力气花在真正昂贵的那几个组件上。理解了渲染传播的原理,React的性能问题就不再神秘。
React重新渲染children组件渲染React性能优化修改时间:2026-09-06 21:42:53