Props 改变导致重新渲染
我来先看一个简单的例子,当我点击这个按钮时,count 会加 1。
tsx
function Counter({initialCount}) {
const [count, setCount] = useState(initialCount);
return <div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
}
需要注意的是,这里讨论的 Props 改变并非指 initialCount 或其他 Props 的值发生变化,而是指 Props 对象本身被重新创建了。
让我们深入理解一下:当 React 渲染这个组件时,本质上是在调用 Counter({initialCount: 0})
。每次调用时,都会创建一个全新的对象作为 Props 传入,即使对象的内容完全相同。
tsx
function App() {
return <>
<Counter initialCount={0} />
{/* 其他组件 */}
</>
}
当我们的父组件重新渲染时,Props 对象本身被重新创建,所以 Counter 和其他组件都会重新渲染。
这显然不是我们希望看到的,那么我们如何优化这个组件呢?
使用 React.memo
tsx
const Counter = React.memo(({ initialCount }: { initialCount: number }): JSX.Element => {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
})
它会对 Props 的值进行一个浅比较,如果值没有发生变化,则不会重新渲染。
提升到组件外部、useMemo、useCallback
然而需要注意的是,即使使用了 memo,我们也要谨慎处理 Props 的值。 由于 memo 只进行浅比较,如果我们在传递 Props 时直接使用对象或内联函数,组件仍然会重新渲染
tsx
function App() {
return <>
// 都使用 memo 包裹
<SlowComponent data={{foo:"bar"}} />
<SlowComponentTwo data={["foo","bar"]} />
<OtherSlowComponent setCount={() => setCount(count + 1)} />
{/* 其他组件 */}
</>
}
以上可能是我们在日常开发中经常会写的代码,这些代码会造成不必要的重新渲染,那么我们如何优化这些代码呢?
对于静态数据,我们可以将其提升到组件外部。而对于依赖 state、props 或需要在 useEffect 中使用的数据, 我们可以通过 useMemo 或 useCallback 这些 Hook 来进行性能优化。这样可以避免在每次渲染时重新创建数据或函数。
tsx
const data = ["foo","bar"]
function App() {
const setCount = useCallback(() => {
setCount(count + 1)
}, [count])
const memoizedData = useMemo(() => data, [count])
return <>
<SlowComponent data={data} />
<SlowComponentTwo data={memoizedData} />
<OtherSlowComponent setCount={setCount} />
{/* 其他组件 */}
</>
}
显然,这种写法与我们编写代码的初衷相去甚远。作为开发者,我们希望能够简单直接地传递函数或对象,而不是因此导致组件频繁地重新渲染。 虽然我们可以通过手动优化或使用 lint 规则来规避这些问题,但这并不是一个理想的解决方案。这种复杂的优化过程,反而增加了开发的心智负担。
React Compiler
React Compiler 为这些问题提供了一个优雅的解决方案。尽管目前仍处于实验阶段,但是我觉得它是一个非常值得期待的解决方案。
只需启用 React Compiler,我们就可以摆脱 memo、useMemo、useCallback 等优化方案的困扰。它支持 React 17/18/19 版本,可以在项目或指定目录级别启用。
这种优化方式如此简单直接,可能这就是它讨论热度很低的原因。未来关于组件优化的面试题可能会变得很简单 - 启用 React Compiler 就够了。
Ryan Carniato 在直播中有一个关于 React Compiler 的性能测试
用原生 js 做为基准值,通过加权几何平均值可以看出,React 相关实现相比原生都有一定的性能损耗
- 经过手动优化后 react-hooks: 慢 1.51 倍
- 经过 react compiler 优化后的 react-hooks: 慢 1.57 倍
- 没有经过优化的 react-hooks: 慢 1.85 倍
从这次 benchmark 可以看出,React Compiler 并不一定可以让 react 更快,但是他大概率让你的 react 代码更快
State 改变导致重新渲染
还是第一个例子,当 count 改变时,组件会重新渲染,这当然是没问题
tsx
function Counter({initialCount}) {
const [count, setCount] = useState(initialCount);
return <div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
}
然而,随着代码层级的增加和多个自定义 hooks 的引入,state 的管理会变得愈发复杂。 这种复杂性往往会导致组件树中出现不必要的多层级渲染,影响应用性能。为了有效应对这个问题,我们可以采取以下策略:
- 降低组件复杂度,将 state 下移到真正需要它的子组件
- 将不依赖 state 的 JSX 上移,这样这部分在 state 改变时不会重新渲染
- 如果数据不需要响应式更新,考虑使用 useRef 替代 useState
Context 变化导致重新渲染
prop drilling
有了以上这些优化,我们还需要解决最后一个问题,那就是 prop drilling
。那么什么是 prop drilling
呢?
当多个组件需要共享同一个 state 数据时,传统的做法是将这个 state 提升到它们最近的共同父组件中, 然后通过 props 层层传递给需要使用该数据的子组件。这种将数据在组件树中自上而下逐级传递的模式被称为 prop drilling
。 然而,这种模式的一个明显缺陷是:即使某些中间组件完全不需要这个 state,它们也会因为 props 的变化而被迫重新渲染。
通过创建 Context,我们可以实现数据的跨层级传递,使数据能够直接到达需要它的组件,而无需通过中间组件的 props 层层传递,简化了数据流, 避免不必要的中间组件渲染,
Context 的问题
Context 也有一些问题,当我们的应用变得复杂的时候。
当我们的 Context 值被多个组件共享时,一旦 Context 的值发生变化,即使只有 A 组件的 UI 需要更新, 所有订阅了该 Context 的组件及其子组件都会被触发重新渲染。为此,使用 context 时,也有一些建议
-
将 Context Provider 放在组件树顶层
建议将 Provider 放置在根组件或 App 组件中,这样可以确保 Provider 只在应用初始挂载时渲染一次, 避免频繁的重新渲染和不必要的性能开销
-
合理拆分 Context
避免创建一个包含所有状态的大型 Context 例如,不要将用户信息、主题设置等所有状态都放在同一个 Context 中 应该根据功能将其拆分为独立的 Context(如 ThemeContext、UserContext) 这样可以实现更细粒度的更新,提高渲染性能
-
谨慎在自定义 Hook 中使用嵌套过深的 state (context,useState)
随着应用复杂度增加,过多的嵌套过深的 state 会导致:
- 代码可维护性降低
- 调试难度增加
- 渲染逻辑难以追踪
建议保持 hooks 结构扁平化,适度使用组合 这样不仅提高了代码可读性,也便于性能优化和问题排查
总结
Props 改变导致的重新渲染
- 使用 React.memo 包裹组件
- 将静态数据提升到组件外部
- 使用 useMemo 和 useCallback 缓存数据和函数
- 使用 React Compiler(实验阶段)自动优化
State 改变导致的重新渲染
- 将 state 下移到真正需要的子组件
- 将不依赖 state 的 JSX 上移
- 考虑使用 useRef 替代不需要响应式更新的数据
Context 改变导致的重新渲染
- 将 Context Provider 放在组件树顶层
- 根据功能合理拆分 Context
- 谨慎在自定义 hooks 中使用嵌套过深的 state (context,useState)
以上文字参考
the big problem with React useContext
how does React re-render?