從源碼入手探究一個因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加個斷點。因為對於其他常見的hookclass組件生命周期在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,該部分會執行setStateuseLayoutEffectuseImpreativeHandle這些方法的回調。

依據React以深度優先遍歷方式生成fiber樹且邊生成邊收集副作用的規則,子組件1中setState回調會比useImpreativeHandle的回調先執行,那麼此時ref.current仍然還為null

Tags: