React 中的 memo、useMemo 和 useCallback

317 阅读5分钟

在 React 中,下面几个概念容易搞混:

  • React.memo
  • React.useMemo
  • React.useCallback

在讲它们的使用场景和区别之前,有必要了解一个概念,叫做记忆化(Memoization)

简单来讲,记忆化的意思就是缓存函数的计算结果,当下次使用相同的参数调用该函数时,直接返回缓存值而不需要执行这个函数了。或者缓存某个变量,当依赖项未发生变化时,直接返回原来的变量,否则将缓存设置为新变量。

弄清楚概念之后,我们再来看在 React 中为什么需要做记忆化。

案例

我们知道,在 React 组件中,如果 props 发生了变化,组件会自上而下重新渲染,它的子组件也会走重新渲染的流程,即使子组件的属性其实并没有发生任何变化。

例如我们在 Parent 组件中定义三个属性:

  • 姓名(name):当用户在 input 框中输入姓名的时候,会实时更新 name,不传给子组件
  • 年龄(age):传递给子组件,在子组件内更新年龄,点击后每次自增1
  • 兴趣爱好(hobbies):传递给子组件,在子组件内更新兴趣爱好,点击后反转数组

如果我们只更新父组件中 name 属性的值,对子组件有何影响呢?具体可以分为以下四种场景:

父是类组件,子也是类组件

class ClassChild extends React.Component {
  render() {
    console.log('Child render')
    return (
      <div>
        <button onClick={this.props.handleAgeClick}>{this.props.age}</button>
        <button onClick={this.props.handleHobbiesClick}>{this.props.hobbies.join(',')}</button>
      </div>
    )
  }
}

class ClassParent extends React.Component {
  state = {
    name: 'keliq',
    age: 18,
    hobbies: ['music', 'football'],
  }

  handleNameChange = evt => this.setState({ name: evt.target.value })
  handleAgeClick = () => this.setState({ age: this.state.age + 1 })
  handleHobbiesClick = () => this.setState({ hobbies: this.state.hobbies.reverse() })

  render() {
    console.log('Parent render')
    return (
      <div>
        <input type="text" value={this.state.name} onChange={this.handleNameChange} />
        <strong>{this.state.name}</strong>
        <ClassChild
          age={this.state.age}
          hobbies={this.state.hobbies}
          handleAgeClick={this.handleAgeClick}
          handleHobbiesClick={this.handleHobbiesClick}
        />
      </div>
    )
  }
}

可以看到,当我们即使我们传给 Child 子组件的属性其实并没有发生任何变化,依然重新渲染了。这个时候的优化方式为:将子组件改成继承自 React.PureComponent 即可。这个时候再修改 name,子组件是不会重新渲染的。

但是这个时候会带来另外一个问题,就是当我们点击子组件的 hobbies 按钮时,发现不会数组并没有反转。相信大部分同学已经知道原因了,就是 PureComponent 对 props 的比较是浅比较,我们用 arr.reverse() 的时候,其实并没有改变数组的引用,所以导致子组件未更新,解决方案就是:

// before
this.setState({ hobbies: this.state.hobbies.reverse() })
// after
this.setState({ hobbies: [...this.state.hobbies.reverse()] })

父是类组件,子是函数组件

function FunctionChild({ age, handleAgeClick, hobbies, handleHobbiesClick }) {
  console.log('Child render')
  return (
    <div>
      <button onClick={handleAgeClick}>{age}</button>
      <button onClick={handleHobbiesClick}>{hobbies.join(',')}</button>
    </div>
  )
}

class ClassParent extends React.Component {
  state = {
    name: 'keliq',
    age: 18,
    hobbies: ['music', 'football'],
  }

  handleNameChange = evt => this.setState({ name: evt.target.value })
  handleAgeClick = () => this.setState({ age: this.state.age + 1 })
  handleHobbiesClick = () => this.setState({ hobbies: this.state.hobbies.reverse() })

