getSnapshotBeforeUpdate()
函数签名:getSnapshotBeforeUpdate(prevProps, prevState)=> any
在 react 新的官方文档中,getSnapshotBeforeUpdate() 已经是一个被废弃的 API 了。时值 2023-04-12,react 官网如是说道:
At the moment, there is no equivalent to
getSnapshotBeforeUpdatefor function components. This use case is very uncommon, but if you have the need for it, for now you’ll have to write a class component.
可见,虽然它被标记为废弃的 API 且只能在 class component 中使用, 但是如果需要去获取 DOM 更新之前的相关快照信息,我们还必须要用它。它目前是唯一一个官方提供的,实现这种目标的钩子函数 - 钩在 DOM 更新之前。
我在想,在 function component中,我们如果将
useRef(),useLayoutEffect()和useEffect()三者结合起来应该是能实现类似的功能。
我们作为 getSnapshotBeforeUpdate() 这个 API 的使用者,最关心的是两个问题:
- 内部源码中,它什么时候,在哪里被调用?
- 它被调用的时候,我们能得到哪些快照信息呢?
下面,我们带着这两个疑问,一起看看。
内部源码中,它什么时候,在哪里被调用?
关于这一点,官方文档如是说:
If you implement
getSnapshotBeforeUpdate, React will call it immediately before React updates the DOM.
从内部原理的角度来看,这句话显得稍显笼统,不够具体。下面,我们深入源码,一起收货更深入的认知。
组件 mount 阶段的 getSnapshotBeforeUpdate()方法调用
我在文章 770 行代码还原 react fiber 初始链表构建过程 介绍过,在 render 阶段,react 主要干了两件事:
- 在 begin work 子阶段根据 react element 来创建下一个 workInProgress fiber 节点;
- 在 complete work 子阶段,根据 fiber 的不同
tag值来做不同的 work。
假如我们现在有一个 class component:
// 定义
class Test extends Component {
constructor(props) {
super(props);
this.state = {
count: 1,
};
}
getSnapshotBeforeUpdate() {
return this.props.getSnapshot();
}
render() {
return (
<div
onClick={() => this.setState({ count: this.state.count + 1 })}
style={{ width: this.state.count * 10 }}
>
{this.state.count}
</div>
);
}
}
// 使用
<App>
<Test ref={ref} getSnapshot={getSnapshot} />
</App>
毫无疑问,<Test ref={ref} getSnapshot={getSnapshot} /> 是一个 react element。这个 react element 会在 render 阶段 App fiber(当前 workInProgress 是 App fiber) 的 begin work 子阶段被用于创建 fiber 节点。具体看代码:
function shouldConstruct(Component) {
const prototype = Component.prototype;
return !!(prototype && prototype.isReactComponent);
}
function createFiberFromTypeAndProps(
type, // React$ElementType
key,
pendingProps,
owner,
mode,
lanes
) {
let fiberTag = IndeterminateComponent; // The resolved type is set if we know what the final type will be. I.e. it's not lazy.
let resolvedType = type;
if (typeof type === "function") {
if (shouldConstruct(type)) {
fiberTag = ClassComponent;
}
} else if (typeof type === "string") {
fiberTag = HostComponent;
} else {
getTag: switch (type) {
// 省略了很多根据 react element type 来生成 fiber tag 的代码
}
}
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
return fiber;
}
可以看出,react 是根据 react element type 来生成 fiber tag。因为 <Test> 满足条件:
typeof type === "function"shouldConstruct(type)
所以,Test fiber 节点会被打上 tag 为 ClassComponent 的标签。这符合我们的预期。
接下来,react 会进入下一轮的 begin work 阶段。此时,workInProgress fiber 是Test fiber 节点。对 Test fiber 节点进行 begin work,react 会进入 updateClassComponent() helper 函数:
源码片段 1
function updateClassComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes
) {
// ......
const instance = workInProgress.stateNode;
let shouldUpdate;
if (instance === null) {
// ......
constructClassInstance(workInProgress, Component, nextProps);
mountClassInstance(workInProgress, Component, nextProps, renderLanes);
shouldUpdate = true;
} else if (current === null) {
// In a resume, we'll already have an instance we can reuse.
shouldUpdate = resumeMountClassInstance(
workInProgress,
Component,
nextProps,
renderLanes
);
} else {
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderLanes
);
}
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderLanes
);
return nextUnitOfWork;
}
因为当前是组件的 mount 阶段,workInProgress.stateNode 的值是为 null的,所以,代码会执行第一个条件分支。这里主要有两条语句:
constructClassInstance(workInProgress, Component, nextProps);。这里主要是创建 class component 的实例(new Test()), 然后再组件实例与 fiber 节点之间建立循环引用。
mountClassInstance(workInProgress, Component, nextProps, renderLanes);。这行代码主要负责:- 如果当前组件定义了静态方法
getDerivedStateFromProps(),UNSAFE_componentWillMount()和componentWillMount()等生命周期方法,那么我们就调用它; - 如果当前组件定义了
componentDidMount(),我们就给当前的 fiber 节点的 effect flag 叠加两个 flag :Update和LayoutStatic。
- 如果当前组件定义了静态方法
到这里,对 Test fiber 节点进行 begin work 已经完成了。而在 Test fiber 节点 complete work 阶段,我们并没有发现给 fiber 节点打 effect flag 的代码。也就是说,在组件的 mount 阶段,react 只会根据用户是否定义componentDidMount() 生命周期方法来最多打上一个 effect flag。我们期待的 Snapshot flag 并没有出现在代码中。
在组件的 mount 阶段的 render 阶段没有打上 Snapshot flag ,则意味着,在 commit 阶段,我们的getSnapshotBeforeUpdate()生命周期方法是不会被调用的。具体代码看 commit 阶段的 commitBeforeMutationEffectsOnFiber():
源码片段 2
function commitBeforeMutationEffectsOnFiber(finishedWork) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// (flags & Snapshot) 不等于 NoFlags 翻译过来就是 “如果 flags 里面包含 Snapshot 标志位的话”
if ((flags & Snapshot) !== NoFlags) {
switch (finishedWork.tag) {
// ......
case ClassComponent: {
if (current !== null) {
const prevProps = current.memoizedProps;
const prevState = current.memoizedState;
const instance = finishedWork.stateNode; // We could update instance props and state here,
const snapshot = instance.getSnapshotBeforeUpdate(
finishedWork.elementType === finishedWork.type
? prevProps
: resolveDefaultProps(finishedWork.type, prevProps),
prevState
);
instance.__reactInternalSnapshotBeforeUpdate = snapshot;
}
break;
}
// ......
}
}
}
最后用测试代码去测试,react 确实是不会在组件的 mount 阶段去执行 getSnapshotBeforeUpdate()方法,这更加印证了我们从源码中得到的认知。
在组件的 mount 阶段的 render 阶段不调用 getSnapshotBeforeUpdate()。这似乎也是合理的:在组件的 mount 阶段,组件的 snapshot 都不存在,执行该生命周期方法也就没意义。
组件 update 阶段的 getSnapshotBeforeUpdate()方法调用
在组件的 update 阶段,<Test> 还是会经历 render , commit 两个阶段。在 render 阶段的 begin work 子阶段,它还是会进入 updateClassComponent(源码片段 1)函数里面。只不过,相比于 mount 阶段,这次,instance 跟 current 比变量的值都是 null。所以,这次,react 会进入最后一个 else 条件分支语句,也就意味着进入 updateClassInstance() 的函数调用中:
function updateClassInstance(
current,
workInProgress,
ctor,
newProps,
renderLanes
) {
// .......
const shouldUpdate =
checkHasForceUpdateAfterProcessing() ||
checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext
) || // TODO: In some cases, we'll end up checking if context has changed twice,
// both before and after `shouldComponentUpdate` has been called. Not ideal,
// but I'm loath to refactor this function. This only happens for memoized
// components so it's not that common.
enableLazyContextPropagation;
if (shouldUpdate) {
// ......
if (typeof instance.getSnapshotBeforeUpdate === "function") {
workInProgress.flags |= Snapshot;
}
} else {
// .......
}
// ......
return shouldUpdate;
}
从上面可以看出,一旦 shouldUpdate 的值为 true, 我们就会为 <Test> fiber 节点打上 Snapshot 的 effect flag。
那 react 是怎么计算出 shouldUpdate 的值呢?源码给出三种情况:
checkHasForceUpdateAfterProcessing()checkShouldComponentUpdate()enableLazyContextPropagation
一般情况下,我们只需要关注第二种情况:
function checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext
) {
const instance = workInProgress.stateNode;
if (typeof instance.shouldComponentUpdate === "function") {
let shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext
);
return shouldUpdate;
}
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
return true;
}
从上面的代码可以看出,用户定义的 shouldComponentUpdate() 方法就是在这里调用。
言归正传。checkHasForceUpdateAfterProcessing() 中,react 优先考虑用户定义的 shouldComponentUpdate() 方法的调用结果,让用户自己来决定是否需要更新组件。
如果用户没有定义 shouldComponentUpdate() 方法,react 再来考虑当前组件是不是 PureComponent。如果是,则用浅比较的结果来决定当前组件是否需要更新。
react 所采用的浅比较的算法实现如下:
/**
* inlined Object.is polyfill to avoid requiring consumers ship their own
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
*/
function is(x, y) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
const objectIs = typeof Object.is === "function" ? Object.is : is;
/**
* Performs equality by iterating through keys on an object and returning false
* when any key has values which are not strictly equal between the arguments.
* Returns true when the values of all keys are strictly equal.
*/
function shallowEqual(objA, objB) {
if (objectIs(objA, objB)) {
return true;
}
if (
typeof objA !== "object" ||
objA === null ||
typeof objB !== "object" ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
} // Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!objectIs(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
一旦 react 计算出的 shouldUpdate 变量的值为 true,当前的 class component 的 fiber 节点就会被打上 Snapshot 的 effect flag。
接下来,react 会进入 commit 阶段。老生常谈,commit 阶段又可以分为三个子阶段:
- beforeMutation
- mutation
- layout
react 会在 beforeMutation 的子阶段来调用用户定义的 getSnapshotBeforeUpdate()。具体是发生在上面提到的源码片段 2中:
function commitBeforeMutationEffectsOnFiber(finishedWork) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// (flags & Snapshot) 不等于 NoFlags 翻译过来就是 “如果 flags 里面包含 Snapshot 标志位的话”
if ((flags & Snapshot) !== NoFlags) {
switch (finishedWork.tag) {
// ......
case ClassComponent: {
if (current !== null) {
const prevProps = current.memoizedProps;
const prevState = current.memoizedState;
const instance = finishedWork.stateNode; // We could update instance props and state here,
const snapshot = instance.getSnapshotBeforeUpdate(
finishedWork.elementType === finishedWork.type
? prevProps
: resolveDefaultProps(finishedWork.type, prevProps),
prevState
);
instance.__reactInternalSnapshotBeforeUpdate = snapshot;
}
break;
}
// ......
}
}
}
一旦当前的 class component 所对应的 fiber 节点的 effect flag 包含 Snapshot 这个标志位,那么 react 就是调用我们定义的getSnapshotBeforeUpdate() 方法。从源码来看,react 调用我们的getSnapshotBeforeUpdate() 方法时候所传递的实参与官网所给出的函数签名是一致的。
最后,react 会新开辟一个属性 __reactInternalSnapshotBeforeUpdate, 然后把 getSnapshotBeforeUpdate() 的调用结果放在这个属性上面。
最后,react 会在 layout 子阶段调用组件实例的 componentDidUpdate()方法,把组件实例的 __reactInternalSnapshotBeforeUpdate 属性值作为第三个实参传递进入。这一幕发生在 commitClassLayoutLifecycles 函数里面:
function commitClassLayoutLifecycles(finishedWork, current) {
const instance = finishedWork.stateNode;
if (current === null) {
// ......
} else {
const prevProps =
finishedWork.elementType === finishedWork.type
? current.memoizedProps
: resolveDefaultProps(finishedWork.type, current.memoizedProps);
const prevState = current.memoizedState; // We could update instance props and state here,
{
try {
instance.componentDidUpdate(
prevProps,
prevState,
instance.__reactInternalSnapshotBeforeUpdate
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
}
}
小结
至此,我们梳理完了getSnapshotBeforeUpdate()方法在组件的 mount 阶段和 update 阶段被调用的两种情况。我们得到的认知是:
- 在组件的 mount 阶段,由于 react 没有给 fiber 节点打上
Snapshot这个 effect flag,所以,即使用户定义了getSnapshotBeforeUpdate()方法,它也不会被调用; - 在组件的 update 阶段。
getSnapshotBeforeUpdate()方法是否被调用所遵循判断逻辑是:
flowchart TD
A([开始]) --> B{用户是否实现了 \nshouldComponentUpdate 方法?}
B -->|是| C[调用 shouldComponentUpdate 方法]
C --> E{返回结果为 true ?}
E -->|是| G[调用 getSnapshotBeforeUpdate 方法]
G --> H([结束])
E -->|否| H
B -->|否| D{当前组件是否是 PureComponent ?}
D -->|否| G
D -->|是| I[拿当前 state 和 props 分别跟 prevState 和 prevProps 进行浅比较]
I --> J{两种情况是否都是浅相等?}
J -->|否| G
J -->|是| H
它被调用的时候,我们能得到哪些快照信息呢?
从函数签名getSnapshotBeforeUpdate(prevProps, prevState)=> any,我们可以看得出,在 getSnapshotBeforeUpdate() 被调用的时候,我们是可以得到上一次组件更新的 state 和 props,即:
- preState
- preProps
众所周知,react 的更新流程可以划分为两个阶段:
- render 阶段
- commit 阶段
而 commit 阶段又可以依次划分为三个子阶段:
- beforeMutation
- mutation
- layout
这些术语都不是我或者谁发明的,这是在 commit 阶段的入口函数 commitRootImpl()的实现代码板上钉钉写着的:
function commitRootImpl(
root,
recoverableErrors,
transitions,
renderPriorityLevel
) {
// ......
// 1. beforeMutation 子阶段
const shouldFireAfterActiveInstanceBlur = commitBeforeMutationEffects(
root,
finishedWork
);
// 2. mutation 子阶段
commitMutationEffects(root, finishedWork);
resetAfterCommit(root.containerInfo);
root.current = finishedWork;
// 3. layout 子阶段
commitLayoutEffects(finishedWork, root, lanes);
// ......
}
我在上面也提到了,getSnapshotBeforeUpdate() 的调用是发生在function commitBeforeMutationEffectsOnFiber(finishedWork) 函数里面。我们稍微 debug 一下源码,我们会得到 getSnapshotBeforeUpdate() 的调用栈:
flowchart BT
commitRoot -->|调用| commitRootImpl
commitRootImpl -->|调用| commitBeforeMutationEffects
commitBeforeMutationEffects -->|调用|commitBeforeMutationEffects_begin
commitBeforeMutationEffects_begin -->|调用|commitBeforeMutationEffects_complete
commitBeforeMutationEffects_complete -->|调用| commitBeforeMutationEffectsOnFiber
commitBeforeMutationEffectsOnFiber -->|调用| getSnapshotBeforeUpdate
可以看出,在栈顶往下追溯一下,我们就会发现,当前的调用是发生在 commitBeforeMutationEffectsOnFiber() 函数内部。也就是说getSnapshotBeforeUpdate() 的调用是发生在「beforeMutation」子阶段。
因为在 mutation 阶段,react 才会真真切切地去做 DOM 节点的插入,删除和更新。而 beforeMutation 阶段是在 mutation 阶段前面,所以,我们是能访问本次界面更新前的 DOM 树的相关信息。
综上所述,在getSnapshotBeforeUpdate() 被调用的时候,我们能得到的快照信息还包括:
- 更新前的 DOM 树的相关信息
这与 react 官网的 API 文档描述是一致的。
小结
在getSnapshotBeforeUpdate() 被调用的时候,我们能得到的快照信息还包括:
- 上一次更新得到的状态 -
prevState; - 上一次更新得到的 props -
prevProps; - 本次更新前的 DOM 树的相关信息