ClickAwayListener 的回调函数在执行中途停止

Callback function for ClickAwayListener stop midway on execution

我正在为我的应用程序使用 Material-UI ClickAwayListenerreact-router。我遇到的错误是,当执行 ClickAwayListener 的回调时,它会在 useEffect 到 运行 的中途停止,然后恢复 运行ning。回调不会出现此行为。回调应该在 useEffect 可以 运行 之前完全执行。下面是我为演示问题而创建的代码和 this is the demo 代码

import React, { useEffect, useState } from "react";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link,
  useHistory,
  useParams
} from "react-router-dom";

export default function BasicExample() {
  return (
    <Router>
      <div>
        <Switch>
          <Route exact path="/">
            <ButtonPage />
          </Route>
          {/*Main focus route here*/ }
          <Route path="/:routeId">
            <Home />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

// Main focus here
function Home() {
  const history = useHistory();
  const { routeId } = useParams();

  const [count, setCount] = useState(0);

  const handleClick1 = () => {
    history.push("/route1");
  };
  const handleClick2 = () => {
    history.push("/route2");
  };

  // useEffect run on re-render and re-mount
  useEffect(() => {
    console.log("Re-render or remount");
  });
  
  useEffect(() => {
    console.log("Run on route change from inside useEffect");
  }, [routeId]);

  const handleClickAway = () => {
    console.log("First line in handle click away");
    setCount(count + 1);
    console.log("Second line in handle click away");
  };

  return (
    <div>
      <button onClick={handleClick1}>Route 1 </button>
      <button onClick={handleClick2}>Route 2 </button>
      <ClickAwayListener onClickAway={handleClickAway}>
        <div style={{ height: 100, width: 100, backgroundColor: "green" }}>
          Hello here
        </div>
      </ClickAwayListener>
    </div>
  );
}

// Just a component such that home route can navigate
// Not important for question
function ButtonPage() {
  const history = useHistory();

  const handleClick1 = () => {
    history.push("/route1");
  };
  const handleClick2 = () => {
    history.push("/route2");
  };

  return (
    <div>
      <button onClick={handleClick1}>Route 1 </button>
      <button onClick={handleClick2}>Route 2 </button>
    </div>
  );
}

具体来说,当我在 ClickAwayListener 之外单击 handleClickAway 运行 时,日志消息是

First line in handle click away
Second line in handle click away
Re-render or remount 

直到我选择点击导航到其他路线的button。事情变得奇怪了:handleClickAway 运行 第一行记录,然后是 useEffect 运行 并打印它的记录,然后 handleClickAway 恢复并打印它的第二行。所以如果我这样做,这就是日志记录

First line in handle click away
Re-render or remount
Run on route change from inside useEffect
Second line in handle click away

在对这个错误做了一些测试后,我发现导致这个错误的是 handleClickAway 里面的 setCount。如果我删除此行,函数 handleClickAway 将按预期在所有情况下 运行 。我的结论是,更改组件状态,或者我应该说,执行任何导致组件在内部重新呈现的操作 handleClickAway 结合路由导航可能会导致此错误。

这种行为很奇怪,因为据我所知,正常的、非承诺相关的回调没有办法像这样中途停止。我猜 ClickAwayListener 以某种方式使 handleClickAway 变成了 Promise 之类的东西。但即便如此,它也没有理由停在setCount而让useEffect运行? 谁能给我解释一下?

编辑 1:正如@Rex Pan 在这个答案中指出的那样,看起来 handleClickAwayuseEffect 运行 里面。这是我通过阅读跟踪堆栈可以得出的唯一结论。但是只有当用户点击导航到不同路线的 link 时才会发生这种行为。单击旁边的其他区域不会导致此错误。有人可以向我解释为什么会这样吗?

编辑 2:阅读@Rex Pan 的回答后,我做了更多调查。在我看来,他的回答是正确的,但只适用于 ClickAwayListener 回调。所以下面的代码将产生与上面相同的“错误”

export default function ClickAway() {
  const classes = useStyles();
  const [count, setCount] = React.useState(0);

  useEffect(() => {
    console.log("Test in useEffect");
  });

  const handleClickAway = () => {
    console.log("Count is " + count);
    setCount(count + 1);
    setCount(count + 10);
    console.log("Count is " + count);
  };

  return (
    <ClickAwayListener onClickAway={handleClickAway}>
      <div className={classes.root}>Click away</div>
    </ClickAwayListener>
  );
}

但是,他的解释在其他情况下不起作用,例如下面的代码

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Run inside useEffect");
  });

  const handleClick = () => {
    console.log("First line");
    setCount(count + 1);
    console.log("Second line");
    setCount(count + 100);
    console.log("Third line");
  };

  return (
    <>
      <div>Count is: {count} </div>
      <button onClick={handleClick}>Click</button>
    </>
  );
}

