React Memo不是你优化的第一选择

生活就是当你忙着制定其他计划时,发生在你身上的事情 - 约翰·列侬

大家好,我是柒八九

前言

Dan的文章在使用React.memo之前的注意事项中,通过几个例子来描述,有时候我们可以通过组件组合 的方式来优化组件的多余渲染。文章中提到要么通过将下放State ,要么将内容提升 。因为组件组合是React的自然思维模式。正如Dan所指出的,这也将与Server Components非常契合。

然后,在各种文章中,都提倡克制useMemo的使用,优先使用组件组合 来处理组件冗余渲染的问题。但是,它们都没讲明白,遇到这些问题,为什么不首选使用React.memo呢?

最核心的点,就是

Memo很容易被破坏

下面,我们就由浅入深的来窥探其中的门道。

好了,天不早了,干点正事哇。

你能所学到的知识点

  1. 前置知识点
  2. 问题复现
  3. children
  4. 替代方案
  5. 问题的根源

前置知识点

前置知识点 ,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略

同时,由于阅读我文章的群体有很多,所以有些知识点可能我视之若珍宝,尔视只如草芥,弃之如敝履 。以下知识点,请酌情使用

Object.is

Object.isJavaScript 中的一个内建函数 ,用于比较两个值是否严格相等。它的作用类似于严格相等操作符 ===,但有一些关键区别。

语法

Object.is(value1, value2)

参数

  • value1:比较的第一个值。
  • value2:比较的第二个值。

返回值

Object.is 返回一个布尔值,表示两个值是否严格相等

特点

  1. NaN 相等性: Object.is 在比较 NaN 值时与其他方法不同。它认为 Object.is(NaN, NaN)true,而严格相等操作符 === 认为 NaN === NaNfalse

    javascript 复制代码
    Object.is(NaN, NaN); // true
    NaN === NaN; // false
  2. +0 和 -0 不相等: Object.is 能够区分正零负零,即 Object.is(+0, -0) 返回 false,而 === 会认为它们相等。

    javascript 复制代码
    Object.is(+0, -0); // false
    +0 === -0; // true
  3. +0 和 -0 与0相等: 除了自身之外,正零和负零都与其他数字相等。

    javascript 复制代码
    Object.is(+0, 0); // true
    Object.is(-0, 0); // true
  4. 其它值的比较: 对于其他值,Object.is 表现与 === 相同。

    javascript 复制代码
    Object.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]}'

它们只能包含其他RecordTuple,或简单数据类型。

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. 浏览器之性能指标_FCP
  2. 浏览器之性能指标-LCP
  3. 浏览器之性能指标-CLS
  4. 浏览器之性能指标-FID
  5. 浏览器之性能指标-TTI
  6. 浏览器之性能指标-TBT
  7. 浏览器之性能指标-INP

下面,我们就来解决上面出现的问题。

解法 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一样,做一次层级的改变,而是需要大刀阔斧。无疑增加了心智负担。(相比其他的解决方案,还是优先推荐)

出现这个问题的真正根源还是。非基本数据类型的特性导致的。就像上面讲到的那样,函数/对象/数组这种数据对比。所以真正的解决之道是改变游戏规则。RecordsTuples,它们可以帮助我们处理数组和对象,但不适用于函数。

React团队也曾暗示他们正在开发一个名为React Forget的编译器,据说将自动为我们进行记忆化。有了这个工具,我们可以获得React.memo的性能优化,同时减少错误的发生机会。


后记

分享是一种态度

参考链接:

  1. memoization
  2. proposal-record-tuple
  3. React performance optimizations
  4. records-and-tuples-for-react
  5. before-you-memo

全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。

相关推荐
不做超级小白26 分钟前
深入理解 Axios 拦截器的执行链机制
开发语言·前端·javascript
以对_44 分钟前
【el-table】表格后端排序
前端·javascript·vue.js
永乐春秋1 小时前
WEB攻防-通用漏洞&XSS跨站&绕过修复&http_only&CSP&标签符号
前端·xss
北城笑笑1 小时前
Vue 90 ,Element 13 ,Vue + Element UI 中 el-switch 使用小细节解析,避免入坑(获取后端的数据类型自动转变)
前端·javascript·vue.js·elementui
chenchihwen1 小时前
常见问题QA的前端代码
前端
Lemon_man_1 小时前
算法——反转字符串中的单词(leetcode151)
java·linux·前端
前端Hardy1 小时前
HTML&CSS 奇幻森林:小熊的甜蜜蛋糕派对大冒险
前端·javascript·css·html
花心蝴蝶.2 小时前
HTML 快速上手
前端·html
brzhang2 小时前
解锁新姿势:10 倍效率刷 leetcode
前端·后端
qq_364371722 小时前
子模块、Fork、NPM 包与脚手架概述
前端·npm·node.js