生活就是当你忙着制定其他计划时,发生在你身上的事情 - 约翰·列侬
大家好,我是柒八九。
前言
Dan
的文章在使用React.memo之前的注意事项中,通过几个例子来描述,有时候我们可以通过组件组合 的方式来优化组件的多余渲染。文章中提到要么通过将下放State ,要么将内容提升 。因为组件组合是React
的自然思维模式。正如Dan所指出的,这也将与Server Components非常契合。
然后,在各种文章中,都提倡克制useMemo
的使用,优先使用组件组合 来处理组件冗余渲染的问题。但是,它们都没讲明白,遇到这些问题,为什么不首选使用React.memo
呢?
最核心的点,就是
Memo
很容易被破坏
下面,我们就由浅入深的来窥探其中的门道。
好了,天不早了,干点正事哇。
你能所学到的知识点
- 前置知识点
- 问题复现
- children
- 替代方案
- 问题的根源
前置知识点
前置知识点 ,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略
同时,由于阅读我文章的群体有很多,所以有些知识点可能我视之若珍宝,尔视只如草芥,弃之如敝履 。以下知识点,请酌情使用。
Object.is
Object.is
是 JavaScript
中的一个内建函数 ,用于比较两个值是否严格相等。它的作用类似于严格相等操作符 ===
,但有一些关键区别。
语法
Object.is(value1, value2)
参数
value1
:比较的第一个值。value2
:比较的第二个值。
返回值
Object.is
返回一个布尔值
,表示两个值是否严格相等。
特点
-
NaN 相等性:
Object.is
在比较NaN
值时与其他方法不同。它认为Object.is(NaN, NaN)
为true
,而严格相等操作符===
认为NaN === NaN
为false
。javascriptObject.is(NaN, NaN); // true NaN === NaN; // false
-
+0 和 -0 不相等:
Object.is
能够区分正零
和负零
,即Object.is(+0, -0)
返回false
,而===
会认为它们相等。javascriptObject.is(+0, -0); // false +0 === -0; // true
-
+0 和 -0 与0相等: 除了自身之外,正零和负零都与其他数字相等。
javascriptObject.is(+0, 0); // true Object.is(-0, 0); // true
-
其它值的比较: 对于其他值,
Object.is
表现与===
相同。javascriptObject.is(1, 1); // true Object.is('foo', 'foo'); // true
用途
Object.is
主要用于比较两个值,特别是在需要明确处理 NaN
或区分正零和负零时。这可以用于创建更精确的相等性检查,而不受 JavaScript
中一些奇怪的行为的影响。例如,当比较浮点数或需要区分 NaN
时,Object.is
可能更有用。
javascript
function areTheyEqual(value1, value2) {
return Object.is(value1, value2);
}
areTheyEqual(NaN, NaN); // true
areTheyEqual(+0, -0); // false
Record 和Tuple
它们属于ECMAScript
提案Records and Tuples。
-
Record
(记录):这将是一种深度不可变 的类对象结构
,与普通JavaScript
对象不同,其属性和值将是不可变的。这将有助于避免对象的属性被无意中更改。 -
Tuple
(元组):这将是一种深度不可变 的类数组结构
,与普通JavaScript
数组不同,其元素将是不可变的。这将有助于确保元组的内容在创建后不可更改。
这些看起来类似于普通的对象和数组,但它们具有以"#"前缀为特征:
javascript
const record = #{a: 1, b: 2};
record.a;
// 1
const updatedRecord = #{...record, b: 3};
// #{a: 1, b: 3};
const tuple = #[1, 5, 2, 3, 4];
tuple[1];
// 5
const filteredTuple = tuple.filter(num => num > 2);
// #[5, 3, 4];
它们默认是深度不可变的:
javascript
const record = #{a: 1, b: 2};
record.b = 3;
// 抛出 TypeError
它们可以被视为复合基本类型,并且可以通过值进行比较。
非常重要 :两个深度相等的Record
将始终使用 ===
运算符返回 true
。
javascript
{a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}
// 使用对象 => false
#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}
// 使用记录 => true
我们可以认为Record
的变量就是其实际值,类似于常规JS原始类型。
它们与JSON
互操作:
javascript
const record = JSON.parseImmutable('{a: 1, b: [2, 3]}');
// #{a: 1, b: #[2, 3]}
JSON.stringify(record);
// '{a: 1, b: [2, 3]}'
它们只能包含其他Record
和Tuple
,或简单数据类型。
javascript
const record1 = #{
a: {
regular: 'object'
},
};
// 抛出 TypeError,因为记录不能包含对象
const record2 = #{
b: new Date(),
};
// 抛出 TypeError,因为记录不能包含日期
const record3 = #{
c: new MyClass(),
};
// 抛出 TypeError,因为记录不能包含类
const record4 = #{
d: function () {
alert('forbidden');
},
};
// 抛出 TypeError,因为记录不能包含函数
2. 问题复现
上面提到了 -Memo很容易被破坏
简而言之:当React
渲染一个组件树时,它会从上往下渲染所有子组件。一旦渲染开始,我们就没有办法停止它。通常情况下,这是一件好事,因为渲染确保我们在屏幕上看到正确的状态反映。此外,渲染通常是快速的。
当然还有那些特殊情况
,它们需要处理一下耗时任务,从而使的渲染变得步履蹒跚 。同时,由于某些原因,我们都有一些组件(前任留下的💩⛰️,或者核心业务
),我们无法轻易改变它们,与此同时它们的渲染速度还不尽人意。而此时,小可爱产品,又提出了优化需求。而我们就不得不赶鸭子上架。
幸运的是,React
内置机制中存在优化策略,那就是
在渲染时候,当它发现此次需要渲染的东西和之前是相同的,它是选择使用之前的结果。
假设,我们有如下的组件。
jsx
import { useState } from 'react';
export default function App() {
let [color, setColor] = useState('red');
return (
<div>
// 触发
<input value={color} onChange={(e) => setColor(e.target.value)} />
<p style={{ color }}>Hello, 789!</p>
<ExpensiveComponent />
</div>
);
}
function ExpensiveComponent() {
let now = performance.now();
while (performance.now() - now < 100) {
// 手动模拟,耗时任务 -- 此处会卡顿100ms
}
// 打印被渲染的次数
console.log('我被渲染了');
return <p>耗时渲染</p>;
}
我们可以将上面的代码,放置在任何线上环境进行测试。运行后我们就会发现,当App
中的color
变化时,会重新渲染一次 被我们人为延缓渲染的<ExpensiveComponent />
组件。
在实际开发中,如果ExpensiveComponent
渲染需要很长时间,那这个部分就会很引起性能崩塌。
这是我们之前写的关于如何测试浏览器性能的文章,然后大家可以按需获取。
下面,我们就来解决上面出现的问题。
解法 1: 下放State
如果我们仔细看一下上面的问题代码
,我们会注意到返回的组件树
中只有一部分 真正关心当前的color
。而<ExpensiveComponent/>
却对这些信息充耳不闻。
diff
export default function App() {
+ let [color, setColor] = useState('red');
return (
<div>
+ <input value={color} onChange={(e) => setColor(e.target.value)} />
+ <p style={{ color }}>Hello, 789!</p>
<ExpensiveComponent />
</div>
);
}
我们把关心color
部分提取到Form
组件中然后将state
移动到该组件里:
diff
export default function App() {
return (
<>
+ <Form />
<ExpensiveComponent />
</>
);
}
function Form() {
+ let [color, setColor] = useState('red');
return (
<>
+ <input value={color} onChange={(e) => setColor(e.target.value)} />
+ <p style={{ color }}>Hello, 789!</p>
</>
);
}
如果color
变化了,只有Form
会重新渲染。将state
下放到真正关心的组件中,这样就可以完美避开渲染污染。
动物世界看过哇。我们可以认为,这个是生殖隔离 。虽然,有马和驴生下骡子的特例,但是骡子无法生育,就算存在污染,那也是限制在有限范围内。而不会出现子子孙孙无穷匮也的情况。
解法 2:内容提升
当一部分state
在高开销组件树
的上层代码中使用时下放State 就无法奏效了。举个例子,如果我们将color
放到父元素div
中。
diff
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
不会重新渲染。
代码改造如下:
diff
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
:
diff
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
:
jsx
function ExpensiveComponent({ style }) {
return <div style={style}>耗时任务</div>
}
const ExpensiveTree = React.memo(ExpensiveComponent)
通常情况下,组件随着时间的推移会逐渐演化,props
会被添加进来。问题在于,ExpensiveTree
组件的使用者并不一定知道它是否被Memo
处理。毕竟,这是ExpensiveComponent
的使用细节。
如果现在在渲染ExpensiveTree
组件时添加一个内联样式prop
:
jsx
<ExpensiveTree style={{ backgroundColor: 'blue' }} />
我们无意中破坏了记忆化 ,因为style prop
在每次渲染时都会是一个新的对象 。对于React
来说,看起来props
已经发生变化,因此它无法跳过渲染。
好的,当然,我们可以通过将style prop
包装在React.useMemo
中来解决这个问题:
diff
function App() {
+ const memoizedStyle = React.useMemo(
+ () => ({ backgroundColor: 'blue' }),
+ []
+ )
return <ExpensiveTree style={memoizedStyle} />
}
在我们这个简单的情况下是可行的,但想象一下,如果我们有更多需要记忆化的props
,我们的代码将会变得更加难以理解,而且不能保证使用者会对数据进行Memo处理。
而当style
本身作为一个prop
传递给渲染ExpensiveTree
的组件时,情况会变得更加复杂:
diff
function App({ style }) {
+ const memoizedStyle = React.useMemo(() => style, [style])
return <ExpensiveTree style={memoizedStyle} />
}
这种记忆化实际上没有实现任何效果。我们无法确定style
是否作为内联对象传递给App
,因此在这里进行记忆化是没有意义的。我们需要在App
调用处创建一个稳定的引用。
3. children
另一个需要注意的地方是,如果被Memo
处理过的组件接受children
,它们的行为可能不如我们期望的那样:
diff
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
的回退值。如果这样,我们总不能对[]
进行记忆处理。如果这么做也没错,这无疑让我们的代码变成老太婆的裹脚布又臭又长。
jsx
<ExpensiveTree someProp={someStableArray ?? []} />
4. 替代方案
因此,使用React.memo
有一些潜在问题,但有时,似乎我们无法避免对一个组件进行记忆化处理。那是否又一个折中的方案来解决这种问题呢?有!!
其实,我们大部分的组件很少需要进行React性能优化。凡事就怕一个万一。
假设,我们有一个页面,其中包含5个大表格和一个摘要栏。当一个表格发生变化时,所有内容都重新渲染。这导致页面加载速度很慢。
代码结构如下,出于简洁起见,使用了两个表格而不是五个:
jsx
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-全局状态管理的群魔乱舞我们讲过各个库的适用场景。
jsx
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
全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。