当前位置: 首页 > Web前端 > JavaScript

React为什么要重新渲染

时间:2023-03-27 10:14:01 JavaScript

更新(re-rendering)是React的一个重要特性——当用户与应用程序交互时,React需要重新渲染和更新UI以响应用户输入。但是为什么React会重新渲染呢?我们如何在不知道React为什么要重新渲染的情况下避免额外的重新渲染?TL;DR状态更改是React树内部发生更新的仅有的两个原因之一。这句话是React更新的公理,无一例外。本文也将围绕解释这句话展开。为了避免人家抬杠,这句话引入了一些限制性的定语和关键字:名词解释“update”和“re-rendering”在React中,“update”和“re-rendering”是密切相关的,但意义完全不同单词。下面这句话可以正确表达这两个词的正确含义:React的“更新”包括三个阶段:渲染(Render),使用createElement或jsx-runtime生成新的ReactElement对象,组装React树;Reconciliation,ReactReconciler将新生成的React树与当前的React树进行比较,判断如何使用最高效的方法来实现“更新”;Commit,操作Host(如DOM、Native等),让新的UI呈现给用户。大多数开发者会混淆“更新”和“重新渲染”,因为在以上三个阶段中,只有“渲染”阶段是开发者可以控制的(“Reconcilation”和“Commit”分别由react-reconciler和ReactHost提供控制)。在本文其余部分,“重新渲染”是指React组件“更新”时的“渲染”阶段,“更新”是指(重新)渲染、Reconcilation和Commit的整个过程。“ReactTree”和“InsidetheReactTree”ReactTree本身可以随时更新。事实上,如果你曾经从React文档中学习过React,那么你已经在“HelloWorld”一章中看到了这个模式:constroot=ReactDOM.createRoot(document.getElementById('root'));functiontick(){constelement=(

你好,世界!

它是{newDate().toLocaleTimeString()}。

);root.render(元素);//如果你在React18发布之前学习过React,你可能会使用ReactDOM.render()://ReactDOM.render(element,document.getElementById('root'));}setInterval(tick,1000);每秒调用ReactDOM提供的渲染,对整个React树进行一次完整的更新。但大多数时候,你不会更新整个React树,而是更新React树中的一部分组件(在React应用程序中,你只会调用一次createRoot().render或hydrateRoot())。“唯一的原因”如果你正在使用React类组件,那么你可以使用继承自React.Component的forceUpdate方法来更新一个组件:因此,我们也可以将这句话改写为:如果一棵React树中所有的类组件都没有使用forceUpdate方法,那么状态变化是React树内部更新的唯一原因。在正文开始之前,先放一句很迷惑的一句话:误区0:React组件更新的原因有3个:state变化,prop变化,Context变化。如果你问一些使用React的开发者“为什么React更新/重新渲染”,你可能会得到这个答案。这句话不无道理,但并不能体现出真正的React更新机制。本文将只解释为什么会发生React更新,而不是如何避免“不必要的”更新(也许我会写另一篇关于这个主题的文章?)。状态更新和单向数据流让我们以一个计数器为例:constBigNumber=({number})=>({number}
);constCounter=()=>{const[count,setCount]=useState(0);consthandleButtonClick=useCallback(()=>setCount(count=>count+1),[]);return(
增量
);};constApp=()=>(<>
Sukka
);在这个例子中,我们声明了三个组件,根组件渲染;和呈现。在组件中,我们在组件中声明了一个状态计数,当按钮被点击时,状态计数会发生变化并递增。当我们点击按钮时,setCount被调用,计数状态改变,React更新组件。并且React在更新一个组件的时候,也会更新这个组件下的所有子组件(至于为什么,稍后会讲到)。所以当组件更新时,子组件也会更新。现在让我们来澄清一个最简单的误解:误区一:当一个状态改变时,整个React树都会更新。少数使用React的开发人员会相信这一点(幸好不是大多数!)。事实上,当状态改变时,React只会更新“拥有这个状态”的组件,以及这个组件的所有子组件。为什么不更新父组件(在本例中,的父组件)?因为React的主要任务是让React中的状态与React渲染的UI保持同步。React更新就是弄清楚如何更改UI以使其与新状态同步。在React中,数据从上到下单向传递(单向数据流,TheDataFlowsDown)。在此示例中,组件的状态计数向下流向组件的prop编号,但不会向上流向组件。因此,计数状态发生变化,组件不需要更新。当计数状态发生变化时,组件及其子组件都会更新。当组件更新时,它会使用新的propnumber值进行渲染。那么组件是否因为propnumber的变化而更新了?不,它与props无关误解2:React组件更新的原因之一是它的props改变了。现在让我们修改上面的例子:importBigNumberfrom'./big-number';constSomeDecoration=()=>
Hooray!
constCounter=()=>{const[count,setCount]=useState(0);consthandleButtonClick=useCallback(()=>setCount(count=>count+1),[]);return(
Increment
);};constApp=()=>(<><柜台/>
苏卡
);组件不接受任何props,也不使用其父组件的计数状态,但是当计数状态改变时,组件仍然会发生更新。当组件更新时,React会更新所有子组件,无论子组件是否接受prop:React不能100%确定组件是否直接/间接依赖于计数状态。理想情况下,每个React组件都应该是一个纯函数——一个“纯”React组件,在给定相同的props时总是呈现相同的UI。但现实是骨感的,我们可以轻松编写一个“不纯”的React组件:constCurrentTime=()=>

