解法 2:内容提升
当一部分state
在高开销组件树
的上层代码中使用时下放State就无法奏效了。举个例子,如果我们将color
放到父元素div
中。
export default function App() { + let [color, setColor] = useState('red'); return ( + <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p>Hello, 789!</p> <ExpensiveComponent /> </div> ); }
我们将App
组件分割为两个子组件。依赖color
的代码就和color state
变量一起放入ColorPicker
组件里。 不关心color
的部分就依然放在App
组件中,然后以JSX内容的形式传递给ColorPicker
,也被称为children
属性。 当color
变化时,ColorPicker
会重新渲染。但是它仍然保存着上一次从App
中拿到的相同的children
属性,所以React
并不会访问那棵子树。 因此,ExpensiveComponent
不会重新渲染。
代码改造如下:
function App() { return ( <ColorPicker> + <p>Hello, 789!</p> + <ExpensiveComponent /> </ColorPicker> ) } function ColorPicker({ children }) { const [color, setColor] = React.useState('red') return ( <div style={{ color }}> <input value={color} onChange={(event) => setColor(event.target.value)} /> + {children} </div> ) }
如果子组件始终是完全相同的引用,React
可以直接跳过渲染过程。即使颜色发生变化,ExpensiveComponent
也不会随之重新渲染。
上面两种解法,都是利用组件组合,从而避免重复渲染。下面,我们采用React.memo
的语法,看看会发生啥。
解法 3:React.memo
另外的方案就是将所有内容都从同一个组件内部进行渲染,但在ExpensiveComponent
组件周围包裹一个React.memo
:
function App() { const [color, setColor] = useState('red') return ( <div style={{ color }}> <input value={color} onChange={(event) => setColor(event.target.value)} /> <p>Hello, 789!</p> + <ExpensiveTree /> </div> ) } function ExpensiveComponent() { let now = performance.now(); while (performance.now() - now < 100) { // 手动模拟,耗时任务 -- 此处会卡顿100ms } // 打印被渲染的次数 console.log('我被渲染了'); return <p>耗时渲染</p>; } + const ExpensiveTree = React.memo(ExpensiveComponent)
如果我们使用React.memo
包装一个组件,如果其props
没有发生变化,React
将跳过渲染该组件(以及其子组件)。这当然可以实现与改变组件组合相同的结果,但在将来容易出现问题。
当一个组件被
Memo
处理后,React
将使用Object.is
比较每个prop。如果它们没有发生变化,就可以跳过重新渲染。
上面利用React.Memo
处理的情况就满足要求,因为我们的组件实际上没有props
。它也适用于将原始值作为props
,但对于函数
、对象
和数组
等情况,效果就不那么好了。
大家是否还记得,针对
JS
来说,函数
、对象
和数组
等非基本数据类型,它们是存在堆中的,也就是在引用它们的时候,我们只是引用了它存在堆中的地址(指针
)。关于这个知识点,可以看我们之前的写的JS篇之数据类型那些事儿
让我们对ExpensiveComponent
进行一个看似无害的更改 - 添加一个style prop
:
function ExpensiveComponent({ style }) { return <div style={style}>耗时任务</div> } const ExpensiveTree = React.memo(ExpensiveComponent)
通常情况下,组件随着时间的推移会逐渐演化,props
会被添加进来。问题在于,ExpensiveTree
组件的使用者并不一定知道它是否被Memo
处理。毕竟,这是ExpensiveComponent
的使用细节。
如果现在在渲染ExpensiveTree
组件时添加一个内联样式prop
:
<ExpensiveTree style={{ backgroundColor: 'blue' }} />
我们无意中破坏了记忆化,因为style prop
在每次渲染时都会是一个新的对象。对于React
来说,看起来props
已经发生变化,因此它无法跳过渲染。
好的,当然,我们可以通过将style prop
包装在React.useMemo
中来解决这个问题:
function App() { + const memoizedStyle = React.useMemo( + () => ({ backgroundColor: 'blue' }), + [] + ) return <ExpensiveTree style={memoizedStyle} /> }
在我们这个简单的情况下是可行的,但想象一下,如果我们有更多需要记忆化的props
,我们的代码将会变得更加难以理解,而且不能保证使用者会对数据进行Memo处理。
而当style
本身作为一个prop
传递给渲染ExpensiveTree
的组件时,情况会变得更加复杂:
function App({ style }) { + const memoizedStyle = React.useMemo(() => style, [style]) return <ExpensiveTree style={memoizedStyle} /> }
这种记忆化实际上没有实现任何效果。我们无法确定style
是否作为内联对象传递给App
,因此在这里进行记忆化是没有意义的。我们需要在App
调用处创建一个稳定的引用。
3. children
另一个需要注意的地方是,如果被Memo
处理过的组件接受children
,它们的行为可能不如我们期望的那样:
function App() { return ( <ExpensiveTree> + <p>Hello, 789!</p> </ExpensiveTree> ) } + function ExpensiveComponent({ children }) { return ( <div> 耗时任务 + {children} </div> ) } const ExpensiveTree = React.memo(ExpensiveComponent)
上面的代码看起来人畜无害,但是它却在破坏我们组件稳定上包藏祸心。
为什么会破坏呢?表面上,我总是传递相同的、稳定的<p>
标签作为children
。实际上并不是。JSX
只是React.createElement
的语法糖,它会在每次渲染时创建一个新的对象。因此,尽管对我们来说<p>
标签看起来是相同的,但它们不是相同的引用。
当然,我们可以将传递给记忆化组件的children
包装在useMemo
中,这无疑让我们悉心呵护的组件陷入"人民战争"的汪洋大海。 因为,你永远不知道,下个弄潮儿不知道在什么地方,什么时机没做Memo
处理。如果这样的话,兜兜转转我们又回到了原点。
下面的代码大家肯定熟悉。只传递一个空对象或数组作为记忆化组件的prop
的回退值。如果这样,我们总不能对[]
进行记忆处理。如果这么做也没错,这无疑让我们的代码变成老太婆的裹脚布又臭又长。
<ExpensiveTree someProp={someStableArray ?? []} />
4. 替代方案
因此,使用React.memo
有一些潜在问题,但有时,似乎我们无法避免对一个组件进行记忆化处理。那是否又一个折中的方案来解决这种问题呢?有!!
其实,我们大部分的组件很少需要进行React性能优化。凡事就怕一个万一。
假设,我们有一个页面,其中包含5个大表格和一个摘要栏。当一个表格发生变化时,所有内容都重新渲染。这导致页面加载速度很慢。
代码结构如下,出于简洁起见,使用了两个表格而不是五个:
function App() { const [state, setState] = React.useState({ table1Data: [], table2Data: [], }) return ( <div> <Table1 data={state.table1Data} /> <Table2 data={state.table2Data} /> <SummaryBar /> </div> ) }
state
保存了两个表格的数据,而SummaryBar
需要访问所有这些数据。我们无法将状态下放
到表格中,也无法以不同的方式组合这些组件。似乎对组件进行memo
处理是我们唯一的选择。
其实在twitter
上已经有人给出了解决方案。
解决方案就是:
- 将每个表格包裹在
React.memo
中。- 将传递的函数包裹在
useCallback
中。
但是,我们再另辟蹊径,用其他方式解决这个问题。
不要开始渲染
还记得我之前说过一旦渲染开始,我们就没有办法停止它吗?这仍然是正确的,但如果我们从一开始就阻止渲染呢... 🤔
如果状态不位于应用程序的顶部,我们就不需要在它发生变化时重新渲染整个树。
但它可以放在哪里呢?我们已经确定无法将其下移 - 那么就将其放在一边 - 放在React
触及不到的地方。
这正是大多数状态管理
解决方案所做的。它们将状态存储在React
之外,并有针对性地触发需要更改的组件树部分的重新渲染。像React Query
/zustand
/Recoil
都是这么做的。
因此,是的,我提出的替代解决方案是引入一个有效的状态管理器。下面我们使用zustand
来演示。(当然,也可以换成你熟悉的状态管理库)
这里多说一点,之前在React-全局状态管理的群魔乱舞我们讲过各个库的适用场景。
const useTableStore = create((set) => ({ table1Data: [], table2Data: [], actions: {...} })) export const useTable1Data = () => useTableStore((state) => state.table1Data) export const useTable2Data = () => useTableStore((state) => state.table2Data) export const useSummaryData = () => useTableStore((state) => calculateSummary(state.table1Data, state.table2Data) )
现在,每个组件都可以内部订阅其感兴趣的状态,避免了任何自顶向下的重新渲染。如果table2Data
更新,Table1
将不会重新渲染。这与对表格进行记忆化一样有效,但不会受到添加新props
可能对性能产生负面影响的问题。
5. 问题的根源
无论是使用组件组合的方式还是使用React.memo
亦或者利用状态管理器都不是最佳选择。
- 进行记忆化会使我们的代码难以阅读,而且很容易出错 (最差)
- 使用外部状态管理器会稍微好一些,但是增加了我们项目的学习负担 (稍好)
- 组件组合似乎可以完美解决,但是有些组件的改造可不是像上面
Demo
一样,做一次层级的改变,而是需要大刀阔斧。无疑增加了心智负担。(相比其他的解决方案,还是优先推荐)
出现这个问题的真正根源还是。非基本数据类型的特性导致的。就像上面讲到的那样,函数
/对象
/数组
这种数据对比。所以真正的解决之道是改变游戏规则。Records
和 Tuples
,它们可以帮助我们处理数组和对象,但不适用于函数。
React团队也曾暗示他们正在开发一个名为React Forget
的编译器,据说将自动为我们进行记忆化。有了这个工具,我们可以获得React.memo
的性能优化,同时减少错误的发生机会。
后记
分享是一种态度。
参考链接:
- memoization
- proposal-record-tuple
- React performance optimizations
- records-and-tuples-for-react
- before-you-memo
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。