bailout
React 是一个注重运行时性能的库,所以在运行时做了大量的性能优化,其中一个性能优化就是在 Render 阶段去判断当前树以及子树是否存在 baiout,从而跳过 render,实现优化,下面一起来看看具体怎么做的吧。
beginWork 阶段
if (
oldProps !== newProps ||
hasContextChanged() || // Force a re-render if the implementation changed due to hot reload:
workInProgress.type !== current.type
) {
// If props or context changed, mark the fiber as having performed work.
// This may be unset if the props are determined to be equal later (memo).
didReceiveUpdate = true;
// 如果oldProps 和 newProps 是相同的
// 上下文相同
// fiberNode.type没有变化,比如没有从DIV变为 UL
} else {
var hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes
);
if (
!hasScheduledUpdateOrContext && // If this is the second pass of an error or suspense boundary, there
// may not be work scheduled on `current`, so we check for this flag.
(workInProgress.flags & DidCapture) === NoFlags
) {
// No pending updates or context. Bail out now.
didReceiveUpdate = false;
// 进入bailout 策略
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes
);
}
}
在 beginWork 阶段,进入优化策略的条件有 4 个,分别是
- oldProps 和newProps完全相等。
- Legacy Context(旧的 API)没有变化
- fiberNode.type没有变化(新旧 fiberNode 没变化)
- 是否存在更新
如果这四个条件都满足,会进入 bailout 进行优化
function bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
) {
if (current !== null) {
// Reuse previous dependencies
workInProgress.dependencies = current.dependencies;
}
{
// Don't update "base" render times for bailouts.
stopProfilerTimerIfRunning();
}
markSkippedUpdateLanes(workInProgress.lanes); // Check if the children have any pending work.
// 跳过孩子更新
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// The children don't have any work either. We can skip them.
// TODO: Once we add back resuming, we should check if the children are
// a work-in-progress set. If so, we need to transfer their effects.
{
/*KaSong*/ logHook("bailoutOnAlreadyFinishedWork", "skip children");
return null;
}
} // This fiber doesn't have work, but its subtree does. Clone the child
// fibers and continue.
// 当前节点没有需要做的工作,但是子树有需要更新的操作,所以需要克隆当前节点,返回当前节点的子节点。
// 因为当前节点可能粒度太大了,返回子节点的话还可以继续判断,可能存在更小粒度的更新可以跳过。
cloneChildFibers(current, workInProgress);
/*KaSong*/ logHook(
"bailoutOnAlreadyFinishedWork",
"cloneChildFibers",
workInProgress.child
);
return workInProgress.child;
}
- 通过childLanes判断子树是否需要跳过,如果不存在更新的话,整颗子树可以直接跳过
- 跳过当前 FiberNode 节点的更新,继续reconciler后续的节点。
使用了性能优化 API
刚进入 beginWork 构造树的时候会检测当前节点是否可以命中 bailout,但是条件比较苛刻。React 为开发者提供了 API 可以命中策略,如 React.memo。下面一起来看看
var hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes
);
if (!hasScheduledUpdateOrContext) {
// This will be the props with resolved defaultProps,
// unlike current.memoizedProps which will be the unresolved ones.
var prevProps = currentChild.memoizedProps; // Default to shallow comparison
var compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (
compare(prevProps, nextProps) &&
current.ref === workInProgress.ref
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
);
}
} //
代码比较清晰,可以看到,先判断当前 FiberNode 是否存在更新,如果不存在更新,然后会调用 memo 中的compare 函数判断上一个 Props 和当前 props 是否相等,如果相等,也会进入 bailout 策略。所以需要满足三个条件才能进入:
- 当前 FiberNode 不存在更新
- prevProps 等于currentProps。
- ref 没变
虽有更新,但 state 未发生变化
在 beginWork 阶段会根据当前 FiberNode 的 tag 生成不同的 FiberNode,当走到 Function 节点时,也会有判断 bailout 的条件,直接看源码
function updateFunctionComponent(){
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes
);
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
);
}
}
renderWithHooks可以理解成去执行当前 FiberNode 的 FC 函数,去生成对应的节点。然后会根据didReceiveUpdate变量去判断是否应该 bailout。
执行 renderWithHooks 时会调用 useState 方法,在里面会设置didReceiveUpdate的值。直接看源码
function updateReducer(reducer, initialArg, init) {
// 判断计算出的结果是否跟原来的结果一致,如果一致,对didReceiveUpdate进行标记,这个变量是 bailout 优化的手段
if (!objectIs(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
}
function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}
可以看到当运行 useState 时,去计算最新的 state 和旧的 state 是否一致,如果一致,didReceiveUpdate为 false,就会进入bailoutOnAlreadyFinishedWork进行运行时优化。
Context
bailout和Context结合起来有一些有意思的点,下面我们一起分析一下。
处理 Context 的地方在下面的源码中
function beginWork{
// ...
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes);
}
function updateContextProvider(current, workInProgress, renderLanes) {
// ...
{
if (oldProps !== null) {
var oldValue = oldProps.value;
if (objectIs(oldValue, newValue)) {
// No change. Bailout early if children are the same.
if (
oldProps.children === newProps.children &&
!hasContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
);
}
} else {
// The context value changed. Search for matching consumers and schedule
// them to update.
// 生产者生产出新的数据,通知消费者进行更新,会将子树的 lanes 设置为 renderlanes
propagateContextChange(workInProgress, context, renderLanes);
}
}
}
var newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}
- 这个组件会检测oldValue 和 newValue 是否一致,会通过额外的判断来决定是否进入bailoutOnAlreadyFinishedWork。
- 如果不一致,直接propagateContextChange。
- propagateContextChange会寻找 Context Consumer(比如用了Consumer或者说用了 useContext),然后给这些组件的 FiberNode.lanes全部附加 renderLanes(渲染优先级),让全部重新渲染。
- 这里可以得出,即使子树的父级 FiberNode 命中了 bailout 策略,但是由于子级被附加了 Lanes,所以不会完全跳过子树的 beginWork。所以会存在性能问题。
如何命中bailout
在做 React 项目中,或多或少会遇到一些性能问题,那么如何解决性能问题是开发者比较关心的点。而 React 又是重运行时的库,所以我们需要命中他的 bailout 就可以解决性能问题。
这里借由卡老师提供的案例来分析一下出现的性能问题
import React, { useState, useEffect, ReactNode } from "react";
export default function App() {
const [num, updateNum] = useState(0);
return (
<input value={num} onChange={(e) => updateNum(+e.target.value)} />
<p>num is {num}</p>
<ExpensiveCpn />
</div>
);
}
function ExpensiveCpn() {
let now = performance.now();
while (performance.now() - now < 100) {}
console.log("耗时的组件 render");
return <p>耗时的组件</p>;
}
当在 input 中触发 state 变化时,会明显感觉到卡顿,是因为每次 state 更新,会重新 render【ExpensiveCpn】组件导致的。
分析一下ExpensiveCpn没有命中 bailout 的原因,在 App 组件中触发 state 更新,App 本身不会命中 bailout,而每次 render,ExpensiveCpn又会重新渲染,所以ExpensiveCpn也不会命中。
为了使得ExpensiveCpn命中 bailout,可以进行视图的分离,如下:
function Input() {
const [num, updateNum] = useState(0);
return (
<>
<input value={num} onChange={(e) => updateNum(+e.target.value)} />
<p>num is {num}</p>
</>
);
}
export default function App() {
return (
<>
<Input />
<ExpensiveCpn />
</>
);
}
function ExpensiveCpn() {
let now = performance.now();
while (performance.now() - now < 100) {}
return <p>耗时的组件</p>;
}
- 通过将 state 分离到 input 组件中,input 只会触发他自身的 render,其他的组件都会命中 bailout。所以达到了性能优化的目的。
下面再来看一个例子
import React, { useState, useEffect, ReactNode } from "react";
export default function App() {
const [num, updateNum] = useState(0);
return (
<div title={num + ""}>
<input value={num} onChange={(e) => updateNum(+e.target.value)} />
<p>num is {num}</p>
<ExpensiveCpn />
</div>
);
}
function ExpensiveCpn() {
let now = performance.now();
while (performance.now() - now < 100) {}
console.log("耗时的组件 render");
return <p>耗时的组件</p>;
}
- 这种情况下,因为父级要用 num,所以无法分离,所以需要思考别的情况。
下面是优化的策略
import React, {useState, useEffect} from 'react';
import {bindHook, utils, getLibraryMethod} from 'log';
const {log, COLOR: {SCHEDULE_COLOR, RENDER_COLOR, COMMIT_COLOR}} = utils;
// bindHook('beginWork', (current, wip) => {
// log(RENDER_COLOR, `beginWork`, getLibraryMethod('getComponentNameFromFiber')?.(wip));
// })
function InputWrapper({children}: {children: React.ReactNode}) {
const [num, updateNum] = useState(0);
return (
<div title={num + ''}>
<input value={num} onChange={(e) => updateNum(+e.target.value)} />
<p>num is {num}</p>
{children}
</div>
)
}
export default function App() {
return (
<InputWrapper>
<ExpensiveCpn />
</InputWrapper>
);
}
function ExpensiveCpn() {
let now = performance.now();
while (performance.now() - now < 100) {}
console.log('耗时的组件 render');
return <p>耗时的组件</p>;
}
- 通过使用 children 属性,使得他命中 bailout 策略。
- children 是父级的属性,引用是不会变的,所以可以命中 bailout 策略。
原则
将可变部分与不变部分分离,使不变部分可以命中 bailout。