浅谈 React 函数组件性能优化手段
前言
前段时间看到卡颂的一篇关于「React 组件到底什么时候 render」 的探讨文章,介绍了 React 组件进行更新重渲染的条件机制。
刚好最近在编写一个业务需求,为了近一步对更新动作做到更优的性能优化,对组件的重渲染触发机制进行了研究和学习,接下来通过本文来介绍这一过程。
读完本文,你将掌握函数组件的三个层面优化(含源码分析):
React.memo
,决定函数组件是否进行重渲染;React.useMemo
,在函数组件重渲染之后,决定「变量」是否会重新计算;React.useCallback
,在函数组件重渲染之后,决定「函数本身」是否会重新创建;
小彩蛋:如果想快速编写 React DEMO,你只需要一个 HTML 文件即可。
<body>
<script crossorigin src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="https://static.runoob.com/assets/react/browser.min.js"></script>
<div id="root"></div>
<script type="text/babel">
function App() {
return (
<div>App</div>
);
}
ReactDOM.render(<App />, document.querySelector("#root"));
</script>
</body>
一、思考题
我们先来看一个示例,当点击 Parent/div 触发更新后,Child 组件会进行重渲染并打印 Child render!
吗?
// 示例1
function Child() {
console.log('Child render!');
return <div>Child</div>;
}
function Parent(props) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => {setCount(count + 1)}}>
count:{count}
{props.children}
</div>
);
}
function App() {
return (
<Parent>
<Child />
</Parent>
);
}
ReactDOM.render(<App/>, document.querySelector("#root"));
再看一个示例,将 <Child />
组件的调用位置进行更换。当点击 Parent/div 触发更新后,Child 组件会进行重渲染并打印 Child render!
吗?
// 示例2
function Child() {
console.log('Child render!');
return <div>Child</div>;
}
function Parent(props) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => {setCount(count + 1)}}>
count:{count}
<Child />
</div>
);
}
function App() {
return <Parent />;
}
ReactDOM.render(<App/>, document.querySelector("#root"));
两者的区别在于 Child
组件在 Parent
组件中使用的方式不同。而多数情况我们会使用「示例2」的方式。
答案在这里公布一下:
- 示例1,Parent 的每次更新,Child 组件不会进行重渲染,不会有打印输出;
- 示例2,Parent 的每次更新,Child 组件会进行重渲染,且有打印输出。
可能你会困惑,仅仅是组件的注册位置不同,得到的结果却不相同,我们接着往下看寻找原因。
二、组件不进行重渲染的条件
在更新场景下,以「函数组件」为例,在源码层面作为一个 Fiber 节点进入 Reconciler/beginWork
阶段「查找更新」时会有两个选择:
- 组件不满足更新条件,进入
bailoutOnAlreadyFinishedWork
复用 current Fiber 信息,组件不会进行重渲染; - 组件自身存在更新(内部 setState)或所依赖的 props 值发生变化等,组件进行重渲染。
对于示例1,其实就是满足 bailout
的条件,从而跳过了更新。当一个 Fiber 同时满足以下 4 个条件时,会跳过更新。
oldProps === newProps
,第一个条件是最新的 newProps 要和上一次的 oldProps 相同,注意这里使用的是全等,props 结构为对象,比较的是引用地址;!hasContextChanged()
,context value 没有发生变化(老版本 context);workInProgress.type === current.type
,更新前后 Fiber 节点类型没有发生变化(如:div 没有变成 p);!includesSomeLane(renderLanes, updateLanes)
,当满足上面三个条件后,会判断 Fiber 本身是否存在更新(如内部 setState),且要求更新
的优先级
和本次整棵Fiber
树调度的优先级
一致。
若同时满足以上 4 个条件,组件将会跳过更新,不进行重渲染。在源码中判断逻辑如下:
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
var oldProps = current.memoizedProps;
var newProps = workInProgress.pendingProps;
// 满足前三个条件
if (oldProps !== newProps || hasContextChanged() || (workInProgress.type !== current.type)) {
didReceiveUpdate = true;
} else if (!includesSomeLane(renderLanes, updateLanes)) {
...
// 满足第四个条件后,进入 bailout 复用 fiber,如果是组件类型的 fiber,将不会执行重渲染
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
...
}
bailout
的优化还不止如此:在bailout
中,会检查该fiber
的所有子孙fiber
是否满足条件4,如果一棵fiber
子树所有节点都没有更新需要执行,则bailout
会直接返回null
,整棵子树都被跳过。
现在,我们对比一下两个示例的差异出现在了哪里。
- 示例中我们没有使用
context
,排除条件 2; - 更新前后,
Child Fiber workInProgress.type
依旧是Child()
本身,排除条件 3; - 更新动作来自 Parent 中,Child 组件内部没有更新动作,所以排除条件 4;
- 那现在可以确定,问题发生在
oldProps !== newProps
。
我们知道,在 React 中,JSX 语法经过 babel
编译后会变成 React.createElement
函数调用,你可以在 babel repl 在线平台 试一试,对于 <Child />
,编译后的结果如下:
而 React.createElement(Child, null)
经过执行后,会返回一个全新的具有 $$typeof: Symbol(react.element)
属性对象,其中 props
会被赋予一个新的对象地址,如图所示:
那么此时对于 Child 组件,尽管 props 更新前后看上去没有任何变化,但源码中使用 oldProps === newProps
比较的是对象引用地址,故无法满足这一条件。
对于示例一,Child
的定义在 App
组件中,App 组件进入了 bailout
没有进行重渲染,所以不会重新执行 React.createElement(Child, null)
去返回新 props 对象;
而对于示例二,Child
的定义在 Parent
组件中,Parent 本身存在更新,经过重渲染后会执行 React.createElement(Child, null)
,从而导致 Child
前后 props 不一致带来的意外重渲染。
而我们大多数情况下使用组件都采用「示例二」方式,有没有办法避免 props 未发生变化而带来的意外更新呢?
换句话说,如何让本该render
的组件走bailout
逻辑。React.memo
可以帮助我们解决。
三、React.memo
3.1、概念
React.memo
是一个高阶组件。它与 React.PureComponent 非常相似,作为「性能优化」的方式存在。但只适用于函数组件,而不适用 class 组件。
通常,父组件发生一次更新重渲染,即使子组件所依赖的 Props 没有发生变化,它仍旧会被 re-render 重渲染。
当使用 React.memo 包裹函数组件后,它默认会对 Props 进行浅层比较来跳过渲染直接复用最近一次渲染的结果。
如果你想要控制对比过程,可通过自定义比较函数 areEqual(第二个参数传入)来实现。
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
export default React.memo(MyComponent, areEqual);
注意:与 class 组件中 shouldComponentUpdate() 方法不同的是,如果 props 相等,areEqual 会返回 true;如果 props 不相等,则返回 false。这与 shouldComponentUpdate 方法的返回值相反。
下面我们从源码层面了解具体实现。
3.2、函数体实现
在源码位置 react/src/ReactMemo.js
下,我们可以看到 memo 函数体代码实现。
let REACT_MEMO_TYPE = symbolFor('react.memo');
export function memo<Props>(type: React$ElementType, compare?: (oldProps: Props, newProps: Props) => boolean) {
const elementType = {
$$typeof: REACT_MEMO_TYPE,
type,
compare: compare === undefined ? null : compare,
};
return elementType;
}
memo 接收一个经过 JSX 编译后的函数组件 ReactElement 对象,将其保存在 type 属性上。并且它的返回值可以作为一个组件方式去使用,假如我们的示例如下:
function Child() {
console.log('child render.');
return <div>Child</div>
}
const MemoChild = React.memo(Child);
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
<span onClick={() => setCount(count + 1)}>Hello World.</span>
<MemoChild />
</div>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App />, rootEl);
<MemoChild />
经过 JSX 编译后的 ReactElement
对象结构如下,下文简称 MemoReactElement
:
{
"$$typeof": Symbol(react.element)
"type": {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": Child(),
},
"key": null,
"ref": null,
"props": {},
"_owner": null,
"_store": {}
}
ReactElement
处理成 Fiber
节点的时机是在 Reconciler/beginWork
阶段。下面,我们分别从「初渲染」和「更新渲染」两类场景看看 React.memo
如何渲染 Child
组件。
3.3、初渲染
memo
的处理主要发生在 Reconcile/beginWork
阶段,它会拿到包裹的函数组件去调用执行。
对于初渲染,首先会为 MemoReactElement
创建 Fiber
节点,并且设置 Fiber.tag 类型为 14(MemoComponent)
,然后让 Memo Fiber
进入 Reconcile/beginWork 去命中 case = MemoComponent
处理 Child
组件。
在处理过程中,先从 Fiber.type.type
中取出所包裹的函数组件(本例是 Child
)去执行渲染;当没有传递第二参数 compare
时,会将 Fiber.tag
标记为 15(SimpleMemoComponent)
,在更新渲染时进入 beginWork,则会命中 SimpleMemoComponent
case。
// 核心代码如下:
function updateMemoComponent(current, workInProgress, nextProps, updateLanes, renderLanes) {
const Component = workInProgress.type; // React.memo() 执行后返回的对象
const type = Component.type; // Child()'
workInProgress.tag = SimpleMemoComponent; // 15
workInProgress.type = type;
// 执行 Child() 函数组件
return updateFunctionComponent(current, workInProgress, type, nextProps, renderLanes);
}
3.4、更新阶段
在点击 span 标签在 App
组件内触发一次更新后,会重新进行 render 对 <MemoChild />
执行 React.createElement()
,其中 props 返回了新的引用地址,因此在 beginWork
中,并不会命中 bailoutOnAlreadyFinishedWork
。
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
var oldProps = current.memoizedProps;
var newProps = workInProgress.pendingProps;
if (oldProps !== newProps || hasContextChanged() || (workInProgress.type !== current.type )) {
// 命中这里
didReceiveUpdate = true;
}
...
}
// 匹配到 case SimpleMemoComponent
switch (workInProgress.tag) {
case SimpleMemoComponent:
return updateSimpleMemoComponent(current, workInProgress, workInProgress.type, workInProgress.pendingProps, updateLanes, renderLanes);
...
}
}
虽然没有直接命中 bailout
去跳过更新,但是在 updateSimpleMemoComponent
通过 compare
对比 props,若 props 没有发生变化,则进入 bailout
跳过更新。
function updateSimpleMemoComponent(current, workInProgress, Component, nextProps, updateLanes, renderLanes) {
if (current !== null) {
var prevProps = current.memoizedProps;
var compare = Component.compare; // 外部传递的比较函数
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref && (workInProgress.type === current.type )) {
didReceiveUpdate = false;
if (!includesSomeLane(renderLanes, updateLanes)) {
// 若 props 之间没有变化,且组件本身没有更新,进入这里,跳过更新
workInProgress.lanes = current.lanes;
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
} else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
didReceiveUpdate = true;
}
}
}
// 若 props 发生变化,对 Child 进行重渲染
return updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes);
}
这就是 React.memo
在 props
层面对函数组件的优化原理。
默认提供的复杂对象比较函数 shallowEqual
,是一个浅比较,比较对象的第一层属性值是否相同。在源码中的实现具体如下:
function shallowEqual(objA, objB) {
if (objectIs(objA, objB)) {
return true;
}
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// 浅比较
for (var i = 0; i < keysA.length; i++) {
if (!hasOwnProperty$2.call(objB, keysA[i]) || !objectIs(objA[keysA[i]], objB[keysA[i]])) {
return false;
}
}
return true;
}
四、React.useMemo
React.memo
优化方向是避免函数组件被重新调用;
React.useMemo
则是在函数组件被调用后,在它依赖项没有变化的情况下,不去执行复杂逻辑去计算新的变量值。
我们来看看官方文档给出的概念:
useMemo,返回一个 memoized 值。把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。
代码示例:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
useMemo
对比依赖项变化的逻辑,在源码中的实现也比较容易理解(mount 和 update 实现不同):
// mount 阶段
function mountMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
// 创建并返回当前hook
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 计算 value
const nextValue = nextCreate();
// 将 value 与 deps 保存在 hook.memoizedState(更新阶段比较差异时会使用)
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
// update 阶段
function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
// 获取当前 hook
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
// 判断 update 前后 deps 是否变化
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 未变化
return prevState[0];
}
}
}
// 变化,重新计算 value
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
可见,React.memo
和 useMemo
针对函数组件的性能优化的「方向」有所不同,按需选择两者去进行性能优化。
五、useCallback
当然,除此之外,useCallback
也是一种优化手段。
与 useMemo
类似,两者都接收一个 callback,唯一区别是 useCallback
用于优化缓存这个 callback「函数」本身,useMemo
用于优化缓存「变量」,即 callback 函数的执行结果。
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
useCallback(fn, deps)
相当于 useMemo(() => fn, deps)
。在源码中实现如下:
// mount 阶段
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
// 创建并返回当前 hook
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 将 callback 与 deps 保存在 hook.memoizedState
hook.memoizedState = [callback, nextDeps];
return callback;
}
// update 阶段
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
// 返回当前 hook
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
// 判断 update 前后 deps 是否变化
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 未变化
return prevState[0];
}
}
}
// 变化,将新的 callback 作为 value
hook.memoizedState = [callback, nextDeps];
return callback;
}
最后
感谢阅读,如有不足之处,欢迎指出。
转载自:https://juejin.cn/post/7136853917832642597