從源碼入手探究一個因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
。