React性能优化
目录
前言
本文是React源码学习系列第六篇,该系列整体都基于React18.0.0版本源码。旨在学习过程中记录一些个人的理解。该篇介绍React优化策略。
优化手段
React作为一款“重运行时”框架,拥有多个“性能优化”相关的API。
- shoudComponentUpdate
- PureComponent
- React.memo
- useMemo
- useCallback
因为React无法像Vue一样在编译时做出优化,所以这部分工作放在运行时交由开发者完成。React内部有一套完整的运行时优化策略,开发者调用性能优化API的本质就是命中这些优化策略。我们可以从两方面来提升性能。
- 编写“符合性能优化策略的组件”,命中优化策略。
- 调用性能优化API,命中优化策略。
例子1
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 <span>child</span>;
}
- 首次渲染时,打印:App render 0; child render
- 第一次点击div时,打印:App render 1; child render
- 第二次点击div时,打印:App render 1;
- 第三次点击div时,不会打印。
分析
为什么第二次点击时没有打印child render?这是一种“发生在render阶段”的优化策略,被称为bailout。命中该策略的组件的子组件会跳过reconcile流程(即子组件不会进入render阶段)。
为什么第三次及之后的点击不会打印?这是因为App、Child都未进入render阶段,这是一种“发生在触发状态更新时”的优化策略,被称为eagerState。命中该策略的更新不会进入schedule阶段,也不会进入render阶段。
eagerState策略
eagerState策略逻辑很简单,如果某个状态更新前后没有变化,则可跳过后续更新流程。我们知道state是基于update计算而来,计算过程发生在render阶段的beginWork中。eagerState(急迫的state)表示:在当前fiberNode中不存在待执行的更新时(就是说当前产生的update是fiberNode的第一个update),计算state不会受到其他update的影响。可以将这一计算过程提前到schedule阶段之前执行。
useState举例
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const lane = requestUpdateLane(fiber);
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
enqueueUpdate(fiber, queue, update, lane);
const alternate = fiber.alternate;
// 判断wip、current的lanes是否为NoLanes
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 上次计算时使用的reducer
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
try {
const currentState: S = (queue.lastRenderedState: any);
// 直接计算出state
const eagerState = lastRenderedReducer(currentState, action);
// 将计算出来的状态存储在update上。
update.hasEagerState = true;
update.eagerState = eagerState;
// Object.is
if (is(eagerState, currentState)) {
// 状态与当前状态相同,直接return
return;
}
}
}
}
// 没有命中优化策略,发起调度
const eventTime = requestEventTime();
const root = scheduleUpdateOnFiber(fiber, lane, eventTime);
}
通过代码可以看到如果Object.is(eagerState, currentState)为true,说明state没有变化,命中eagerState策略,不会发起一个调度。 有的同学可能会疑惑,如果不为true的情况下会不会多做一次计算state的过程?由于这是“当前fiberNode”的第一个更新,在它之前不会有update影响它的计算结果,所以可以将eagerState保存下来,在beginWork中计算state时,可以直接使用update.eagerState,不需要重新计算。这就是update数据结构中如下字段的意义。
const update = {
// 是否是eagerState
hasEagerState: false,
// eagerState的计算结果
eagerState: null
}
提问
那第二次点击num前后都为1,为什么App组件没有命中eagerState策略?
if (fiber.lanes === NoLanes &&(alternate === null || alternate.lanes === NoLanes)
) {
//...
}
我们可以看到要想命中eagerState策略,必须wip和current的lanes都为NoLanes。
lanes消费过程
发起一个调度的时候,会把lanes合并的wip和current的lanes中。
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
beginWork开始后会重置workInProgress.lanes。
workInProgress.lanes = NoLanes;
commit阶段会切换fiber树
root.current = finishedWork;
第一次点击,wip、curent的lanes都不为空。调度执行更新任务。
- beginWork执行后,wip.lanes重置为NoLanes;
- commit后wip与current树互换。
第二次点击时,wip的lanes不为空,所以无法命中eagerState策略。虽然没有命中eagerState策略,但是这次点击没有打印“child render”,说明App命中了bailout策略,复用了App的current节点。
bailout策略
beginWork的目的是生成当前FiberNode的子FiberNode。有两种路径:
- 通过reconcile流程生成子FiberNode。
- 通过bailout策略复用子FiberNode。
命中bailout策略策略表示子FiberNode没有变化可以复用。即以下字段没有变化。
- state
- props
- context
beginWork
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// 全等
if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceiveUpdate = true;
} else { // props和legacy context都没有改变。
// 检查是否有pending update or context change。
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
if (
!hasScheduledUpdateOrContext &&
// error或suspense 忽略。
(workInProgress.flags & DidCapture) === NoFlags
) {
// 没有更新内容
didReceiveUpdate = false;
// 命中优化策略
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}
if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
// 这是仅存在于遗留模式的特殊情况,忽略它。
didReceiveUpdate = true;
} else {
// fiberNode上有本次更新的lanes,但是props和context都没改变,设为false,后面还有机会命中bailout策略。
// 如果更新导致state的值发生变化或者有consumer的值产生变化,后面会把它设为true。
didReceiveUpdate = false;
}
}
}
bailout策略条件
1. oldProps === newProps:
注意是全等。这就需要父FiberNode命中bailout策略,复用老节点才有可能。 HostRootFiber默认情况下满足oldProps === newProps。
2. Legacy Context(旧的Context API)没有变化。
3. fiberNode.type没有变化
例子1中的Child组件是在App组件内定义的,每次App render都会创建一个新的Child引用,所以对于Child来说,fiberNode.type是始终变化的。所以尽量不要在组件内定义组件,以免无法命中bailout策略。
4. 当前fiberNode没有更新。
checkScheduledUpdateOrContext方法,主要检查当前Fiber是否有本次更新的lanes。
// 保留关键代码
function checkScheduledUpdateOrContext(
current: Fiber,
renderLanes: Lanes,
): boolean {
const updateLanes = current.lanes;
// 该Fiber是否有本次更新的lanes
if (includesSomeLane(updateLanes, renderLanes)) {
return true;
}
return false;
}
命中bailout策略
如果满足以上条件就会进入bailoutOnAlreadyFinishedWork方法。该方法会进一步判断“优化可以进行到何种程度”。
bailoutOnAlreadyFinishedWork
检查子树是否有更新。
- 子树没有更新:直接跳过整个Diff过程。
- 子树有更新,复用该FiberNode,继续执行子树beginWork。
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// 检查子树是否有本次更新的lanes。
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 子树本次没有要更新的lanes,return null 跳过整该fiber的beginWork
return null;
}
// 子树有更新。克隆fiber并继续。
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
没有命中bailout策略
如果没有命中bailout策略,会根据tag进入不同的分支进行处理,还有两种命中bailout策略的可能。
1.开发者调用性能优化API
第一次判断是否命中bailout策略时,props是全等判断,要满足该条件比较困难。性能优化API的工作原理就是改写此判断。
React.memo
该API创建的fiberNode的tag为MemoComponent。在beginWork中的逻辑是updateMemoComponent。满足以下条件命中bailout策略。
- 不存在更新。
- 经过比较(默认浅比较)后props未变化。
- ref不变
function updateMemoComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
): null | Fiber {
const currentChild = ((current.child: any): Fiber);
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
if (!hasScheduledUpdateOrContext) {
const prevProps = currentChild.memoizedProps;
// 比较函数,默认是浅比较
let compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
// 命中bailout策略
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
}
PureComponent和shoudComponentUpdate
if (!shouldUpdate && !didCaptureError) {
// 命中bailout策略
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
shouldUpdate受checkShouldComponentUpdate方法影响。
- shoudComponentUpdate通过返回值影响shouldUpdate变量。
- PureComponent通过浅比较影响shouldUpdate变量。
- 注意:PureComponent组件如果存在shoudComponentUpdate方法,shoudComponentUpdate方法返回值优先级高。
- 最终影响是否命中bailout策略。
function checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext,
) {
const instance = workInProgress.stateNode;
if (typeof instance.shouldComponentUpdate === 'function') {
// 根据shouldComponentUpdate返回值决定是否需要更新
let shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext,
);
return shouldUpdate;
}
// PureComponent 浅比较
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
return true;
}
2.虽然有更新,但是state没有变化
第一次判断是否命中bailout策略时,还有一个条件是“当前fiberNode没有更新”。没有更新state肯定没有变化,有更新的话需要先计算出state,判断其有没有变化。
FC组件
在beginWork中的逻辑是updateFunctionComponent。满足以下条件命中bailout策略。
- current !== null
- didReceiveUpdate为false
// 组件render
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
);
if (current !== null && !didReceiveUpdate) {
// 命中bailout策略
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// reconcile流程
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
计算state时会执行updateReducer方法(useState、useReducer后会执行的内部方法)。
if (!Object.is(newState, hook.memoizedState)) {
// 把didReceiveUpdate设为true
markWorkInProgressReceivedUpdate();
}
例子2
function App() {
const [num, setNum] = useState(0);
return <>
<input value={num} onChange={(e) => setNum(e.target.value)} />
<p>num is {num}</p>
<ExpensiveCpn />
</>;
}
function ExpensiveCpn() {
const now = performance.now();
while(performance.now() - now < 100) {};
return <p>耗时的组件</p>;
}
该例子在input输入内容时,会有明显卡顿,是因为ExpensiveCpn组件没有命中bailout策略。因为App中会触发state改变,所以App不会命中bailout策略,意味着“ExpensiveCpn对应的JSX”是App render的返回值。在ExpensiveCpn beginWork的时候“判断是否命中bailout策略”时oldProps !== newProps,未命中bailout策略。会执行耗时的render,造成卡顿。
优化
在不调用性能优化API的情况下,把可变部分与不变部分拆开。
- HostRootFiber命中bailout策略。
- App没有state变化,且是HostRootFiber命中bailout策略复用而来。满足oldProps === newProps。命中bailout策略。
- ExpensiveCpn没有state变化,且是App命中bailout策略复用而来。满足oldProps === newProps。命中bailout策略。
- 命中bailout策略,不会执行耗时的render。
function App() {
return <>
<Input />
<ExpensiveCpn />
</>;
}
function Input() {
const [num, setNum] = useState(0);
return <>
<input value={num} onChange={(e) => setNum(e.target.value)} />
<p>num is {num}</p>
</>;
}
function ExpensiveCpn() {
const now = performance.now();
while(performance.now() - now < 100) {};
return <p>耗时的组件</p>;
}
export default App;
总结
React中包含两种优化策略。
- eagerState策略
- bailout策略
eagerState策略需要满足的条件比较苛刻,开发时不必强求。开发者应该追求写出“满足bailout策略的组件”。
- 即使不使用性能优化API,只要满足一定条件,也能命中bailout策略
- 将可变部分与不变部分分离,使不变部分能够命中bailout策略。
参考
React设计原理 - 卡颂。
转载自:https://juejin.cn/post/7397352785606639651