React函数式组件的性能优化

React函数式组件是目前比较推崇的React编程方法,对于函数式组件来说,也需要我们手动的去完成一些性能优化。

减少重渲染

从React的生命周期可以看出,只要组件的props和state发生改变,组件就会重新渲染,且会触发所有子组件的重新渲染,即使有diff算法,重渲染仍然有代价,特别是DOM数量多的情况,在开发过程中,我们应该尽量去减少重新渲染的次数。

在类组件中,我们可以使用 shouldComponentUpdate 来对props和state进行判断来决定是否重渲染,也可以使用 PureComponent 自动进行浅层对比。

React.memo

一个简单的例子:

React.memo例子

父组件App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState } from 'react'
import Child from './Child'

export default function App() {
let [fatherMsg, setFatherMsg] = useState('fatherMsg')
let [childMsg, setChildMsg] = useState('childMsg')
return (
<>
<p>{fatherMsg}</p>
<button onClick={() => setFatherMsg('fatherMsg from father')}>
change fatherMsg from father
</button>
<div>
<Child msg={childMsg}></Child>
</div>
</>
)
}

子组件Child:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useEffect } from 'react'
import React from 'react'

function Child(props) {
useEffect(() => {
console.log('child update')
})
return (
<>
<p>I am Child</p>
<p>{props.msg}</p>
</>
)
}

export default Child

当点击change fatherMsg from father这个按钮时,我们改变的是fatherMsg,传入Child的childMsg并没有改变,可是控制台还是打印了child update,说明子组件重新渲染了。

而在函数式组件中没有这两个API,所以函数式组件提供了 React.memo 这个方法用于比较props,在导出组件时候使用 React.memo 包一层就可以了。

1
export default React.memo(child)

这样的效果与类组件的 PureComponent 类似,会对props进行浅层比较,只要props不改变,就不会触发组件的重渲染。

当传入的props比较复杂时候,比如传入了对象,浅比较无法区别对象的不同,我们可能需要自己写一个函数去判断。

1
2
3
4
export default React.memo(
child,
(prevProps, nextProps) => prevProps.obj.id === nextProps.obj.id
)

React.memo 的第二个参数还可以填入一个函数用于比较前后props来决定是否重渲染,这里与类逐渐的 shouldComponentUpdate 相似,但是不同的是这里返回两个props是否视为相同的布尔值,如果真则不触发重渲染,而 shouldComponentUpdate 返回是否重新渲染组件的布尔值,如果真则会触发重渲染。

useCallback

假如我们传给子组件的不只是普通变量呢?在实践中我们经常会需要传一个函数给子组件以达到子传父的效果,可当父组件重新渲染时候,即使函数体没有改变,子组件也会重新渲染,例如下面的场景。

useCallback例子

父组件App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useState } from 'react'
import Child from './components/Child'

function App() {
let [fatherMsg, setFatherMsg] = useState('fatherMsg')
let [childMsg, setChildMsg] = useState('childMsg')
function changeMsg(msg) {
setChildMsg(msg)
}
return (
<>
<p>{fatherMsg}</p>
<button onClick={() => setFatherMsg('fatherMsg from father')}>
change fatherMsg from father
</button>
<div>
<Child
msg={childMsg}
changeMsg={changeMsg}
></Child>
<br />
<button onClick={() => setChildMsg('childMsg from father')}>
change childMsg from father
</button>
</div>
</>
)
}

子组件Child:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useEffect } from 'react'
import React from 'react'

function Child(props) {
useEffect(() => {
console.log('child update')
})
return (
<>
<p>I am Child</p>
<p>{props.msg}</p>
<button onClick={() => props.changeMsg('childMsg from child')}>
change childMsg from child
</button>
</>
)
}

export default React.memo(Child)

父组件需要传一个函数changeMsg给子组件用于修改fatherMsg,这里我们已经使用了 React.memo ,可是当我们点击change fatherMsg from father的按钮时候我们会发现控制台打印了child update,也就是触发了子组件的重新渲染,这是因为我们改变了父组件App的state,触发了父组件的重渲染,函数式组件的重渲染会重新执行一遍函数,也就是说重新创建了一个changeMsg函数,再传入给子组件,即使我们使用了 React.memo ,也不能检查出来,因为 React.memo 只能进行浅层比较,不能区分函数。

