因为市场上react hook 已经基本普及,所以本文的介绍也是建立在hook的基础之上,也正是因为使用的是hook,所以才有了各种优化的手段。神奇不神奇?因为hook提供的常规用法对性能上是有拖累的,所以才有了我们性能提升的操作,新鲜不新鲜?
先列举一下,react hook里,导致影响性能低下的错误用法
❌ 错误做法
- 组件里二次定义组件,原因——懒,不愿意提出来单独作为一个文件import
import { useEffect, useState } from "react";
interface IProps {
className?: string
}
export default (prpos: IProps) => {
const [state, setState] = useState(0);
const B = () => {// ❌ 严重影响性能的错误用法
useEffect(() => {
console.log('B 销毁重建了')
}, [])
return <div>这是子组件</div>
}
const clickFun = () => {
setState(state + 1);
}
return <div>
<div>计算器:{state}</div>
<button onClick={clickFun}>点击计算</button>
<B />
</div >
}
解读错误做法
- 因为函数式组件,非常灵活,而且框架层与js层并没有太多这种规范约束,js又是可以无限套娃function的(箭头函数也如此),而react函数式组件的本质也是function,所以组件里套组件B的错误用法就产生了。
- 上面示范代码的运行结果就是,每次点击按钮时,都会打印出『B 销毁重建了』,小数据量,当然看不出问题,以某个公司的项目里的列表为例,列表的每一行里属于中等复杂度吧,然后大约300条数据,就已经达到性能上限了,浏览器明显感觉卡顿,因为销毁重建是非常消耗渲染性能的,相当于大块dom删除后再创建。
✅ 正确做法
把所有自定义的组件都单独抽出来,以独立的一个文件形式存在,使用方通过import导入来使用
影响性能的一般表现
我们知道,触发一个组件被更新「update」的方法有如下几点:
-
父组件传给自己的属性props里数据变化,这其中包括:
- 属性的变化
- 回调函数的变化
-
组件内部执行setState操作,触发update
看下面示例:
//Bag.tsx
export interface IBag {
penColor: string,
id: number,
penNum: number
date: string,
}
interface IPropsBag {
toolInfo: IBag,
name: string,
peopleId: number,
onClick: (peopleId: number, dateId: number) => void,
}
export const Bag = (props: IPropsBag) => {
const { name, toolInfo, onClick, peopleId } = props;
const { date, penColor, penNum, id: dateId } = toolInfo
console.log(`${name}的书包-${dateId} update`)
return <div style={{ marginTop: 16 }}>
<li>日期:{date}</li>
<li>钢笔数量:{penNum}</li>
<li>钢笔颜色:{penColor}</li>
<button onClick={() => {
onClick(peopleId, dateId)
}}>增加这一天钢笔的数量</button>
</div>
}
//People.tsx
import type { IBag } from "./Bag";
import { Bag } from "./Bag";
export interface IPeople {
name: string,
id: number,
bag: IBag[]
}
interface IPropsPeople {
onClick: (peopleId: number, dateId: number) => void,
peopleId: number,
peopleInfo: IPeople,
}
export const People = (props: IPropsPeople) => {
const { onClick, peopleInfo, peopleId } = props;
const { name, bag } = peopleInfo
console.log(`${name}这个人 update`)
return <div>
<div>姓名:{name}</div>
<div>书包里工具列表:</div>
<div style={{ marginLeft: 16 }}>
{
bag.map((everyTool) => {
const { id } = everyTool;
return <Bag key={id}
peopleId={peopleId}
onClick={onClick}
name={name}
toolInfo={everyTool} />
})
}
</div>
<div>------------------------------------------</div>
</div>
}
//Main.tsx
import { useState } from "react";
import type { IPeople } from "./People";
import { People } from "./People";
export default () => {
const [people, setPeople] = useState<IPeople[]>([
{
name: "韩梅梅",
id: 0,
bag: [
{
penColor: 'red',//钢笔颜色
id: 0,
penNum: 1,//钢笔数量
date: '星期一',
},
{
penColor: 'black',//钢笔颜色
id: 1,
penNum: 1,//钢笔数量
date: '星期二',
}
]
},
{
name: "李雷",
id: 1,
bag: [
{
penColor: 'yellow',//钢笔颜色
id: 0,
penNum: 2,//钢笔数量
date: '星期一',
},
{
penColor: 'green',//钢笔颜色
id: 1,
penNum: 2,//钢笔数量
date: '星期二',
}
]
},
]);
const clickFun = (peopleId: number, dateId: number) => {//子组件调用的,增加钢笔的数量 的回调
setPeople((peopleSource) => {
peopleSource.forEach((everyPeople) => {
if (everyPeople.id === peopleId) {
everyPeople.bag.forEach((everyTool) => {
if (everyTool.id === dateId) {
everyTool.penNum += 1
}
});
}
})
return [...peopleSource]
})
}
return <div>
{
people.map((everyPeople) => {
return <People
key={everyPeople.id}
peopleId={everyPeople.id}
onClick={clickFun}
peopleInfo={everyPeople} />
})
}
</div >
}
上面渲染出来的就是下图所示:
从上面代码看,我点击韩梅梅星期一那天增加钢笔数量按钮,下列地区都会被重新执行「update」,打印如下:
People.tsx:16 韩梅梅这个人 update
Bag.tsx:16 韩梅梅的书包-0 update
Bag.tsx:16 韩梅梅的书包-1 update
People.tsx:16 李雷这个人 update
Bag.tsx:16 李雷的书包-0 update
Bag.tsx:16 李雷的书包-1 update
图中四个按钮,不论点击哪个都会有6条打印输出,这不是我们想要的,相当于2个People组件,4个Bag组件都被执行了「update」.
在某一家公司里,产品要求实现一个列表7000+条,非分页,虽然你可能会说这种情况很少,但是遇到了,我们就把它当成一个性能提高的案例。
- 我们知道,代码中,导致多次update原因是main.tsx里的 clickFun 方法,因为每次setPeople时,clickFun方法都被重新定义重新初始化,引用改变了,这将触发People组件的update
- 由于react的「不可变数据的信仰」,setPeople时,把people解构重组,作为一个新的引用,最终实现了触发数据更新。不论这个信仰多么高尚,这个做法导致了整个数据从根到叶子(终点)的全部执行了update操作,哪怕我只改了韩梅梅一个人的数据。其他人都得update。
- 经过现实业务里1万条组件列表的测试,改一条,其他9999条都得执行一次update,16G内存,ip5处理器,大约跑了8秒,因为列表还是有些复杂度的,嵌套也比较多。所以这个结果是我们不能接受的。
我们的目标是,改韩梅梅星期一的数据,那么,寒梅梅星期二还有李雷的所有数据都不执行update。一般我们能想到的办法是
- 给clickFun增加 useCallback 空依赖数组
- 给People,Bag组件套上memo
如下:
const clickFun = useCallback((peopleId: number, dateId: number) => {//子组件调用的,增加钢笔的数量 的回调
setPeople((peopleSource) => {
peopleSource.forEach((everyPeople) => {
if (everyPeople.id === peopleId) {
everyPeople.bag.forEach((everyTool) => {
if (everyTool.id === dateId) {
everyTool.penNum += 1
}
});
}
})
return [...peopleSource]
})
}, [])
export const People = memo((props: IPropsPeople) => {
}
export const Bag = memo((props: IPropsBag) => {
}
但问题来了,点击按钮,是不打印update了,数据也不更新了。因为memo作用是对传入的props数据进行浅比较,如果没有变化则不更新,而我们只对最根部那层解构重组了,people层与bag层没有做引用改变,于是代码改成这下面
const clickFun = useCallback((peopleId: number, dateId: number) => {//子组件调用的,增加钢笔的数量 的回调
setPeople((peopleSource) => {
const newPeopleSource = peopleSource.map((everyPeople) => {
if (everyPeople.id === peopleId) {
everyPeople.bag = everyPeople.bag.map((everyTool) => {
if (everyTool.id === dateId) {
everyTool.penNum += 1
return { ...everyTool }
}
return everyTool
});
return { ...everyPeople }
}
return everyPeople
})
return newPeopleSource
})
}, [])
改完之后,看到代码量巨大,为了能触发bag层需要改引用,而想到达bag层的话,people层就得先改引用,于是,三层数据都进行了解构重组,点击韩梅梅星期一里的增加按钮打印如下:
People.tsx:19 韩梅梅这个人 update
Bag.tsx:18 韩梅梅的书包-0 update
最终,实现了韩梅梅星期一这一单棵树上的2次update,people一次,bag一次。
韩梅梅星期二与李雷星期一,星期二都不受影响,都没有输出update,虽然我们算是解决了,但是想一想,累不累,那么多层循环,解构,重组,如果这个数据结构有10层,是不是要解构重组10次?
终极解决方案——useImmer
安装
yarn add use-immer
使用
对于数组或者对象类型的复杂数据,直接用useImmer代替useState
- 保持 useCallback 空依赖
- 保持 People层与Bag层 memo
- useState替换为useImmer
- setPeople回调函数直接赋值,不需要return
const [people, setPeople] = useImmer<IPeople[]>([数据])
const clickFun = useCallback((peopleId: number, dateId: number) => {//子组件调用的,增加钢笔的数量 的回调
setPeople((draft) => {
draft[peopleId].bag[dateId].penNum += 1;
})
}, [])
是不是超级简单? 对于useImmer有很多原理的介绍,所以这里不再多说,用一句话概括:
useImmer是基于Proxy代理实现了JS的不可变数据结构检测,在这个过程中共享了未被修改的数据,更新后返回了一个全新的引用。
现在ie已经成为历史,所以,我们可以放心的去使用useImmer了,数组,对象,都可以使用它来代替useState,方便,快捷,用户体验更舒适。