错误的 React 挂钩行为与事件监听器

Wrong React hooks behaviour with event listener

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

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

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

为什么会显示错误的状态?
在第一张卡片中,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,问题就消失了。 使用 class 的 CodeSandbox:https://codesandbox.io/s/w2nn3mq9vl

这是使用 useState 挂钩的功能组件的常见问题。同样的问题适用于任何使用 useState 状态的回调函数,例如.

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

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

Card 功能组件中使用的

handleCardClick 作为 prop 接收,并在 useEffect 的组件安装上注册一次。它在整个组件生命周期中都是相同的函数,并且指的是第一次定义 handleCardClick 函数时新鲜的陈旧状态。 handleButtonClick 作为道具接收并在每次 Card 渲染时重新注册,每次都是一个新函数并引用新状态。

可变状态

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

const ref = useRef(0);

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

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

可以单独保持状态更新和可变状态,但 forceUpdate 在 class 和功能组件(列出仅供参考)中都被认为是一种反模式:

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 这样的同步副作用需要一个状态,解决方法是 return 相同的状态以防止更新。

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

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

const eventListener = useCallback(() => {
  console.log(state);
}, [state]);
  • 此答案的上一版本建议使用适用于 React 16.7.0-alpha 版本中初始 useState 挂钩实现但在最终 React 16.8 实现中不可用的可变状态。 useState 目前仅支持不可变状态。*

解决这个问题的更简洁的方法是创建一个我称之为 useStateRef

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

  const ref = useRef(value);

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

  return [value, setValue, ref];
}

您现在可以使用 ref 作为状态值的参考。

我的简短回答是 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}));
  }

  //...
}

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

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

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

我的简短回答

this WILL NOT not trigger re-render ever time myvar changes.

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

This WILL trigger render -> putting myvar in []

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

更改 index.js 文件中的以下行后,button2 运行良好:

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

你不应该使用 [] 作为第二个参数 useEffect 除非你希望它 运行 一次。

更多详情:https://reactjs.org/docs/hooks-effect.html

这样您的回调将始终具有更新的状态值;)

 // registers an event listener to component parent
 React.useEffect(() => {

    const parentNode = elementRef.current.parentNode

    parentNode.addEventListener('mouseleave', handleAutoClose)

    return () => {
        parentNode.removeEventListener('mouseleave', handleAutoClose)
    }

}, [handleAutoClose])