【译】为什么React会重新渲染

说实话。我已经专业地使用React多年了,但对React的重新渲染过程并没有真正理解。

我认为这对许多React开发者来说都是真实的情况。我们了解的足够多以应付工作,但如果你向一群React开发者提问:"什么会触发React的重新渲染?"你可能会得到几个不同且含糊的答案。

关于这个问题有很多误解,这可能会导致很多不确定性。如果我们不了解React的渲染周期,又怎么能理解如何使用React.memo,或者何时应该在函数周围包裹useCallback呢?

在本教程中,我们将建立一个心理模型,了解何时以及为什么React会重新渲染。

React的核心循环

让我们从一个基本的事实开始:React中的每次重新渲染都始于状态变化。这是React中唯一的"触发器",用于使组件重新渲染。

现在,这可能听起来不太对...毕竟,当组件的props变化时,它们不会重新渲染吗?那上下文(context)呢?

事实是:当一个组件重新渲染时,它的所有后代组件也会重新渲染。

让我们看一个例子:

js 复制代码
import React from 'react';

function App() {
  return (
    <>
      <Counter />
      <footer>
        <p>Copyright 2022 Big Count Inc.</p>
      </footer>
    </>
  );
}

function Counter() {
  const [count, setCount] = React.useState(0);
  
  return (
    <main>
      <BigCountNumber count={count} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </main>
  );
}

function BigCountNumber({ count }) {
  return (
    <p>
      <span className="prefix">Count:</span>
      {count}
    </p>
  );
}

export default App;

在这个例子中,我们有3个组件:顶层的App组件,它渲染Counter组件,而Counter组件又渲染BigCountNumber组件。

在React中,每个状态变量都与特定的组件实例相关联。在这个例子中,我们有一个单独的状态变量count,它与Counter组件相关联。

每当这个状态发生变化时,Counter组件重新渲染。而由于BigCountNumber是由Counter组件渲染的,它也会重新渲染。

好的,让我们澄清一个误解:每当状态变量发生变化时,整个应用都会重新渲染。

我知道有些开发者认为在React中每次状态变化都会强制整个应用重新渲染,但这是不正确的。重新渲染只会影响拥有该状态的组件以及其后代(如果有的话)。在这个例子中,当count状态变量发生变化时,App组件不必重新渲染。

然而,我们不必把这视为一条规则,让我们退后一步,看看为什么会是这样的。

React的"主要任务"是将应用程序UI与React状态保持同步。重新渲染的目的是找出需要变化的部分。

让我们考虑上面的"Counter"例子。当应用程序首次挂载时,React会渲染所有组件,并得出以下DOM草图:

js 复制代码
<main>
  <p>
    <span class="prefix">Count:</span>
    0
  </p>
  <button>
    Increment
  </button>
</main>
<footer>
  <p>Copyright 2022 Big Count Inc.</p>
</footer>

当用户点击按钮时,count状态变量从0变为1。这将如何影响UI呢?这就是我们希望通过进行另一次渲染来了解的!

React重新运行Counter和BigCountNumber组件的代码,并生成我们想要的新的DOM草图:

js 复制代码
<main>
  <p>
    <span class="prefix">Count:</span>
    1
  </p>
  <button>
    Increment
  </button>
</main>
<footer>
  <p>Copyright 2022 Big Count Inc.</p>
</footer>

每次渲染都是一个快照,就像相机拍摄的照片一样,它展示了基于当前应用程序状态应该是什么样子的UI。

React进行了一个"寻找差异"的游戏,以确定这两个快照之间发生了什么变化。在这种情况下,React发现我们的段落有一个文本节点,从0变为1,因此它编辑文本节点以与快照匹配。满意地完成了工作,React回到原位,等待下一次状态变化。

这就是React的核心循环。

我们的count状态与Counter组件相关联。由于数据在React应用程序中不能向"上"流动,我们知道这个状态变化不可能影响组件。因此我们不需要重新渲染那个组件。

但是我们需要重新渲染Counter的子组件BigCountNumber。这是实际显示count状态的组件。如果我们不渲染它,我们将不知道我们的段落的文本节点应该从0变为1。我们需要在草图中包含这个组件。

重新渲染的目的是确定状态变化应该如何影响用户界面。因此,我们需要重新渲染所有可能受影响的组件,以获得准确的快照。

这与props无关。

好的,让我们来谈谈误解#2:一个组件会因为其props的改变而重新渲染。

让我们用一个更新后的例子来探讨。

在下面的代码中,我们的"Counter"应用程序新增了一个全新的组件Decoration:

js 复制代码
//App.js
import React from 'react';
import Counter from './Counter';
function App() {
  return (
    <>
      <Counter />
      <footer>
        <p>Copyright 2022 Big Count Inc.</p>
      </footer>
    </>
  );
}
export default App;


