めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

バックエンドエンジニアのための(かどうかは本当はよく分からないけど、とにかく書いてみる)React 入門(パート6)

何の話かと言うと

enakai00.hatenablog.com

こちらの続きです。今回は、ブランクのセルにコマを置く機能と、盤面の状態をリセットする機能を実装します。

コマを置く処理の実装

前回の Board.js の内容を次のように拡張します。ここでは変更・追加した部分を抜粋して掲載します。

     1  import React, { useRef, useState } from "react";
     2  import { Box } from "@chakra-ui/react";
     3
     4  import board from "../assets/board.png";
     5  import black from "../assets/black.png";
     6  import white from "../assets/white.png";
     7  import blank from "../assets/blank.png";
     8
     9
    10  const Cell = (props) => {
...
    34      default:
    35        element = (
    36          <img src={blank} alt="blank" style={style}
    37               onClick={props.onClick}/>
    38        );
    39    }
    40    return element;
    41  }
...
    58  export const Board = (props) => {
    59    const size = 8;
    60    const fieldRef = useRef(getField(size));
    61    const field = fieldRef.current;
    62
    63    // Since `field` stores an array object, updating it
    64    // doesn't rerender the component. Instead, dummyState
    65    // is used to rerender the compoent.
    66    // eslint-disable-next-line
    67    const [dummyState, setDummyState] = useState([]);
    68
    69    const onClick = (x, y) => {
    70      field[y][x] = "black";
    71      setDummyState([]);
    72    }
    73
    74    const fieldElements = [];
    75    for (let y = 0; y < size; y++) {
    76      for (let x = 0; x < size; x++) {
    77        fieldElements.push(
    78          <Cell key={y.toString()+x.toString()}
    79                pos_x={x} pos_y={y} mark={field[y][x]}
    80                onClick={() => onClick(x, y)} />
    81        );
    82      }
    83    }
...
   101    return element;
   102  }

まず、69〜71 行目で座標 (x, y) のセルをクリックした際に実行する関数 onClick を定義しています。「どちらの手番か」という情報をまだ用意していなかったので、とりあえず、指定のセルに黒ゴマを置くようにしています。盤面の情報を保存した2次元配列 field の情報を更新したのちに、この変更を画面に反映するため、71 行目で「setDummyState([])」を実行しています。

・・・はい。この部分は説明が必要ですね。前回、useRef で状態変数を用意しましたが、React では、これとは別に useState で作成するまた別の種類の状態変数があります。67行目が作成部分になりますが、現在の値(オブジェクト)を保存するオブジェクトと、変数の値(オブジェクト)を変更するための専用の関数がペアで用意されます。useState の引数には初期値(今の場合は空の配列 [])を与えます。ここでは、それぞれ、 dummyState、および、setDummyState に保存しています。dummyState から現在の値(オブジェクト)が得られて、setDummyState(x) を実行すると、新しい値(オブジェクト)x がセットされます。

そして、この状態変数は、値(オブジェクト)の変更によって、コンポーネントの再描画を引き起こすと言う副作用があります。dummyState は関数 Board の中で定義されているので、setDummyState で新しい値(オブジェクト)をセットすることで、Board コンポーネント(とその子コンポーネント)が再描画されます。これにより、新しく置いた黒ゴマが画面に表示されるというわけです。71行目では初期値と同じ空の配列をセットしていますが、配列としては別のオブジェクトになるので、値が変更されたものと認識されます。

なお、一般的な実装であれば、2次元配列 field そのものを useRef ではなく、useState で作っておいて、field の変更を直接のトリガーとして画面を更新するという方法も考えられますが、今の場合、これはうまくいきません。なぜなら、コマを置くという操作は、filed に格納された二次元配列の要素を変更しているだけで、二次元配列のオブジェクトそのものは同一のままになるため、「新しい値(オブジェクト)がセットされた」とは認識されません。ここまで、「値(オブジェクト)」という書き方をしていたのは、この点を強調するためで、格納されているオブジェクトそのものを新しいオブジェクトに置き換える必要がある点に注意が必要です。

