5

从源码入手探究一个因useImperativeHandle引起的Bug - 谜原

 1 year ago
source link: https://www.cnblogs.com/geek1116/p/16834267.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从源码入手探究一个因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映入眼帘:

841228-20221027222621726-2144066223.png

在几个关键位置加上打印:

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)
    })
}

结果:

841228-20221027222646410-1612627740.png

有点amazing啊。在调用前ref.current是有正确值的,在setState的回调中ref.current变为null了,而在setState的回调中加上一个异步后,立即又变为正确值了。

要debug这个问题,一个非常关键的位置就在setState的回调函数。熟悉React内部渲染流程的同学,应该知道,在React触发更新之后的commit阶段,也就是在React更新完DOM之后,针对fiber节点的类型分别做不同的处理(位于commitLifeCycles方法)。例如class组件中,会同步地执行setState的回调;函数组件的话,则会同步地执行useLayoutEffect的回调函数。

带着这个前提知识的情况下,我们给useImperativeHandle加个断点。因为对于其他常见的hookclass组件生命周期在React更新渲染中的执行时机都是比较熟悉的,唯独这个useImperativeHandle内部机制还不太了解,然我们看看代码在进入该断点时的执行栈是怎样的:

841228-20221027222655931-605767851.png

首先,在左侧的callstack面板里看到了commitLifeCycles方法,说明 useImperativeHandle这个hook也是在更新渲染后的commit同步执行的。接着我们进去impreativeHandleEffect,也就是useImperativeHandle回调函数的上一层:

841228-20221027222702265-1974915514.png

方法体里先判断父组件传入的ref的类型。如果是一个函数,则将执行useImperativeHandle回调函数执行后的对象传入去并执行;否则将对象赋值到ref.current上。但这两种情况都会返回一个清理副作用的函数,而这个清理函数的任务就是——把我的ref.current给置为null !?

抓到这个最重要的线索了,赶紧给这个清理函数打个断点,然后再触发一次更新看下:

841228-20221027222709005-343472300.png

这个清理函数是在commitMutationEffects时期执行的;commitMutationEffects里做的主要工作就是就是fiber节点的类型执行需要操作的副作用(位于commitWork方法),例如对DOM的增删改,以及我们熟知的useLayoutEffect的清理函数也是在这时候完成的。

到目前为止,引发报错问题的整条链路就清晰了:

在触发更新后,在commit阶段的commitMutationEffects部分会先执行useImperativeHandle的清理函数,自这之后ref.current就被置为了null

接着才到commitLayoutEffects,该部分会执行setStateuseLayoutEffectuseImpreativeHandle这些方法的回调。

依据React以深度优先遍历方式生成fiber树且边生成边收集副作用的规则,子组件1中setState回调会比useImpreativeHandle的回调先执行,那么此时ref.current仍然还为null


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK