浅谈React函数式组件性能优化之React.memo

200 阅读13分钟

前言

今天来聊一聊 React.memo 的使用~

场景

需求:模拟商店的商品陈列和更新。

思路:首先组件 StoreComponent 相当于一个商店,控制商品陈列和更新。其引用了组件 CategoryComponent ,负责根据商品类别展示。而组件 CategoryComponent 又引用了组件 GoodComponent,展示具体的商品。

综上,涉及三个组件:父组件 StoreComponent,子组件 CategoryComponent,孙组件 GoodComponent。其中父组件StoreComponent使用类组件以定义逻辑控制商品的更新,子组件CategoryComponent和孙组件GoodComponent使用函数组件且是纯函数。函数组件使用了useEffect模拟生命周期componentDidUpdate

实现

1版本

父组件StoreComponent,负责商品陈列:

import React, { Component, Fragment } from "react"
import CategoryComponent from "./category"

export default class StoreComponent extends Component {
    constructor(props) {
        // ==继承父组件传的props,可以通过this.props来访问==
        super(props)
        // ==定义状态==
        this.state = {
            dataList: [
                {
                    id: 1,
                    category: "水果",
                    children: [
                        { id: 1, name: "苹果", price: 10, },
                    ]
                }
            ]
        }
    }
    render() {
        const {dataList} = this.state
        console.log("渲染组件StoreComponent")
        return (
            <Fragment>
                <h3>百货商店</h3>
                {
                    dataList.map(item => <CategoryComponent key={item.id} category={item.category} children={item.children} />)
                }
            </Fragment>
        )
    }
}

子组件CategoryComponent,接收父组件传入的商品类别和该类下的商品,并进行展示:

import React, {useEffect} from "react"
import GoodComponent from "./good"

function CategoryComponent({
    category,
    children,
}) {
    useEffect(()=> {
        // ==相当于componentDidUpdate==
        console.log("渲染组件CategoryComponent")
    })
    return (
        <div>
            <h3>{category}</h3>
            {
                children && children.map(item => <GoodComponent key={item.id} name={item.name} price={item.price} />)
            }
        </div>
    )
}

export default CategoryComponent

孙组件GoodComponent,负责商品的布局和展示:

import React, {useEffect} from "react"

function GoodComponent({
    // data = {}
    name,
    price,
}) {
    useEffect(()=> {
        // ==相当于componentDidUpdate==
        console.log("渲染组件GoodComponent")
    })
    return (
        <div>
            <span>{name}</span>
            <span>&nbsp;&nbsp;{price}</span>
        </div>
    )
}

export default GoodComponent

以上便是三个组件的具体代码,现在刷新页面看控制台打印信息(这里我暂时关闭了严格模式<StrictMode>):

渲染组件StoreComponent
渲染组件GoodComponent
渲染组件CategoryComponent

渲染正常~

2版本

增加商品更新,在父组件StoreComponent里增加方法handleUpdateGood,通过点击按钮触发该方法以实现商品更新:

export default class StoreComponent extend Component {
    ....
    handleUpdateGood = ()=> {
        // ==新增:点击按钮触发商品更新==
        // ==JSON.stringify:深拷贝,但不能深拷贝循环引用对象==
        const dataList = JSON.parse(JSON.stringify(this.state.dataList))
        // console.log("handleUpdateGood", this)
        dataList[0].category = "更新水果1"
        this.setState({
            dataList, // 重新赋值dataList
        })
    }
    render() {
        return (
            <Fragment>
                <h3>百货商店</h3>
                <button onClick={this.handleUpdateGood}>更新</button>
                {/* CategoryComponent */}
            </Fragment>
        )
    }
}

点击button,触发handleUpdateGood方法,看控制台打印信息:

渲染组件StoreComponent 
渲染组件GoodComponent
渲染组件CategoryComponent

点击一次更新,打印上述三条信息,说明三个组件都重新渲染了。

再次点击,再打印三条,再次点击,打印三条...