次に、ブランクのセルをクリックした際に、この関数が実行されるようにコールバックを仕込みます。80行目を見ると、座標 (x, y) のセルに対して、onClick 属性として、「空の引数を受けて onClick(x, y) を実行する」という関数を指定しています。(「onClick(x, y)」だけを指定すると、この関数の実行結果(つまり空の返り値)がセットされてしまうので注意してください。)そして、37行目で img 要素の onClick 属性(つまり、画像をクリックした際に実行されるコールバック関数)にこの関数を設定しています。これで、座標 (x, y) のブランクセルをクリックすると、めでたく onClick(x, y) が実行されます。

これで、ブランクのセルを黒ゴマでどんどん埋めていくことができます。・・・が、全部のセルが埋まったら、それ以上やることがなくなります。盤面を初期状態に戻する「Restart ボタン」が必要です。

Restart ボタンの実装

Restart ボタンは、Layout コンポーネント内で Board コンポーネントと並列に配置するので、Layout コンポーネントに実装します。ファイル「src/components/Layout.js」を次のように拡張します。

     1  import React, { useState } from "react";
     2  import { Container, Stack,
     3           Heading, Box, HStack, Button } from "@chakra-ui/react";
     4
     5  import { Board } from "./Board";
     6
     7
     8  export const Layout = (props) => {
     9    const [gameID, setGameID] = useState(new Date().getTime());
    10
    11    const restart = () => {
    12      setGameID(new Date().getTime());
    13    }
    14
    15    const pass = () => {
    16    }
    17
    18    const element = (
    19      <Container padding="8">
    20        <Stack spacing="1">
    21          <Heading marginLeft="4" fontSize="3xl">Reversi</Heading>
    22          <Box>
    23            <Board key={gameID} />
    24          </Box>
    25          <Box>
    26            <HStack>
    27              <Button colorScheme="red" size="sm"
    28                      onClick={pass}>Pass</Button>
    29              <Button colorScheme="blue" size="sm"
    30                      onClick={restart}>Restart</Button>
    31            </HStack>
    32          </Box>
    33        </Stack>
    34      </Container>
    35    );
    36
    37    return element;
    38  }

まず、26〜31行目で「Pass ボタン」と「Restart ボタン」を配置しています。これらは、Chakra UI の Button コンポーネントをそのまま利用しています。HStack コンポーネントを用いて横に並べています。これらのボタンを押すと、それぞれ、関数 pass、および、関数 restart が実行されます。

「Pass ボタン」は打てる場所がない時にパスする機能を提供するものですが、今の段階ではボタンがあるだけで、関数 pass の中身は空っぽです(15〜16行目)。こちらは、後ほど実装することにして、まずは、Restart ボタンの方を完成させましょう。9 行目では、先ほど説明した useState を用いて、状態変数 gameID を用意しています。ゲームを開始するごとにユニークな ID を割り当てることが目的で、ここでは、ゲーム開始時の時刻を ID として使用しています。そして、11〜13行目の関数 restart では、この状態変数を更新しています。

・・・というだけで、なぜ、盤面がリセットできるのでしょう・・・。不思議ですね・・・。

実は、23 行目に秘密があります。Board コンポーネントの key 属性として、この ID を指定しているのです。前回説明したように、コンポーネントの key 属性は、そのコンポーネントを特定するためのユニークな ID です。そのため、この値が変わると、まったく新しいコンポーネントと認識されて、初期状態のコンポーネントが再度用意されます。setGameID で状態変数を更新すると、これをトリガーとして Board コンポーネントの再描画が発生して、このタイミングで、新しい Board コンポーネントが用意されるという寸法です。なかなかうまいやり方ですね*1

この段階のコードは、下記のリポジトリ(v0.3 ブランチ)で参照できます。

github.com

アプリケーションを実行すると、次のように、黒ゴマを自由に配置することができます。

次回予告

次回は、「手番」の概念を導入して、黒と白が交互にコマを置けるようにしましょう。余裕があれば、リバーシのルールに従って、コマをめくるロジックも追加するかもしれません。

パート7はこちらです。

enakai00.hatenablog.com

*1:古いコンポーネントから非同期に実行中の関数が存在する場合、この関数は事前に停止しておく必要があります。さもなくば、古いコンポーネントが削除(Unmount)された後も非同期関数は実行を継続します。