错误的 React 使用事件侦听器挂钩行为

IT技术 javascript reactjs react-hooks
2021-02-09 22:14:31

我在玩React Hooks并且遇到了一个问题。当我尝试使用由事件侦听器处理的按钮进行控制台记录时,它显示错误的状态。

代码沙盒: https ://codesandbox.io/s/lrxw1wr97m

  1. 单击“添加卡”按钮 2 次
  2. 在第一张卡片中,单击Button1并在控制台中看到有 2 张卡片处于状态(正确的行为)
  3. 在第一张卡片中,单击Button2(由事件侦听器处理)并在控制台中看到只有 1 张卡片处于状态(错误行为)

为什么它显示错误的状态?
在第一张卡片中,Button2应该2在控制台中显示卡片。有任何想法吗?

const { useState, useContext, useRef, useEffect } = React;

const CardsContext = React.createContext();

const CardsProvider = props => {
  const [cards, setCards] = useState([]);

  const addCard = () => {
    const id = cards.length;
    setCards([...cards, { id: id, json: {} }]);
  };

  const handleCardClick = id => console.log(cards);
  const handleButtonClick = id => console.log(cards);

  return (
    <CardsContext.Provider
      value={{ cards, addCard, handleCardClick, handleButtonClick }}
    >
      {props.children}
    </CardsContext.Provider>
  );
};

function App() {
  const { cards, addCard, handleCardClick, handleButtonClick } = useContext(
    CardsContext
  );

  return (
    <div className="App">
      <button onClick={addCard}>Add card</button>
      {cards.map((card, index) => (
        <Card
          key={card.id}
          id={card.id}
          handleCardClick={() => handleCardClick(card.id)}
          handleButtonClick={() => handleButtonClick(card.id)}
        />
      ))}
    </div>
  );
}

function Card(props) {
  const ref = useRef();

  useEffect(() => {
    ref.current.addEventListener("click", props.handleCardClick);
    return () => {
      ref.current.removeEventListener("click", props.handleCardClick);
    };
  }, []);

  return (
    <div className="card">
      Card {props.id}
      <div>
        <button onClick={props.handleButtonClick}>Button1</button>
        <button ref={node => (ref.current = node)}>Button2</button>
      </div>
    </div>
  );
}

ReactDOM.render(
  <CardsProvider>
    <App />
  </CardsProvider>,
  document.getElementById("root")
);
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<div id='root'></div>

我正在使用 React 16.7.0-alpha.0 和 Chrome 70.0.3538.110

顺便说一句,如果我使用类重写 CardsProvider,问题就消失了。CodeSandbox 使用类:https ://codesandbox.io/s/w2nn3mq9vl

6个回答

这是使用useState钩子的功能组件的常见问题同样的问题适用于任何useState使用状态的回调函数,例如setTimeoutsetInterval定时器函数

事件处理程序在CardsProviderCard组件中的处理方式不同

handleCardClickhandleButtonClick使用在CardsProvider功能组件中的定义在其范围内。每次运行都会有新的函数,它们指的cards是定义它们时获得的状态。每次CardsProvider呈现组件时都会重新注册事件处理程序

handleCardClickCard功能组件中使用的作为 prop 接收并在组件挂载时注册一次useEffect它在整个组件生命周期中都是相同的函数,并且指的handleCardClick是第一次定义函数时新鲜的陈旧状态handleButtonClick作为props接收并在每次Card渲染时重新注册,它每次都是一个新函数并且指的是新鲜状态。

可变状态

解决此问题的常用方法是使用useRef代替useStateref 基本上是一个配方,它提供了一个可以通过引用传递的可变对象:

const ref = useRef(0);

function eventListener() {
  ref.current++;
}

在这种情况下,应该像预期的那样在状态更新时重新呈现组件useState,引用不适用。

可以分别保持状态更新和可变状态,但forceUpdate在类和函数组件中都被视为反模式(列出仅供参考):

const useForceUpdate = () => {
  const [, setState] = useState();
  return () => setState({});
}

const ref = useRef(0);
const forceUpdate = useForceUpdate();

function eventListener() {
  ref.current++;
  forceUpdate();
}

状态更新器功能

