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

简介: React Memo不是你优化的第一选择(二)

解法 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上已经有人给出了解决方案。

image.png

解决方案就是:

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

出现这个问题的真正根源还是。非基本数据类型的特性导致的。就像上面讲到的那样,函数/对象/数组这种数据对比。所以真正的解决之道是改变游戏规则。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

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

相关实践学习
阿里云表格存储使用教程
表格存储(Table Store)是构建在阿里云飞天分布式系统之上的分布式NoSQL数据存储服务,根据99.99%的高可用以及11个9的数据可靠性的标准设计。表格存储通过数据分片和负载均衡技术,实现数据规模与访问并发上的无缝扩展,提供海量结构化数据的存储和实时访问。 产品详情:https://www.aliyun.com/product/ots
相关文章
|
2月前
|
前端开发 JavaScript UED
使用React Hooks优化前端应用性能
本文将深入探讨如何使用React Hooks来优化前端应用的性能,重点介绍Hooks在状态管理、副作用处理和组件逻辑复用方面的应用。通过本文的指导,读者将了解到如何利用React Hooks提升前端应用的响应速度和用户体验。
|
4月前
|
前端开发 JavaScript
使用 MobX 优化 React 代码
使用 MobX 优化 React 代码
38 0
|
6月前
|
JavaScript 前端开发 虚拟化
理解React页面渲染原理,如何优化React性能?
理解React页面渲染原理,如何优化React性能?
66 0
|
2月前
|
前端开发
利用React Hooks优化前端状态管理
本文将深入介绍如何利用React Hooks优化前端状态管理,包括Hooks的概念、使用方法以及与传统状态管理方式的对比分析,帮助前端开发人员更好地理解和应用这一现代化的状态管理方案。
|
3月前
|
前端开发 JavaScript
React渲染性能的优化
React渲染性能的优化
27 2
|
4月前
|
前端开发 JavaScript API
React有哪些优化性能的手段?
React有哪些优化性能的手段
17 0
|
5月前
|
前端开发 JavaScript
React中通过children prop或者React.memo来优化子组件渲染【react性能优化】
React中通过children prop或者React.memo来优化子组件渲染【react性能优化】
46 0
|
5月前
|
前端开发 JavaScript UED
react中的forwardRef 和memo的区别?
react中的forwardRef 和memo的区别?
47 0
|
5月前
|
JSON 前端开发 JavaScript
React Memo不是你优化的第一选择(一)
React Memo不是你优化的第一选择(一)
|
前端开发 JavaScript 开发者
React 的慢与快:优化 React 应用实战
本文讲的是React 的慢与快:优化 React 应用实战,我正在为 admin-on-rest 这个开源项目工作,它使用 material-ui 和 Redux 为任一 REST API 提供一个 admin 用户图形界面。
1404 0