我正在尝试阅读 ClickAwayListner 的源代码,但到目前为止还没有找到导致此行为的位置。有人可以指出这一点吗?

这就是发生这种情况的原因。 useEffect 使用 routeId 作为其道具之一。当 handleClickAway 触发时,您可能正在导航,因此 useEffect 被触发,然后记录到您的控制台。

此外,您确定它是停止执行,而不仅仅是异步登录吗?一般useEffects不会阻塞任何其他代码的执行。

一个简单的测试方法是延迟 useEffect 中的日志记录。如果它没有阻止执行,您的其他日志应该先显示,然后这个日志显示。

useEffect(() => {
   setTimeout(() => console.log('delayed log'), 3000)
}, [routeId])

正在回复您的评论:

根据 this 文章,任何异步调用都是在调用堆栈之外完成的,因此实际上不会阻塞代码的执行。

In a nutshell, the asynchronous implementation in Javascript is done through a call stack, call back queue and Web API and event loop.

React 可能对 useEffect 做同样的事情,所以这并不意味着它停止了对 运行 useEffect 的回调的执行,只是它 运行 它与它一起。

在文章中,他使用了一个超时示例,就像我在这里所做的那样,然后解释了它在堆栈中的处理方式:

In Javascript, All instructions are put on a call stack. When the stack arrives at setTimeout, the engine sees it as a Web API instruction and pops it out and sends it to Web API. Once the Web API is done with the execution, it will arrive at the call back queue.

setCount() 会导致渲染,这会刷新先前渲染中的 useEffect()。这导致 useEffect()s 在 setCount() return 到 handleClickAway().

之前被调用
handleClickAway()
    console.log("First line in handle click away");
    setCount(count + 1);
        ...
            effect0000000()
                console.log("Re-render or remount");
            effect_on_routeId()
                console.log("Run on route change");
    console.log("Second line in handle click away");

您可以通过添加函数名称和 new Error() 并检查调用堆栈来验证。

useEffect(function effect0000000() {
    console.log(new Error())
    console.log("Re-render or remount");
  });

  useEffect(function effect_on_routeId() {
    console.log(new Error())
    console.log("Run on route change");
  }, [routeId]);

  const handleClickAway = function handleClickAway() {
    console.log("First line in handle click away");
    setCount(count + 1);
    console.log("Second line in handle click away");
  };

如果你点击外部,

  1. 调用handleClickAway(),记录First line in handle click away
  2. setCount() 被调用,React 将重新渲染 2,这将安排 useEffect() 绑定到 2
  3. Home() returned, setCount() returned
  4. handleClickAway()日志Second line in handle click away
  5. useEffect() 绑定到 2 按计划调用

点击路由按钮时

  1. history.push() 导致渲染 2,这将安排 useEffect() 绑定到 this = 2
  2. handleClickAway() 调用并记录 First line in handle click away
  3. setCount()被称为
  4. React 需要在下一次渲染之前刷新(调用)之前的效果(绑定到 this = 2
  5. React 调用 Home() 渲染 3,它将安排 useEffect() 绑定到 3
  6. Home() returned, setCount() returned
  7. handleClickAway()日志Second line in handle click away
  8. useEffect() 绑定到 3 按计划调用

当你点击一个路由按钮时,两个事件被同时触发,并且在两个处理程序内部的 history.push() 和 setcount() 之间存在竞争条件,因此发生了两个独立的重新渲染,每个重新渲染触发效果。所以这不是错误,两个处理函数同时完成它们的工作,并且每个函数都执行其中的所有代码。