React提供了 useCallback 这个hook用于缓存函数,可以保证函数的引用一致。

这里我们只需简单的使用 useCallback 将changeMsg包起来,依赖数组为空表示函数不会改变,然后传入子组件的函数改为这个函数缓存即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function App() {
let [fatherMsg, setFatherMsg] = useState('fatherMsg')
let [childMsg, setChildMsg] = useState('childMsg')
function changeMsg(msg) {
setChildMsg(msg)
}
// 函数缓存
const memoizedCallback = useCallback(changeMsg, [])
return (
<>
<p>{fatherMsg}</p>
<button onClick={() => setFatherMsg('fatherMsg from father')}>
change fatherMsg from father
</button>
<div>
<Child
msg={childMsg}
changeMsg={memoizedCallback}
></Child>
<br />
<button onClick={() => setChildMsg('childMsg from father')}>
change childMsg from father
</button>
</div>
</>
)
}

当然,如果函数有相关的依赖的话我们也可以在数组中填入,只要依赖不改变则函数的引用不会改变。

1
const memoizedCallback = useCallback(func, [a,b])

如果没有提供依赖项数组,useCallback 在每次渲染时都会计算新的值,与不使用没有差异,正常不会这样写。

1
const memoizedCallback = useCallback(func)

缓存计算

有时候在代码中可能有计算量很大的函数,且我们很有可能传入多次相同的参数去计算,所以我们可以想办法将计算出来的值缓存起来,每次调用函数直接返回缓存的值,这样也可以提升一些性能。

React提供了 useMemo 这个hook用于缓存值,当第一次执行或者依赖改变时才会去重新计算结果并缓存起来。

useMemo类似于Vue中的Computed,Vue中的Computed会自动帮我们收集依赖,React需要我们手动去填入。

1
2
3
4
5
6
7
function computeExpensiveValue() {
let ans
// 计算量很大的代码,计算依赖于a和b
return ans
}

const memoizedValue = useMemo(computeExpensiveValue, [a, b]);

与useCallback相似,当依赖数组为空时表示只计算一次,当没有提供依赖数组时每次重渲染都会重新计算。

一个简单的例子:

useMemo例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function App() {
let [base, setBase] = useState(0.01)
let [count, setCount] = useState(0)
let add_num = useMemo(() => {
let ans = 0
for (let i = 0; i < 100000; i++) {
ans += i * base
}
console.log(`compute add_num ${ans}`)
return ans
}, [base])
return (
<>
<p>Count is : {count}</p>
<button onClick={() => setCount((preCount) => preCount + add_num)}>
add
</button>
<button onClick={() => setBase((preBase) => preBase + 0.01)}>
add base
</button>
</>
)
}

页面首次加载我们可以看到控制台打印 compute add_num 49999500 也就是第一次计算,之后我们点击add时候并不会去重新计算add_num这个值,因为他的依赖base并没有改变,而当我们点击add base的时候控制台又会重新打印计算结果。

拆分组件

对于一个应用来说,当state很多的时候,我们可能就需要考虑一下拆分组件了,因为当一个组件的一个state发生改变时,整个组件都会重渲染,拆分组件后我们才可以使用上述提到的减少重渲染的方法去提升性能,也可以提高代码复用性。

总结

在日常自己的小项目开发中可能很少去关心性能问题,因为DOM的数量少,计算量也不大,不优化也不会有性能瓶颈,可如果是大项目,DOM的数量多,重渲染过程中diff算法的代价也会更大,所以我们需要进行性能优化减少重渲染次数,大项目还经常涉及到一些复杂的重复计算,所以缓存计算也很重要。

对于性能优化还有很多方面:网络、打包、图片资源、缓存等等方面,具体需要根据项目需要和现实情况去决定优化方向,本文主要提到的是针对React函数式组件的性能优化。


React函数式组件的性能优化
http://example.com/2022/09/27/React函数式组件的性能优化/
作者
Sonce
发布于
2022年9月27日
许可协议