渲染组件StoreComponent 
渲染组件GoodComponent
渲染组件CategoryComponent
渲染组件StoreComponent 
渲染组件GoodComponent
渲染组件CategoryComponent
渲染组件StoreComponent 
渲染组件GoodComponent
渲染组件CategoryComponent
...

从打印结果来说,每次点击按钮更改category值为更新水果1,便触发三个组件的重新渲染。

对此分析:因为点击更新按钮,触发方法handleUpdateGood,该方法调用了this.setState方法来更改dataList的值,由于React中更新状态是整个重新赋值并且触发组件的重新渲染。

但实际情况是:我只更改了category的值,而且还是固定值更新水果1,而孙组件GoodComponent纯函数组件(返回结果只依赖入参且执行过程中无副作用的函数称为纯函数),接收的入参props并没有发生任何改变即该组件不需要重新渲染,但是它重新渲染了,造成性能上的浪费,怎么办呢?。

React.memo

React.memo 是React性能优化的范畴~

1732007690929.png

从上面可知,React.memo可以缓存组件,避免组件不必要的再次渲染。

React.memo的作用机理是通过比较新旧props来判断是否需要重新渲染组件,如果相同则不渲染,若不相同则重新渲染。其第一个参数是函数式组件,而第二个参数是自定义比较函数,返回布尔值,该比较函数更加精准控制重复渲染逻辑。

注意:默认情况,只有入参函数式组件时,React.memo做浅层比较,这意味着它只会比较 props 的一级内容,嵌套对象需要自定义比较函数。且React.memo只关注 props 的变化,组件内部的状态和上下文的变化不会触发重新渲染。

img.png

3版本

通过上面对 React.memo 的学习,开始对组件改造。

上面我们只是更改category值为更新水果1,便会触发没有任何变化的孙组件GoodComponent的重新渲染,现在用React.memo来包装该组件:

// ==使用React.memo==
// ==不传第二个参数,默认情况浅比较==
// ==因为该组件接收的props是两个基本数据类型(name和price),所以浅比较就够了==
export default React.memo(GoodComponent)

再次刷新页面,点击更新按钮,看打印信息:

渲染组件StoreComponent
渲染组件CategoryComponent

再次点击,再打印两条,再次点击,打印两条...

渲染组件StoreComponent
渲染组件CategoryComponent
渲染组件StoreComponent
渲染组件CategoryComponent
渲染组件StoreComponent
渲染组件CategoryComponent
...

现在被React.memo包裹的孙组件GoodComponent不再渲染了,该组件的渲染达到了理想状态~

修改category值为更新水果1导致没有数据变化的孙组件CategoryComponent再次渲染而造成性能浪费的问题已解决了。

接着看第二个问题,页面初始渲染完毕后,第一次点击更新按钮修改category值为更新水果1触发父组件StoreComponentCategoryComponent的重新渲染,正常。但是第二次、第三次、...点击更新按钮,这两个组件的重新渲染就不正常了。

4版本

现在给子组件CategoryComponent也用React.memo包裹,

// ==新增:React.memo==
export default React.memo(CategoryComponent)

刷新页面,点击更新按钮,看打印信息:

渲染组件StoreComponent
渲染组件CategoryComponent

再次点击,再打印两条,再次点击,打印两条...

渲染组件StoreComponent
渲染组件CategoryComponent
渲染组件StoreComponent
渲染组件CategoryComponent
渲染组件StoreComponent
渲染组件CategoryComponent
...

哎,我不是加上了React.memo了吗?第二次点击更新按钮和第一次的更改是一样的的呀,怎么在第二次点击还重新渲染呢?

原因就是this.setState是整个将整个dataList重新赋值了(深拷贝上次的dataList)。而子组件CategoryComponent虽然被React.memo包裹了一层,但是没有自定义比较函数,所以是默认的浅比较。而子组件CategoryComponent接收的props是category(字符串)和children(数组),而children是数组类型,浅比较的结果是这次与上一次的children不相同(引用地址改变了,因为在父组件是深拷贝的)。

