本文将从 render 函数的角度总结 React App 的优化技巧。须要提醒的是,文中将涉及 React 16.8.2 版本的内容(也即 Hooks),所以请至少了解 useState 以保证食用效果。javascript
正文开始。html
当咱们讨论 React App 的性能问题时,组件的渲染速度是一个重要问题。在进入到具体优化建议以前,咱们先要理解如下 3 点:java
这部分涉及 reconciliation 和 diffing 的概念,固然官方文档在这里。react
这个问题其实写过 React 的人都会知道,这里再简单说下:算法
在 class 组件中,咱们指的是 render 方法:api
class Foo extends React.Component {
render() {
return <h1> Foo </h1>;
}
}
复制代码
在函数式组件中,咱们指的是函数组件自己:数组
function Foo() {
return <h1> Foo </h1>;
}
复制代码
render 函数会在两种场景下被调用:bash
import React from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
render() {
return <Foo />; } } class Foo extends React.Component { state = { count: 0 }; increment = () => { const { count } = this.state; const newCount = count < 10 ? count + 1 : count; this.setState({ count: newCount }); }; render() { const { count } = this.state; console.log("Foo render"); return ( <div> <h1> {count} </h1> <button onClick={this.increment}>Increment</button> </div> ); } } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement); 复制代码
能够看到,代码中的逻辑是咱们点击就会更新 count,到 10 之后,就会维持在 10。增长一个 console.log,这样咱们就能够知道 render 是否被调用了。从执行结果能够知道,即便 count 到了 10 以上,render 仍然会被调用。数据结构
总结:继承了 React.Component 的 class 组件,即便状态没变化,只要调用了setState 就会触发 render。dom
咱们用函数实现相同的组件,固然由于要有状态,咱们用上了 useState hook:
import React, { useState } from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
render() {
return <Foo />; } } function Foo() { const [count, setCount] = useState(0); function increment() { const newCount = count < 10 ? count + 1 : count; setCount(newCount); } console.log("Foo render"); return ( <div> <h1> {count} </h1> <button onClick={increment}>Increment</button> </div> ); } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement); 复制代码
咱们能够注意到,当状态值再也不改变以后,render 的调用就中止了。
总结:对函数式组件来讲,状态值改变时才会触发 render 函数的调用。
import React from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
state = { name: "App" };
render() {
return (
<div className="App"> <Foo /> <button onClick={() => this.setState({ name: "App" })}> Change name </button> </div>
);
}
}
function Foo() {
console.log("Foo render");
return (
<div> <h1> Foo </h1> </div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement); 复制代码
只要点击了 App 组件内的 Change name 按钮,就会从新 render。并且能够注意到,无论 Foo 具体实现是什么,Foo 都会被从新渲染。
总结:不管组件是继承自 React.Component 的 class 组件仍是函数式组件,一旦父容器从新 render,组件的 render 都会再次被调用。
只要 render 函数被调用,就会有两个步骤按顺序执行。这两个步骤很是重要,理解了它们才好知道如何去优化 React App。
在此步骤中,React 将新调用的 render 函数返回的树与旧版本的树进行比较,这一步是 React 决定如何更新 DOM 的必要步骤。虽然 React 使用高度优化的算法执行此步骤,但仍然有必定的性能开销。
基于 diffing 的结果,React 更新 DOM 树。这一步由于须要卸载和挂载 DOM 节点一样存在许多性能开销。
咱们如下面为例,其中 App 会渲染两个组件:
CounterLabel
,接收 count 值和一个 inc 父组件 App 中状态 count 的方法。List
,接收 item 的列表。import React, { useState } from "react";
import ReactDOM from "react-dom";
const ITEMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
function App() {
const [count, setCount] = useState(0);
const [items, setItems] = useState(ITEMS);
return (
<div className="App">
<CounterLabel count={count} increment={() => setCount(count + 1)} />
<List items={items} />
</div>
);
}
function CounterLabel({ count, increment }) {
return (
<>
<h1>{count} </h1>
<button onClick={increment}> Increment </button>
</>
);
}
function List({ items }) {
console.log("List render");
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item} </li>
))}
</ul>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码
执行上面代码可知,只要父组件 App 中的状态被更新,CounterLabel
和 List
就都会更新。
固然,CounterLabel
从新渲染是正常的,由于 count 发生了变化,天然要从新渲染;可是对于 List
而言,就彻底是没必要要的更新了,由于它的渲染与 count 无关。尽管 React 并不会在 reconciliation 阶段真的更新 DOM,毕竟彻底没变化,可是仍然会执行 diffing 阶段来对先后的树进行对比,这仍然存在性能开销。
还记得 render 执行过程当中的 diffing 和 reconciliation 阶段吗?前面讲过的东西在这里碰到了。
所以,为了不没必要要的 diffing 开销,咱们应当考虑将特定的状态值放到更低的层级或组件中(与 React 中所说的「提高」概念恰好相反)。在这个例子中,咱们能够经过将 count 放到 CounterLabel
组件中管理来解决这个问题。
由于每次状态更新都会触发新的 render 调用,那么更少的状态更新也就能够更少的调用 render 了。
咱们知道,React class 组件有 componentDidUpdate(prevProps, prevState)
的钩子,能够用来检测 props 或 state 有没有发生变化。尽管有时有必要在 props 发生变化时再触发 state 更新,但咱们总能够避免在一次 state 变化后再进行一次 state 更新这种操做:
import React from "react";
import ReactDOM from "react-dom";
function getRange(limit) {
let range = [];
for (let i = 0; i < limit; i++) {
range.push(i);
}
return range;
}
class App extends React.Component {
state = {
numbers: getRange(7),
limit: 7
};
handleLimitChange = e => {
const limit = e.target.value;
const limitChanged = limit !== this.state.limit;
if (limitChanged) {
this.setState({ limit });
}
};
componentDidUpdate(prevProps, prevState) {
const limitChanged = prevState.limit !== this.state.limit;
if (limitChanged) {
this.setState({ numbers: getRange(this.state.limit) });
}
}
render() {
return (
<div> <input onChange={this.handleLimitChange} placeholder="limit" value={this.state.limit} /> {this.state.numbers.map((number, idx) => ( <p key={idx}>{number} </p> ))} </div> ); } } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement); 复制代码
这里渲染了一个范围数字序列,即范围为 0 到 limit。只要用户改变了 limit 值,咱们就会在 componentDidUpdate 中进行检测,并设定新的数字列表。
毫无疑问,上面的代码是能够知足需求的,可是,咱们仍然能够进行优化。
上面的代码中,每次 limit 发生改变,咱们都会触发两次状态更新:第一次是为了修改 limit,第二次是为了修改展现的数字列表。这样一来,每次 limit 的变化会带来两次 render 开销:
// 初始状态
{ limit: 7, numbers: [0, 1, 2, 3, 4, 5, 6]
// 更新 limit -> 4
render 1: { limit: 4, numbers: [0, 1, 2, 3, 4, 5, 6] } //
render 2: { limit: 4, numbers: [0, 2, 3]
复制代码
咱们的代码逻辑带来了下面的问题:
为了改进,咱们应避免在不一样的状态更新中改变数字列表。事实上,咱们能够在一次状态更新中搞定:
import React from "react";
import ReactDOM from "react-dom";
function getRange(limit) {
let range = [];
for (let i = 0; i < limit; i++) {
range.push(i);
}
return range;
}
class App extends React.Component {
state = {
numbers: [1, 2, 3, 4, 5, 6],
limit: 7
};
handleLimitChange = e => {
const limit = e.target.value;
const limitChanged = limit !== this.state.limit;
if (limitChanged) {
this.setState({ limit, numbers: getRange(limit) });
}
};
render() {
return (
<div> <input onChange={this.handleLimitChange} placeholder="limit" value={this.state.limit} /> {this.state.numbers.map((number, idx) => ( <p key={idx}>{number} </p> ))} </div> ); } } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement); 复制代码
咱们在以前的例子中看到将特定状态值放到更低的层级来避免没必要要渲染的方法,不过这并不老是有用。
咱们来看下下面的例子:
import React, { useState } from "react";
import ReactDOM from "react-dom";
function App() {
const [isFooVisible, setFooVisibility] = useState(false);
return (
<div className="App">
{isFooVisible ? (
<Foo hideFoo={() => setFooVisibility(false)} />
) : (
<button onClick={() => setFooVisibility(true)}>Show Foo </button>
)}
<Bar name="Bar" />
</div>
);
}
function Foo({ hideFoo }) {
return (
<>
<h1>Foo</h1>
<button onClick={hideFoo}>Hide Foo</button>
</>
);
}
function Bar({ name }) {
return <h1>{name}</h1>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码
能够看到,只要父组件 App 的状态值 isFooVisible 发生变化,Foo 和 Bar 就都会被从新渲染。
这里由于为了决定 Foo 是否要被渲染出来,咱们须要将 isFooVisible 放在 App中维护,所以也就不能将状态拆出放到更低的层级。不过,在 isFooVisible 发生变化时从新渲染 Bar 仍然是没必要要的,由于 Bar 并不依赖 isFooVisible。咱们只但愿 Bar 在传入属性 name 变化时从新渲染。
那咱们该怎么搞呢?两种方法。
其一,对 Bar 作记忆化(memoize):
const Bar = React.memo(function Bar({name}) {
return <h1>{name}</h1>;
});
复制代码
这就能保证 Bar 只在 name 发生变化时才从新渲染。
此外,另外一个方法就是让 Bar 继承 React.PureComponent 而非 React.Component:
class Bar extends React.PureComponent {
render() {
return <h1>{name}</h1>;
}
}
复制代码
是否是很熟悉?咱们常常提到使用 React.PureComponent 能带来必定的性能提高,避免没必要要的 render。
总结:避免组件没必要要的渲染的方法有:React.memo 包裹的函数式组件,继承自 React.PureComponent 的 class 组件。
若是这条建议可让咱们避免没必要要的从新渲染,那咱们为何不把每一个 class 组件变成 PureComponent、把每一个函数式组件用 React.memo 包起来?为何有了更好的方法还要保留 React.Component 呢?为何函数式组件不默认记忆化呢?
毫无疑问,这些方法并不老是万灵药。
咱们先来考虑下 PureComponent 和 React.memo 的组件到底作了什么?
每次更新的时候(包括状态更新或上层组件从新渲染),它们就会在新 props、state 和旧 props、state 之间对 key 和 value 进行浅比较。浅比较是个严格相等的检查,若是检测到差别,render 就会执行:
// 基本类型的比较
shallowCompare({ name: 'bar'}, { name: 'bar'}); // output: true
shallowCompare({ name: 'bar'}, { name: 'bar1'}); // output: false
复制代码
尽管基本类型(如字符串、数字、布尔)的比较能够工做的很好,但对象这类复杂的状况可能就会带来意想不到的行为:
shallowCompare({ name: {first: 'John', last: 'Schilling'}},
{ name: {first: 'John', last: 'Schilling'}}); // output: false
复制代码
上述两个 name 对应的对象的引用是不一样的。
咱们从新看下以前的例子,而后修改咱们传入 Bar 的 props:
import React, { useState } from "react";
import ReactDOM from "react-dom";
const Bar = React.memo(function Bar({ name: { first, last } }) {
console.log("Bar render");
return (
<h1> {first} {last} </h1>
);
});
function Foo({ hideFoo }) {
return (
<>
<h1>Foo</h1>
<button onClick={hideFoo}>Hide Foo</button>
</>
);
}
function App() {
const [isFooVisible, setFooVisibility] = useState(false);
return (
<div className="App">
{isFooVisible ? (
<Foo hideFoo={() => setFooVisibility(false)} />
) : (
<button onClick={() => setFooVisibility(true)}>Show Foo</button>
)}
<Bar name={{ first: "John", last: "Schilling" }} />
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码
尽管 Bar 作了记忆化且 props 值并无发生变更,每次父组件从新渲染时它仍然会从新渲染。这是由于尽管每次比较的两个对象拥有相同的值,引用并不一样。
咱们也能够把函数做为 props 向组件传递,固然,在 JavaScript 中函数也会传递引用,所以浅比较也是基于其传递的引用。
所以,若是咱们传递的是箭头函数(匿名函数),组件仍然会在父组件从新渲染时从新渲染。
前面的问题的一种解决方法是改写咱们的 props。
咱们不传递对象做为 props,而是将对象拆分红基本类型:
<Bar firstName="John" lastName="Schilling" />
复制代码
而对于传递箭头函数的场景,咱们能够代以只惟一声明过一次的函数,从而总能够拿到相同的引用,以下所示:
class App extends React.Component{
constructor(props) {
this.doSomethingMethod = this.doSomethingMethod.bind(this);
}
doSomethingMethod () { // do something}
render() {
return <Bar onSomething={this.doSomethingMethod} /> } } 复制代码
仍是那句话,任何方法总有其适用范围。
第三条建议虽然处理了没必要要的更新问题,但咱们也不总能使用它。
而第四条,在某些状况下咱们并不能拆分对象,若是咱们传递了某种嵌套确实复杂的数据结构,那咱们也很难将其拆分开来。
不只如此,咱们也不总能传递只声明了一次的函数。好比在咱们的例子中,若是 App 是个函数式组件,恐怕就不能作到这一点了(在 class 组件中,咱们能够用 bind 或者类内箭头函数来保证 this 的指向及惟一声明,而在函数式组件中则可能会有些问题)。
幸运的是,不管是 class 组件仍是函数式组件,咱们都有办法控制浅比较的逻辑。
在 class 组件中,咱们可使用生命周期钩子 shouldComponentUpdate(prevProps, prevState)
来返回一个布尔值,当返回值为 true 时才会触发 render。
而若是咱们使用 React.memo,咱们能够传递一个比较函数做为第二个参数。
**注意!**React.memo 的第二参数(比较函数)和
shouldComponentUpdate
的逻辑是相反的,只有当返回值为 false 的时候才会触发 render。参考文档。
const Bar = React.memo(
function Bar({ name: { first, last } }) {
console.log("update");
return (
<h1> {first} {last} </h1>
);
},
(prevProps, newProps) =>
prevProps.name.first === newProps.name.first &&
prevProps.name.last === newProps.name.last
);
复制代码
尽管这条建议是可行的,但咱们仍要注意比较函数的性能开销。若是 props 对象过深,反而会消耗很多的性能。
上述场景仍不够全面,但多少能带来一些启发性思考。固然在性能方面,咱们还有许多其余的问题须要考虑,但遵照上述的准则仍能带来至关不错的性能提高。