一种解决方案是使用状态更新器函数,该函数从封闭范围接收新鲜状态而不是陈旧状态:

function eventListener() {
  // doesn't matter how often the listener is registered
  setState(freshState => freshState + 1);
}

在这种情况下,同步副作用需要一个状态,例如console.log,解决方法是返回相同的状态以防止更新。

function eventListener() {
  setState(freshState => {
    console.log(freshState);
    return freshState;
  });
}

useEffect(() => {
  // register eventListener once

  return () => {
    // unregister eventListener once
  };
}, []);

这不适用于异步副作用,尤其是async函数。

手动事件监听器重新注册

另一种解决方案是每次都重新注册事件侦听器,因此回调始终从封闭范围内获得新状态:

function eventListener() {
  console.log(state);
}

useEffect(() => {
  // register eventListener on each state update

  return () => {
    // unregister eventListener
  };
}, [state]);

内置事件处理

除非事件侦听器注册在或当前组件范围之外的其他事件目标documentwindow否则必须尽可能使用 React 自己的 DOM 事件处理,这样就不需要useEffect

<button onClick={eventListener} />

在最后一种情况下,事件侦听器可以在作为 prop 传递时使用useMemouseCallback防止不必要的重新渲染:

const eventListener = useCallback(() => {
  console.log(state);
}, [state]);
  • 此答案的上一版本建议使用可变状态,该状态适用useState于 React 16.7.0-alpha 版本中的初始钩子实现,但在最终的 React 16.8 实现中不可用。useState目前仅支持不可变状态。*
@vsync 假定侦听器在某个时候未注册,因为永远不需要不进行清理的组件。卸载时应该已经有注销代码,是的,在这种情况下应该执行多次。为清晰起见更新。
2021-03-23 22:14:31
这个问题也在 React 文档中进行了解释:reactjs.org/docs/...
2021-04-01 22:14:31
Manual event listener re-registration - 每次重新注册前都需要注销,以消除重复调用。
2021-04-07 22:14:31

解决这个问题的更简洁的方法是创建一个我调用的钩子 useStateRef

function useStateRef(initialValue) {
  const [value, setValue] = useState(initialValue);

  const ref = useRef(value);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return [value, setValue, ref];
}

您现在可以将ref用作状态值的引用。

优秀的解决方案!谢谢先生,您的解决方案完美地解决了我的问题。
2021-03-13 22:14:31
这完全解决了问题,也超级干净。只需使用事件处理程序中的 refs 和 jsx 中的状态。爱它。
2021-03-25 22:14:31
这个 ref 值是更新的值吗?还是之前的值?
2021-04-11 22:14:31
@KamalHossain 此方法返回当前状态值的引用。您应该将您的状态重构为 [state, setState, stateRef] 并使用此方法而不是 useState(initialValue)
2021-04-11 22:14:31

对我的简短回答

不会在myvar 更改时触发重新渲染。

const [myvar, setMyvar] = useState('')
  useEffect(() => {    
    setMyvar('foo')
  }, []);

触发渲染 -> 将myvar 放入 []

const [myvar, setMyvar] = useState('')
  useEffect(() => {    
    setMyvar('foo')
  }, [myvar]);
不,这两个WILL触发重新呈现,它只是第一个useEffect将flash一次-在组件安装,而第二个将触发每一次myvar更新。
2021-04-10 22:14:31

对我来说简短的回答是 useState 有一个简单的解决方案:

function Example() {
  const [state, setState] = useState(initialState);

  function update(updates) {
    // this might be stale
    setState({...state, ...updates});
    // but you can pass setState a function instead
    setState(currentState => ({...currentState, ...updates}));
  }

  //...
}
我认为你错过了一个更大的图景,如果你不想 setState 你只想使用状态来返回一个特定的值或者想要映射状态的特定属性呢?你的答案只是一个更大的答案的一部分。
2021-04-07 22:14:31
@ncubica,绝对,但其他答案都强调参考和效果,我想强调您可能不需要它们。我第一次错过了这个。
2021-04-09 22:14:31

检查控制台,你会得到答案:

React Hook useEffect has a missing dependency: 'props.handleCardClick'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)

只需添加props.handleCardClick到依赖项数组,它就会正常工作。