大佬,怎么办?升级React17,Toast组件不能用了

你们好,我是卡颂,人称卡尔摩斯。html

今天,咱们来追查一个棘手的React bug,知名组件库material-ui就受其影响。react

这个bug的产生涉及多方因素,包括:git

  • useEffect执行时机(极可能与你想的不同)github

  • 合成事件原理算法

  • v17源码中对合成事件的改动浏览器

  • Portal原理markdown

这篇文章很长很长,有很是多源码细节。架构

你能够用以下Demo和我一块儿debug源码,更有破案的感受app

在线Demo地址dom

相信整篇文章过完,你能对如上知识点有更深的理解。

接下来,让咱们复现案发现场吧。

只在v17下复现的bug

假设,咱们有个ToastButton组件,代码以下:

function ToastButton() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    if (!show) return;

    function clickHandler(e) {
      setShow(false);
    }

    document.addEventListener("click", clickHandler);
    return () => {
      document.removeEventListener("click", clickHandler);
    };
  }, [show]);

  return (
    <div> <button type="button" onClick={() => setShow(true)}>Show Toast</button> {show && <div className="toast">Hey, Ka Song~</div>} </div>
  );
}
复制代码

点击button后,show状态变为true,展现toast

同时在useEffect回调中,在document上注册点击事件

触发点击事件会让show状态置为false,达到点击页面任意区域关闭toast的效果。

入口函数以下:

