Dark Dwarf Blog background

React useEffect 实践: React 实现翻牌游戏项目

React useEffect 实践:React 实现翻牌游戏项目

下面我们通过讲解一下使用 React 实现 翻牌游戏的小项目,进一步讲解 React Effect 的一些知识。

最终效果如下:

alt text

这个游戏的组件设计也很简单,只有 Card、Board 和用来装 Score 这些东西的 Header。

下面我们直接讨论游戏的流程引起的状态变化。首先先设计如下的状态:

const [cards, setCards] = useState([]);
const [flipped, setFlipped] = useState([]);
const [locked, setLocked] = useState(false);
const [score, setScore] = useState(0);
const [best, setBest] = useState(() => {
  const v = localStorage.getItem("memory_best");
  return v ? Number(v) : 0;
});

const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState(null);

在开始之前,我们先复习一下 useEffect 的使用场景:

  1. 与“外界”交互。
  2. 响应 propsstate 的变化来执行一段逻辑。

首先是游戏刚开始的时候:我们需要从外部 API fetch 需要的数据,所以我们需要 useEffect。这个操作只有在刚进入的时候需要调用,因此依赖数组为 []。这个流程包含了如下步骤:

  1. 切换 Loading 模式:setLoading(true)。设置一个 mount 标记位(这是一个常用技巧)。
  2. 开始加载卡片初始数据,然后把加载到的卡片放到 cards 里面,设置好其他的初始值。如果报错了就 setErrors
  3. 不管成功与否,最后关掉 Loading 模式。
// fetch images -> build pairs on mount or when pairCount changes
useEffect(() => {
  let mounted = true;
  setLoading(true);
  setErrors(null);

  async function init() {
    try {
      const images = await fetchImages(pairCount);
      if (!mounted) return;
      const pairs = makePairs(images);
      setCards(shuffle(pairs));
      setScore(0);
      setFlipped([]);
    } catch (err) {
      if (!mounted) return;
      setErrors(err?.message || "Failed to fetch images");
    } finally {
      if (mounted) setLoading(false);
    }
  }

  init();
  return () => {
    mounted = false;
  };
}, [pairCount]);

然后是翻牌的逻辑:如果翻到的两张牌匹配了,就标记上 isMatched 并加分,否则直接 return。一旦有两张牌被翻,这个逻辑就被触发:

useEffect(() => {
  if (flipped.length !== 2) return;
  const [id1, id2] = flipped;
  const c1 = cards.find((c) => c.id === id1);
  const c2 = cards.find((c) => c.id === id2);

  if (!c1 || !c2) return;

  if (c1.pairId === c2.pairId) {
    setCards((prev) =>
      prev.map((c) => (c.pairId === c1.pairId ? { ...c, isMatched: true } : c)),
    );
    setFlipped([]);
    setScore((s) => s + 1);
    return;
  }

  setLocked(true);
  const t = setTimeout(() => {
    setFlipped([]);
    setLocked(false);
  }, 700);

  return () => {
    clearTimeout(t);
  };
}, [flipped, cards]);

// persist best score
useEffect(() => {
  if (score > best) {
    setBest(score);
    localStorage.setItem("memory_best", String(score));
  }
}, [score, best]);

然后剩下的逻辑就都是一些对事件响应的回调了,这里略过:

// clickCard: keep the callback stable. We avoid depending on `cards` here
const clickCard = useCallback(
  (id) => {
    if (locked) return;
    setFlipped((prev) => {
      // prevent duplicate or third flips
      if (prev.includes(id) || prev.length >= 2) return prev;
      if (prev.length === 0) return [id];
      return [...prev, id];
    });
  },
  [locked],
);

const reset = useCallback(() => {
  setScore(0);
  setFlipped([]);
  setLocked(false);
  setCards((prev) => shuffle(prev.map((c) => ({ ...c, isMatched: false }))));
}, []);

const newGame = useCallback(async () => {
  setLoading(true);
  setErrors(null);
  try {
    const images = await fetchImages(pairCount);
    setCards(shuffle(makePairs(images)));
    setScore(0);
    setFlipped([]);
    setLocked(false);
  } catch (err) {
    setErrors(err?.message || "Failed to fetch images");
  } finally {
    setLoading(false);
  }
}, [pairCount]);

这里,我们把所有事件触发的回调放在了 hooks 中。如果回调比较简单的话,也可以直接在对应 component 里面实现了。