//Counter.js
import React from 'react';
import Decoration from './Decoration';
import BigCountNumber from './BigCountNumber';
function Counter() {
  const [count, setCount] = React.useState(0);  
  return (
    <main>
      <BigCountNumber count={count} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>      
      {/* 👇 This fella is new 👇 */}
      <Decoration />
    </main>
  );
}
export default Counter;

//Decoration.js
function Decoration() {
  return (
    <div className="decoration">
      ⛵️
    </div>
  );
}
export default Decoration

//BigCountNumber.js
function BigCountNumber({ count }) {
  return (
    <p>
      <span className="prefix">Count:</span>
      {count}
    </p>
  );
}
export default BigCountNumber;

(由于将所有组件放在一个大文件中有些拥挤,我稍微重新组织了一下。但是总体的组件结构与之前相同,除了新增的Decoration组件。)

现在我们的计数器在角落里有一艘可爱的小帆船,由Decoration组件渲染。它不依赖于count,所以当count发生变化时它可能不会重新渲染,对吗?

嗯,不完全是这样的。

当一个组件重新渲染时,它会尝试重新渲染所有后代组件,无论它们是否通过props传递了特定的状态变量。

现在,这似乎有些违反直觉...如果我们没有将count作为props传递给,为什么它需要重新渲染呢?

答案是:React很难以100%的确定, 是否直接或间接地依赖于count状态变量。

在理想的情况下,React组件应该始终是"纯粹"的。纯组件是指当给定相同的props时,它总是产生相同的UI。

但在现实世界中,我们的许多组件是"不纯"的。创建一个不纯组件非常容易:

js 复制代码
function CurrentTime() {
  const now = new Date();
  return (
    <p>It is currently {now.toString()}</p>
  );
}

这个组件每次渲染时会显示不同的值,因为它依赖于当前的时间!

更隐蔽的问题版本涉及到refs。如果我们将一个ref作为props传递,React将无法确定在上一次渲染后是否对其进行了更改。因此,为了保险起见,它选择重新渲染。

React的首要目标是确保用户看到的UI与应用程序状态保持"同步"。因此,React会在重新渲染方面保持谨慎。它不想冒着显示过时UI的风险。

所以,回到我们的误解:props与重新渲染无关。我们的组件并不是因为count prop发生变化而重新渲染的。

当一个组件重新渲染时,因为它的某个状态变量已更新,这次重新渲染将会级联地传递到整个组件树,以便React填充这个新草图的细节,捕捉新的快照。

这是标准的操作流程,但有一种方法可以稍微调整它。

创建纯组件

你可能对React.memo或React.PureComponent类组件很熟悉。这两个工具允许我们忽略某些重新渲染请求。

具体实现如下:

javascript 复制代码
function Decoration() {
  return (
    <div className="decoration">
      ⛵️
    </div>
  );
}
export default React.memo(Decoration);
通过使用React.memo包裹我们的Decoration组件,我们告诉React:"嘿,我知道这个组件是纯组件。除非它的props发生了变化,你不需要重新渲染它。"

这使用了一种被称为"记忆化"的技术。

虽然少了一个re-Render,但我们可以把它看作是"记忆化"。其思想是,React会记住之前的快照。如果props都没有发生变化,React将重用那个过时的快照,而不是费劲地生成一个全新的快照。

假设我将BigCountNumber和Decoration两者都用React.memo包裹。这将如何影响重新渲染呢?

当count发生变化时,我们重新渲染Counter,React将尝试渲染两个后代组件。

由于BigCountNumber接受count作为prop,并且该prop已经改变,BigCountNumber会被重新渲染。但由于Decoration的props没有改变(因为它没有任何props),所以原始快照会被重用。

我喜欢假装React.memo有点像一个懒惰的摄影师。如果你让它拍摄同样的东西5张照片,它只会拍摄1张照片并给你5份拷贝。只有当你的指令改变时,摄影师才会拍摄新的照片。

以下是一个实时代码版本,如果你想自己尝试一下。每个被memo化的组件都添加了console.info调用,这样你可以在控制台中看到每个组件的渲染时间:

js 复制代码
//App.js
import React from 'react';
import Counter from './Counter';
function App() {
  return (
    <>
      <Counter />
      <footer>
        <p>Copyright 2022 Big Count Inc.</p>
      </footer>
    </>
  );
}
export default App;
    
//Counter.js
import React from 'react';
import Decoration from './Decoration';
import BigCountNumber from './BigCountNumber';

function Counter() {
  const [count, setCount] = React.useState(0);
  
  return (
    <main>
      <BigCountNumber count={count} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      <Decoration />
    </main>
  );
}
export default Counter;

