本章节主要是讲解react 性能优化中的一个bailout策略,下一节我们会讲解另外一个策略eagerState。
bailout
策略:减少不必要的子组件render
eagerState
策略:不必要的更新,没必要开启后续调度流程
性能优化的条件
在编写React的代码时候,一般不需要我们主动去考虑一些性能优化点,当出现性能瓶颈的时候,才会去分析和查找性能优化点。
我们知道React每次更新都是从根节点开始的,也就是说,React会从根调和直到叶子结点。
性能优化的一般思路都是讲【变化的部分】与 【不变的部分】进行分离,这样就可以明确的知道,那些子节点是需要重新渲染的。 命中「性能优化」的组件可以不通过reconcile生成wip.child,而是直接复用上次更新生成的wip.child。
注意:
命中性能优化的组件的子组件(而不是他本身)不需要render
在React中,变化的部分主要是分为以下几点:
State
Props
Context
如果这些状态没有发生变化的话,我们就可以不用再次重新渲染已经存在的子组件,比如下图中的白色的节点就是不需要重新渲染的。
Bailout
的介绍
什么是bailout
Bailout 是 React Fiber 架构的一部分。它指的是在 reconciliation(协调)阶段中,React 根据状态(state
)、属性(props
)和上下文(context
)的变化,判断是否需要更新组件。
如果 React 判断组件的输出不需要变化,跳过该组件的子树的 reconciliation
和渲染过程,从而提升性能。
想象你在开一家餐馆,每次有客人进来点餐时,服务员都会去厨房告诉厨师。如果客人点的餐没有变化,服务员实际上不需要再去告诉厨师,因为厨师已经知道客人点了什么。
在 React 中,父组件就像服务员,子组件就像厨师。每次父组件重新渲染时,子组件也会重新渲染。如果父组件的 props
或 state
没有变化,子组件不需要重新渲染,这就是 bailout 策略的核心思想。
命中bailout策略
命中 「性能优化」(bailout
策略)的组件可以不通过reconcile
生成wip.child
,而是直接复用上次更新生成的wip.child
。
Bailout
的策略存在于beginWork
中,我们需要判断变化的部分是否发生了变化,如果没有发生变化,我们就不需要更新。
bailout
四要素:
props
不变
比较props
变化是通过 「全等比较」,使用React.memo
后会变为 「浅比较」
state
不变
两种情况可能造成state
不变:
- 不存在
update
- 存在
update
,但计算得出的state
没变化
context
不变type
不变
如果Div
变为P
,返回值肯定变了。
下面的图片是整体的bailout
流程
fiber.lanes标记
我们之前讲解flag
副作用的标识的时候,有一个subtreeFlags
标记子树中所有副作用。为了判断 「bailout四要素」 中的 「state不变」,需要判断当前fiber
是否存在未执行的update
。
我们需要给每一个fiber添加2个属性。
lanes
: 保存当前fiberNode
中 「所有未执行更新对应的lane」childLanes
: 保存当前fiberNode
的子节点中所有的未执行的更新
实际例子
export default function App() {
const [num, update] = useState(0);
console.log("App render ", num);
return (
<div onClick={() => {update(1)}}>
<Cpn />
</div>
);
}
function Cpn() {
console.log("cpn render");
return <div>cpn</div>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
这是一个简单的react
的渲染代码,包裹一个父组件和一个子组件。在点击的过程中,并没有改变子组件的四要素。按照bailout
策略,当命中的时候,Cpn
应该不会再进行渲染。
初始化:
第一次点击:
第二次点击:
第三次点击:就什么都不会执行了。
这里从初始化到第二次点击的时候,cpn render
不执行了,就是命中了bailout
的策略。我们看到了当App
命中了bailout
的时候,它本身还是会渲染,但是子组件就不会进行渲染了。
第三次是命中了另外一个策略eagerState
。
代码实现
基于我们之前实现的非suspense
的情况下代码进行改造,我们这次实现的bailout
策略不兼容suspense
的情况。
beginWork部分
我们新增一个标识didReceiveUpdate
默认我们设置为false
, 就是默认命中bailout
策略,不接受更新。
// 是否能命中bailout
let didReceiveUpdate = false; //(默认命中bailout策略,不接受更新)
export function markWipReceivedUpdate() {
didReceiveUpdate = true; // 接受更新,没有命中bailout
}
由于性能优化是针对更新的部分,对应挂载mount
阶段是不存在的,所以我们要判断条件在current !== null
export const beginWork = (wip: FiberNode, renderLane: Lane) => {
// 四要素 -> 判断是否变化 (props state context type)
didReceiveUpdate = false;
const current = wip.alternate;
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = wip.pendingProps;
// props 和 type
if (oldProps !== newProps || current.type !== wip.type) {
didReceiveUpdate = true; // 不能命中bailout
} else {
console.warn("命中bailout --- 满足props 和 type");
// state context比较
const hasScheduledStateOrContext = checkScheduledUpdateOrContext(
current,
renderLane
);
if (!hasScheduledStateOrContext) {
// 四要素中的 state / context 不变
// 命中bailout
didReceiveUpdate = false;
// context的入栈、出栈
switch (wip.tag) {
case ContextProvider:
const newValue = wip.memoizedProps.value;
const context = wip.type._context;
pushProvider(context, newValue);
break;
// TODO: Suspense
default:
break;
}
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
}
/**
* beginWork消费update update -> state
*/
wip.lanes = NoLanes;
return null;
};
beginWork
的改动主要是针对变化的部分的判断,看看是否存在属性的变动
- 设置默认进入
bailout
策略,并针对更新的情况 - 对比props和type: 我们获取新旧的2个
props
, 进行对比看看是否相等,如果不相等,就不进入bailout
策略,继续协调子组件部分 - 对比State和Context: 如果props和type相等的话,我们再看看
state
和context
是否相等。 主要是通过checkScheduledUpdateOrContext
检查是否存在更新的lane
- 获取当前的
fiber
的更新lane
,检查本次更新的lane
是否存在,如果存在就说明state或者Context
发生了变化,不进入bailout策略
/**
* renderLane 代表本次更新对应的优先级
* updateLanes 代表当前fiber所有未执行的update对应的更新的优先级
*
* 所以这行代码的意思是:
当前这个fiber中所有未执行的update对应更新的优先级中是否包含了本次更新的优先级,
也就是本次更新当前这个fiber是否有状态会变化
* @param current
* @param renderLane
*/
function checkScheduledUpdateOrContext(
current: FiberNode,
renderLane: Lane
): boolean {
const updateLanes = current.lanes;
if (includeSomeLanes(updateLanes, renderLane)) {
// 本次更新存在的优先级,在当前的fiber中存在
return true;
}
return false;
}
- 如果命中了
bailout
策略,即checkScheduledUpdateOrContext
的返回值为false。直接进入bailoutOnAlreadyFinishedWork
的部分,并终止向下协调的逻辑
// 命中bailout
didReceiveUpdate = false;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
- 在bailoutOnAlreadyFinishedWork中,我们主要是判断
bailout
的范围,看看是所有的子组件都不更新还是只是当前的子组件。cloneChildFibers
主要是基于当前fiber.child
,复用一个新的fiber
。
/**
* 复用上一次的结果,不进行本次更新
* @param wip
* @param renderLane
*/
function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
// 1. 检查优化程度
/**
* 如果这个检查返回false,
* 说明当前fiber的子节点不包含任何应该在当前render lane更新的内容。这种情况下,
* 这个fiber subtree(该节点及其所有子节点)在当前渲染过程中可以被跳过(bailout),
* 因为没有相关的更新需要应用于这部分的DOM。
* 因此,通过返回null来中止当前fiber的工作。
*/
if (!includeSomeLanes(wip.childLanes, renderLane)) {
// 检查整个子树
if (__DEV__) {
console.warn("bailout整课子树", wip);
}
return null;
}
if (__DEV__) {
console.warn("bailout一个fiber", wip);
}
cloneChildFibers(wip);
return wip.child;
}
复用子fiber部分
export function cloneChildFibers(wip: FiberNode) {
// child sibling
if (wip.child === null) {
return;
}
let currentChild = wip.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
wip.child = newChild;
newChild.return = wip;
while (currentChild.sibling !== null) {
currentChild = currentChild.sibling;
newChild = newChild.sibling = createWorkInProgress(
newChild,
newChild.pendingProps
);
newChild.return = wip;
}
}
复用子组件的部分主要是注意下面一句。如果我们没有命中bailout
策略,重新创建createWorkInProgress
的时候传递的pendingProps
是一个新对象。但是命中了后,传递的是同一个引用。
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
// beginwork当前组件结束后。在performUnitOfWork中
// 工作完成,需要将pendingProps 复制给 已经渲染的props
fiber.memoizedProps = fiber.pendingProps;
这就保证了我们在beginWork
进行判断的新旧props
判断的时候。在调和到当前子组件的时候,判断为相等,从而不进行渲染操作。这就保证了oldProps === newProps
。
const oldProps = current.memoizedProps;
const newProps = wip.pendingProps;
根节点和函数组件优化
根组件
除了每次进入beginWork
的开始的时候进行是否进入bailout
优化,还有一种情况,也可以进行优化处理,比如我们的根组件<App />
组件,每次进入的时候props
是一个新的空对象,但是引用不同,正常情况下进入不了bailout
的判断,但是实际他本身并不需要进行额外的调和。
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
所以在进入beginWork
后,我们还可以针对根组件做一个判断,比较前后2次挂在的<App />
组件的内容是否发生了变化
/**
* hostRoot的beginWork工作流程
* 1. 计算状态的最新值 2. 创造子fiberNode
* @param {FiberNode} wip
*/
function updateHostRoot(wip: FiberNode, renderLane: Lane) {
// *******
const prevChildren = wip.memoizedState; // 计算前的值
const { memoizedState } = processUpdateQueue(baseState, pending, renderLane); // 计算最新状态
wip.memoizedState = memoizedState; // 其实就是传入的element
// ******
const nextChildren = wip.memoizedState; // 子对应的ReactElement
if (prevChildren === nextChildren) { 前后都是<App /> 内容没有变化
// 没有变化
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
// ******
}
函数组件
同理在函数组件的fiber调和过程中,可能存在虽然有状态的变更,但是每次变更的值都相同,这样我们也可以进行优化。
比如这个执行更新的逻辑, 在update
执行的时候,我们需要判断前后2次state
的值是否相等,如果相等就是说明此次更新是无效的。
<div onClick={() => {update(1)}}>
在beginWork
进入到函数节点判断的时候,我们根据didReceiveUpdate
的标识,可以知道本次是否需要进行调和。所有我们再进入renderWithHooks
的逻辑后,可以根据state
的值前后对比,看看之后是否满足bailout
策略。这样虽然本身自己还是要被执行,但是子组件可以不再进行渲染。
/**
* 函数组件的beginWork
* @param wip
*/
function updateFunctionComponent(wip: FiberNode, renderLane: Lane) {
const nextChildren = renderWithHooks(wip, renderLane);
const current = wip.alternate;
if (current !== null && !didReceiveUpdate) {
// 命中bailout策略
bailOutHook(wip, renderLane);
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
reconcileChildren(wip, nextChildren);
return wip.child;
}
renderWithHooks的部分逻辑
在进入renderWithHooks
的时候,实际上就会调用函数组件的本身。
从之前的章节中,我们晓得update
内部实际执行的是updateState
,它是更新阶段通过useState
返回用于实际更新state
的函数。
function updateState<State>(): [State, Dispatch<State>] {
// 找到当前useState对应的hook数据
const hook = updateWorkInProgressHook();
// 计算新的state逻辑
// *******
if (baseQueue !== null) {
const prevState = hook.memoizedState; // 更新前的状态
const {
memoizedState,
baseQueue: newBaseQueue,
baseState: newBaseState,
} = processUpdateQueue(baseState, baseQueue, renderLane, (update) => {
// *******
});
if (!Object.is(prevState, memoizedState)) {
// 更新前后有变化,没有命中bailout
markWipReceivedUpdate();
}
// *******
}
return [hook.memoizedState, queue.dispatch as Dispatch<State>];
}
如果值前后发生了变化,我们就要标记didReceiveUpdate = true
,跳过函数组件的bailout
策略。