当前位置: 首页 > 科技观察

React性能优化技巧总结

时间:2023-03-12 08:04:54 科技观察

本文将从render函数的角度总结ReactApp的优化技巧。需要提醒的是,本文会涉及到React16.8.2版本的内容(即Hooks),所以请至少了解useState,以保证可食用的效果。正文开始。当我们讨论ReactApp的性能问题时,组件的渲染速度是一个重要的问题。在进入具体的优化建议之前,我们需要了解以下3点:当我们说“渲染”时,我们在说什么?什么时候执行“render”?“渲染”过程中会发生什么?解释渲染函数涉及协调和差异的概念。当然,官方文档在这里。当我们说“渲染”时,我们在说什么?其实写过React的人都会知道这个问题。这里简单介绍一下:在类组件中,我们指的是render方法:classFooextendsReact.Component{render(){return

Foo

;}}在功能组件中,我们指的是功能组件本身:functionFoo(){return

Foo

;}什么时候执行“render”?渲染函数将在两种情况下被调用:1.当状态更新时a.继承自React.Component的class组件更新状态时importReactfrom"react";importReactDOMfrom"react-dom";classAppextendsReact.Component{render(){return;}}classFooextendsReact.Component{state={count:0};increment=()=>{const{count}=this.state;constnewCount=count<10?count+1:count;this.setState({count:newCount});};render(){const{count}=this.state;console.log("Foorender");return(

{count}

增量
);}}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);可以看到,代码中的逻辑如果我们点击,计数就会更新,10次之后,会一直保持在10。添加一个console.log以便我们可以知道是否调用了渲染。从执行结果可以知道,即使计数超过10,render还是会被调用。总结:继承React.Component类组件,即使state没变,只要调用setState就会触发render。b.当功能组件更新状态时,我们使用函数来实现相同的组件。当然,因为我们需要状态,所以我们使用useState钩子:{const[count,setCount]=useState(0);functionincrement(){constnewCount=count<10?count+1:count;setCount(newCount);}console.log("Foorender");return(

{count}

增量
);}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);我们可以注意到,当状态值不再变化时,对渲染的调用停止。总结:对于函数式组件来说,只有当state值发生变化时才会触发render函数的调用。2.当父容器重新渲染时/>this.setState({name:"App"})}>Changename
);}}functionFoo(){console.log("Foorender");return(

Foo

);}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);只需单击App组件中的Changename按钮,它就会重新呈现。请注意,无论Foo的实现是什么,Foo都将被重新渲染。总结:无论组件是继承自React.Component的类组件还是函数式组件,一旦父容器重新渲染,都会再次调用组件的render。“渲染”过程中会发生什么?每当调用渲染函数时,都会依次执行两个步骤。这两步非常重要,理解了它们才能知道如何优化ReactApp。Diffing在此步骤中,React将新调用的render函数返回的树与旧版本的树进行比较。这一步对于React决定如何更新DOM是必要的。虽然React使用高度优化的算法执行此步骤,但仍然存在一定的性能开销。协调基于差异的结果,React更新DOM树。由于需要卸载和挂载DOM节点,这一步也有很大的性能开销。开始我们的TipsTip#1:仔细分配状态以避免不必要的渲染调用让我们以下面的例子为例,其中App将渲染两个组件:CounterLabelListimportReact,{useState}from"react";importReactDOMfrom"react-dom";constITEMS=[1,2,3,4,5,6,7,8,9,10,11,12];functionApp(){const[count,setCount]=useState(0);const[items,setItems]=useState(ITEMS);return(setCount(count+1)}/>
);}functionCounterLabel({count,increment}){return(<

{count}

Increment/>);}functionList({items}){console.log("Listrender");return(
    {items.map((item,index)=>({item}))}
);}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);执行上面的代码可以看出,只要父组件App中的state更新了,CounterLabel和List都会更新。当然,CounterLabel重新渲染是正常的,因为计数变了,自然要重新渲染;但是对于List来说,完全没有必要update,因为它的渲染与count无关。虽然React在reconciliation阶段实际上并没有更新DOM,毕竟根本没有变化,但是它还是会执行diffing阶段来比较前后树,这还是有性能开销的。还记得渲染执行的差异和协调阶段吗?之前说的都到这里了。因此,为了避免不必要的diffing开销,我们应该考虑将特定的状态值放到较低的层或组件中(与React中“提升”的概念相反)。在本例中,我们可以通过将计数放入CounterLabel组件中进行管理来解决这个问题。提示#2:合并状态更新由于每个状态更新都会触发一个新的渲染调用,因此较少的状态更新会导致较少的渲染调用。我们知道React类组件有componentDidUpdate(prevProps,prevState)钩子,可以用来检测props或state是否发生变化。虽然有时需要在props变化时触发状态更新,但我们总是可以避免在状态变化后进行状态更新:importReactfrom"react";importReactDOMfrom"react-dom";functiongetRange(limit){letrange=[];for(leti=0;i{constlimit=e.target.value;constlimitChanged=limit!==this.state.limit;if(limitChanged){this.setState({limit});}};componentDidUpdate(prevProps,prevState){constlimitChanged=prevState.limit!==this.state.limit;if(limitChanged){this.setState({numbers:getRange(this.state.limit)});}}render(){return(
{this.state.numbers.map((number,idx)=>({number}

))}
);}}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);这里呈现了一系列范围数字,范围从0到limit。每当用户更改限制值时,我们都会在componentDidUpdate中检测到它并设置新的数字列表。毫无疑问,上面的代码可以满足需求,但是我们还是可以对其进行优化。上面的代码中,每次限额变化,我们都会触发两次状态更新:第一次是修改限额,第二次是修改显示的数字列表。这样每次limit变化都会带来两次渲染开销://initialstate{limit:7,numbers:[0,1,2,3,4,5,6]//updatelimit->4render1:{limit:4,numbers:[0,1,2,3,4,5,6]}//render2:{limit:4,numbers:[0,2,3]我们的代码逻辑带来了以下问题:我们触发更多状态更新比我们实际需要的要多;我们得到“不连续”的渲染结果,其中数字列表不符合限制。为了改进,我们应该避免在不同的状态更新中更改数字列表。事实上,我们可以在一次状态更新中完成:importReactfrom"react";importReactDOMfrom"react-dom";functiongetRange(limit){letrange=[];for(leti=0;i{constlimit=e.target.value;constlimitChanged=limit!==this.state.limit;if(limitChanged){this.setState({limit,numbers:getRange(limit)});}};render(){return(
{this.state.numbers.map((number,idx)=>({number}

))}
);}}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);提示#3:使用PureComponent和React.memo来避免不必要的渲染调用我们在前面的例子中看到的方法将某些状态值放在较低的级别以避免不必要的渲染,但这并不总是有用的。让我们看看下面的例子:importReact,{useState}from"react";importReactDOMfrom"react-dom";functionApp(){const[isFooVisible,setFooVisibility]=useState(false);return({isFooVisible?(setFooVisibility(false)}/>):(setFooVisibility(true)}>ShowFoo)}
);}functionFoo({hideFoo}){return(<>

Foo

HideFoo/>);}functionBar({name}){return

{name}

;}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);可以看到,只要父组件App的状态值isFooVisible发生变化,Foo和Bar都会重新渲染。这里,为了判断Foo是否应该被渲染,我们需要在App中维护isFooVisible,所以我们不能将state分离到更底层。不过,当isFooVisible改变时,没有必要重新渲染Bar,因为Bar不依赖于isFooVisible。我们只希望Bar在传入的属性名称更改时重新呈现。那我们该怎么办呢?两种方式。首先,记忆Bar:constBar=React.memo(functionBar({name}){return

{name}

;});这确保了Bar只有在名称Onlythenre-renders时才发生变化。还有,还有一种方法是让Bar继承React.PureComponent而不是React.Component:classBarextendsReact.PureComponent{render(){return

{name}

;}}熟悉吗?我们经常提到使用React.PureComponent可以带来一定的性能提升,避免不必要的渲染。总结:有一些方法可以避免不必要的组件渲染:React.memo包裹的功能组件,继承自React.PureComponent的类组件。为什么不让每个组件都扩展PureComponent或使用memo包?如果这个建议允许我们避免不必要的重新渲染,为什么我们不把每个类组件都变成一个PureComponent并将每个功能组件包装在React.memo中?既然有更好的方法,为什么还要保留React.Component?为什么功能组件默认不记忆?毫无疑问,这些方法并不总是灵丹妙药。嵌套对象的问题我们先考虑一下PureComponent和React.memo的组件是干什么的?在每次更新(包括状态更新或上层组件重新渲染)时,他们都会对新propsstate和旧propsstate之间的键和值进行浅比较。浅比较是一种严格的相等性检查。如果检测到差异,render将执行它://基本类型比较shallowCompare({name:'bar'},{name:'bar'});//output:trueshallowCompare({name:'bar'},{name:'bar1'});//output:false虽然基本类型(如字符串、数字、布尔值)的比较可以很好地工作,但对象等复杂情况可能会带来意想不到的行为:shallowCompare({name:{first:'John',last:'Schilling'}},{name:{first:'John',last:'Schilling'}});//output:false上面两个名字对应的对象的引用是不同的。让我们回顾一下前面的例子,修改我们传递给Bar的props:console.log("Barrender");return(

