React 中的性能优化策略
React 无法像 Vue 在编译时做出优化,所以这部分工作放在运行时交给开发者完成。
在 React 内部有完成的运行时优化策略,开发者调用性能优化 API 的本质就是命中这些优化策略。
- 编写“符合性能优化策略的组件”,命中优化策略
- 调用性能优化 API,命中策略
function App() {
const [num, setNum] = useState(0);
console.log("App render", num);
return (
<div onClick={() => setNum(1)}>
<Child />
</div>
);
}
function Child() {
console.log("Child render");
return <div>child</div>;
}首次渲染时,打印:
App render 0
Child render
第一次点击 App 组件 中的 div 时,打印:
App render 1
Child render
第二次点击 App 组件中的 div 时,打印:
App render 1
之后的点击不会打印。
第二次点击时,没有打印 “Child render”,因为命中了 bailout(放弃)策略。发生在 render 阶段,命中该策略的组件的子组件会跳过 reconcile 流程(也就是不会进入 render 阶段)。
后续点击不会打印,因为命中了 eagerState 策略,发生在触发状态更新时。命中该策略的更新不会进入 schedule 阶段和 render 阶段。
eagerState 策略
如果某个状态更新前后没有变化,则可以跳过后续更新流程
state 是 “基于 update 计算而来”,发生在 render 阶段中的 beginWork 中。
eagerState 表示为:在当前 fiberNode 中不存在 “待执行更新” 时,可以把这一计算过程提前到 schedule 阶段之前进行。
因为要保证,产生的 update,在计算时不会收到其他的 uadate 影响,所以策略前提要判断 当前 fiberNode 中不存在 “待执行更新”。
从而保证 产生的 update 是 fiberNode 中的一个等待执行的更新”。
这里判断 fiberNode 时,workInprogress 和 current 要同时满足 ‘nolanes’ 的条件。
然后尝试基于本次更新对应的 ‘action’ 计算 eagerState。
如果 Object.is(eagerState,memoizedState) 为 true,代表 state 没有变化。命中策略。
if (
fiber.lanes === Nolanes &&
(alternate === null || alternate.lanes === Nolanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
try {
const currentState = queue.lastRenderedState; // memoizedState
// 尝试计算的 eagerState
const eagerState = lastRenderedReducer(currentState, action);
// 标记 update 存在 eagerState
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
//命中 eagerState 策略
return;
}
} catch (e) {}
}
}因为这是当前 fiberNode 上第一个待执行的更新,所以可以直接保存在 update 上。在 beginwork 阶段 计算 state 时直接使用。
const update = {
hasEagerState: false, // 是否为eagerState
eagerState: null, // eagerState 计算结果
//略...
};在示例中第二次点击 App 组件中的 div 时,明明更新前后的 num 都是 1,但是它还是执行了更新流程。
原因就在于本次触发更新时,没有满足 eagerState 的前置判断条件。当前 fiberNode 中不存在 “待执行更新”,即判断 fiberNode 时,workInprogress 和 current 要同时满足 ‘nolanes’ 的条件。
在执行 setNum 时,对应的 fiberNode 作为 “预设参数” 传入
const dispatch = (queue.dispatch = dispatchSetState.bind(
null,
currentlyRenderingFiber, // App 对应的 fiberNode
queue // updateQueue
));在 render 阶段之前的 schedule 阶段中 lane 模型工作流程 root.pendingLanes("当前 FiberRootNode 下 '未执行的更新对应 lane'的集合") 中 包含两个流程
- 更新 fiberNode.lanes, 同时更新 workInprogress 和 current
- 重置 fiberNode.lanes,重置 workInprogress
所以,在一次更新过程中,执行 beginWork 之前 workInprogress.lanes 和 current.lanes 都不是 NoLanes,beginWork 执行后,workInprogress.lanes 重置为 NoLanes, commit 阶段 workInprogress 跟 current 切换。
因为过程中仅重置了 workInprogress.lanes 第二次点击 div 时没有命中 eagerState。
但是它命中了 bailout 策略,没有打印“Childe render”,FC 执行 bailoutHooks 方法:
其中最后会从 current.lanes 中移除 renderLanes。 所以之后的点击 workInprogress.lanes 和 current.lanes 都是 NoLanes ,就都会命中 eagerState 策略了。
bailOut 策略
beginWork 的目的是 “生成 workInprogress fiberNode 的子 fiberNode ”,有两条路径:
- 通过 reconcile 流程生成子 fiberNode;
- 通过 bailOut 策略 复用子 fiberNode。
进入 beginWork 中有两次判断 是否命中 bailOut,第一次在刚进入 beginWork 时。
要同时满足以下条件才能命中 bailOut:
- oldProps === newProps (全等比较);
- Legacy Context (旧的 Context API) 没有变化;
- fiberNode.type 没有变化;
- 当前 fiberNode 没有更新发生
当条件都满足时,会执行 bailoutOnAlreadyFinishedWork。方法内会进一步判断 “优化可以进行到什么程度”:
function bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
) {
// 略
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 整颗子树都命中了 bailout
return null;
}
// 只有子 fiberNode 命中了 bailout
// 基于 current child 克隆,省去了 子fiberNode的 reconcile 流程
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}workInProgress.childLanes 可以快速排查“当前 fiberNode 的整个子树中是否存在更新”。
如果不存在就跳过整棵子树的 beginWork。这也是 “React 每次更新都会生成整颗 Fiber Tree,但性能并不差”的原因。
如果不能跳过整颗子树,则基于 current child 克隆出 workInProgress child,省去了 子 fiberNode 的 reconcile 流程。
如果 beginWork 中第一次判断没有命中 bailOut,则会根据 tag 进入不同的 fiberNode 处理逻辑,还有两种命中的可能。
- 开发者使用了性能优化 API
在第一次判断“是否命中 bailOut”时,props 进行全等比较,性能优化 API 就是要改写这个判断条件。
此时如果 fiberNode 同时满足以下条件,则命中 bailout 策略:
- 不存在更新;
- 经过比较(浅比较)props 未变化;
- ref 不变
比如使用 React.memo 创建的 FC 对应的 fiberNode.tag 为 MemoConponent, 只需要对 props 进行浅比较。
- 虽然有更新,但 state 没有变化
在首次 bailOut 策略判断时,有一个条件是 “当前 fiberNode 没有发生更新”。
没有更新意味着 state 一定没有变化,存在更新时,要通过对 update 进行计算后才能确定 state 有没有变化。所以在 beginWork 阶段的 renderWithHooks 内会执行组件的 “render” 逻辑,计算 state。同时判断是否命中 bailOut 策略。
bailout 与 Context API
旧 Context 的数据保存在栈中,beginWork 过程中 context 不断入栈,context Consumer 可以通过 context 栈向上找到对应的 context value。complate Work 中, context 不断出栈。
但是对于“跳过整个子树的 beginWork” 这种程度的 bailOut 策略时,被跳过的子树不会再经历 context 入栈、出栈的过程。
所以在 旧的 Context 中,即使 context value 变化,只要子树命中 bailOut 策略且被跳过,子树中的 Consumer 就不会响应更新。
所以新的 Context API 针对这一点做了改变,当 beginWork 进行到 Ctx.Provider 时,会判断 context value 是否变化:
if (objectIs(oldValue, newValue)) {
// context value 没有变化
if (oldProps.children === newProps.children && !hasContextChanged()) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
} else {
// context value 变化, 向下寻找 Consumer,标记
propagateContextChange(workInProgress, context, renderLanes);
}当 context value 发生变化时,benginWork 会立刻从 Ctx.Provider 向下开启一次深度优先遍历,寻找 context Consumer,对其附加 renderLanes。通过改变其 childLanes 避免命中 “跳过整颗子树的 beginWork 逻辑”。