跳至主要内容

效能优化函数

警告

本章节介绍的功能旨在改善应用程序中的效能。在不必要的情况下使用这些函数不仅会降低代码的可读性,也会增加维护的难度。

一般来说,若您的应用程序中没有效能问题,那就不要费心使用这些功能!(除了 useMemo() 之外,因为它有时候可以作为别种用途)。

memo()

memo() 是一个内建的 HOC,用于建立一个基于属性的可记忆版本的组件。简化版的 memo() 介面如下:

const memo = (
component: FunctionOrClass,
arePropsEqual?: CompareFunction
): Component => {
// ...
}

type CompareFunction<T> = (currentProps: T, nextProps: T) => boolean

// 使用 `memo()`
const Component = () => {
return (
// ...
)
}

const MemoizedComponent = memo(Component, () => {
// ...
})

memo() 的运作方式如下:

  • 组件完成首次渲染后,React 会记住这次的渲染结果。
  • 当组件因为父组件的重新渲染而随之重新渲染时,React 会使用 Object.is() 来检查 props 中每个属性的数值是否和前一次渲染相同。
    • 若没有任何属性发生变化,React 就会直接返回上次记住的渲染结果,不会运行组件中的任何代码。
    • 否则组件就会照常重新渲染,并将先前的记忆值替换为新的渲染结果。
  • 若您想要组件仅在特定的属性发生变化时才重新渲染,您可以传递一个函数给第二个参数 arePropsEqual() 来自订属性相等的检查逻辑。

因此,只有在被记忆的组件作为子组件时,memo() 的效果才得以显现

何时该使用 memo()?

通常 memo() 会用在较消耗资源的组件,并且某些属性会导致不必要的重新渲染的情况。这通常发生在父组件重新渲染较频繁,例如涉及拖拉功能 (drag and drop),或是子组件较庞大的情况,例如编辑器。

以下范例是如何使用 memo() 来解决我们在组件渲染中提到的一个问题

import { memo, useState, useEffect } from 'react'

const Child = memo(() => {
useEffect(() => {
console.log('[Child] re-renders')
})

return <h1>I am child</h1>
})

export const Parent = () => {
const [count, setCount] = useState(0)

const increment = () => {
setCount(count + 1)
}

return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<Child />
</div>
)
}

这种写法中的 Child 永远不会随着 Parent 一起重新渲染,因为这里的 arePropsEqual() 永远返回 true

信息

假设某个组件被 memo() 记忆起来,这是否代表只要 arePropsEqual() 返回的是真值 (truthy),该组件就不会重新渲染?

不,并不是这样!我们知道组件会在响应式数值改变时重新渲染,但是属性并不是组件中唯一一个响应式数值。memo() 仅有在该次重新渲染是由父组件触发时才会作用,如果该次重新渲染是由非属性的响应式数值 (例如状态) 所导致的,那么组件依然会重新渲染。

您可以这样想:memo() 记忆的并不是组件输出的 HTML,也不是组件在某一个时刻的快照 (snapshot);相反地,他运作的方式比较像是指向某个特定组件实体的指标。当 arePropsEqual() 返回的为假值 (falsy) 时,新的组件实体会被产生,然后该指标就会从旧的实体转向新的实体。因此组件内部的变化依然会照常发生,不受 memo() 影响。

useMemo()

备註

若您曾经学过 Vue,可以把 useMemo() 看成是不知道何时该更新自己的 computed()

useMemo() 是一个内建的钩子,用于记忆任何您想记忆的东西。与 useEffect() 相似,useMemo() 接收一个回呼函数 (calback function) 和一个依赖值阵列 作为参数。简化版的 useMemo() 介面如下:

type useMemo<T> = (
callback: () => T,
dependencies: any[],
) => void

// 使用 `useMemo()`
const something = useMemo(() => {
return ...
}, [])

