めもめも

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

バックエンドエンジニアのための(かどうかは本当はよく分からないけど、とにかく書いてみる)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)された後も非同期関数は実行を継続します。

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

何の話かと言うと

enakai00.hatenablog.com

上記の続きになります。次のステップとして、ボードの上にコマを置いて表示する機能を実装しますが、段階的に進めていきましょう。まずは、コマの情報を保存する状態変数を用意して、この内容に応じてコマを表示する部分まで実装します。

状態変数の利用

コマの情報を保持、表示するために、前回作成した Board.js を次のように拡張します。

     1  import React, { useRef } 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) => {
    11    const x = 25 + 67 * props.pos_x;
    12    const y = 28 + 67 * props.pos_y;
    13
    14    const style = {
    15      position: "absolute",
    16      width: "65px",
    17      height: "65px",
    18      left: x,
    19      top: y,
    20    };
    21
    22    var element;
    23    switch (props.mark) {
    24      case "black":
    25        element = (
    26          <img src={black} alt="black" style={style} />
    27        );
    28      break;
    29      case "white":
    30        element = (
    31          <img src={white} alt="white" style={style} />
    32        );
    33      break;
    34      default:
    35        element = (
    36          <img src={blank} alt="blank" style={style} />
    37        );
    38    }
    39    return element;
    40  }
    41
    42
    43  const getField = (size) => {
    44    const field = new Array(size);
    45    for (let y = 0; y < size; y++) {
    46      field[y] = new Array(size);
    47      field[y].fill("blank");
    48    }
    49    field[3][3] = "white";
    50    field[3][4] = "black";
    51    field[4][3] = "black";
    52    field[4][4] = "white";
    53    return field;
    54  }
    55
    56
    57  export const Board = (props) => {
    58    const size = 8;
    59    const fieldRef = useRef(getField(size));
    60    const field = fieldRef.current;
    61
    62    const fieldElements = [];
    63    for (let y = 0; y < size; y++) {
    64      for (let x = 0; x < size; x++) {
    65        fieldElements.push(
    66          <Cell pos_x={x} pos_y={y} mark={field[y][x]} />
    67        );
    68      }
    69    }
    70
    71    const style = {
    72      position: "relative",
    73      width: "584px",
    74      height: "592px",
    75      backgroundRepeat: "no-repeat",
    76      backgroundPosition: "center",
    77      backgroundImage: `url(${board})`,
    78      backgroundSize: "100%"
    79    };
    80
    81    const element = (
    82        <Box style={style}>
    83          {fieldElements}
    84        </Box>
    85    );
    86
    87    return element;
    88  }

43行目からの関数 getField では、8×8 のマス目に対応した2次元配列 field を用意して、各マス目のコマを "black"(黒)、"white"(白)、"blank"(ブランク)という文字列で表現します。中央部分の2枚ずつ白黒のコマがある初期状態を構成して、これを返却します。

そして、59行目では、この初期状態の2次元配列を関数 useRef に代入しています。useRef は、 一度初期化されると、そのあとは再初期化されずに現在の値を保持し続ける「状態変数」を用意します。関数 Board が初めて実行された時は、59 行目で初期状態の field の値がオブジェクト fieldRef の current 属性に保存されます。その後、関数 Board 内で fieldRef.current の内容(つまり二次元配列 field の内容)を変更して、関数 Board の実行が終わったとします。通常、関数 Board 内で使用した変数の内容はこの時点で失われますが、filedRef の内容は失われずにそのままメモリー上に保持されます。そして、再度、関数 Board を実行すると、59 行目で前回の fieldRef の値が再現されます。イメージとしては、fieldRef は簡易的な外部データベースになっていると考えられます。これにより、ゲームを実行中の盤面の状態を保持することができます。なお、関数 Board 内では、fieldRef.current の値を何度も使用するので、コードを短くするために、60行目で fieldRef.current を変数 field に入れて、この後のコードでは、fieldRef.current の代わりに field を使用しています。

次に、62〜69行目では、配列 fieldElements に Cell コンポーネントを詰め込んでいます。この後で説明するように、Cell コンポーネントは、1つのマス目(白、黒、もしくは、ブランク)を表示するコンポーネントです。属性値 pos_x、pos_y にマス目の位置、属性値 mark にマス目にあるコマを指定して、画面上の対応する位置に対応するコマの画像を表示します。これを83行目で、(前回までは空だった)Box コンポーネントの中に配置しています。前回説明したように、波括弧内には Javascript のコードが配置できて、変数を配置した場合、変数の内容に置き換わります。

