组件渲染优化
React 组件渲染优化
组件渲染优化的核心即是尽可能地 避免冗余渲染
why-did-you-update 在
A function that monkey patches React and notifies you in the console when potentially unnecessary re-renders occur. Super helpful for easy perf gainzzzzz.
import React from "react";
import { whyDidYouUpdate } from "why-did-you-update";
if (process.env.NODE_ENV !== "production") {
whyDidYouUpdate(React);
}
You can include or exclude components by their displayName with the include and exclude options
whyDidYouUpdate(React, { include: /^pure/, exclude: /^Connect/ })
shouldComponentUpdate 避免无谓渲染
参考这篇文章
所谓的展示型
import React, { Component } from "react";
class User extends Component {
shouldComponentUpdate(nextProps) {
// Because we KNOW that only these props would change the output
// of this component.
return (
nextProps.name !== this.props.name ||
nextProps.highlighted !== this.props.highlighted
);
}
render() {
const { name, highlighted, userSelected } = this.props;
console.log("Hey User is being rendered for", [name, highlighted]);
return (
<div>
<h3
style={{ fontStyle: highlighted ? "italic" : "normal" }}
onClick={(event) => {
userSelected();
}}
>
{name}
</h3>
</div>
);
}
}
import React, { PureComponent } from 'react';
class User extends PureComponent {
render() {
...
}
}
{
this.state.users.map((user) => {
return (
<User
name={user.name}
highlighted={user.highlighted}
userSelected={() => {
this.setState((prevState) => {
users: prevState.users.map((u) => {
if (u.name === user.name) {
u.highlighted = !u.highlighted;
}
return u;
});
});
}}
/>
);
});
}
sCU
This method takes the arguments nextProps
and nextState
, which you can use to compare against this.props
and this.state
to determine if there was a change, and if that change warrants an update to the component. sCU must always return a boolean:
shouldComponentUpdate = (nextProps, nextState) => {
return (true|false)
}
Behind the scenes, React implements sCU for every component which always return true. It’s up to the developer to override this method and define the conditions for updating (re-rendering). A useful technique to enforce strict rendering rules is to initially write the component with sCU return false
. By doing this, we’re telling the component not to re-render under any circumstances. In the example below, we have a simple component which renders a Button.
class MyComponent extends React.Component {
shouldComponentUpdate = (nextProps, nextState) => false
render() {
<Button label='Click me' />
}
}
class MyComponent extends React.Component {
state = {
loading: false
}
loadData = () => {
this.setState({ loading: true })
api.loadData.then(() => {
this.setState({ loading: false })
})
.catch(() {
this.setState({ loading: false })
})
}
shouldComponentUpdate = (nextProps, nextState) => {
return nextState.loading !== this.state.loading
}
render() {
<div>
<Button label='Click me' />
<Loader display={this.state.loading} />
</div>
}
}
点击与触摸优化
作为
避免过早优化
无论你在做的是啥应用,注意要避免如惊弓之鸟般过早优化。换言之,在你真实的发现某些性能问题之前不要为了优化而优化,在
- 确定发现存在性能的缺陷
- 使用
DevTools 来解析发现瓶颈所在 - 尝试使用优化技巧解决这些问题
- 测试是否确实有性能提升
- 重复第二步
React 15.4
需要使用shouldComponentUpdate 吗?
相信几乎每个setState
或者传入了不同的shouldComponentUpdate
方法可能是最简单的组件优化方式了,不过这种方式仍然存在某些不足或者副作用。譬如当你在某个高阶组件中重载了该方法之后,尽管你只是希望不重渲染该组件,而实际上shouldComponentUpdate
返回值而取消对组件树中的整个子组件的重渲染。基于该函数最著名的实现当属shouldPureComponentUpdate,该重载方式会浅层比较当前的与未来的
- 它并没有深层比较两个对象,不过如果它真的进行了深层比较,该操作会变得异常缓慢,这也就是使用不可变数据结构的原因。
- 如果传入的某个
Props 是某个回调函数,那么该函数会一直返回True
。 - 比较检测本身也是有性能损耗的,应用中过多的冗余比较反而会降低性能。
总结而言,在实际应用开发中,建议的重载shouldComponentUpdate
应该适用于以下类型的组件:
- 使用简单
Props 的纯组件 - 叶子组件或者在组件树中较深位置的组件
不过还是需要强调的是,无论你是选择重载shouldComponentUpdate
函数还是使用pure HoC这样的模式,还是首先应该找出那个拖慢整个应用性能的组件。
将高性能消耗的代码放置到较高阶组件中
如果在你的渲染函数中存在着部分性能消耗较高的计算代码,那么建议是将这部分代码尽可能地放置到较高阶的组件中,或者使用memorizing(reselect
- 将可视化数据与覆盖层信息抽取出来放置到独立的组件中。
- 将执行大量数据转换的代码移出到容器组件中。
- 仅仅对于可视化组件与覆盖层组件覆写
shouldComponentUpdate
函数。 - 使用不可变数据结构
(Immutable) 来降低比较带来的性能消耗
我发现最常见的降低应用性能的原因就是用户输入引发的
同步滚动组件
为了更好地解释这个问题,我构建了某个同步滚动的组件来演示这个问题,其效果如下所示:
该组件的主要职能在于保持左右两个滚动面板的一致性
不要滥用this.setState
this.setState
函数的使用,我们不应该将render()
函数中用不到的状态放置到this.state
对象中。下面我们来看下第一个版本的滚动面板的实现:
class ScrollPane extends React.Component {
componentDidUpdate() {
// Each time we get new props we set the
// new scrollTop position on the DOM element
this.el.scrollTop = this.props.scrollTop
}
render() {
<div ref={(el) => {this.el = el}}>
}
}
class ScrollContainer extends React.Component {
constructor() {
super()
this.leftPane = null
this.rightPane = null
this.state = {
leftPaneScrollTop: 0,
rightPaneScrollTop: 0
}
}
handleLeftScroll = (evt) => {
// Calculate new scrollTop positions
// for left and right panes based on
// DOM nodes and evt.target.scrollTop
const leftPaneScrollTop = …
const rightPaneScrollTop = …
// Don't do this since this will re-render everything
// on each `scroll` event!
this.setState({
leftPaneScrollTop,
rightPaneScrollTop
})
}
render() {
return (
<div>
<ScrollPane
ref={(el) => {this.leftPane = el}}
onScroll={this.handleScroll}
scrollTop={this.state.leftPaneScrollTop}
>
<ExpensiveComponent />
</ScrollPane>
<ScrollPane
ref={(el) => {this.rightPane = el}}
onScroll={this.handleScroll}
scrollTop={this.state.rightPaneScrollTop}
>
<ExpensiveComponent />
</ScrollPane>
</div>
)
}
}
在这个版本的实现中,我们将所有的状态放置到了this.state
中,此时问题就在于每次你调用this.setState
来设置组件状态时,scrollTop
的值以
handleScroll = (evt) => {
// Calculate new scrollTop positions
// for left and right panes based on
// DOM nodes and evt.target.scrollTop
this.leftPaneScrollTop = …
this.rightPaneScrollTop = …
}
将滚动高度作为类成员属性就不会触发重渲染,不过此时我们应该如何更新兄弟组件的滚动位置呢?这里的建议是直接进行
class ScrollPanel extends Component {
static contextTypes = {
registerPane: PropTypes.func.isRequired,
unregisterPane: PropTypes.func.isRequired
};
componentDidMount() {
this.context.registerPane(this.el);
}
componentWillUnmount() {
this.context.unregisterPane(this.el);
}
render() {
return (
<div
ref={el => {
this.el = el;
}}
>
{this.props.children}
</div>
);
}
}
class ScrollContainer extends Component {
static childContextTypes = {
registerPane: PropTypes.func,
unregisterPane: PropTypes.func
};
getChildContext() {
return {
registerPane: this.registerPane,
unregisterPane: this.unregisterPane
};
}
panes = [];
registerPane = node => {
if (!this.findPane(node)) {
this.addEvents(node);
this.panes.push(node);
}
};
unregisterPane = node => {
if (this.findPane(node)) {
this.removeEvents(node);
this.panes.splice(this.panes.indexOf(node), 1);
}
};
addEvents = node => {
node.onscroll = this.handlePaneScroll.bind(this, node);
};
removeEvents = node => {
node.onscroll = null;
};
findPane = node => this.panes.find(pane => pane === node);
handlePaneScroll = node => {
window.requestAnimationFrame(() => {
// Calculate new scrollTop positions
// for left and right panes based on
// DOM nodes and evt.target.scrollTop
// and set it directly on DOM nodes
this.panes.forEach(pane => {
pane.scrollTop = ...;
});
});
};
render() {
return (
<div>
<ScrollPane>
<ExpensiveComponent />
</ScrollPane>
<ScrollPane>
<ExpensiveComponent />
</ScrollPane>
</div>
);
}
}
在上述实践中,ScrollContainer
组件实现了register/unregister
方法用来添加或者删除面板以及注册ScrollPane
组件仅用来在挂载时注册,在卸载时注销。每次面板触发onScroll
事件的时候,回调函数会获得新的滚动高度然后自动为其他面板设置scrollTop
位置值。可以在这里查看源代码,并且这种方式也用于了
PureComponent
在
import User from './User'
const Users = ({users}) =>
<div>
{
users.map(user => <User {...user} />
}
</div>
这种写法的问题在于,无论user
shouldComponentUpdate
渲染限流
class App extends React.Component {
state = {
max: 100,
percent: 0,
};
changePercent = () => {
let intVal = setInterval(() => {
if (this.state.percent < this.state.max) {
let percent = this.state.percent + 1;
this.setState({ percent });
}
if (this.state.percent === this.state.max) {
clearInterval(intVal);
}
}, 10);
};
render() {
return (
<div>
<Progress
percent={this.state.percent}
strokeWidth={5}
showInfo={false}
/>
<progress max="100" value={this.props.percent} />
<CurrentProgress percent={this.state.percent} max={this.state.max} />
<button
onClick={() => {
this.changePercent();
}}
>
点我开始变化
</button>
</div>
);
}
}
export default App;
export default class CurrentProgress extends Component {
innerPercent = 0;
shouldComponentUpdate(nextProps, nextState) {
console.log(nextProps.percent + ":" + this.props.percent);
if (nextProps.percent === nextProps.max) {
return true;
}
if (nextProps.percent - this.innerPercent < 10) {
return false;
} else {
return true;
}
}
render() {
const prefixCls = "ant-progress";
this.innerPercent = this.props.percent;
const percentStyle = {
width: `${this.props.percent / this.props.max}%`,
height: 10,
};
const progressDiv = (
<div>
<div className={`${prefixCls}-outer`}>
<div className={`${prefixCls}-inner`}>
<div className={`${prefixCls}-bg`} style={percentStyle} />
</div>
</div>
</div>
);
return <div>{progressDiv}</div>;
}
}
Debounce
A debounce is a tool that every web developer should have in their kit. It improves performance by limiting the number of expensive calculations, API calls, and DOM updates. Although the debounce technique has been around for years, it’s still a great option to employ with modern libraries and frameworks
import React, { Component } from 'react'
import debounce from 'lodash/debounce';
// Create fake projects instead of using a database
let fakeProjects = [];
for (let i = 0; i < 1000000000; i++) {
fakeProjects.push({
id: id,
name: `project ${i}`,
featured: i % 2 === 0,
rank: Math.ceil(Math.random() * i),
});
}
mockApi = () => {
// 2. Cost here of iterating and also a database delay in real apps
return fakeProjects
.filter(project => project.featured)
.filter(project => project.name.includes(value))
.sort((a, b) => a.rank - b.rank);
}
const Projects extends Component {
state = {
projects: fakeProjects,
}
handleFilter = debounce((e) => {
const value = e.target.value;
// 1. Network delay in a real-world context
const displayedProjects = mockApi();
this.setState({ projects: displayedProjects });
}, 500)
render() {
// 3. Cost of a React reconciliation as well as updating the DOM
return (
<div>
<input onKeyUp={this.handleFilter.bind(this)}/>
<ul>
{
this.props.projects.map((project) => {
return (
<li key={project.id}>
{project.name}
</li>
);
})
}
</ul>
</div>
);
}
}
export default Projects;
We have a simple
- that lists projects by name, and an that allows us to filter these projects. When there is an onKeyUp triggered on the it executes the handleFilter() which is wrapped in our debounce function. This limits our mockApi() function to only be called every 500ms. By debouncing, we remove the need to perform these operations every key press, and we only do it after the user has stopped typing for a short amount of time. This ensures that we don’t perform unnecessary operations to find projects the user didn’t even care to see. Let’s take a look at the individual steps to understand how much we actually saved.
After the debounce time expires after the final key press, the first step is to make our API call. Since we have debounced the function that fetches from our API, we only make the request once at the end of typing into the input. There are two things happening here — retrieving projects from the database and then performing heavy calculations to filter and sort the data. In a practical context, the server would be more efficient about querying and performing calculations on this data, but the learning outcome is still completely valid. In this instance, let’s assume we type 5 characters within our debounce timer. Without the debounce, we would perform 5 queries of 1 billion items and then perform the filtering, string matching, and sorting. Thus we’ve gained a 5x performance improvement on a what is already an incredibly heavy computationally intensive action. React is fast, but the biggest thing that can slow it down is too many renders and reconciliations. In addition, DOM interaction are incredibly slow. By debouncing, we prevent the setState() which serves to significantly reduce the number of times we force React to reconcile and append the list to the DOM. Without a debounce, this component would be almost unusable with such a large amount of data. This concept is easily extended to Redux for any state updates or API calls. If these interactions dispatched other actions, we are once again achieving a multiplicative gain.
Common scenarios for a debounce are resize, scroll, and keyup/keydown events. In addition, you should consider wrapping any interaction that triggers excessive calculations or API calls with a debounce.