React 为什么重新渲染

更新日期: 2022-11-18阅读: 555标签: 渲染

更新(重新渲染)是 react 的重要特性 —— 当用户与应用交互的时候,React 需要重新渲染、更新 UI,以响应用户的输入。但是,React 为什么会重新渲染呢?如果不知道 React 为什么会重新渲染,我们如何才能避免额外的重新渲染呢?

题图来自 Render and Commit - React Beta Docs


TL; DR

状态改变是 React 树内部发生更新的唯二原因之一

这句话是 React 更新的公理,不存在任何例外。本文也将会围绕解释这句话展开。为了避免有人抬杠,这句话引入了一些限制定语和关键词:


名词解释

「更新」和「重新渲染」

在 React 中,「更新」和「重新渲染」是关系紧密,但是含义完全不同的两个词。下面这句话才能正确表达这两个词的正确含义:

React 的「更新」包含三个阶段:渲染(Render),使用 createElement 或 jsx-runtime 产生全新的 React Element 对象、组装出一颗 React 树;Reconcilation,React Reconciler 比较 新生成的 React 树 和 当前的 React 树,判断如何用最高效的方法实现「更新」;Commit,操作 Host(如 dom、Native 等),使新的 UI 呈现在用户面前。

大部分开发者会把「更新」和「重新渲染」混为一谈,因为在上述三个阶段中,只有「渲染」这一阶段是开发者可以控制的(「Reconcilation」和「Commit」分别由 react-reconciler 和 React Host 控制)。本文接下来的部分中,「重新渲染」一律指代 React 组件在「更新」时的「渲染」阶段,而「更新」则一律指代(重新)渲染、Reconcilation 和 Commit 整个过程。

「React 树」和「React 树内部」

React Tree 本身可以在任意时候更新。实际上,如果你曾经通过 React 文档学习 React,你在「Hello World」一章就已经见过这个 Pattern 了:

const root = ReactDOM.createRoot(document.getElementById('root'));

function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  root.render(element);
  // 如果你是在 React 18 发布以前学习的 React,你可能会用 ReactDOM.render():
  // ReactDOM.render(element, document.getElementById('root'));
}

setInterval(tick, 1000);

每秒钟调用一次 ReactDOM 提供的 render 使一整颗 React 树进行了完整的更新。但是绝大部分时候,你不会更新一整颗 React 树,而是 React 树内的一部分组件(在 React 应用中,你只会调用一次 createRoot().render 或者 hydrateRoot())。

「唯二原因」

如果你在使用 React class 组件,那么你可以使用继承自 React.Component 的 forceUpdate 方法更新一个组件:

class MyComponent extends React.Component {
  handleInput() {
    this.forceUpdate();
  }
}

因此,我们也可以把这句话改写成:如果一颗 React 树中所有的 class 组件都没有使用 forceUpdate 方法,那么状态改变是这颗 React Tree 内部发生更新的唯一原因


在正文开始之前,先放出一句非常具有迷惑性的话:

误区 0:React 组件更新有三个原因:状态改变,prop 改变,Context 改变。

如果你去问一些使用 React 的开发者「为什么 React 会更新/重新渲染」,大概会得到这个答案。这句话不无道理,但是并不能反应真实的 React 更新机制。

本文只会介绍 React 为什么会发生更新,不会介绍如何避免「不必要」的更新(也许我会以这个为话题另外写一篇文章?)。


状态更新和单向数据

让我们以计数器为例:

const BigNumber = ({ number }) => (
  <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
);

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

在这个例子中,我们声明了三个组件,根组件 <App /> 渲染了 <Counter />;而 <Counter /> 渲染了 <BigNumber />。在 <Counter /> 组件中,我们声明了一个组件内的状态 count,当点击按钮时会改变状态 count、使其递增。

当我们点击按钮的时候,setCount 被调用、count 状态发生改变,React 更新了 <Counter /> 组件。而当 React 更新一个组件时,也会更新这个组件下的所有子组件(至于为什么,很快就会讲的)。因此 <Counter /> 组件更新时,子组件 <BigNumber /> 也会更新。

现在让我们先厘清一个最简单的误区:

误区 1:当一个状态发生改变时,整颗 React 树都会更新。

有少数使用 React 的开发者会相信这一点(还好不是大多数!)。实际上,当状态发生改变的时候,React 只会更新「拥有这个状态」的组件,和这个组件的所有子组件。

为什么父组件(在这个例子中,<App /> 是 <Counter /> 的父组件)没有发生更新呢?因为 React 的主要任务就是保持 React 内的状态和 React 渲染的 UI 的同步。React 更新,就是找出如何改变 UI,使其和新的状态同步。而在 React 中,数据是自上而下单向传递的(单向数据流,The Data Flows Down)。在这个例子中,<Counter /> 组件的状态 count 向下流向了 <BigNumber /> 组件的 prop number,但是不可能向上流向了 <App /> 组件。因此,count 状态改变,<App /> 组件并不需要更新。