//Decoration.js
import React from 'react';
function Decoration() {
  console.info('Decoration render');
  
  return (
    <div className="decoration">
      ⛵️
    </div>
  );
}
export default React.memo(Decoration);

//BigCountNumber.js
import React from 'react';
function BigCountNumber({ count }) {
  console.info('BigCountNumber render');
  
  return (
    <p>
      <span className="prefix">Count:</span>
      {count}
    </p>
  );
}
export default React.memo(BigCountNumber);
    

你可能会想:为什么这不是默认行为?大多数情况下,这不是我们想要的吗?如果我们跳过不需要重新渲染的组件,性能肯定会有所提升,对吗?

作为开发者,我们往往高估了重新渲染的成本。对于我们的Decoration组件而言,重新渲染是非常快速的。

如果一个组件有很多props而且后代组件不多,与检查任何props是否发生变化相比,重新渲染该组件实际上可能更慢。

因此,对于我们创建的每个组件都进行记忆化处理可能会适得其反。React被设计得非常快速地捕获这些快照!但在特定情况下,对于有很多后代组件或执行大量内部工作的组件,这个辅助函数可以帮助提升性能。

这种情况在未来可能会发生改变! React团队正在积极研究是否可以在编译阶段"自动记忆化"代码。目前仍处于研究阶段,但早期的实验看起来很有希望。

想了解更多信息,请查看Xuan Huang的演讲《React without memo》

那么上下文(context)呢?

我们还没有讨论过上下文,但幸运的是,它并不会让这些东西变得太复杂。

默认情况下,如果一个组件的状态发生变化,它的所有后代组件都会重新渲染。因此,如果我们通过上下文将该状态提供给所有后代组件,这并不会改变任何事情;无论如何,这些组件都会重新渲染!

从纯组件的角度来看,上下文有点像是"隐形props",或者可以说是"内部props"。

让我们看一个例子。下面是一个纯组件,它使用了一个UserContext上下文:

ini 复制代码
const GreetUser = React.memo(() => {
  const user = React.useContext(UserContext);
  if (!user) {
    return "Hi there!";
  }
  return `Hello ${user.name}!`;
});

在这个例子中,GreetUser是一个没有props的纯组件,但它有一个"隐形"或"内部"的依赖:存储在React状态中的用户,并通过上下文传递。

如果用户状态变量发生变化,将会进行重新渲染,GreetUser会生成一个新的快照,而不是依赖于过时的图片。React可以知道这个组件正在使用特定的上下文,因此它将其视为一个prop。

这基本上相当于:

javascript 复制代码
const GreetUser = React.memo(({ user }) => {
  if (!user) {
    return "Hi there!";
  }
  return `Hello ${user.name}!`;
});

请尝试一个实时例子:

js 复制代码
import React from 'react';
const UserContext = React.createContext();

function UserProvider({ children }) {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    // Pretend that this is a network request,
    // fetching user data from the backend.
    window.setTimeout(() => {
      setUser({ name: 'Kiara' });
    }, 1000)
  }, [])

  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
}

function App() {
  return (
    <UserProvider>
      <GreetUser />
    </UserProvider>
  );
}

const GreetUser = React.memo(() => {
  const user = React.useContext(UserContext);

  if (!user) {
    return "Hi there!";
  }

  return `Hello ${user.name}!`;
});

export default App;

请注意,这仅在纯组件使用React.useContext钩子消费上下文时才会发生。如果纯组件不尝试使用上下文,您不必担心上下文会破坏大量纯组件的功能。

深入探讨

当你开始使用性能分析工具时,你会注意到有时候纯组件(Pure Components)即使在没有任何变化的情况下也会重新渲染!

React 中一个微妙又让人费解的事情是组件本质上是 JavaScript 函数。当我们渲染一个组件时,实际上是在调用这个函数。

这意味着每次渲染时,React 组件内部定义的所有内容都会被重新创建。

举个快速的例子,考虑以下情况:

js 复制代码
    function App() {
  const dog = {
    name: 'Spot',
    breed: 'Jack Russell Terrier'
  };
  return (
    <DogProfile dog={dog} />
  );
}    

每次渲染这个App组件,我们都在生成一个全新的对象。这可能会对我们的纯组件造成严重影响;即使我们用React.memo包裹它,这个DogProfile子组件还是会重新渲染!

还想继续学习吗?我发布了第二篇博文《理解useMemo和useCallback》,更深入地探讨了记忆化和优化的概念。我们在这篇博文中扩展了我们在之前博文中构建的思维模型,并学习了如何使用React中两个最难解的钩子。

相关推荐
程序员爱技术1 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
并不会2 小时前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
衣乌安、2 小时前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜2 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师2 小时前
CSS的三个重点
前端·css
耶啵奶膘3 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^5 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie5 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic6 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿6 小时前
webWorker基本用法
前端·javascript·vue.js