共计 6543 个字符,预计需要花费 17 分钟才能阅读完成。
更新(从新渲染)是 React 的重要个性 —— 当用户与利用交互的时候,React 须要从新渲染、更新 UI,以响应用户的输出。然而,React 为什么会从新渲染呢?如果不晓得 React 为什么会从新渲染,咱们如何能力防止额定的从新渲染呢?
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>
蕴含了状态(应用了 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/rea…