你们的React很慢
抱歉,标题夸张了一点。
最近在进行React项目页面的开发时,我发现页面渲染有点慢(负载很高),所以思考了如何减少不必要的渲染,我总结了以下的方法。不会讨论preact或其他库的事。
如果有兴趣,这里也可以看看:
以ReactJS创建现代SPA入门(基础篇)
2019年07月06日補充:
关于浏览器渲染机制有一篇不错的文章,建议先阅读一下。
优质文章1:实际上“打开浏览器直到页面显示出来”会发生什么?
优质文章2:浏览器渲染入门-通过了解变得可见的世界
一个像素显示在浏览器中的过程:像素的生命 2018
这篇文章讲述了关于React DOM树(布局)渲染的优化策略。
2020年02月15日更新:
续集:你们的React太慢了(SSG版本)
2022年07月04日更新:
通过禁用ISR和CSR在基础架构级别动态生成静态HTML!:推荐高速的Headless NextJS.
2023年10月09日更新:
高速可伸缩网页创建功能的最终形式:Qwik展示了令人惊叹的内容可伸缩并且极速的网页最终形式
铁路模型
首先,有一份谷歌的性能指南,其中使用RAIL模型来衡量性能。
-
- ユーザーを第一に考えます。最終目標は、特定の端末でのサイトの処理速度を上げることではありません。ユーザーが満足感を得ることが最終目標です。
-
- ユーザーに対して即座に応答します。ユーザー入力は、100 ミリ秒以内に認識します。
-
- アニメーションやスクロールでは、10 ミリ秒以内にフレームを生成します。
-
- メインスレッドのアイドル時間を最大限に活用します。
- ユーザーの作業を妨げません。インタラクティブ コンテンツは 1000 ミリ秒以内に提供します。

用户对延迟的反应
-
- 0~16 ミリ秒: とても良い、ゲームとかでも60FPS(1フレームあたりの描画間隔が16ミリ秒)とかになっていますね
-
- 0~100 ミリ秒: ページ内動作としては許容範囲
-
- 100~300 ミリ秒: ページ内動作のレスポンスとしてはやや遅い
-
- 300~1000 ミリ秒: ページの読み込み単位であればスムーズな体験を提供している
-
- 1000 ミリ秒以上: 1秒を超えるとユーザは実行したタスクへの関心を失う
- 10,000 ミリ秒以上: ユーザーは不満を感じてタスクを中断し、そのまま戻ってこない恐れがあります
在用户体验方面,快速响应是一种优势。
可视化显示哪个组件正在进行渲染。
可以使用Chrome的React Devtools插件中的Highlight Update功能来可视化渲染的位置。渲染的地方将以边框方式高亮显示。

React 16的渲染性能测量方法
使用React 16和Chrome Devtools调试React性能的方法与原文完全一样。
由于React Devtool插件等可能对渲染性能产生负面影响,建议在实际测量时将其禁用。
为了充分利用屏幕空间,将Chrome Dev tool的窗口拆分出来。

实际运行环境未必是高性能的个人电脑。可能是低性能的移动设备。
在性能选项卡中,有一个可以模拟降级的CPU设置。
在网络设置中,还可以模拟通信环境不佳,比如3G网络。

刷新页面并测量加载性能需要按下性能选项卡上的更新按钮。
要在页面内执行操作并进行测量,请按下指定按钮,然后按下停止。

脚本执行的部分是黄色部分。总结中显示了脚本执行的总时间。蓝线表示DOMContentLoaded事件发生的时间,即首次完成DOM解析的时间,红线表示load事件发生的时间,即页面完全加载的时间。

打开 User Timing,然后转到 React 处理的部分。

如果存在源代码映射文件,您可以更进一步了解在处理过程的哪个部分花费了时间。
-
- 点击框架图的条形图,可以了解每个操作所花费的时间。
-
- 打开“Bottom-Up”标签。
-
- 对时间进行排序。
-
- 如果有源代码映射文件,将会显示出来。
- 可以调查实际源代码中哪里花费了时间。


