从源码入手探究一个因useImperativeHandle引起的Bug
今天本来正在工位上写着一段很普通的业务代码,将其简化后大致如下:
function App(props: any) { // 父组件
const subRef = useRef<any>(null)
const [forceUpdate, setForceUpdate] = useState<number>(0)
const callRef = () => {
subRef.current.sayName() // 调用子组件的方法
}
const refreshApp = () => { // 模拟父组件刷新的方法
setForceUpdate(forceUpdate + 1)
}
return <div>
<SubCmp1 refreshApp={refreshApp} callRef={callRef} />
<SubCmp2 ref={subRef} />
</div>
}
class SubCmp1 extends React.Component<any, any> { // 子组件1
constructor(props: any) {
super(props)
this.state = {
count: 0
}
}
add = () => {
this.props.refreshApp() // 会导致父组件重渲染的操作
// 修改自身数据,并在回调函数中调用外部方法
this.setState({ count: this.state.count + 1 }, () => {
this.props.callRef()
})
}
render() {
return <div>
<button onClick={this.add}>Add</button>
<span>{this.state.count}</span>
</div>
}
}
const SubCmp2 = forwardRef((props: any, ref) => { // 子组件2
useImperativeHandle(ref, () => {
return {
sayName: () => {
console.log('SubCmp2')
}
}
})
return <div>SubCmp2</div>
})
代码结构其实不复杂,一个父组件包含有两个子组件。其中的组件2因为要在父组件中调用它的内部方法,所以用forwardRef
包裹,并通过useImperativeHandle
向外暴露方法。组件1则是通过props传递了两个父组件的方法,一个是用于间接地访问组件2中的方法,另一个则是可能导致父组件重渲染的方法(当然这种结构的安排明显是不太合理的,但由于项目历史包袱的原因咱就先不考虑这个问题了\doge)。
然后当我满心欢喜地Click组件时,一片红色的Error映入眼帘:
在几个关键位置加上打印:
const callRef = (str) => {
console.log(str, ' --- ', subRef.current)
}
add = () => {
this.props.callRef('打印1')
this.props.refreshApp()
this.setState({ count: this.state.count + 1 }, () => {
this.props.callRef('打印2')
setTimeout(() => {
this.props.callRef('打印3')
}, 0)
})
}
结果:
有点amazing啊。在调用前ref.current
是有正确值的,在setState
的回调中ref.current
变为null
了,而在setState
的回调中加上一个异步后,立即又变为正确值了。
要debug这个问题,一个非常关键的位置就在setState
的回调函数。熟悉React内部渲染流程的同学,应该知道,在React触发更新之后的commit阶段,也就是在React更新完DOM之后,针对fiber节点的类型分别做不同的处理(位于源码commitLifeCycles方法)。例如class组件中,会同步地执行setState
的回调;函数组件的话,则会同步地执行useLayoutEffect
的回调函数。
带着这个前提知识的情况下,我们给useImperativeHandle
加个断点。因为对于其他常见的hook和class组件生命周期在React更新渲染中的执行时机都是比较熟悉的,唯独这个useImperativeHandle
内部机制还不太了解,那么让我看看代码在进入该断点时的执行栈是怎样的:
首先,在左侧的callstack面板里看到了commitLifeCycles
方法,说明 useImperativeHandle
这个hook也是在更新渲染后的commit阶段同步执行的。接着我们进去impreativeHandleEffect
,也就是useImperativeHandle
回调函数的上一层:
方法体里先判断父组件传入的ref的类型。如果是一个函数,则将执行useImperativeHandle
回调函数执行后的对象传入去并执行;否则将对象赋值到ref.current
上。但这两种情况都会返回一个清理副作用的函数,而这个清理函数的任务就是——把我的ref.current
给置为null !?
抓到这个最重要的线索了,赶紧给这个清理函数打个断点,然后再触发一次更新看下:
这个清理函数是在commitMutationEffects
时期执行的;commitMutationEffects
里做的主要工作就是根据fiber节点的类型执行需要操作的副作用(位于commitWork方法),例如对DOM的增删改,以及我们熟知的useLayoutEffect
的清理函数也是在这时候完成的。
到目前为止,引发报错问题的整条链路就清晰了:
在触发更新后,在commit阶段的commitMutationEffects
部分会先执行useImperativeHandle
的清理函数,自这之后ref.current
就被置为了null
。
⬇
接着才到commitLayoutEffects
,该部分会执行setState
,useLayoutEffect
和useImpreativeHandle
这些方法的回调。
⬇
依据React以深度优先遍历方式生成fiber树且边生成边收集副作用的规则,子组件1中setState
回调会比useImpreativeHandle
的回调先执行,那么此时ref.current
仍然还为null
。
最后Bug的解决有一种较为简单的方式就是依据实现逻辑给useImperativeHandle
这个hook传入第三个参数,也就是依赖数组,使其不必在每次渲染时都重复创建就可以了:
useImperativeHandle(ref () => ({
/* ... */
}), [/* 依赖项 */])