当前位置:首页 / 编程技术 / 后端技术 / 浅谈 React 函数组件性能优化手段
浅谈 React 函数组件性能优化手段
发布时间:2022/07/09来源:51CTO

刚好最近在编写一个业务需求,为了近一步对更新动作做到更优的性能优化,对组件的重渲染触发机制进行了研究和学习,接下来通过本文来介绍这一过程。

读完本文,你将掌握函数组件的三个层面优化:

  • 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="http://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 没有发生变化;
  • workInProgress.type === current.type,更新前后 Fiber 节点类型没有发生变化(如:div 没有变成 p);
  • !includesSomeLane(renderLanes, updateLanes),当满足上面三个条件后,会判断 Fiber 本身是否存在更新(如内部 setState)。

若同时满足以上 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)) {
...
// 满足第四个条件
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
...
}

现在,我们对比一下两个示例的差异出现在了哪里。

  • 示例中我们没有使用context,排除条件 2;
  • 更新前后,Child Fiber workInProgress.type​ 依旧是Child() 本身,排除条件 3;
  • 更新动作来自 Parent 中,Child 组件内部没有更新动作,所以排除条件 4;
  • 那现在可以确定,问题发生在oldProps !== newProps。

我们知道,在 React 中,JSX 语法经过 babel​ 编译后会变成 React.createElement​ 函数调用,你可以在 babel repl 在线平台​ 试一试,对于 <Child />,编译后的结果如下:

截屏2022-08-28 下午12.01.57.png

而 React.createElement(Child, null)​ 经过执行后,会返回一个全新的具有 $$typeof: Symbol(react.element)​ 属性对象,其中 props 会被赋予一个新的对象地址,如图所示:

截屏2022-08-28 下午12.07.10.png

那么此时对于 Child 组件,尽管 props 更新前后看上去没有任何变化,但源码中使用 oldProps === newProps 比较的是对象引用地址,故无法满足这一条件。

对于示例一,Child​ 的定义在 App​ 组件中,App 组件进入了 bailout​ 没有进行重渲染,所以不会重新执行 React.createElement(Child, null) 去返回新 props 对象;

而对于示例二,Child​ 的定义在 Parent​ 组件中,Parent 本身存在更新,经过重渲染后会执行 React.createElement(Child, null)​,从而导致 Child 前后 props 不一致带来的意外重渲染。

而我们大多数情况下使用组件都采用「示例二」方式,有没有办法避免 props 未发生变化而带来的意外更新呢?React.memo 可以帮助我们解决。

三、React.memo

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 方法的返回值相反。

下面我们从源码层面了解具体实现。

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. 初渲染

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);
}

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;
}
分享到:
免责声明:本文仅代表文章作者的个人观点,与本站无关,请读者仅作参考,并自行核实相关内容。文章内容来源于网络,版权归原作者所有,如有侵权请与我们联系,我们将及时删除。
资讯推荐
热门最新
精品工具
全部评论(0)
剩余输入数量90/90
暂无任何评论,欢迎留下你的想法
你可能感兴趣的资讯
换一批