如果在webpack中构建SourceMap文件,可以通过webpack.config.js中的devtool设置进行输出。
有关输出格式,请参考webpack官方文档。
参考:开发工具
module.exports = {
mode: 'development', // 開発モード
devtool: 'source-map', // ソースマップファイル出力
}
在React中提升渲染性能的优化策略
假设在某个页面上显示了如下DOM树结构。每个节点都是一个React组件。

当在末端节点D上调用setState来改变组件状态时,将触发D的重新渲染。
在这种情况下,受影响的范围仅限于D的重新渲染。

如果在父组件A中调用了setState,那么A的render方法会被调用,并且所有子组件都会被重新渲染。(只有子组件是React.Component或者无状态组件的情况下才会触发重新渲染)
在这种情况下,即使一些子组件不需要重新渲染,它们的render方法也会被调用,这可能会导致一些无用的渲染。
(无论是将state参数传递给子组件的props还是其他方式,子组件及以下的render方法都会被调用!)
如果父组件下挂载的子组件很多,那么如果没有采取任何措施,渲染负担会逐渐增加。

如果使用redux和react-redux,可以将渲染影响的组件限定在特定范围内(只参考connect的第一个参数mapStateToProps的部分)。这种结构的好处是可以通过connect的第二个参数mapDispatchToProps从任何地方调用redux的操作。需要注意的是,父组件(在图中为A)不应该引用mapStateToProps(如果引用并更新A的props,整个树将会重新渲染)。但是,在这种情况下,B以下的D和E仍然会重新渲染。

为了显著减少不必要的渲染,您需要在子组件中重写 shouldComponentUpdate 方法,并根据条件返回 false(默认为 true)。