Lastrenderedat{newDate().toString()}

containsComponentswithstate(usinguseState)不是纯组件:即使prop不改变,组件也会因状态不同而呈现不同的UI。有时候,你很难判断一个组件是否是一个纯组件。您可以将Ref作为prop传递给组件(forwardRef、useImperativeHandle等等)。Ref本身是ReferenceStable,React不知道Ref中的值是否发生变化。React的目标是呈现一个最新的、一致的UI。为了避免向用户显示过时的UI,React在更新父组件时更新所有子组件,即使子组件不接受任何props。道具与组件更新无关。纯组件和备忘录你可能熟悉(或至少听说过)React.memo、shouldComponentUpdate或React.PureComponent,这些工具允许我们“忽略更新”:constSomeDecoration=memo(()=>
Hooray!
);当我们将组件的声明包装在备忘录中时,我们实际上是在告诉React“嘿!我认为这是一个纯组件,所以只要它的props不改变,我们就不要更新它”。现在,让我们将包裹在memo中,看看会发生什么:constBigNumber=memo(({number})=>({number}
));constSomeDecoration=memo(()=>
Hooray!
);constCounter=()=>{const[count,setCount]=useState(0);consthandleButtonClick=useCallback(()=>setCount(count=>count+1),[]);return(
增量
);};constApp=()=>(<><计数器/>
Sukka
);现在,当更新计数状态时,React会更新组件及其所有子组件,。由于接受一个propnumber并且number的值发生变化,将被更新。但是道具没有改变(因为没有道具被接受),所以React跳过更新。所以你想,为什么React不默认让所有组件都是纯的?为什么React不memo所有组件?事实上,更新React组件的开销并没有想象的那么大。以组件为例,它只需要渲染一个
即可。如果一个组件接受了很多复杂的props,那么渲染组件和比较VirtualDOM的性能开销甚至比浅层比较所有props的开销还要小。大多数时候,React足够快。因此,只有当一个纯组件有大量的纯子组件,或者这个纯组件内部有很多复杂的计算时,我们才需要用memo包裹起来。当memo包裹的组件使用useState、useReducer或useContext时,当组件内部的state发生变化时,组件仍然会被更新。React默认不memo所有组件的另一个原因是,React在Runtime中判断子组件的所有依赖关系来跳过子组件不必要的更新是非常困难和不现实的。计算子组件依赖关系的最佳时间是在编译期间。关于这个想法的更多细节,可以看看黄轩在ReactConf2021上的演讲Reactwithoutmemo。说说Context误区三:React组件更新的原因之一是因为Context.Provider的值被更新了。比如说,当一个组件由于状态变化而更新时,它的所有子组件都会相应地更新。那么当我们通过Context传递的状态发生变化时,也就不足为奇了,所有订阅了Context的子组件都会被更新。对于纯组件,Context可以被视为“隐藏”或“内部”prop:constUser=memo(()=>{constuser=useContext(UserContext);if(!user){return'Hellonewcomer!';}return`Hello,${user.name}!`;})在上面的例子中,组件是一个纯组件。但是,组件依赖于UserContext。当UserContext保存的状态发生变化时,组件也会更新。众所周知,当Context的值发生变化时,的所有子组件都会更新。那么为什么即使子组件不依赖于Context也会更新它们?Context本身不是状态管理工具,而是状态传递工具。Context的值变化的根本原因是状态的变化:constCountContext=createContext(0);constBigNumber=memo(()=>{constnumber=useContext(CounterContext);return({number}
)});constCounter=()=>{const[count,setCount]=useState(0);consthandleButtonClick=useCallback(()=>setCount(count=>count+1),[]);return(
增量
);};如上例,CountContext发生变化的原因是组件的计数状态发生了变化;更新的不仅是CountContext消费者组件(及其子组件),还包括的所有子组件。来源:https://blog.skk.moe/post/rea...