本来是准备优先分享两个官方定义的 Hook useMemo,useCallback,不过这两个 hook 本身其实没有太多探讨的空间,他们只是两个记忆函数,本身并没有特殊的、更进一步的含义。
许多人的困惑往往来源于对于它们两个过度解读,认为他们的存在对 React 性能的优化有非常重要的意义。过渡解读导致了对他们的滥用。在我看过的项目中,有个别优秀前端团队里的项目规范里,也错误抬高了他们的作用,把他们用在了每一个组件里。
出现这样问题的根源就在于对 React 的自身机制理解不够精准。因此我决定换一个角度去带大家理解 React 本身的优化机制,从而能够正确的使用 useMemo 与 useCallback。
本文将会从应用层面来为大家分析我们应该怎么做。后续的章节将会从 Fiber 的双缓存策略开始分享底层的优化机制。
一、精简节点
首先我们要明确一些前置知识。
React 在内存中维护了一颗 虚拟 DOM 树,这颗树的每一个节点是一个 Fiber,每一个 Fiber 都由 JSX 中的组件解析而来。
type Fiber = {
// 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
tag: WorkTag,
// ReactElement里面的key
key: null | string,
// ReactElement.type,调用`createElement`的第一个参数
elementType: any,
// The resolved function/class/ associated with this fiber.
// 表示当前代表的节点类型
type: any,
// 表示当前FiberNode对应的element组件实例
stateNode: any,
// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
return: Fiber | null,
// 指向自己的第一个子节点
child: Fiber | null,
// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,
// 当前处理过程中的组件props对象
pendingProps: any,
// 上一次渲染完成之后的props
memoizedProps: any,
// 该Fiber对应的组件产生的Update会存放在这个队列里面
updateQueue: UpdateQueue | null,
// 上一次渲染的时候的state
memoizedState: any,
// 一个列表,存放这个Fiber依赖的context
firstContextDependency: ContextDependency | null,
mode: TypeOfMode,
// Effect
// 用来记录Side Effect
effectTag: SideEffectTag,
// 单链表用来快速查找下一个side effect
nextEffect: Fiber | null,
// 子树中第一个side effect
firstEffect: Fiber | null,
// 子树中最后一个side effect
lastEffect: Fiber | null,
// 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
expirationTime: ExpirationTime,
// 快速确定子树中是否有不在等待的变化
childExpirationTime: ExpirationTime,
// fiber的版本池,即记录fiber更新过程,便于恢复
alternate: Fiber | null,
}
state 的每次变化,都会引发整棵树的前后对比,从而导致许多组件重新执行。这也是 React 性能消耗的主要成本。但是 React 内部采用缓存机制和优秀的 Diff 算法极大的减少了这里的成本,后续我们会详细介绍这两个机制。
这里我要重点介绍的是,在使用中,我们可以通过减小这颗 Fiber tree 的方式来达到性能优化的目的。只要 Fiber tree 足够小,diff 的成本就会非常的低。
例如,我们有一个非常大的巨石项目,当我们路由切换的时候,会直接删掉前一个页面的所有内容,只渲染新页面的内容,那么,虽然随着访问页面的数量越来越多,缓存在全局状态管理器中的数据越来越复杂,但是 Fiber tree 的大小其实并没有变得越来越大,依然维持在一个页面的量级,此时的 diff 压力跟一个小型项目没有什么区别。通过这种手段,我们可以轻松保持一个巨石项目的高性能。
落实到具体的页面上,特别是在一些管理系统里,许多开发者喜欢在在列表页中,维护一个内容超级复杂的弹窗组件,弹窗的内容是列表的详情。此时,弹窗内容和列表内容同时存在,从而导致了 Fiber tree 的庞大。
从交互上,我们可以将复杂的弹窗内容移植到一个新的详情页,就能极大的缓解 diff 压力。
在某些项目中,一个详情页有几百条表单需要填写。我们可以通过分步骤的方式,把这几百个表单项切分到不同的步骤里,从而让同时渲染出来的表单项大量减少,性能也会有很大的提高。
总的来说,只要我们把 Fiber 节点数量控制在一定范围内,React 都能保持一个非常高的性能。因此大多数情况下,我们并不需要做额外的性能优化。
二、比较方式
由于大量的 re-render 存在,我们很容易能想到一个优化策略,在 diff 过程中,当我比较之后发现有的节点并没有发生任何变化,那么我们就可以跳过该组件的 re-render,从而提高性能。
而要让这个优化想法落地,我们就必须了解内部的比较规则,首先要考虑的第一个问题就是。
如何知道一个组件是否发生了变化
一个 React 组件是否发生了变化由三个因素决定。
- props
- state
- context
这三个因素中的任何一个发生了变化,组件都会认为自己应该发生变化。state 和 context 都是不可变数据,而且由于是我们主动调用 dispatch 去触发他们发生改变,因此 state 和 context 的变化一般不会对我们造成理解上的困扰。
最麻烦的是 props。
React 组件的每次执行,都会传入新的 props 对象,虽然内容可能一样,但是在内存中却已经发生了变化。
function Child(props) {}
// 执行一次传入新的对象
Child({})
// 执行一次传入新的对象
Child({})
与 state 不一样的是,props 并没有缓存在别的地方,因此,一个组件 的 props 哪怕什么都没有变化,比较的结果也是 false。
var preProps = {}
var curProps = {}
preProps === curProps // false
var preProps = { name: 'Jake' }
var curProps = { name: 'Jake' }
preProps === curProps // false
呵!没想到吧。
也就是说,当一个子组件接收一个函数作为 props,为了保证函数的引用不发生变化,有的人选择使用 useCallback 来缓存函数引用,从而期望子组件不会因为 props 发生了变化而导致子组件重新渲染。
function Demo() {
...
const change = useCallback(() => {}, [])
return (
...
)
}
结合我们刚才说的,这里只使用 useCallback 是做了无用功。
preProps = { change: change }
curProps = { change: change }
preProps === curProps // false
那么问题就来了,如果这样子的话,岂不是每个组件的 props 都会发生变化了?
当然不是,React 内部针对 props 有另外一个策略:
如果父组件被判定为没有变化,那么,在判断子组件是否发生变化时,不会比较子组件的 props。
源码里少一个判断,却衍生出这样一个精妙的设计。
高级!
除此之外,Fiber Tree 的根节点,被判定为始终不会发生变化。
这样,根节点的子组件在比较时,react 就一定会跳过 props 的比较,以此类推。我们就有机会构造一个高性能的更新过程。
回到我们经典的数字递增案例,来分析这个案例。
function Child() {
console.log('我不想重新渲染')
return (
我不想重新渲染
)
}
export default function Demo02() {
const [count, setCount] = useState(0)
return (
setCount(count + 1)}>{count}
)
}
当我们点击数字的时候,数字递增,父组件 Demo02 被判定为改变,因此,内部的所有子组件都需要比较 props,props 为不可变数据,子组件 Child 的 props 进行了如下比较,结果为 false 。
{} === {} // false
因此,Child 虽然不想 re-render,但是每次 count 变化都 render 了。
调整的方式非常简单,只需要让父组件的 state 没有发生变化即可,把变化的部分单独封装在另外一个子组件里。
function Change() {
const [count, setCount] = useState(0)
return (
setCount(count + 1)}>{count}
)
}
export default function Demo02() {
return (
)
}
这个时候,父组件被判定为没有发生变化,因此子组件就会跳过 props 的比较,从而 Child 判定为没有发生变化。这样我们的目的就达到了。
但是,这里有一个前期条件,那就是我们需要确保 Demo02 的父组件也被判定为没有发生变化,因此,如果你是 React 架构师,顶层结构的设计是你需要关注的重中之重,因为如果顶层出了问题,导致父组件不满足这样的稳定结构,那么后续的子组件都会 re-render。
那么理解这个规则很难吗?其实不难,难就难在,在看这篇文章之前,可能你压根就不知道这个设计啊。
如果我们有一个不靠谱的 React 架构师,顶层组件的稳定结构出了问题,那么我们有什么手段,能够低成本的让你能接触到的页面结构保持稳定呢?
答案就是 React.memo。
memo 函数会让组件的 props 比较方式发生变化,我们之前都是一直用的 === 全等比较,使用 memo 包裹组件之后,React 内部会改变比较策略,他会遍历 props 的每个属性,如果每个属性都能通过全等比较,那么就判定为 props 没有发生变化。
这个遍历过程只会发生在 props 对象的第一层属性,不会更进一步深入。
因此,当我们无法确定上层组件是否发生变化时,我们可以在某一个节点使用 memo 来确保从这一层开始建立稳定的高性能模式。
function _Child() {
console.log('我不想重新渲染')
return (
我不想重新渲染
)
}
var Child = memo(_Child)
export default function Demo02() {
const [count, setCount] = useState(0)
return (
setCount(count + 1)}>{count}
)
}
当我们使用 memo 包裹子组件导致 props 的比较方式发生变化时,useCallback 缓存引用就有用了。这也是 useCallback 的主要作用,他一定要结合 memo 去使用。
当然,我们也可以用一些骚操作来达到同样的目标,利用 useMemo 来缓存组件。
export default function Demo02() {
const [count, setCount] = useState(0)
const _child = useMemo(() => {
return
}, [])
return (
setCount(count + 1)}>{count}
{_child}
)
}
当你决定要自己设计比较规则时就可以采用这样的方式。
三、总结
这篇文章分享了两个 React 项目性能优化的最重要的手段。我们只要了解了真实的底层机制,就能写出高性能的代码,他们的理解难度并不高。我们只需要在项目中正确的去编写符合他们机制的代码即可。
如果你是 React 项目架构师,那么你一定要吃透这个机制,在顶层架构中,我们会额外添加 Router/Redux 等诸多顶层组件,他们会不会导致高性能结构的崩塌,你一定要非常明确。
除此之外,当顶层的父组件不变判定被破坏,我们也不需要每一个组件都用 memo 包裹起来,只需要在合适的节点包裹一个组件即可。因为 memo 的比较本身也会增加程序的执行成本,大量的 memo 反而会导致性能变得更低。
除此之外,我们要明确,组件的 re-render 是内存行为,他是执行了一次 JS 函数,他并不会导致浏览器真的发生渲染行为,因此 re-render 的执行也是非常快速的,大多数情况下的 re-render 都可以接受,不过超大量的 re-render 会导致执行压力变大,所以用大量 memo 减少 re-render 并不一定是一件划算的事情。
利用少量的 memo 与 React 本身的缓存机制减少大量的 re-render 才是合理的方案。