useMemo() 的运作方式如下:

  • React 在组件首次渲染时调用 callback,并记住他的返回值。
  • 当组件重新渲染时,React 会使用 Object.is() 来检查 dependencies 中每个元素的值是否和前一次渲染相同。
    • 若没有任何元素发生变化,React 就会直接返回上次记住的数值。
    • 否则 callback 就会被调用,并用他的返回值取代先前的记忆值。

何时该使用 useMemo()?

通常 useMemo() 适用于以下情况:

  1. 在组件重新渲染时跳过较消耗资源的运算。
  2. 使变数在不同渲染间仍然能指向相同的记忆体位置。
  3. useEffect()useState() 一起使用。

在组件重新渲染时跳过较消耗资源的运算

有时候我们需要在组件内运行较消耗资源的运算。若这些运算在每次渲染都被运行,我们可能就会在组件在重新渲染时感受到明显的延迟。然而,在 useMemo() 的帮助下,我们可以确保这些运算只会在某些数值发生变化时运行。例如:

import { useState, useMemo } from 'react'

export const Example = () => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])

// 这会在每次渲染时运行。
const matchedUsers = users.filter(
(user) => user.name.includes('A')
)

// 这只会在 `users` 改变时运行。
const matchedUsers = useMemo(
() => users.filter((user) => user.name.includes('A')),
[users]
)

return (
// ...
)
}

使变数在不同渲染间仍然能指向相同的记忆体位置

有时候我们需要将某个非原始型别的数值 (例如函数) 当做子组件的属性。由于未被记忆的值会随着组件的重新渲染被重新声明,他们每次都会指向不同的物件,导致子组件上的 memo() 失效。

要解决这个问题,我们可以使用 useMemo() 来将数值记忆起来,这样我们就能在不同的渲染中取得相同的值。例如:

import { useMemo } from 'react'

export const Example = () => {
// 小心!
// 这会导致 `user` 在每次渲染中都指向不同的物件。
const user = {
age: 5,
}

// 这会使得 `user` 总是指向相同的物件。
const user = useMemo(() => ({
age: 5,
}), [])

return (
// ...
)
}

useEffect()useState() 一起使用

有时候我们需要在某些属性或状态改变时更新另外一个状态。在某些情况下,使用 useMemo() 会比使用 useEffect() + setState() 还要理想。

长话短说,这种写法:

import { useState, useMemo } from 'react'

interface IExampleProps {
keyword: string
}

export const Example = ({ keyword }: IExampleProps) => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])

const matchedUsers = useMemo(
() => users.filter((user) => user.name.includes(keyword)),
[keyword]
)

return (
// ...
)
}

会比下面这种写法还要简洁:

import { useState, useEffect } from 'react'

interface IExampleProps {
keyword: string
}

export const Example = ({ keyword }: IExampleProps) => {
const [users, setUsers] = useState([
{ id: 1, name: 'User A' },
{ id: 2, name: 'User B' },
{ id: 3, name: 'User C' },
])

const [matchedUsers, setMatchedUsers] = useState([])

useEffect(() => {
setMatchedUsers(
users.filter((user) => user.name.includes(keyword))
)
}, [keyword])

return (
// ...
)
}
信息

我们可以使用 useMemo() 来记忆某个组件吗?

我们可以这么做!和 memo() 相似,若组件中任何非属性的响应式数值发生变化,被记忆的组件就会重新渲染。主要的差别是 memo() 会在 arePropsEqual() 的返回值为假值时建立新的组件实体,而 useMemo() 则会在 dependencies 发生变化时建立新的组件实体。

很重要的一点是,传入 useMemo()callback 不该有副作用,例如修改变量或是调用 API。该函数应该要是纯净的,意即相同的输入总是会得到相同的输出,而且不会影响到其他的变量。

useCallback()

useCallback() 是一个内建的钩子,用于记忆一个函数。与 useEffect() 相似,useMemo() 接收一个回呼函数和一个依赖值阵列作为参数。简化版的 useCallback() 介面如下:

type useCallback<T extends Function> = (
callback: T,
dependencies: any[],
) => void