所以,这里就需要给子组件CategoryComponentReact.memo传入第二个参数,自定义比较函数,比较数组children是否值有更新。因为是数组,用深比较(这里作者写了一个深层比较工具deepCompare)。

更改子组件CategoryComponent代码:

import { compareObject } from "@/utils/deepCompare"
...
// ==新增:传入第二个参数:自定义比较函数==
export default React.memo(CategoryComponent, (prevProps, nextProps) => {
    // ==compareObject:深层比较==
    const compareResult = compareObject(prevProps, nextProps)
    console.log("CategoryComponent memo: ", prevProps, nextProps, compareResult) // 方便看数据的变化
    return compareResult
})

刷新页面,点击更新按钮,看打印信息:

渲染组件StoreComponent
CategoryComponent memo:  {category: '水果', children: Array(1)}category: "水果"children: Array(1)0: {id: 1, name: '苹果', price: 10}length: 1[[Prototype]]: Array(0)key: (...)get key: ƒ ()[[Prototype]]: Object {category: '更新水果1', children: Array(1)}category: "更新水果1"children: Array(1)0: {id: 1, name: '苹果', price: 10}length: 1[[Prototype]]: Array(0)key: (...)get key: ƒ ()[[Prototype]]: Object false
渲染组件CategoryComponent

第二次点击,第三次点击,...

渲染组件StoreComponent
CategoryComponent memo:  {category: '更新水果1', children: Array(1)} {category: '更新水果1', children: Array(1)} true
渲染组件StoreComponent
CategoryComponent memo:  {category: '更新水果1', children: Array(1)} {category: '更新水果1', children: Array(1)} true
...

从打印结果来看,子组件CategoryComponent除了在第一次点击更新按钮后会重新渲染,第二次、第三次、...、第n次点击更新按钮不再触发重新渲染。

控制台打印结果图示:

1732015730752.png

将子组件CategoryComponentReact.memo自定义比较函数里那条打印信息注释掉,再看控制台打印结果,效果会更加明显(灰色+2):

1732016365266.png

好啦,利用React.memo实现组件非必要的再次渲染,完成任务!

总结

说明:实际开发过程中,可能遇见零次或少次这种方式更改状态。笔者只是为了更好的理解React.memo~

纯函数

返回结果只依赖入参且执行过程中无副作用的函数称为纯函数。

React.memo

作用

  • 是缓存组件,避免不必要的重新渲染。

接收两个参数

  • 第一个参数是函数式组,即React.memo是对函数组件的优化。
  • 第二个参数是可选参数,自定义比较函数,返回一个布尔值,来决定是否需要重新渲染组件(子组件CategoryComponent中使用第二个参数做深层比较);若不传第二个参数,React.memo默认使用浅比较(基本数据类型的比较准确,引用数据类型就不准确了)。

特点

  • 会将组件上一次接收到的props给缓存下来,从而与更新后的props做比较。
  • 默认是浅比较,若对引用类型比较需要传入第二个参数自定义比较函数(deepCompare)。
  • 只能根据父组件传入的props来判断是否重新渲染组件,如果是自身的状态发生变化并不会触发重新渲染(React.memo 只关注 props 的变化,组件内部的状态和上下文的变化不会触发重新渲染)。

应用场景(菜鸟教程)

  • 静态数据展示:组件接收的 props 很少变化,但组件本身较为复杂,重新渲染成本高。
  • 性能优化:在大列表或表格中,每个项目都是独立的组件,使用 React.memo 可以避免不必要的重新渲染。
  • 避免深度相等检查:自定义比较函数可以避免深度相等检查,特别是在 props 包含大量数据时。

deepCompare深比较

深层比较两个引用数据类型,该模块提供了三个方法:

  • compareObject:深层比较两个对象
  • compareArray:深层比较两个数组
  • compareTwoData:深层比较两个数据

比较过程以不同为优先判断条件,只要不相同即可结束执行,不再比较后面的数据~

