useState() 没有从事件处理程序更新状态?

useState() is not updating state from event handler?

我正在尝试在 React 中重新创建一个旧的 Flash 游戏。游戏的目标是按下一个按钮一段时间。

这是老游戏: http://www.zefrank.com/everysecond/index.html

这是我的新 React 实现: https://codesandbox.io/s/github/inspectordanno/every_second

我 运行 遇到了问题。松开鼠标时,我使用 Moment.js 时间库计算按下按钮和松开按钮之间的时间量。如果 onMouseDownonMouseUp 事件之间的 timeDifferencetargetTime 之内,我希望游戏 level 增加并且 targetTime 增加还有。

我正在 handleMouseUp 事件处理程序中实现此逻辑。我将预期的时间打印到屏幕上,但逻辑不工作。此外,当我 console.log() 次时,它们与打印到屏幕上的不同。我相当确定 timeHeldtimeDifference 没有正确更新。

一开始我以为我做事件处理器的方式有问题,需要用useRef()useCallback(),但是浏览了其他几个问题后我不明白这些足以知道我是否必须在这种情况下使用它们。因为我不需要访问以前的状态,所以我认为我不需要使用它们,对吗?

游戏逻辑在此包装器组件中:

import React, { useState } from 'react';
import moment from 'moment';
import Button from './Button';
import Level from './Level';
import TargetTime from './TargetTime';
import TimeIndicator from './TimeIndicator';
import Tries from './Tries';

const TimerApp = () => {
  const [level, setLevel] = useState(1);
  const [targetTime, setTargetTime] = useState(.2);
  const [isPressed, setIsPressed] = useState(false);
  const [whenPressed, setPressed] = useState(moment());
  const [whenReleased, setReleased] = useState(moment());
  const [tries, setTries] = useState(3);
  const [gameStarted, setGameStarted] = useState(false);
  const [gameOver, setGameOver] = useState(false);

  const timeHeld = whenReleased.diff(whenPressed) / 1000;
  let timeDifference = Math.abs(targetTime - timeHeld);
  timeDifference = Math.round(1000 * timeDifference) / 1000; //rounded

  const handleMouseDown = () => {
    !gameStarted && setGameStarted(true); //initialize game on the first click
    setIsPressed(true);
    setPressed(moment());
  };

  const handleMouseUp = () => {
    setIsPressed(false);
    setReleased(moment());

    console.log(timeHeld);
    console.log(timeDifference);

    if (timeDifference <= .1) {
      setLevel(level + 1);
      setTargetTime(targetTime + .2);
    } else if (timeDifference > .1 && tries >= 1) {
      setTries(tries - 1);
    }

    if (tries === 1) {
      setGameOver(true);
    }
  };

  return (
    <div>
      <Level level={level}/>
      <TargetTime targetTime={targetTime} />
      <Button handleMouseDown={handleMouseDown} handleMouseUp={handleMouseUp} isGameOver={gameOver} />
      <TimeIndicator timeHeld={timeHeld} timeDifference={timeDifference} isPressed={isPressed} gameStarted={gameStarted} />
      <Tries tries={tries} />
      {gameOver && <h1>Game Over!</h1>}
    </div>
  )
}

export default TimerApp;

如果你想检查整个应用程序,请参考沙箱。

When the mouse is released, I calculate the amount of time between when the button was pressed and when it was released...

这不是真的 ...但可以...只需将时差计算移入 handleMouseUp()

...还有 - 你不需要 whenReleased

我认为这里发生了两件微妙的事情:

  1. 当您调用 setState 方法(例如 setRelease(moment()))时,关联变量的值(例如 whenReleased)不会立即更新。相反,它将 re-render 排队,并且只有在渲染发生后才会更新值。

  2. 事件处理程序(例如 handleMouseUp)是闭包。这意味着它们从父范围捕获值。同样,仅在 re-render 上更新 。因此,当 handleMouseUp 运行时,timeDifference(和 timeHeld)将是上次渲染期间计算的值。