// 使用 `useCallback()`
const myFunction = useCallback(() => {
// ...
}, [])

useCallback() 的运作方式如下:

  • 在组件首次渲染时,React 会记住 callback
  • 当组件重新渲染时,React 会使用 Object.is() 来检查 dependencies 中每个元素的值是否和前一次渲染相同。
    • 若没有任何元素发生变化,React 就会直接返回上次记住的数值。
    • 否则旧的记忆值就会被新的 callback 取代。

举例来说:

import { useState, useCallback } from 'react'

export const Example = () => {
const [count, setCount] = useState(0)

const increment = () => {
setCount(count + 1)
}

const showCount = () => {
console.log(count)
}

const memoizedShowCount = useCallback(showCount, [])

return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<button onClick={showCount}>
Show Count
</button>
<button onClick={memoizedShowCount}>
Show Count (Memoized)
</button>
</div>
)
}

在这个范例中,一开始点击 "Show Count" 和 "Show Count (Memoized)" 都会在主控台中显示 0。在点击 "Increment" 三次后,点击 "Show Count" 显示了 3,而点击 "Show Count (Memoized)" 却依然显示 0

发生这种情况是因为在首次渲染中,count 的数值为 0,代表组件中所有的 count 都会被取代成 0。我们并没有放置任何数值到 useCallback() 的依赖值阵列中,因此 memoizedShowCount() 中的 count 永远不会被更新,从而在调用的时候显示了 0

何时该使用 useCallback()?

通常 useCallback() 使用在函数被作为子组件的属性,或是函数是某个副作用的依赖值的情况。举例来说:

import { memo, useState } from 'react'

const MemoizedChild = memo(() => {
// ...
})

export const Example = () => {
const [count, setCount] = useState(0)

const increment = () => {
setCount(count + 1)
}

return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<MemoizedChild increment={increment} />
</div>
)
}

在这个范例中,尽管 MemoizedChild 已经用 memo() 记忆起来了,他还是会随着 Example 一同重新渲染。

这是因为每次 Example 重新渲染时,increment() 都会被重新声明;由于 increment() 属于非原始型别,他每次都会指向不同的物件,导致 memo() 认为 increment() 在两次渲染之间发生变化了。

要解决这个问题,我们可以将 increment() 包裹在 useCallback() 中,这样即使 Example 重新渲染,他也能指向相同的物件:

import { memo, useState, useCallback } from 'react'

const MemoizedChild = memo(() => {
// ...
})

export const Example = () => {
const [count, setCount] = useState(0)

const increment = useCallback(() => {
setCount(prev => prev + 1)
}, [])

return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>
Increment
</button>
<MemoizedChild increment={increment} />
</div>
)
}

请注意,我们在 setCount() 中使用了更新函数,这样我们就不需要将 count 放在 useCallback() 的依赖值阵列中。如此一来我们就能保证被传递给 MemoizedChildincrement() 在每次渲染中都会指向相同的物件,从而使 memo() 能如预期的运作。

信息

您可能已经注意到 useCallback()useMemo() 非常相似,确实是如此!您也可以使用 useMemo() 来记忆一个函数,然而这可能会稍微降低代码的可读性。例如:

import { useMemo } from 'react'

// 这种写法有点难阅读。
const increment = useMemo(() => () => {
setCount(prev => prev + 1)
}, [])

// 这种写法比较好读,但是他的作用和 `useCallback()` 一模一样。
const increment = useMemo(() => {
return () => {
setCount(prev => prev + 1)
}
}, [])

虽然您可以藉由显性返回来让代码变得好看一些 (看起来其实挺不错的!),但是那样做的结果就和 useCallback() 一模一样。总而言之,只要将 useCallback() 视为返回 callback 本身,而不是返回 callback 调用的结果的 useMemo() 即可。

import { useMemo } from 'react'

const useCallback = (callback: () => any, dependencies: any[]) => {
return useMemo(
() => callback,
dependencies
)
}
警告

我们不得不再说一次:尽量不要在不需要这些功能的地方使用他们