{first}{last}

);});functionFoo({hideFoo}){return(<>

Foo

HideFoo/>);}functionApp(){const[isFooVisible,setFooVisibility]=useState(false);return({isFooVisible?(setFooVisibility(false)}/>):(setFooVisibility(true)}>ShowFoo)}
);}constrootElement=document.getElementById("root");ReactDOM.render(,rootElement);虽然Bar被memoized并且props值没有改变,但每次父组件重新渲染时它仍然会重新渲染。这是因为尽管每次比较中的两个对象具有相同的值,但引用却不同。函数props的问题我们也可以将函数作为props传递给组件。当然,函数在JavaScript中也会传递引用,所以浅比较也是基于传递的引用。所以如果我们传递一个箭头函数(匿名函数),当父组件重新渲染时,组件仍然会重新渲染。提示#4:更好地编写props解决前面问题的一个方法是重写我们的props。我们不把对象作为props传递,而是将对象拆分成基本类型:总能得到相同的引用,如下:this.doSomethingMethod}/>}}Tip#5:控制更新同样,任何方法总是有它的应用范围。第三个建议,在处理不必要更新的问题时,并不总是可用的。第四,在某些情况下我们不能拆分对象,如果我们传递某种嵌套的非常复杂的数据结构,那么我们就很难拆分它。不仅如此,我们也不能总是传递只声明一次的函数。比如我们的例子,如果App是一个函数式组件,恐怕我们做不到这一点(在类组件中,我们可以使用bind或者类中的箭头函数来保证this和*的指向**语句,而在这在功能组件中可能会出现问题)。幸运的是,无论是类组件还是功能组件,我们都有办法控制浅比较的逻辑。在class组件中,我们可以使用生命周期钩子shouldComponentUpdate(prevProps,prevState)返回一个boolean值,只有当返回值为true时才会触发render。如果我们使用React.memo,我们可以传递一个比较函数作为第二个参数。注意!React.memo的第二个参数(比较函数)和shouldComponentUpdate的逻辑相反,只有返回值为false时才会触发render。参考文档。constBar=React.memo(functionBar({name:{first,last}}){console.log("update");return(

{first}{last}

);},(prevProps,newProps)=>prevProps.name.first===newProps.name.first&&prevProps.name.last===newProps.name.last);虽然这个建议是可行的,但是我们还是要注意比较函数的性能开销。如果props对象太深,会消耗大量的性能。总结以上场景还是不够全面,但是可以带来一些启发性的思考。当然,在性能方面我们还有很多其他问题需要考虑,但是遵循上述准则仍然可以带来相当不错的性能提升。