function App() {
  return (
    <ToastButton />
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
复制代码

效果以下:

show.gif

接下来,咱们再增长一个渲染Portal的组件PortalRenderer,代码以下:

function PortalRenderer() {
  const [show, setShow] = useState(false);

  return (
    <React.Fragment> <button type="button" onClick={() => setShow(true)}> Render portal </button> {show && ReactDOM.createPortal( <div>who is handsome?</div>, document.body )} </React.Fragment>
  );
}
复制代码

点击button后会将show状态置为true

会使用ReactDOM.createPortaldocument.body上挂载一个div,内容为who is handsome?

咱们将两个组件一块儿放在App中:

function App() {
  return (
    <div> <PortalRenderer /> <ToastButton /> </div>
  );
}
复制代码

点击PortalRenderer效果以下:

portal-dev.gif

如今问题来了:

若是先点击PortalRendererbutton,再点击ToastButton会怎么样?

理所固然的答案是:

  • 先显示who is handsome?

  • 再显示Hey, Ka Song~

然而,在React v17效果以下:

bug.gif

先点击PortalRendererbutton后,再点击ToastButton,不会看见toast的内容。

可是,只要不点击PortalRendererbutton就不会有问题:

bug2.gif

这只是一个可复现该bug的极简Demo

事实上,在一个大型项目中,若是从v16升级到v17

在使用了如上所示的在document挂载原生click事件方式实现toast的同时,

再使用Portaldocument.body挂载DOM都会触发该bug

一旦先渲染了Portal,你的toast就不能用了。意不意外?惊不惊喜?

接下来,让咱们一步步揭开这个bug的庐山真面目。

div去哪了?

首先,咱们要明确,点击Show Toast没反应,是由于没渲染toast,仍是由于渲染了toast又马上删除了。

审查元素后发现,每当点击Show ToastToastButton渲染的div都会闪一下。

这表明该div下发生了DOM变化。

而咱们并无看到DOM的插入,那么这就表示:

这里先发生了DOM插入,紧接着发生了DOM移除

而这个DOM就是toast对应DOM

<div className="toast">Hey, Ka Song!</div>

咱们知道,该DOM显示与否受ToastButton组件的show状态影响,

因而,接下来的线索有三条:

  1. 为何一次点击,ToastButton组件的show状态先变为true,后变为false

  2. 为何只有在挂载了Portal的状况下bug能复现?

  3. 为何该bug只在v17复现?

该从哪条线索下手呢?

v17有哪些变化?

相比第1、二条,第三条线索能更好控制影响范围。

看看v17的更新log,一条特性变化引发了卡尔摩斯的注意:

v17以前,整个应用的事件会冒泡到同一个根节点(html DOM节点)。

而在v17,每一个应用的事件都会冒泡到该应用本身的根节点(ReactDOM.render挂载的节点,在Demo中是div#root)。

这个改动是为了让一个应用下能够存在多个不一样模式的子应用(兼容legacy modeconcurrent mode同时存在于一个应用)。

会不会是这个缘由呢?

因而,卡尔摩斯将目光锁定在源码中注册事件的方法:addTrappedEventListener

在应用初始化时(调用ReactDOM.render首屏渲染时),React会遍历全部原生事件名,依次在根节点调用该方法注册事件回调。

在应用运行过程当中,全部原生事件都会由根节点(Demo中的div#root)代理。

以一个React组件的onClick事件举例,当点击发生后,会依次执行:

  1. 原生点击事件向上冒泡

  2. 原生点击事件冒泡到根节点,触发addTrappedEventListener注册的事件处理函数

  3. 合成事件会在React组件树中从底向上冒泡

  4. 合成事件冒泡到触发点击的组件时,调用onClick方法

这就是React合成事件的原理。

那么,为何只有在挂载了Portal的状况下bug能复现?

难道Portal与合成事件有关?

果真,当咱们点击PortalRendererbutton后,又进入了addTrappedEventListener的断点。

与初始化时(执行ReactDOM.render时)事件挂载的目标节点(div#root)不一样,

因为Portal挂载在document.body上,见以下节选代码:

// 节选自PortalRenderer
{show &&
  ReactDOM.createPortal(
    <div>who is handsome?</div>,
    document.body
)}
复制代码

因此会在document.body再执行一遍全部原生事件的代理逻辑。

能够看到此时事件会在body上注册:

这就意味着,原生事件冒泡到根节点(div#root)后,继续向上冒泡,在document.body又会触发一遍事件处理函数。

以一个React组件的onClick事件举例,当点击发生后,会依次执行:

  1. 原生点击事件向上冒泡

  2. 原生事件冒泡到根节点(div#root),触发addTrappedEventListener注册的事件处理函数

  3. 合成事件会在React组件树中从底向上冒泡

  4. 合成事件冒泡到触发点击的组件时,调用onClick方法

  5. 原生点击事件继续向上冒泡到document.body

  6. 重复触发步骤3

难道bug的缘由是onClick被重复执行两次?

若是是这么明显的bug你们开发过程当中确定很容易复现。

咱们能够在onClick中打印日志,能够看到:一次点击只会打印一条日志。

click.gif

那么问题出在哪呢?

useEffect的执行时机

让咱们回到第一条线索:

为何一次点击,ToastButton组件的show状态先变为true,后变为false

咱们能够从useEffect回调中找找线索。

// 节选自ToastButton
 useEffect(() => {
  if (!show) return;

  function clickHandler(e) {
    setShow(false);
  }

  document.addEventListener("click", clickHandler);
  return () => {
    document.removeEventListener("click", clickHandler);
  };
}, [show]);
复制代码

能够看到,state变为false是因为clickHandler调用。

clickHandler调用是因为document被点击。

因此show状态连续变化的缘由极可能是:

  1. 点击ToastButton原生点击事件冒泡到应用挂载的根节点

  2. 进入合成事件的冒泡逻辑,冒泡到ToastButton时触发onClick

  3. onClicksetShow(true)state变为true,渲染toast DOM

  4. useEffect回调执行,为document绑定click事件

  5. 原生点击事件继续冒泡,当冒泡到document时,触发其绑定的click事件

  6. 调用clickHandlerstate变为false,移除toast DOM

正当我为这精妙的推理沾沾自喜时,忽然意识到一个问题:

要知足如上逻辑,步骤4和步骤5之间必须是同步执行。

由于一旦步骤4是异步执行,则当步骤5原生点击事件冒泡到document时,步骤4documentclick事件还未绑定。

步骤4在useEffect回调函数中,而useEffect的回调是在执行完DOM操做后异步执行的。

若是useEffect回调在DOM变化后同步执行,会阻塞DOM重排、重绘,因此被设计为异步执行。若是必定要在DOM变化后同步执行反作用,可使用useLayoutEffect

因此,正常状况下,步骤4和步骤5是在不一样的两个浏览器task执行。

然而,总有意外。

useEffect的边界case

React中,一个常见的操做链路是:

用户触发事件 -> 改变state -> 依赖该stateuseEffect回调执行

去掉中间环节,就是这样:

用户触发事件 -> ... -> useEffect回调执行

而咱们刚才说,useEffect回调是异步执行的。

那么设想如下场景:

用户快速点击鼠标触发onClick事件,如何保证每次点击产生的useEffect回调按顺序执行呢?

为了解决这个问题,React将不一样原生事件分类。

其中clickkeydown等这种不连续触发的事件被称为离散事件(与之对应的就是scroll这种能连续触发的事件)。

源码中全部离散事件的定义见这里

为了保证以下链路中的useEffect回调都能按顺序执行

离散事件 -> ... -> useEffect回调执行

每当处理离散事件前,都会执行flushPassiveEffects方法。

该方法会将还未执行的useEffect回调执行。

这样就能保证下一次useEffect回调执行前上一次的useEffect回调已经执行。

因此,当不点击PortalRendererbutton挂载Portal时,点击ToastButton的完整流程以下:

  1. 点击ToastButton原生点击事件冒泡到应用挂载的根节点

  2. 进入合成事件的冒泡逻辑,冒泡到ToastButton时触发onClick

  3. onClicksetShow(true)state变为true,渲染toast DOM

  4. useEffect回调异步执行,为document绑定click事件

  5. 原生点击事件继续冒泡到document,此时document还未绑定click事件

UI表现为:点击ToastButton,展现toast

当点击PortalRendererbutton挂载Portal后,再点击ToastButton的完整流程以下:

  1. 点击PortalRendererbutton,在document.body挂载Portal对应DOM

  2. document.body执行绑定事件代理逻辑

  3. 点击ToastButton原生点击事件冒泡到应用挂载的根节点

  4. 进入合成事件的冒泡逻辑,冒泡到ToastButton时触发onClick

  5. onClicksetShow(true)state变为true,渲染toast DOM

  6. useEffect回调异步执行,为document绑定click事件

  7. 原生点击事件继续冒泡到document.body,因为body绑定了事件代理逻辑,因此会处理离散事件

  8. 处理的第一步是将还未执行的步骤6同步执行,此时document绑定click事件

  9. 原生点击事件继续冒泡到document,触发步骤6绑定的click事件

  10. 调用clickHandlerstate变为false,移除toast DOM

UI表现为:点击ToastButton,无反应(实际是先展现toast,再在同一个浏览器task移除toast

bug解决

能够看到,这是React源码运行流程的几个feature综合起来形成的bug

如何修复呢?在现有v17架构下没法很好修复。

v18,伴随Concurrent Mode启发式更新算法,会修复该bug

bug修复见Flush discrete passive effects before paint #21150

修复的方式很简单:若是一个useEffect回调是由离散事件形成的,则该useEffect回调不会异步执行,而是会在本轮DOM更新完成后同步执行。

至于为何v16及以前版本不会复现这个bug

由于以前的版本全部原生事件都注册在html DOM上。

就不存在原生事件在冒泡过程当中触发多个事件代理的状况。

gulu.gif

bug来临,没有一片feature是无辜的。

如今,终于有点能体会为啥React团队开发Concurrent Mode相关功能花了2年多时间。

真是,牵一发动全身啊~

相关文章
相关标签/搜索