  render() {
    console.log('Parent render')
    return (
      <div>
        <input type="text" value={this.state.name} onChange={this.handleNameChange} />
        <strong>{this.state.name}</strong>
        <FunctionChild
          age={this.state.age}
          hobbies={this.state.hobbies}
          handleAgeClick={this.handleAgeClick}
          handleHobbiesClick={this.handleHobbiesClick}
        />
      </div>
    )
  }
}

这个时候 React.memo 就派上用场了,我们对子组件进行包裹:

// 如果属性没有发生变化,则不重新渲染函数组件
const MemoFunctionChild = React.memo(FunctionChild)

然后在 Parent 组件的 render 函数里面,把 Child 换成 MemoChild 即可:

<MemoFunctionChild
  age={this.state.age}
  gender={this.state.gender}
  handleAgeClick={this.handleAgeClick}
  handleGenderClick={this.handleGenderClick}
/>

当再输入姓名的时候,就不会触发 Child 组件重新渲染了,也就是说用 React.memo 包裹函数组件和类组件中的 PureComponent 是非常类似的。但是也存在 arr.reverse() 不能更新的问题,因为本质上都是对 props 进行浅比较。

父是函数组件,子是类组件

class ClassChild extends React.Component {
  render() {
    console.log('Child render')
    return (
      <div>
        <button onClick={this.props.handleAgeClick}>{this.props.age}</button>
        <button onClick={this.props.handleHobbiesClick}>{this.props.hobbies.join(',')}</button>
      </div>
    )
  }
}

function FunctionParent() {
  console.log('Parent render')
  const [name, setName] = React.useState('keliq')
  const [age, setAge] = React.useState(18)
  const [hobbies, setHobbies] = React.useState(['music', 'football'])

  const handleNameChange = evt => setName(evt.target.value)
  const handleAgeClick = () => setAge(age + 1)
  const handleHobbiesClick = () => setHobbies(hobbies.reverse())

  return (
    <div>
      <input type="text" value={name} onChange={handleNameChange} />
      <strong>{name}</strong>
      <ClassChild age={age} hobbies={hobbies} handleAgeClick={handleAgeClick} handleHobbiesClick={handleHobbiesClick} />
    </div>
  )
}

这个时候我们会发现,问题开始变得严重了,出现了两个问题:

  • 即使子组件改成了 PureComponent,修改姓名的时候依然会触发子组件更新
  • 点击子组件中的反转 hobbies 按钮没效果

我们先分析一下第一个问题:

函数组件每次渲染的时候都会重新执行函数,而 handleNameChange、handleAgeClick 和 handleHobbiesClick 是在函数内部创建的新变量,肯定每次都不一样,所以导致子组件使用 PureComponent 无效。

这个时候,React.useCallback 出场了!改成下面的代码之后,发现 name 改变再也不会触发子组件重新渲染了。

// before
const handleAgeClick = () => setAge(age + 1)
const handleHobbiesClick = () => setHobbies(hobbies.reverse())
// after
const handleAgeClick = React.useCallback(() => setAge(age + 1), [age])
const handleHobbiesClick = React.useCallback(() => setHobbies(hobbies.reverse()), [hobbies])

再看第二个问题,相信很多同学心里已经有答案了,原因就是 hobbies.reverse() 之后的引用没变,连父组件都没触发渲染,更别提子组件了。所以解决方案就是:

// before
const handleHobbiesClick = React.useCallback(() => setHobbies(hobbies.reverse()), [hobbies])
// after
const handleHobbiesClick = React.useCallback(() => setHobbies([...hobbies.reverse()]), [hobbies])

父是函数组件,子也是函数组件

function FunctionChild({ age, handleAgeClick, hobbies, handleHobbiesClick }) {
  console.log('Child render')
  return (
    <div>
      <button onClick={handleAgeClick}>{age}</button>
      <button onClick={handleHobbiesClick}>{hobbies.join(',')}</button>
    </div>
  )
}

function FunctionParent() {
  console.log('Parent render')
  const [name, setName] = React.useState('keliq')
  const [age, setAge] = React.useState(18)
  const [hobbies, setHobbies] = React.useState(['music', 'football'])

  const handleNameChange = evt => setName(evt.target.value)
  const handleAgeClick = () => setAge(age + 1)
  const handleHobbiesClick = () => setHobbies(hobbies.reverse())

  return (
    <div>
      <input type="text" value={name} onChange={handleNameChange} />
      <strong>{name}</strong>
      <FunctionChild age={age} hobbies={hobbies} handleAgeClick={handleAgeClick} handleHobbiesClick={handleHobbiesClick} />
    </div>
  )
}