因此您需要进行的更改是:

  1. timeDifference 的计算移到 handleMouseUp 事件处理程序中。
  2. 您需要在 timeDifference 计算中使用 whenReleased,而不是使用设置为 moment() 的局部变量(您可以 也可以 通过 setReleased 设置 whenReleased,但您在事件处理程序中无法使用该值。
  const handleMouseUp = () => {
    const released = moment();

    setIsPressed(false);
    setReleased(released);

    const timeHeld = released.diff(whenPressed) / 1000;
    const timeDifference = Math.round(1000 * Math.abs(targetTime - timeHeld)) / 1000;

    console.log(timeHeld);
    console.log(timeDifference);

    if (timeDifference <= .1) {
      setLevel(level + 1);
      setTargetTime(targetTime + .2);
    } else if (timeDifference > .1 && tries >= 1) {
      setTries(tries - 1);
    }

    if (tries === 1) {
      setGameOver(true);
    }
  };

如果您更新函数内部的某些状态,然后尝试在同一个函数中使用该状态,它将不会使用更新后的值。当函数被调用并在整个函数中使用时,函数会快照状态值。在 class 组件的 this.setState 中不是这种情况,但在钩子中是这种情况。 this.setState 也不会急切地更新值,但它可以在同一函数中更新,具体取决于一些事情(我没有资格解释)。
要使用更新的值,您需要一个参考。因此使用 useRef 钩子。 [docs]
我已经修复了你的代码,你可以在这里看到它:https://codesandbox.io/s/everysecond-4uqvv?fontsize=14
它可以用更好的方式编写,但你必须自己写。

也在答案中添加代码以完成(带有一些注释来解释内容,并提出改进建议):

import React, { useRef, useState } from "react";
import moment from "moment";
import Button from "./Button";
import Level from "./Level";
import TargetTime from "./TargetTime";
import TimeIndicator from "./TimeIndicator";
import Tries from "./Tries";

const TimerApp = () => {
  const [level, setLevel] = useState(1);
  const [targetTime, setTargetTime] = useState(0.2);
  const [isPressed, setIsPressed] = useState(false);
  const whenPressed = useRef(moment());
  const whenReleased = useRef(moment());
  const [tries, setTries] = useState(3);
  const [gameStarted, setGameStarted] = useState(false);
  const [gameOver, setGameOver] = useState(false);
  const timeHeld = useRef(null);  // make it a ref instead of just a variable
  const timeDifference = useRef(null);  // make it a ref instead of just a variable

  const handleMouseDown = () => {
    !gameStarted && setGameStarted(true); //initialize game on the first click
    setIsPressed(true);
    whenPressed.current = moment();
  };

  const handleMouseUp = () => {
    setIsPressed(false);
    whenReleased.current = moment();

    timeHeld.current = whenReleased.current.diff(whenPressed.current) / 1000;
    timeDifference.current = Math.abs(targetTime - timeHeld.current);
    timeDifference.current = Math.round(1000 * timeDifference.current) / 1000; //rounded

    console.log(timeHeld.current);
    console.log(timeDifference.current);

    if (timeDifference.current <= 0.1) {
      setLevel(level + 1);
      setTargetTime(targetTime + 0.2);
    } else if (timeDifference.current > 0.1 && tries >= 1) {
      setTries(tries - 1);
      // consider using ref for tries as well to get rid of this weird tries === 1 and use tries.current === 0
      if (tries === 1) {
        setGameOver(true);
      }
    }
  };

  return (
    <div>
      <Level level={level} />
      <TargetTime targetTime={targetTime} />
      <Button
        handleMouseDown={handleMouseDown}
        handleMouseUp={handleMouseUp}
        isGameOver={gameOver}
      />
      <TimeIndicator
        timeHeld={timeHeld.current}
        timeDifference={timeDifference.current}
        isPressed={isPressed}
        gameStarted={gameStarted}
      />
      <Tries tries={tries} />
      {gameOver && <h1>Game Over!</h1>}
    </div>
  );
};

export default TimerApp;

PS:不要使用不必要的第三方库,尤其是像 MomentJs 这样的大库。它们会显着增加您的捆绑包大小。使用 vanilla js 可以轻松获取当前时间戳。 Date.now() 将为您提供当前的 unix 时间戳,您可以减去两个时间戳以获得以毫秒为单位的持续时间。

你还有一些不必要的状态,比如gameOver,你可以只检查if tries > 0来决定gameOver。
类似地,您可以只使用 level * .2 而不是 targetTime,无需其他状态。
另外 whenReleased 不需要是 ref 或 state,它可以只是 mouseup 处理程序中的局部变量。

状态更新器可以采用指示新状态的值,或将当前状态映射到新状态的函数。当你的状态依赖于自身的变化时,后者是完成这项工作的正确工具。

如果您更新代码中使用该模式的地方,这应该会起作用

[value, setValue ] = useState(initial);
...
setValue(value + change);

[value, setValue ] = useState(initial);
...
setValue((curValue) => curValue + change);

例如,

if (timeDifference <= .1) {
  setLevel((curLevel) => curLevel + 1);
  setTargetTime((curTarget) => curTarget + .2);
} else if (timeDifference > .1 && tries >= 1) {
  setTries((curTries) => {
    const newTries = curTries - 1;
    if (newTries === 1) {
      setGameOver(true);
    }
    return newTries;
  });
}