/**
 * 作者:露水晰123(掘金)
 * 日期:2024年11月19日
 * 功能:深层比较两个数据
 */

/**
 * 获取引用类型的具体类型=
 * @param {Object} obj 
 * @returns "[object 具体类型]"
 */
function getObjectType(obj) {
    return Object.prototype.toString.call(obj)
}

/**
 * 比较两个对象是否相同
 * @param {Object} curNewItem 
 * @param {Object} curOldItem 
 * @returns 
 */
function compareObject (curNewItem, curOldItem) {
    // ==获取自身定义的key集合==
    const newKeys = Object.keys(curNewItem)
    const oldKeys = Object.keys(curOldItem)
    if (newKeys.length !== oldKeys.length) {
        // ==长度不同==
        // console.log("compareObject:", 1)
        return false
    }
    // ==长度相同==
    if (newKeys.length === 0) {
        // ==长度相同且为0==
        // console.log("compareObject:", 2)
        return true
    }
    let i = 0
    while (i < newKeys.length) {
        // ==当前key==
        const curKey = newKeys[i]
        // ==新增:搞乱key的顺序依然可以根据key比较==
        const curOldItemHasKey = oldKeys.includes(curKey)
        if (!curOldItemHasKey) {
            // ==修改:若new中的key在old中不存在即有不同==
            // ==修改:在curOldItem找不到curNewItem对应的key==
            // console.log("compareObject:", 3)
            return false
        }
        // ==有相同的key==
        if (!compareTwoData(curNewItem[curKey], curOldItem[curKey])) {
            // console.log("compareObject:", 4)
            return false
        }
        i++
    }
    // console.log("compareObject:", 5)
    return true
}

/**
 * 比较两个数组是否相同且数组项是对象
 * @param {*} newArr 
 * @param {*} oldArr 
 * @returns 
 */
function compareArray(newArr, oldArr) {
    if (newArr.length !== oldArr.length) {
        // ==长度不同=
        // console.log("compareArray:",1)
        return false
    }
    // ==长度相同==
    if (newArr.length === 0) {
        // console.log("compareArray:", 2)
        return true
    }
    let i = 0
    while (i < newArr.length) {
        if (!compareTwoData(newArr[i], oldArr[i])) {
            // console.log("compareArray:", 3)
            return false
        }
        i++
    }
    // console.log("compareArray:", 4)
    return true
}

/**
 * 比较两个数据是否相同
 * @param {*} curNewItem 
 * @param {*} curOldItem 
 * @returns 
 */
function compareTwoData(curNewItem, curOldItem) {
    let curNewItemType = typeof curNewItem
    let curOldItemType = typeof curOldItem
    if (curNewItemType !== curOldItemType) {
        // ==类型不同==
        // console.log("compareTwoData:", 1)
        return false
    }
    // 类型相同
    if (curNewItemType === "object") {
        // ==类型相同且都是引用数据类型==
        curNewItemType = getObjectType(curNewItem)
        curOldItemType = getObjectType(curOldItem)
        if (curNewItemType !== curOldItemType) {
            // ==具体的引用类型不同==
            // console.log("compareTwoData:", 2)
            return false
        }
        // ==具体引用类型也相同==
        if (curNewItemType === "[object Null]") {
            // ==皆为null==
            // console.log("compareTwoData:", 3)
            return true
        }
        if (curNewItemType === "[object Object]") {
            // ==同为原生的对象类型==
            // console.log("compareTwoData:", 4)
            return compareObject(curNewItem, curOldItem)
        }
        if (curNewItemType === "[object Array]") {
            // ==同为数组==
            // console.log("compareTwoData:", 5)
            return compareArray(curNewItem, curOldItem)
        }
    }
    if (curNewItem !== curOldItem) {
        // ==基本数据类型和非原生对象和数组类型==
        // console.log("compareTwoData:", 6)
        return false
    }
    // console.log("compareTwoData:", 7)
    return true
}

