前端自醒,react性能优化

403 阅读6分钟

因为市场上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 >
}

上面渲染出来的就是下图所示:

默认结构.png

从上面代码看,我点击韩梅梅星期一那天增加钢笔数量按钮,下列地区都会被重新执行「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,方便,快捷,用户体验更舒适。