我在玩React钩子(Hook),遇到了一个问题。
当我尝试使用事件侦听器处理的按钮来控制台记录日志时,它显示错误的状态。
代码沙箱: https://codesandbox.io/s/lrxw1wr97m

  • 单击“添加卡”按钮2次
  • 在第一张卡中,单击Button1并在控制台中看到有2张卡处于状态(正确的行为)
  • 在第一张卡中,单击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
    顺便说一句,如果我使用Ñlass重写CardsProvider,问题就解决了。
    CodeSandbox使用类:https://codesandbox.io/s/w2nn3mq9vl

    最佳答案

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

    CardsProviderCard组件中,事件处理程序的处理方式有所不同。
    handleCardClick功能组件中使用的handleButtonClickCardsProvider在其范围内定义。每次运行时都有新函数,它们引用的是在定义它们时获得的cards状态。每次渲染CardsProvider组件时,事件处理程序都会重新注册。
    handleCardClick功能组件中使用的Card作为prop接收,并在组件安装时使用useEffect注册一次。它在整个组件生命周期期内都具有相同的功能,并且是指在首次定义handleCardClick函数时新鲜的过时状态。 handleButtonClick作为prop接收,并在每个Card渲染器上重新注册,每次都是新功能,并引用新鲜状态。

    可变状态

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

    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]);
    

    内置事件处理

    除非在document上注册了事件侦听器,否则window或其他事件目标不在当前组件的范围之内,否则必须尽可能使用React自己的DOM事件处理,这消除了对useEffect的需要:
    <button onClick={eventListener} />
    

    在最后一种情况下,可以使用useMemouseCallback额外记住事件监听器,以防止在作为 Prop 传递时不必要的重新渲染:
    const eventListener = useCallback(() => {
      console.log(state);
    }, [state]);
    

    答案的先前版本建议使用可变状态,该可变状态适用于React 16.7.0-alpha版本中的初始useState钩子(Hook)实现,但在最终的React 16.8实现中不起作用。 useState当前仅支持不可变状态。

    09-25 16:36
    查看更多