当 count 状态改变时,<Counter /> 组件及其子组件 <BigNumber /> 都发生了更新。而 <BigNumber /> 组件更新时,使用了 prop number 的新的值进行渲染。那么 <BigNumber /> 组件更新的原因是因为 prop number 的改变吗?


不,和 props 完全没有关系

误区 2:React 组件更新的其中一个原因是它的 prop 发生了改变。

现在让我们修改一下上面那个例子:

import BigNumber from './big-number';

const SomeDecoration = () => <div>Hooray!</div>

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
      <SomeDecoration />
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

<SomeDecoration /> 组件不接受任何 prop、不使用其父组件 <Counter /> 的 count 状态,但是当 count 状态发生改变时,<SomeDecoration /> 组件仍然发生了更新。当一个组件更新时,React 会更新 所有的子组件,不管这个子组件是否接受一个 prop:React 并不能百分之百肯定 <SomeDecoration /> 组件是否直接/间接地依赖了 count 状态。

理想中,每一个 React 组件都应该是一个 纯函数 —— 一个「纯」的 React 组件,当输入相同的 props 时,总是会渲染相同的 UI。但是现实是骨感的,我们非常容易写出一个「不纯」的 React 组件:

const CurrentTime = () => <p>Last rendered at {new Date().toString()}</p>

<CurrentTime /> 组件不接受任何 prop,但是每次渲染得到的 UI 却是不一样的。

包含了状态(使用了 useState)的组件也不是纯组件:即使 prop 不改变,组件也会因为状态不同而渲染出不同的 UI。

有的时候,你很难判断一个组件是否是纯组件。你可能会将一个 Ref 作为 prop 传递给一个组件(forwardRef,useImperativeHandle,诸如此类的 case)。Ref 本身是 Reference Stable 的、React 并不能知道 Ref 中的值是否改变。

React 的目标是展示最新、维持一致的 UI。为了避免向用户展示过时的 UI,当父组件更新时,React 会更新所有子组件,即使子组件不接受任何 prop。props 和组件更新没有任何关系


纯组件和 memo

你大概很熟悉(或者至少听说过)React.memo、shouldComponentUpdate 或者 React.PureComponent,这些工具允许我们「忽略更新」:

const SomeDecoration = memo(() => <div>Hooray!</div>);

当我们将 <SomeDecoration /> 组件的声明包裹在 memo 中时,我们实际上做的是告诉 React「嘿!我觉得这是个纯组件,只要它的 prop 不改变,我们就别更新它」。

现在,让我们把 <SomeDecoration /> 和 <BigNumber /> 都包裹在 memo 中,看看会发生什么:

const BigNumber = memo(({ number }) => (
  <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
));

const SomeDecoration = memo(() => <div>Hooray!</div>);

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <BigNumber number={count} />
      <button onClick={handleButtonClick}>Increment</button>
      <SomeDecoration />
    </div>
  );
};

const App = () => (
  <>
    <Counter />
    <footer>
      <a href="https://skk.moe/">Sukka</a>
    </footer>
  </>
);

现在,当 count 状态更新后,React 会更新 <Counter /> 组件及其所有子组件,<BigNumber /> 和 <SomeDecoration />。由于 <BigNumber /> 接受一个 prop number,而 number 的值发生了改变,因此 <BigNumber /> 会更新。但是 <SomeDecoration /> 的 prop 没有发生改变(因为不接受任何 prop),所以 React 跳过了 <SomeDecoration /> 的更新。

于是你想,为什么 React 不默认所有组件都是纯组件呢?为什么 React 不 memo 所有组件呢?事实上,React 组件更新的开销没有想象中的那么大。以 <SomeDecoration /> 组件为例,它只需要渲染一个 <div />。

还记得「渲染」是什么意思吗?不记得了的话赶紧回去看看。

如果一个组件接受很多复杂的 prop,有可能渲染这个组件并对比 Virtual DOM 的性能开销甚至小于等于浅比较所有 prop 的开销。绝大部分时候,React 是足够快的。因此,只有当一个 纯组件 有大量纯的子组件、或者这个 纯组件 内部有很多复杂计算时,我们才需要将其包裹在 memo 中。

当一个包裹在 memo 中的组件使用了 useState、useReducer 或者 useContext,当这个组件内的状态发生改变时,这个组件仍然会更新。

另外一个 React 默认不 memo 所有组件的原因是:让 React 在 Runtime 中判断子组件的全部依赖、以跳过子组件的不必要更新,是非常困难、非常不现实的。计算子组件依赖的最好时机是编译期间。关于这个 idea 的更多细节,可以看看黄玄在 React Conf 2021 上的演讲 React without memo


让我们谈谈 Context