相信大家已经知道解法了,就是把 handleAgeClick 和 handleHobbiesClick 函数通过 React.useCallback 缓存起来,并且把子组件用 React.memo 包裹一下:

// before
const handleAgeClick = () => setAge(age + 1)
const handleHobbiesClick = () => setHobbies([...hobbies.reverse()])
<FunctionChild age={age} hobbies={hobbies} handleAgeClick={handleAgeClick} handleHobbiesClick={handleHobbiesClick} />

// after
const handleAgeClick = React.useCallback(() => setAge(age + 1), [age])
const handleHobbiesClick = React.useCallback(() => setHobbies([...hobbies.reverse()]), [hobbies])
<MemoFunctionChild age={age} hobbies={hobbies} handleAgeClick={handleAgeClick} handleHobbiesClick={handleHobbiesClick} />

不过你可能会问,为什么没有用到 React.useMemo 呢?别急,这就构造出可以使用 useMemo 的场景,我们把代码改造如下:

function FunctionChild({ data: { age, handleAgeClick, hobbies, handleHobbiesClick } }) {
  console.log('Child render')
  return (
    <div>
      <button onClick={handleAgeClick}>{age}</button>
      <button onClick={handleHobbiesClick}>{hobbies.join(',')}</button>
    </div>
  )
}

function FunctionParent() {
  console.log('Parent render')
  const [name, setName] = React.useState('keliq')
  const [age, setAge] = React.useState(18)
  const [hobbies, setHobbies] = React.useState(['music', 'football'])

  const handleNameChange = evt => setName(evt.target.value)
  const handleAgeClick = () => setAge(age + 1)
  const handleHobbiesClick = () => setHobbies([...hobbies.reverse()])
​
  const data = {
    age,
    hobbies,
    handleAgeClick,
    handleHobbiesClick,
  }
​
  return (
    <div>
      <input type="text" value={name} onChange={handleNameChange} />
      <strong>{name}</strong>
      <FunctionChild data={data} />
    </div>
  )
}

区别就在于把之前传给 Child 的属性全部合并到 data 对象里面传过去了,这个时候如果想要父组件修改 name 的时候,不触发子组件更新,就需要 React.useMemo 出场了!

// before
const data = {
  age,
  hobbies,
  handleAgeClick,
  handleHobbiesClick,
}
<FunctionChild data={data} />

// after
const data = React.useMemo(
  () => ({
    age,
    hobbies,
    handleAgeClick,
    handleHobbiesClick,
  }),
  [age, hobbies]
)
const MemoFunctionChild = React.memo(FunctionChild)
<MemoFunctionChild data={data} />

useMemo 其实就是帮助我们保存了一个全局变量(useCallback 是保存一个函数),当 age 和 hobbies 发生变化的时候,则更新这个变量,否则保持不变,从而实现给到 Child 组件的 props 不变的效果。

如果要再做一点优化的话,就是把点击事件用 useCallback 再包裹一下,因为没有必要每次进来都创建不同的 handleAgeClick 和 handleHobbiesClick 函数:

const handleAgeClick = React.useCallback(() => setAge(age + 1), [age])
const handleHobbiesClick = React.useCallback(() => setHobbies([...hobbies.reverse()]), [hobbies])

const data = React.useMemo(
  () => ({
    age,
    hobbies,
    handleAgeClick,
    handleHobbiesClick,
  }),
  [age, handleAgeClick, handleHobbiesClick, hobbies]
)

总结

  • React.memo:作用于函数组件,确保 props 不变时(浅比较)不重新渲染,效果等同于类组件的 PureComponent。
  • React.useMemo:在函数组件内部,用于缓存全局变量,当依赖项不变时,返回相同的值。
  • React.useCallback:在函数组件内部,用于缓存全局函数,当依赖项不变时,返回相同的函数。