コマの表示

Cell コンポーネントを定義する関数 Cell は、同じファイル Board.js 内の10〜40行目にあります。ファイルを分けてインポートしても構わないのですが、Board コンポーネントからしか参照しないものなので、実装を隠蔽する意味で、同じファイル内で定義しています。

これも前回説明しましたが、コンポーネントを配置する際に与えた属性値は、引数 props で受け取ります。ここでは、props.pos_x、props.pos_y でコマの位置、props.mark でコマの種類が得られます。これらの値を利用して、インラインスタイルで画面上の表示位置を指定(18、19行目)し、さらに、表示する画像を使い分けています。コマの画像ファイルは、5〜7行目でインポートしており、26、31、36行目にあるように、img 要素の src 属性に直接指定することができます。

これで新しい Board.js の説明は終わりです。この段階でアプリケーションを実行すると、無事に初期状態の盤面が表示されます。

key 属性の割り当て

これで無事に盤面が表示されたようですが・・・実は、ブラウザで JavaScript コンソールを確認すると、次のような警告が表示されています。

react-jsx-dev-runtime.development.js:87 Warning: Each child in a list should have a unique "key" prop.

先ほど、配列 fieldElements に Cell コンポーネントを詰め込んで表示しましたが、配列にコンポーネントを詰め込む際は、それぞれのコンポーネントにユニークな key 属性を指定することが推奨されます。React のレンダリングエンジンは、変更のあったコンポーネントのみを再レンダリングする最適化を行いますが、配列内のコンポーネントが増減した場合、増減する前後のどのコンポーネント同士を比較して変更を検知すればよいかわからなくなります。そこで、コンポーネントを同定するための key 属性が必要になるのです。(逆に言うと、何らかの理由で強制的に再レンダリングしたいコンポーネントがあった場合は、その key 属性の値を変更すればよいことになります。このテクニックは後ほど使います。)今回は、66行目を次のように変更して、コマの座標値をユニークな key として使用します。

    66          <Cell key={y.toString()+x.toString()}
                      pos_x={x} pos_y={y} mark={field[y][x]} />

残念ながら、新しいコマを置く機能にはたどりつきませんでしたが、今回の説明はここまでとします。ここまで実装したコードは、下記のリポジトリ(v0.2 ブランチ)から参照できます。

github.com

おまけ

Javascript では、次のように、波括弧付きのインポートとそうでないインポートがあります。

import React, { useRef } from "react";

ファイル内で関数やクラスを普通(?)に export した場合、波括弧付きのインポートが必要で、export した時と同じ名前でインポートする必要があります。一方、ファイル内で1つだけ、default を指定した export が可能で、これは波括弧無しでインポートできます。また、default export したモジュールは任意の名前でインポートすることができます。

次回予告

次回は、ブランクのマス目に新しいコマを置く機能を実装します。Reversi のルールに従って置けるかどうかの判断は、その後、あらためて実装することにします。

パート6はこちらです。

enakai00.hatenablog.com

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

何の話かと言うと

enakai00.hatenablog.com

上記の続きになります。今回からは、いよいよ Reversi の実装に入ります。まずは、画面の背景に Reversi のボードを表示することにしましょう。

アプリケーションのコードを用意

まずは、これまでと同様にアプリケーションの雛形を作成します。

% npx create-react-app react_reversi
% cd react_reversi

今回は、見た目も綺麗にしたいので、React の UI コンポーネントを提供する Chakra UI と関連するパッケージを追加しておきます。

% yarn add @chakra-ui/react @chakra-ui/icons @emotion/react @emotion/styled framer-motion

この後、ディレクトリー「public」と「src」の中身をごっそり削除して、下記の内容に入れ替えます。

public/
├── favicon.ico
└── index.html
src/
├── assets
│   ├── black.png
│   ├── blank.png
│   ├── board.png
│   └── white.png
├── components
│   ├── Board.js
│   └── Layout.js
├── index.js
└── theme
    └── theme.js

実際のファイルの中身は、下記のリポジトリ(v0.1 ブランチ)から参照することができます。

github.com

個別にファイルをコピーするのが面倒な場合は、次のコマンドで上記のリポジトリをクローンして再現することもできます。

% cd ~/Documents/work
% git clone https://github.com/enakai00/react_reversi
% cd react_reversi
% git checkout v0.1

次のコマンドで、必要なパッケージをインストールして開発用サーバーを起動すると・・・

% yarn install
% yarn start

めでたくボードが表示されます。


ファイルの説明

