惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy International News Feed
WordPress大学
WordPress大学
Security Latest
Security Latest
Cyberwarzone
Cyberwarzone
K
Kaspersky official blog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
G
GRAHAM CLULEY
N
News | PayPal Newsroom
Apple Machine Learning Research
Apple Machine Learning Research
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
V
Visual Studio Blog
美团技术团队
J
Java Code Geeks
I
Intezer
The Cloudflare Blog
SecWiki News
SecWiki News
S
Secure Thoughts
Microsoft Azure Blog
Microsoft Azure Blog
V2EX - 技术
V2EX - 技术
C
Cyber Attacks, Cyber Crime and Cyber Security
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Spread Privacy
Spread Privacy
D
DataBreaches.Net
S
Security Affairs
Help Net Security
Help Net Security
S
Securelist
F
Full Disclosure
C
Check Point Blog
F
Fortinet All Blogs
Know Your Adversary
Know Your Adversary
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Y
Y Combinator Blog
云风的 BLOG
云风的 BLOG
阮一峰的网络日志
阮一峰的网络日志
The Register - Security
The Register - Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
人人都是产品经理
人人都是产品经理
博客园_首页
G
Google Developers Blog
Google Online Security Blog
Google Online Security Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
H
Help Net Security
酷 壳 – CoolShell
酷 壳 – CoolShell
I
InfoQ
Application and Cybersecurity Blog
Application and Cybersecurity Blog
H
Hacker News: Front Page
L
LINUX DO - 热门话题
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LangChain Blog

博客园 - 爱喝可乐的咖啡

引入AI辅助的3D游戏美术工作流 基于ThreeJs的大屏3D地图(二)——气泡图、渐变柱体与热力图 基于Three.js的大屏3D地图(一)——地图模型渲染 不止于面向对象的SOLID原则 沿SVG路径的颜色渐变 参照DefenseGrid在Unity中实现合理的塔防寻路机制 slate源码解析(三)- 定位 定位解析一个因脚本劫持导致webpack动态加载异常的问题 slate源码解析(二)- 基本框架与数据模型 slate源码解析(一)- 序言 重新捋一捋React源码之更新渲染流程 [译]深入了解现代web浏览器(四) [译]深入了解现代web浏览器(三) [译]深入了解现代web浏览器(二) [译]深入了解现代web浏览器(一) 依据HTML标准再探Javascript事件循环及其与浏览器渲染的关系 记一次flex布局中子项目尺寸不受flex-shrink限制的问题 浅析富文本编辑器框架Slate.js 关于在异步操作中访问React事件对象的问题
从源码入手探究一个因useImperativeHandle引起的Bug
爱喝可乐的咖啡 · 2022-10-27 · via 博客园 - 爱喝可乐的咖啡

今天本来正在工位上写着一段很普通的业务代码,将其简化后大致如下:

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

最后Bug的解决有一种较为简单的方式就是依据实现逻辑给useImperativeHandle这个hook传入第三个参数,也就是依赖数组,使其不必在每次渲染时都重复创建就可以了:

useImperativeHandle(ref () => ({
    /* ... */
    
}), [/* 依赖项 */])