函数式组件与类组件的区别
首先,需要明确的一点是,虽然函数组件和类组件的优化策略不太相同,但是在优化得当的情况下,性能的差异在现代浏览器中是很小的。性能主要取决于代码,而不是选择函数组件和类组件本身的差异上,所以不用从性能角度考虑该选择哪种方式写代码。
下面要讲的是函数式组件与类组件的一些区别,
1)状态同步问题,函数组件会捕获当前渲染时所用的值。但往往这被忽略了。
先来看下这个例子,状态同步,按照下面步骤进行操作:
1.先输入111
2.点击按钮
3.再输入222
复制代码
通过这个操作,我们可以发现,不管是props还是自身的state,函数式组件中弹出的值都是点击按钮那一刻的值,而类组件都是最新的值。
现在我们来分析一下出现这种差异性的原因。
首先我们知道,不论是函数式组件还是类组件,只要状态或者props发生变化了那就会重新渲染,而且对于没有进行过性能优化的子组件来说,只要父组件重新渲染了,子组件就会重新渲染。而且在react中props是不可变的,而this是一直在改变的。所以类组件中的方法可以获取到最新的实例即this,而函数组件在渲染的时候因为闭包的原因捕获了渲染时的值,所以改例子会出现这种现象。
那我们如何让类组件获取渲染时的值或者让函数组件获取最新值呢,看下面这个例子,类组件利用闭包,函数组件利用useRef,对于类组件我们将函数定义在render函数当中,这样我们就形成了一个闭包,就可以像函数组件一样在渲染的时候捕获相应的值;而对于函数组件我们通过useRef来实现获取最新的值,因为useRef的返回值也是可变的。
再来看一个关于状态同步的问题,函数组件与类组件定时器,我们发现两个组件实现的效果是一致的。但是我们仔细看一下函数组件,我们在useEffect中创建了一个定时器,但是每当count值改变,组件重新渲染这时就会把这个定时器清除,并且重新创建一个定时器,这很明显不是我们想要的。但是如果把count依赖去掉,会发现count值永远停留在了1。造成这个原因也是个上面讲的函数组件在渲染时捕获了所用的值,useEffect中的定时器在渲染时捕获到的count值为0,所以count值永远是从0变到1。
这就是讲的第一个差异,即状态的同步问题。
2)函数组件useEffect与类组件生命周期
先介绍几个类组件中的生命周期函数:
组件首次挂载完成:componentDidMount() {}、组件是否需要更新:shouldComponentUpdate() {}、组件更新完成:componentDidUpdate() {} 组件将要卸载:componentWillUnmount() {}。通常在componentDidMount中我们会进行一些依赖于DOM的初始化,进行网络请求,事件绑定,订阅等等;在componentDidUpdate进行一些DOM操作和网络请求;在componentWillUnmount会进行一些事件解绑和取消订阅的操作。
对比函数组件,这些操作我们都可以通过useEffect这个hook来实现,但是useEffect管理起来要比类组件中生命周期更加繁琐,尤其是在业务逻辑复杂的情况下。
首先,当我们在执行useEffect时,为了避免每次 render 都去执行它的 callback,我们通常会传入第二个参数依赖数组。这样,只有当依赖数组发生变化时,才会执行 useEffect 的回调函数。但是当业务逻辑复杂,可能就会导致以来过多的问题。所以可能在项目中会出现下面这样的代码:
useEffect(() => {
// ...
}, [name, searchState, address, status, personA, personB, progress, page, size]);
复制代码
如果一个组件中有多处这样的代码,那光是维护这些依赖就已经比较复杂,就不用说里面的业务逻辑了。由此引发几个思考:
- 1.该使用多个state还是单个state?
- 2.如何减少依赖项?
第一个问题,因为通过useState定义的state常常会成为依赖项注入依赖数组,如果把所有state分开,那么势必会造成依赖过多的问题。但是如果定义单个state,就像类组件中的this.state一样,那么就会造成所有的业务逻辑都在一个useEffect中,不利于代码的维护和通过useEffect的拆分来实现业务逻辑的分离。所以结合实际情况,可以把同一类的状态定义在一起,如下:
//分页属性可以归类为同一类
const [pagination, setPagination] = useState({current: 1, pageSize: 10})
//位置属性也可以归类为同一类
const [position, setPosition] = useState({left: 0, top: 0})
复制代码
对于减少依赖项我们通常有一下几种做法:
- 将 Hook 拆分为更小的单元,每个 Hook 依赖于各自的依赖数组。
- 通过合并相关的 state,将多个依赖值聚合为一个。
- 通过 setState 回调函数获取最新的 state,以减少外部依赖,参考这个定时器例子。
- 通过useReducer将更新与动作解耦。上个方法的缺陷在于如果依赖了两个state那么我们又得添加依赖项,观察以下代码
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
复制代码
我们发现,这里的step仍然无法省略,解决办法就是通过useReducer来实现更新与动作解耦,看这个例子,useReducer实现更新与动作解耦,在代码中我们可以看出在useEffect中的更新操作只依赖了dispatch这一个函数,并且这个函数是永远不会变得,所以这里不需要任何依赖。
针对依赖数组的维护,我们在下面性能优化中还会继续提到。由上可见,当组件,业务逻辑很复杂的时候,响应式的useEffect是很麻烦去管理的。而类组件会减少我们在管理上的压力。
3)性能优化
类组件shouldComponentUpdate这个生命周期,通常我们在这个生命周期中进行组件的优化,通过判断前一个props和当前的props是否有变化来判断组件是否需要渲染,或者通过PureComponent实现;
那么在函数组件中我们通过React.memo()来实现,具体看下面这个例子,React.memo(),点击增加count按钮,观察console,发现只打印了“NotUseMemoComponent ”,这就说明当父组件传递给子组件的值没有发生改变的情况下,使用了memo包裹的子组件不会因为父组件重新渲染而重新渲染,而没有使用memo包裹的组件只要父组件渲染了,子组件也会渲染。
但是当父组件将自己定义的引用类型的值传递给子组件时,即使值没有改变。但是由于每次渲染的时候都会生成新的变量,导致引用发生了改变,所以子组件仍然会渲染,具体看这个例子,传递函数对象或者数组,由打印可知,每次父组件重渲染都会生成新的sayHi函数,这就使得子组件重渲染并且由于useEffect依赖了这个函数,useEffect也重新执行。这就会导致子组件做了很多无用的渲染。
针对上面这个现象,通常考虑使用useCallback,useMemo来实现优化,看下面这个例子,useCallback,useMemo,现在我们发现即使我们不停的点击按钮,也不会重新触发子组件的渲染,并且useEffect也不会执行。这是因为useCallback,useMemo在依赖数组没变的情况下,都读取了缓存,没有重新生成函数或者对象。
注意,用useState定义的状态和改变状态的方法如果成为了依赖,不会因为重渲染而导致回调函数被重新执行,因此不需要用useCallback或useMemo包裹。
4)代码复用
假设现在有A、B、C、D四个组件,B和D为UI不同但是业务逻辑类似的组件,都是从服务端获取数据后循环展示列表数据,结构大致如下
<A>
<B />
</A>
<C>
<D />
</C>
复制代码
函数组件:自定义hook
原本我们需要在B和D组件中都进行状态的定义和数据的获取,B和D组件如下:
组件B/D:
import React, {useState, useEffect} from 'react'
import axios from 'axios'
function B() {
const [lists, setLists] = useState([])
useEffect(() => {
const getLists = async () => {
const data = await axios.get('xxx/xxxx') //数据请求地址
setLists(data)
}
getLists()
}, [])
return(
//渲染
<>
{lists.map(item) => ()}
</>
)
}
export default B
复制代码
现在可以自定义一个hook,将同样的代码只写一次,如下:
useAxios.js:
import {useState, useEffect} from 'react'
import axios from 'axios'
function useAxios(name) {
const [lists, setLists] = useState([])
useEffect(() => {
const getLists = async () => {
const data = await axios.get(name)
setLists(data)
}
getLists()
}, [name])
return lists
}
export default useAxios
组件B/D:
import React from 'react'
import useAxios from '../customHooks/useAxios'
function B() {
const lists = useAxios('xxx/xxxx')//数据请求地址
return(
//渲染
<>
{lists.map(item) => ()}
</>
)
}
export default B
复制代码
下面对比用类组件实现:
类组件:HOC(高阶组件)与Render Props
组件B/D:
import React, {Component} from 'react'
import axios from 'axios'
class B extends Component {
constructor () {
super()
this.state = { lists: [] }
}
componentWillMount () {
const data = await axios.get('xxx/xxxx')//数据请求地址
this.setState({ lists: data })
}
render () {
//渲染
return (
<>
{this.state.lists.map(item) => ()}
</>
)
}
}
export default B
复制代码
利用HOC(高阶组件写法如下):
wrapWithAjax.js(这是一个HOC):
import React, {Component} from 'react'
import axios from 'axios'
const wrapWithAjax = (WrappedComponent, name) => {
return class extends Component {
constructor() {
super()
this.state = { lists: [] }
}
componentWillMount() {
const data = await axios.get(name)//数据请求地址
this.setState({ lists: data })
}
render() {
return (
<WrappedComponent lists={this.state.lists} />
)
}
}
}
B/D组件:
import React from 'react'
import wrapWithAjax from './wrapWithAjax'
class B extends Component {
render() {
//渲染
return (
<>
{this.props.lists.map(item) => ()}
</>
)
}
}
B = wrapWithAjax(B, 'xxx/xxxx')
export default B
复制代码
通过上面这个例子,可以清除的发现函数式组件自定义hook的方式使用的代码量更少,而且相比HOC更加直观,代码可读性更高也更易于理解。而且通过观察HOC的代码,一个HOC相当于对原来的组件做了一层代理,那么就避免不了‘嵌套地狱’的出现。