关于javascript:React-为什么重新渲染

42次阅读

共计 6543 个字符,预计需要花费 17 分钟才能阅读完成。

更新(从新渲染)是 React 的重要个性 —— 当用户与利用交互的时候,React 须要从新渲染、更新 UI,以响应用户的输出。然而,React 为什么会从新渲染呢?如果不晓得 React 为什么会从新渲染,咱们如何能力防止额定的从新渲染呢?

TL; DR

状态扭转是 React 树外部产生更新的唯二起因之一。

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

名词解释

「更新」和「从新渲染」

在 React 中,「更新」和「从新渲染」是关系严密,然而含意齐全不同的两个词。上面这句话能力正确表白这两个词的正确含意:

React 的「更新」蕴含三个阶段:渲染(Render),应用 createElementjsx-runtime 产生全新的 React Element 对象、组装出一颗 React 树;Reconcilation,React Reconciler 比拟 新生成的 React 树 和 以后的 React 树,判断如何用最高效的办法实现「更新」;Commit,操作 Host(如 DOM、Native 等),使新的 UI 出现在用户背后。

大部分开发者会把「更新」和「从新渲染」一概而论,因为在上述三个阶段中,只有「渲染」这一阶段是开发者能够管制的(「Reconcilation」和「Commit」别离由 react-reconcilerReact 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.ComponentforceUpdate 办法更新一个组件:

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>

蕴含了状态(应用了 useState)的组件也不是纯组件:即便 prop 不扭转,组件也会因为状态不同而渲染出不同的 UI。

有的时候,你很难判断一个组件是否是纯组件。你可能会将一个 Ref 作为 prop 传递给一个组件(forwardRefuseImperativeHandle,诸如此类的 case)。Ref 自身是 Reference Stable 的、React 并不能晓得 Ref 中的值是否扭转。

React 的指标是展现最新、维持统一的 UI。为了防止向用户展现过期的 UI,当父组件更新时,React 会更新所有子组件,即便子组件不承受任何 prop。props 和组件更新没有任何关系

纯组件和 memo

你大略很相熟(或者至多据说过)React.memoshouldComponentUpdate 或者 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 中的组件应用了 useStateuseReducer 或者 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 /> 组件也会更新。

家喻户晓,当 Contextvalue 产生扭转的时候,所有 <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/rea…

正文完
 0