個々のファイルの内容を説明していきます。

画像ファイル

このアプリケーションで使う画像ファイルは、ディレクトリー「src/assets」にまとめて保存してあります。この後で見るように、src 以下に配置した画像ファイルは、コードの中でオブジェクトとしてインポートして使用します。

src/
├── assets
│   ├── black.png    ← 黒ゴマ 
│   ├── blank.png    ← 透明ファイル(コマの無い場所に表示)
│   ├── board.png    ← ボード
│   └── white.png    ← 白ゴマ
index.html

前回の Hello, World! アプリケーションとほぼ同じで、ID が "root" の dvi 要素を用意しているだけです。

index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ChakraProvider } from "@chakra-ui/react";

import theme from "./theme/theme";
import { Layout } from './components/Layout';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <ChakraProvider theme={theme}>
    <Layout />
  </ChakraProvider>
);

ここでは、先にインストールした Chakra UI のコンポーネントを利用しています。背景色やデフォルトのフォントサイズなど、UI のデザインに関わる基本設定が「src/theme/theme.js」に記載されており、これがすべてのコンポーネントに適用されます。また、ここでは、「components/Layout.js」で定義した Layout コンポーネントを画面に配置しています。

Layout.js

このファイルでは、ゲームの盤面、得点表、操作ボタンなどのコンポーネントを画面上に適切に配置しますが、今回は、ゲームの盤面を表す Board コンポーネントだけを配置しています。

import React from "react";
import { Container, Stack, Heading, Box } from "@chakra-ui/react";

import { Board } from "./Board";


export const Layout = (props) => {
  const element = (
    <Container padding="8">
      <Stack spacing="1">
        <Heading marginLeft="4" fontSize="3xl">Reversi</Heading>
        <Box>
          <Board/>
        </Box>
      </Stack>
    </Container>
  );

  return element;
}

「Container」「Stack」「Heading」「Box」などは、Chakra UI が提供するコンポーネントです。Box は HTML の div 要素に対応するもので、基本的には、Box の子コンポーネントとして、実際に何かを表示するコンポーネントを配置します。Heading はヘッダーを配置します。そして、その他のコンポーネントは、これらの表示位置をコントロールします。今回は、Continer で中央寄せの表示領域を用意して、その中で、Stack コンポーネントを使って、ヘッダーと Board を縦に積み重なるように配置しています。padding や spacing などの属性値は、名前から役割が想像できると思います。詳細については、Chakra UI のドキュメントを参照してください。

ここで、関数 Layout は、引数 props を持っている点に注意してください。今回のコードでは、props の値は使用していませんが、一般には、上位コンポーネントで Layout を配置した際の属性値を props で受けることができます。たとえば、

    <Layout x={1} y={2} />

という形で Layout を配置すると、関数 Layout 内では、prop.x および prop.y として、x と y の値(今の場合は、1 と 2)を参照できます。さらにちなみに、上記では「{1}」のように、渡したい値に波括弧がついていますが、一般に、波括弧の内部には、Javascript のコードを配置することができて、コードの評価値を渡すことができます。今回は、数値そのものをコードとして記載していますが、変数を配置すれば、変数に格納された値(一般には、オブジェクトのリファレンス)を渡すことができます。

Board.js

これが今回のメインのコードとなります。

import React from "react";
import { Box } from "@chakra-ui/react";

import board from "../assets/board.png";


export const Board = (props) => {
  const style = {
    position: "relative",
    width: "584px",
    height: "592px",
    backgroundRepeat: "no-repeat",
    backgroundPosition: "center",
    backgroundImage: `url(${board})`,
    backgroundSize: "100%"
  };

  const element = (
      <Box style={style}>
      </Box>
  );

  return element;
}

このコードでは、中身のない Box コンポーネントを配置していますが、style オプションにより背景画像を設定しており、これで、先ほどのボードが表示されます。この style オプションの使い方は、「インラインスタイル」と呼ばれるもので、CSS ファイルを用意せずにコード内で直接にスタイルを設定することができます。設定内容は、事前に連想配列 style として用意しておき、この内容を(先に説明した波括弧記法を用いて)属性値として渡します。

背景画像の実態は、事前に board という名前でインポートしてあり、${board} は画像ファイルのパスに対応します。これをスタイルの backgroundImage 要素に指定することで、めでたくボードの画像が表示されるというわけです。

次回予告

今回はボードを表示しただけですが、この次は、このボードをクリックしてコマを置ける機能を実装することにしましょう。

パート5はこちらです。

enakai00.hatenablog.com