误区 3:React 组件更新的其中一个原因是 Context.Provider 的 value 发生了更新。

如果说,当一个组件由于状态改变而更新时,其所有子组件都要随之更新。那么当我们通过 Context 传递的状态发生改变时,订阅了这个 Context 的所有子组件都要更新也是毫不意外的了。

对于纯组件来说,Context 可以视为一个「隐藏的」、或者「内部的」prop:

const User = memo(() => {
  const user = useContext(UserContext);

  if (!user) {
    return 'Hello, new comer!';
  }

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

在上面的例子中,<User /> 组件是一个不接受任何 prop、不使用 useState、也没有任何副作用的纯组件。但是,<User /> 组件依赖 UserContext。当 UserContext 保存的状态发生改变时,<User /> 组件也会更新。

众所周知,当 Context 的 value 发生改变的时候,所有 <Context.Provider /> 的子组件都会更新。那么为什么即使不依赖 Context 的子组件也会更新呢?Context 本身并不是一个状态管理工具,只是一种状态传递工具。Context 的 value 发生改变的根本原因还是状态的改变:

const CountContext = createContext(0);

const BigNumber = memo(() => {
  const number = useContext(CounterContext);
  return (
    <div style={{ fontWeight: 700, fontSize: 36 }}>{number}</div>
  )
});

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleButtonClick = useCallback(() => setCount(count => count + 1), []);

  return (
    <div>
      <CountContext.Provider value={count}>
        <BigNumber number={count} />
      </CountContext.Provider>
      <SomeDecoration />
      <button onClick={handleButtonClick}>Increment</button>
    </div>
  );
};

正如上面的例子,CountContext 发生改变的原因,是 <Counter /> 组件的 count 状态发生了改变;发生更新的,也不仅仅是 CountContext 的消费组件(及其子组件),还包括 <Counter /> 所有的子组件。

来源:https://blog.skk.moe/post/react-re-renders-101/

    链接: https://www.fly63.com/article/detial/12243

    vue中数据更新变化,而页面视图未渲染的解决方案

    在使用vue的时候,我们都知道它是双向数据绑定的,但是在使用不熟的情况下,经常会遇到:data中的数据变化了,但是并没有触发页面渲染。下面就整理一些出现这种情况的场景以及解决办法。

    服务端渲染和客户端渲染的对比

    这里结合art-template模板引擎说明。首先了解下前端页面中如何使用art-template。当不需要对SEO友好的时候,推荐使用客户端渲染;当需要对 SEO友好的时候,推荐使用服务器端渲染

    解决使用vue.js未渲染前代码显示问题

    在使用vue的时候,偶然发现多次刷新或者网络加载缓慢的时候,会一瞬间出现设置的模板的情况。实在很影响美观,可以使用vue现成的指令来解决这个问题:v-cloak

    在微信小程序中渲染html内容的实现

    大部分Web应用的富文本内容都是以HTML字符串的形式存储的,通过HTML文档去展示HTML内容自然没有问题。但是,在微信小程序(下文简称为「小程序」)中,应当如何渲染这部分内容呢?

    原来 CSS 与 JS 是这样阻塞 DOM 解析和渲染的

    估计大家都听过,尽量将 CSS 放头部,JS 放底部,这样可以提高页面的性能。然而,为什么呢?大家有考虑过么?很长一段时间,我都是知其然而不知其所以然,强行背下来应付考核当然可以,但实际应用中必然一塌糊涂

    Vue渲染数据理解以及Vue指令

    原生JS改变页面数据,必须要获取页面节点,也即是进行DOM操作,jQuery之类的框架只是简化DOM操作的写法,实质并没有改变操作页面数据的底层原理,DOM操作影响性能(导致浏览器的重绘和回流),Vue是一个mvvm框架(库),大幅度减少了DOM操作

    Web渲染那些事儿

    在决定渲染方式时,需要测量和理解真正的瓶颈在哪里。静态渲染或服务器渲染在多数情况都比较适用,尤其是可交互性对JS依赖较低的场景。下面是一张便捷的信息图,显示了服务器到客户端的技术频谱:

    vue从后台获取数据赋值给data,如何渲染更细视图

    如果从服务端返回的数据量较少,或者只有几个字段,可以用vue的set方法,如果数据量较大,请直接看第二种情况。官网API是这样介绍的:Vue.set(target,key,value)

    react 异步加载数据时的渲染问题

    当数据需要异步加载时render获取不到数据可能会报一些错误,此时需要在render函数中加一个判断.行到render时,state对象的haveData为false, 所以此时页面展示 loading,当异步获取数据成功时

    Vue.js中v-html渲染的dom添加scoped的样式

    在vue.js中,要将一段字符串渲染成html,可以使用v-html指令。但是 官方文档 中的v-html部分也提醒了

    点击更多...

    内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!