在 React 应用中,避免不必要的重渲染是性能优化的核心。很多时候,性能问题的根源并非组件“渲染得太慢”,而是“渲染了不该渲染的部分”。状态下放与内容提升是两种不需要额外引入 memo 也能有效减少重渲染的策略,它们通过改变状态存放的位置和组件结构,从根本上缩小重渲染的影响范围。
问题场景:状态位置过高引发的“连坐”重渲染
假设我们有一个 App 组件,它内部包含一个受控的搜索输入框,并且渲染了一个庞大的商品列表。搜索关键词状态 keyword 存放在 App 中:
function App() {
const [keyword, setKeyword] = useState('');
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ProductList />
</div>
);
}
当用户每次敲击键盘,keyword 更新,App 就会重新渲染,进而导致 <ProductList /> 也重新渲染——即使列表本身并不依赖 keyword。如果 ProductList 内部渲染开销很大(例如包含数百条数据且未做虚拟化),就会产生明显的卡顿。
这里的根本问题是:状态被放在了过高的层级,使得完全无关的组件也被迫参与重渲染。
状态下放(State Colocation)
状态下放的核心思想是:将状态挪到真正需要它的、层级最低的组件内部。如果一个状态只被某一个子树使用,就不要把它提升到整个应用的根部。
针对上面的例子,我们可以把输入框和关键字状态抽成一个独立的组件:
function SearchInput() {
const [keyword, setKeyword] = useState('');
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}
function App() {
return (
<div>
<SearchInput />
<ProductList />
</div>
);
}
现在,keyword 的更新只会触发 SearchInput 自身重渲染,App 和 ProductList 完全不受影响。状态的“所有权”下沉到了最小范围,这是性能优化的最朴素也最有效的方式。
实际场景中的应用
- 表单独立封装:将每个表单项(输入框、下拉框)的状态封闭在对应的组件内部,表单容器只负责布局,不持有任何细粒度的表单值。
- 弹窗状态内敛:如果一个对话框的显隐状态只被该对话框及其触发按钮使用,那么就将这个状态放到一个共同的包裹组件中,而不是放在页面根组件里,避免触发整页重绘。
- 列表项独立状态:列表每项如果有独立的展开/收起、hover 等状态,直接放在
ListItem组件内部,而不是在父列表中维护一个庞大数组。
内容提升(Lifting Content Up / 以 Children 为边界)
另一种场景是:父组件确实需要持有某些频繁更新的状态,但它内部有一大块“静态内容”不应受状态变更的影响。这时可以将静态内容通过 children props 传入,而不是直接写在父组件的 JSX 中。
其原理是:React 的组件树在协调(Reconciliation)时,如果父组件的 children 来自外部作用域,并且 children 对应的元素引用未发生变化,则 children 对应的子树可以完全跳过渲染。类似于建立了一道“隔离墙”。
示例:频繁计时的仪表盘
Dashboard 组件内部有一个频繁更新的秒表,但它也包含一个渲染开销很大的图表组件:
function Dashboard() {
const [time, setTime] = useState(0);
useEffect(() => {
const id = setInterval(() => setTime(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<h1>已用时:{time}s</h1>
<ExpensiveChart /> {/* 并不会用到 time */}
</div>
);
}
每秒 time 更新,Dashboard 重渲染,<ExpensiveChart /> 也被迫重渲染,尽管它不依赖 time。
优化:将 ExpensiveChart 作为 children 传入
我们创建一个 DashboardLayout 组件,它只负责展示时间,并通过 children 接收其他内容:
function DashboardLayout({ children }) {
const [time, setTime] = useState(0);
useEffect(() => {
const id = setInterval(() => setTime(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<h1>已用时:{time}s</h1>
{children}
</div>
);
}
然后在外层传入不变的部分:
function App() {
return (
<DashboardLayout>
<ExpensiveChart />
</DashboardLayout>
);
}
现在,每秒 time 更新时,DashboardLayout 会重渲染,但它的 children 是在 App 中创建的、引用从未改变。React 在协调 DashboardLayout 内部结构时,发现 children 仍然是同一个元素对象,就会跳过对 <ExpensiveChart /> 的重渲染。不变的内容被“提升”到了上层,通过 children 插槽注入,从而避开了内部状态的反复冲击。
何时使用这两种技巧
| 技巧 | 适用场景 | 操作方式 |
|------|----------|----------|
| 状态下放 | 状态只被局部子树使用,却被放在了高层组件 | 将状态移动到真正使用它的最小公共组件内 |
| 内容提升 | 父组件状态频繁更新,但部分子组件不依赖该状态 | 将不依赖状态的部分通过 children 从外部传入 |
两者都不需要引入额外的 API(如 React.memo 或 useMemo),只需要调整组件结构和状态的所属位置,是一种“零成本”的性能优化手段。它们体现了 React 性能优化的第一原则:尽量让状态在需要它的最小范围内流转,让不变的子树远离更新的源头。