// ==测试==
// console.log(compareArray([{
//     id: 12,
//     child: [],
//     name: "露水晰123",
//     price: null,
// }], [{
//     price: null,
//     child: [],
//     id: 12,
//     name: "露水晰123",
// }]))
// ==打印结果:true==

export {
    compareObject,
    compareArray,
    compareTwoData,
}

彩蛋

useEffect 模拟生命周期

因为函数组件本身没有生命周期方法,所以使用钩子函数useEffect来模拟生命周期。useEffect常见的三个作用:

  • 模拟componentDidMount:第二个参数是空数组,只在在组件渲染完毕执行一次
useEffect(()=> {
    // ==只有组件在第一次渲染结束会触发, 相当于componentDidMount==
    console.log("渲染了组件")
},[])
  • 模拟componentDidUpdate:不传入第二个参数或者添加了依赖数据
useEffect(()=> {
    // ==每次组件的重新渲染结束都会触发, 相当于componentDidUpdate==
    console.log("渲染了组件")
})
useEffect(()=> {
    // ==每次count和name状态发生变化后触发==
    console.log("依赖状态发生了变化")
},[count, name])
  • 模拟componentWillUnmount:在useEffect函数内部返回一个函数,该返回的函数会在组件销毁之前调用
useEffect(()=> {
    // ==每次组件的重新渲染结束都会触发, 相当于componentDidUpdate==
    console.log("渲染了组件")
        
    return ()=> {
        // ==在组件销毁之前触发这里, 相当于componentWillUnmount==
    }
},[])

类组件定义方法时注意this指向

如果想要在类组件中定义的方法里访问到state,需要注意该方法里的this指向。常见的有两种方法:

  • 方法一:使用箭头函数定义方法,因为箭头函数没有自身的作用域,其this指向父级作用域,这样就可以在方法里通过this.state获取到state值
handleUpdateGood = ()=> {
    // ==箭头函数==
    conosle.log(111, this.state)
}
  • 方法二:使用普通函数,但是在调用该函数的时候需要更改this指向
{/* bind相比call和apply不会立即执行,而是返回一个函数* /}
<button onClick={this.handleUpdateGood.bind(this)}>更新</button>

初始渲染时组件被渲染了两次

这是React18新增的一个特性,在开发模式下且使用了严格模式下会触发组件的二次渲染,以便更容易发现潜在的副作用和其他问题。而生产环境模式下和原来一样,仅执行一次。开发期间,可适当将严格模式给关掉~

允许在js/ts文件中写JSX

该项目使用的是vite搭建的,默认只能在.jsx.tsx里写JSX。如果直接在.js.ts中写JSX,浏览器控制台会报如下错,表示在.js.ts文件中出现了非js语法(指的是JSX):

[plugin:vite:import-analysis] Failed to parse source for import analysis because the content contains invalid JS syntax. If you are using JSX, make sure to name the file with the .jsx or .tsx extension.

针对上述问题,可通过以下两步解决:

  • 第一步:安装插件@babel/plugin-transform-react-jsx,其作用是将.js或者.ts文件中的JSX转换为 React.createElement()调用:
pnpm i @babel/plugin-transform-react-jsx -D
  • 第二步:更改配置,在vite.config.js配置文件中修改配置如下:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    // ==旧==
    // react()
    // ==新==
    react({
      babel: {
        plugins: [
          '@babel/plugin-transform-react-jsx', // 转换jsx
        ]
      }
    })
  ],
  ...
})

以上两步就可以在.js.ts文件中写JSX了,如果浏览器控制台报如下错:

Uncaught ReferenceError: React is not defined

因为在.js.ts组件中没有导入React,在组件头部增加import React from 'react'就可以了~

配置了babel,可能还是会报错:

The esbuild loader for this file is currently set to "js" but it must be set to "jsx" to be able to parse JSX syntax. You can use "loader: { '.js': 'jsx' }" to do that.

针对此问题,可以在配置文件中加上:

export default defineConfig({
    // ...
    esbuild: {
        loader: "jsx"
    },
})

参考