如果要自己编写shouldComponentUpdate,需要比较传入shouldComponentUpdate的props、state和组件自身的props、state,只有有差异才会进行渲染判断。
参考:尽量减少React的重新渲染。
class SampleComponent extends React.Component {
static get defaultProps() {
return {
sampleProp: '',
}
}
constructor(props) {
super(props);
this.state = {
sampleState: '',
}
}
shouldComponentUpdate(nextProps, nextState) {
return !(this.state.sampleState === nextState.sampleState &&
this.props.sampleProp === this.props.SampleProp)
}
render() {
// 略
}
}
自React 15.3版本开始,可以使用PureComponent自动处理上述shouldComponentUpdate的判断操作。
简单的做法是将React.Component替换为React.PureComponent,
对于无状态函数组件(SFC),可以在React 16.6版本之后使用React.memo进行包裹。
顺便提一下,通过connect连接的组件默认会被视为PureComponent处理。
参考: connect的第四个参数options。
我认为基本上可以采用PureComponent、React.memo和react-redux的connect结合起来,但必须注意下面的反模式。
引发 PureComponent 的重新渲染的反模式
特别是在将参数传递给子PureComponent的props时,需要注意,否则会引发子组件的重新渲染。
传递给props的内容不需要重新生成,对吗?
在render中尽量避免定义变量或立即执行。
请勿将箭头函数传递给【React】PureComponent。
顺便提一下,关于ref,不必使用props,可以传递内联箭头函数也可以。参考:即使在PureComponent中也可以传递内联箭头函数。
用立即执行函数将箭头函数传递给props。
如果直接将即时函数传递给props,那么每次调用父组件的render时,即时函数都会作为一个新的对象重新生成。
因此,在PureComponent的shouldComponentUpdate方法中,会认为传入的props是不同的,从而导致重新渲染。
下面的示例是将父组件的处理函数通过change props传递给子组件的情况。
・不允许
render () {
return <Child change={() => console.log('hoge')} />
}
・好的 de)
使用bind函数
constructor(props) {
super(props)
this.hoge = this.hoge.bind(this)
}
hoge() {
console.log('hoge')
}
render () {
return <Child change={this.hoge} />
}
如果您使用plugin-proposal-class-properties插件,您可以在类内箭头函数中进行定义并指定。
hoge = () => {
console.log('hoge')
}
render () {
return <Child change={this.hoge} />
}
带有默认参数的情况下将其传递给props。
如果值在过程中发生变化,props被视为已更改,并调用子组件的 render。
・不可以
render () {
return <Child options={this.abc || []} />
}
可以,我们在构造函数中做。
constructor(props) {
super(props)
this.abc = []
}
render () {
return <Child options={this.abc} />
}
将bind操作在render中完成
• “不行” (Bù
hoge() {
console.log('hoge')
}
render () {
return <Child change={this.hoge.bind(this)} />
}
好的,我们用构造函数来做吧。
constructor(props) {
super(props)
this.hoge = this.hoge.bind(this)
}
hoge() {
console.log('hoge')
}
render () {
return <Child change={this.hoge} />
}
SFC vs React.memo vs PureComponent 的比较
关于无状态组件(Stateless Component),将其改为SFC可以避免React中不必要的生命周期处理,从而提高渲染速度。
参考:《现在React功能组件快了45%》
→ 虽然标题中显示了45%,但据文章内的图片显示,将class组件转换为函数组件只能带来6%的性能提升。
在使用React.memo或PureComponent时,需要考虑HOC或生命周期方法的性能开销,因此对于只包含较小规模DOM的组件而言,React.memo或PureComponent可能过于复杂,反而会降低性能。因此,在考虑到需要渲染的DOM数量和其他要素之间需要权衡取舍时,需要考虑在何种情况下使用。
在实际情况下,无法确定权衡取舍的具体点,直到进行实际的测量为止。
对于React.memo来说,只有在props内容不发生变化(即渲染内容从初始状态开始不发生变化)的情况下才适合使用。
同时,在React 16.8引入的React Hooks中,使用useState可以使函数式组件也拥有状态。(以下简称FC)
React 16.8: 对最终版本的React Hooks进行详细介绍
此外,通过使用useMemo和useCallback,可以在函数式组件中重用变量和函数,而无需重新生成,因此性能可能更好。
随意使用React Hooks的useCallback/useMemo可能会降低可读性,并增加错误的可能性,因此在适当的场合使用会更好。
在使用`useMemo`之前,更好的做法似乎是优先删除无用的`div`,而不是努力使用`useMemo`。
参考:在担心`useMemo`的成本之前,先减少多余的`div`!
通过使用 React.Fragment 或重新审查样式,主要是样式不必要的 div 将减少。
// 無駄な例
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center'}}>
<div>
コンテンツ
</div>
</div>
// 無駄が少ない例
<div style={{margin: 'auto'}}>
コンテンツ
</div>
顺便说一句,在样式方面,尽量减少使用div,仅仅使用CSS选择器并不能使网站加载速度变快,这是一个比较困难的地方就是… 参考:考虑网页加载速度的CSS选择器写法。
为了避免反模式,我们将尽量将具有状态的组件限定在末端组件中,并在页面上只保留页面级别的状态。
对于需要引用数据的组件,我们将使用connect直接引用数据。(包括可以在页面内更新的数据)
我个人认为,为FC组件组合React.memo的父组件可以限制渲染的影响范围,这种粒度可以类比为Atomic Design中的Organisms(有机体)。

原子:设计的最小要素,颜色,字体,标题,按钮,输入框等。
分子:通过组合原子进行分组的模式(例如标题和正文的组合)。
有机体:通过组合分子创建的界面(例如标题+菜单栏→导航栏)。
模板:由有机体组合而成的页面线框。
页面:包含页面独有设计元素的线框之外的页面本身。
最后只要全部换成FC就行吗?
React Hooks-比高阶组件(HOC)慢吗?测试结果显示,带有useEffect的函数组件(FC)和HOC进行了基准测试,结果发现HOC更快。
这个基准测试确实有缺陷。
据说componentDidMount比useEffect更早回调,但在回调时用户实际上看不到任何东西。
根据使用useLayoutEffect进行比较的结果,Hooks似乎更具优势。
不过,并不能笼统地说Hooks就一定更快。
(尽管如此,由于使用自定义Hooks可以将逻辑处理和UI显示分离,目前来说使用Hooks的好处更大。)
减轻频繁组件重新渲染的负担
使用CSS来加快React应用的速度
当组件中存在频繁渲染或者渲染粒度较大的逻辑(如useEffect),将其分离到无逻辑的组件中使用是有效的。
检测到无效渲染
当你使用why-did-you-update包时,你可以在属性或状态传递给子组件的时候检测到不必要的渲染(用于调试)。
import React from 'react'
// 無駄なレンダリングを検知する
if (process.env.NODE_ENV !== 'production') {
const {whyDidYouUpdate} = require('why-did-you-update')
whyDidYouUpdate(React)
}

样本
我写了一个关于在StackBlitz上调用呈现比较的测试,可以看到它的行为。 请尝试打开控制台以确认。

使用代码分割和动态导入来加速加载。
使用代码分割功能可以在构建时将指定的组件从bundle.js中拆分到另一个JS文件中。
在Webpack中,你可以通过Webpack的优化选项将主要库分离到单独的文件中。webpack的代码拆分功能。
以下的例子展示了将一个名为bundle.js的文件分成react.js文件、core.js文件和bundle.js文件这三个文件的过程。
(与加载一个庞大的bundle.js相比,同时加载多个大小相同的js文件可以加快加载速度)
与react相关的包被分离到react.js文件中,而其他体积较大的库则被分离到core.js文件中。
const Visualizer = require('webpack-visualizer-plugin')
module.exports = {
mode: 'production', // 本番環境
optimization: {
splitChunks: {
cacheGroups: {
react: {
test: /react/,
name: 'react',
chunks: 'all',
},
core: {
test: /redux|core-js|jss|history|matarial-ui|lodash|moment|rollbar|\.io|platform|axios/,
name: 'core',
chunks: 'all',
},
},
},
},
plugins: [
new Visualizer({filename: './stats.html'})
],
}
使用webpack-visualizer-plugin插件可以将生成的js文件的大小分布可视化(在这个例子中是输出stats.html)。
毋庸置疑地,应该从包中排除未使用的库。或者,需要注意过大的包,以免bundle.js的大小膨胀。

最近,在使用webpack-bundle-analyzer插件时能够更详细地显示细分组件,所以我完全使用这个插件。
只需要将配置添加到插件即可。
plugins: [
new BundleAnalyzerPlugin()
]

此外,还可以使用一种称为动态导入的技术来异步加载JS文件。这样可以缩短由于同步加载而被阻塞的时间。(※然而,在进行SSR时,存在页面组件与同步加载不一致的问题。)为此,我制作了一个关于仅对着陆页进行同步加载的示例。关于SSR(服务器端渲染)的内容。
// 同期
import Sample from './Sample'
// 非同期
import('./Sample').then(module => module.default).then(Sample => {/*処理*/})
另外,在使用webpack进行构建时,可以通过给导入文件添加一个名为webpackChunkName的魔术注释来将导入的文件从bundle.js中分离出来。
下面的示例是将Sample组件分离到sample.js中的示例(webpackChunkName需要指定文件名,必须是唯一的)。
import(/* webpackChunkName: "sample" */ './Sample')
此外,自 webpack 4.6.0 版本以后,还可以添加预取(prefetch)功能。
通过给 webpackPrefetch 添加魔术注释,可以在空闲时间中预先获取 js 资源文件。
您也可以通过类似 z-index 的数值来指定加载优先级(如果这样做,则 true 被当作 0 处理)。
参考:webpack 中的 对于 webpackPrefetch,由于它会异步地获取指定的 js 资源文件,
因此最好将其限制在预计会在下一页中使用的 Component 上。(以避免不必要的通信)
还没有经过验证,不过还有一些像 Guess.js 这样的工具,它可以通过 GA 的跟踪数据等来推测用户可能会转到哪个页面。
import(/* webpackPrefetch: true, webpackChunkName: "sample" */ './Sample')
虽然现在也已经实现了React.lazy功能,但由于尚未支持服务器端渲染,所以还是推荐使用loadable-components。(可以在需要时异步加载)
使用方法大致如下。
import loadable from '@loadable/component'
const Sample = loadable(() => import(/* webpackChunkName: 'sample' */ './Sample'))
制作一个与loadable功能相当的简易组件的方式如下。
import React from 'react'
// 遅延レンダリングを行うコンポーネント
export default (loader) => (
class AsyncComponent extends React.Component {
constructor(props) {
super(props)
this.Component = null
this.state = { Component: AsyncComponent.Component }
}
componentDidMount() {
// 遅延して読み込み完了させる
setTimeout(() => this.setState({startProgress: true}), 500)
if (!this.state.Component) {
loader()
.then(module => module.default) // export defaultされたコンポーネント
.then(Component => {
// コンポーネントを遅延読み込みしたものに差し替え
AsyncComponent.Component = Component
this.setState({ Component })
})
}
}
render() {
if (this.state.Component) {
// Wrapしたコンポーネントをレンダリングする
return <this.state.Component { ...this.props } />
}
if (!this.state.startProgress) {
return null
}
// Loading中コンポーネント
return <div>Now Loading...</div>
}
}
)
对于纵向较长的页面,除了第一视图(First View)之外,采用异步导入(非同步导入)的方式可以加快初始渲染速度。
(但是,对于仅支持SSR的页面或AMP页面,只能在服务器端完成全部渲染。)
React的code splitting用的库loadable-components非常好,它还提供了在链接悬停时预提取功能,也可以使用此功能进一步减少不必要的JS文件加载。(直到需要时再加载)我推荐大家使用loadable-components。
关于SSR(服务器端渲染)和SSG(静态网站生成)的内容。
在过去,我一直认为服务器端渲染(SSR)比客户端端渲染(CSR)更快。但实际上,这取决于服务器和浏览器的性能以及网络连接状况。有趣的是,有时候SSR的总加载时间可能比CSR更慢。(当然,在首次视图交互方面,CSR可以在白屏的情况下进行部分展示,这会更好。)服务器端渲染的好处大于客户端端渲染。
据说这是由于页面的 DOM 元素较多,在后端渲染时需要花费较长的时间,导致 TTFB(首字节到达时间)变慢。(RenderDOMServer.renderToString 的负载较高)作为解决办法,可以考虑:
・通过CDN等方式将页面缓存起来
・通过SSG(静态网站生成)进行预渲染,并输出HTML文件保留下来。
可以想到类似的事情。
在SSG方面,预先生成静态页面非常适用于需要速度优先的LP或博客等更新较少的网站。推荐使用React官方也在使用的GatsbyJS。(尽管称为静态,但已经构建了路由等,并且可以输出生成好的HTML文件,而无需在JavaScript中构建虚拟DOM,因此速度非常快)
追记2020/02/15:
如果尝试一下,动态网站也能做出来,所以全部用Gatsby应该就可以了吧:你们的React太慢了(SSG篇)。
附注2022/07/04:
通过禁用ISR和CSR,在基础架构级别实现了静态HTML的动态生成!:推荐使用高速的Headless NextJS。
总结
渲染
-
- そもそも親コンポーネントでのstate変更が必要な設計は極力さける、react-reduxのconnectでレンダリングするコンポーネントを限定する。末端のノードのみstateを持つ設計にすることでレンダリングの影響範囲を少なくする
親でstateが必要だと感じた場合はデータの影響部分をReduxに切り出し、connect経由で子コンポーネントに伝える。条件ハンドリングしたい場合はthisメンバ変数を使うことも視野に入れる
親コンポーネントではRedux(connectのmapStateToProps)を参照しない
propsにインライン関数は使わない、render内での処理を極力避ける(constructorで前処理する、メンバ関数を指定する)
stateの変更の影響を受けないpropsは渡しても可(初回のレンダリングのみ渡す系のprops)
レンダリングの負荷: Component > PureComponent > FC(wrapped React.memo) > FC
レンダリングするDOMの粒度が小さいコンポーネントはFCにする
FC群をまとめたReact.memoを作る
最终结果似乎仅取决于以哪种粒度组合组件以及连接哪些数据进行参考。
载入中 rù
-
- そもそも使っていないライブラリは削除してbundle.jsを軽くする
-
- WebpackのCodeSplitting機能を使ってjsファイルをbundle.jsから分離する
-
- dynamic importを活用することでjsリソース読み込み時のブロッキングを避ける
- prefetch付きdynamic importをすることで次表示されるページのjsリソースを先読みできる
在引入这个库之前,我们应该考虑是否真的需要它。