めもめも

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

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

何の話かと言うと

enakai00.hatenablog.com

上記の記事の続きです。前回までに、盤面にコマを置けるようになったので、今回は「手番」の概念を導入して、交互に打ち合えるようにします。また、リバーシのルールに基づいて、コマをめくる機能も実装します。

「手番」の導入

「いまどちらの手番か」という情報を状態変数として用意するわけですが、Layer コンポーネントと Board コンポーネントのどちらで定義するべきでしょうか。これは、この情報をどのコンポーネント内に隠蔽するかに依存します。Board コンポーネント内だけで参照するのであれば、Board コンポーネントで定義すれば十分です。しかしながら、今の場合、Layer コンポーネントは、手番を変更する「Pass ボタン」を持っており、さらに、手番の情報を表示する位置は、Layer コンポーネントで管理する必要があります。そこで、手番の情報は、Layer コンポーネントで定義しておき、Board コンポーネントには props として受け渡すことにします。

というわけで、Layer.js に次の修正を加えます。

     1  import React, { useState, useRef } 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    const [turn, setTurn] = useState("black");
    11    const freeze = useRef(false);
    12
    13    const restart = () => {
    14      setTurn("black");
    15      setGameID(new Date().getTime());
    16    }
    17
    18    const pass = () => {
    19      if (!freeze.current) {
    20        const opponent = {black: "white", white: "black"};
    21        setTurn(opponent[turn]);
    22      }
    23    }
    24
    25    const states = {turn: turn, setTurn: setTurn, freeze: freeze};
    26
    27    const element = (
    28      <Container padding="8">
    29        <Stack spacing="1">
    30          <Heading marginLeft="4" fontSize="3xl">Reversi</Heading>
    31          <Box>
    32            <Board key={gameID} states={states}/>
    33          </Box>
    34          <Box>
    35            Turn: {turn}
    36          </Box>
    37          <Box>
    38            <HStack>
    39              <Button colorScheme="red" size="sm"
    40                      onClick={pass}>Pass</Button>
    41              <Button colorScheme="blue" size="sm"
    42                      onClick={restart}>Restart</Button>
    43            </HStack>
    44          </Box>
    45        </Stack>
    46      </Container>
    47    );
    48
    49    return element;
    50  }

まず、10行目の useState で状態変数 turn を導入しています。"black" / "white" の文字列で現在の手番を表します。また、11 行目にも新たな状態変数 freeze があります。この後、コマを置いて相手のコマをめくっていくアニメーション(?)を実装するので、アニメーション中は余計な操作(さらに別の場所にコマを置くとか、パスするとか)ができないように、この状態変数で制御します。アニメーション中は、freeze.current = true にするという使い方です。

これらの状態変数を使って、「Pass ボタン」の処理を実装したのが、18〜23行目です。freeze.current === false であることを確認して、状態変数 turn を相手側に変更します。Layout コンポーネント内(もしくは、その子コンポーネント)で「手番」を表示しておけば、このタイミングで表示が更新されます。ここでは、34〜36行目でまさにその情報を表示しています。変数 turn の文字列を直接表示していて、ちょっとダサいですが、この部分は次回にアップデートする予定です。

そして、手番の情報、および、状態変数 freeze は、Board コンポーネントでも使用するので、何らかの方法で Board コンポーネントに受け渡す必要があります。ここでは、25 行目で受け渡したいものを連想配列 states にまとめて、32行目で states 属性として渡しています。これにより、Board コンポーネント側では、引数 props でこれらの情報を受け取ることができます。

ここで、states に含めた freeze は(current 属性に現在値を格納した)オブジェクトである点に注意してください。たとえば、freeze の代わりに、freeze.current を states に含めてしまうと、現在値のリテラル(true / false)が渡るため、Board コンポーネント側では、現在値は参照できても、freeze.current に格納された現在値の更新はできなくなってしまいます。useRef で作成される状態変数が、値そのものを直接格納せずに、current 属性として保存するのは、このあたりの事情が関連しています。

ちなみに・・・useState で作った変数 turn についてはどうでしょうか? こちらはオブジェクトではなくリテラル値が格納されていますが、ここは問題ありません。値を更新するための関数 setTurn を同時に渡しているので、Board コンポーネント側では、setTurn を使って turn の値を更新します。setTurn で更新した値は、コンポーネントの再描画が行われて、再度、関数 Board が呼び出されたタイミングで更新後の値が得られます。setTurn 直後に turn の値を参照しても更新は反映されていないので、この点は注意が必要です。(「useState 更新」あたりのキーワードで検索すると情報がいろいろ出てきます。)

あとは、Restart ボタンの処理も少しアップデートしています。14行目にあるように、手番を初期値の "black" にリセットしています。

コマをめくるロジックの実装

それでは、Board コンポーネント側の実装に進みましょう。いよいよ、コマをめくるロジックを実装して、リバーシとして遊ぶことができるようになります。変更部分を抜粋して示すと次のようになります。

     1  import React, { useRef, useState } from "react";
     2  import { Box } from "@chakra-ui/react";
...
    58  export const Board = (props) => {
    59    const size = 8;
    60    const fieldRef = useRef(getField(size));
    61    const field = fieldRef.current;
    62
    63    // Unpack states.
    64    const turn = props.states.turn;
    65    const setTurn = props.states.setTurn;
    66    const freeze = props.states.freeze;
...
    74    const onClick = (x, y) => {
    75      if (!freeze.current) {
    76        move(x, y);
    77      }
    78    }
    79
    80    const move = async (x, y) => {
    81      const sleep = (milliseconds) => {
    82        return new Promise(resolve => setTimeout(resolve, milliseconds))
    83      }
    84      freeze.current = true;
    85
    86      // 8 directions to search.
    87      const dx = [1,-1, 0, 0,-1, 1, 1,-1]
    88      const dy = [0, 0, 1,-1,-1,-1, 1, 1]
    89      const allowed = new Array(8)
    90      allowed.fill(false);
    91
    92      const opponent = {black: "white", white: "black"};
    93
    94      for (let i = 0; i < 8; i++) { // search for 8 directions.
    95        if (field[y][x] !== "blank") { // not a blank cell
    96          break;
    97        }
    98
    99        let search_state = 0; // search starts.
   100        for (let j = 1; j < size; j++) {
   101          const xx = x + j*dx[i];
   102          const yy = y + j*dy[i];
   103          if (xx < 0 || xx >= size || yy < 0 || yy >= size) {
   104            break; // search failed.
   105          }
   106          if (field[yy][xx] === opponent[turn]) {
   107            search_state = 1; // search continues.
   108            continue;
   109          }
   110          if (search_state === 1 && field[yy][xx] === turn) {
   111            search_state = 2; // search succeeded.
   112            break;
   113          }
   114          break; // search failed.
   115        }
   116        if (search_state === 2) {
   117          allowed[i] = true;
   118        }
   119      }
   120
   121      if (allowed.includes(true)) {
   122        // Animation starts.
   123        field[y][x] = turn;
   124        await setDummyState([]);
   125        for (let i = 0; i < 8; i++) {
   126          if (!allowed[i]) {
   127            continue;
   128          }
   129          for (let j = 1; j < size; j++) {
   130            const xx = x + j*dx[i];
   131            const yy = y + j*dy[i];
   132            if (field[yy][xx] === turn) {
   133              break;
   134            }
   135            field[yy][xx] = turn;
   136            await sleep(100);
   137            await setDummyState([]);
   138          }
   139        }
   140        // Animation ends.
   141        await setTurn(opponent[turn]);
   142      }
   143      freeze.current = false;
   144    }
...

64〜66行目で、Layout コンポーネント側で用意した状態変数を引数 props から取り出しています。そして、75行目では、ブランクセルをクリックした際に、関数 move を実行するようにしています。先ほど導入した状態変数 freeze を利用して、freeze.current === false の場合のみ実行します。

80行目以降の関数 move では、座標 (x, y) に現在の手番(turn)のコマが置けるか確認して、置ける場合は、相手のコマをめくっていくアニメーションを実行します。80 行目の async キーワードは、sleep を挟みながらアニメーションを進めていくために必要になります。状態変数の更新に伴うコンポーネントの再描画は非同期に行われるため、アニメーションを実行するには、再描画が終わるの待ってから次に進むという同期化が必要になります。非同期処理/同期処理の詳細は省きますが、関数 move に async キーワードをつけておくと、この関数内では、非同期処理を行う関数の前に await キーワードをつけることで、非同期処理が終わるのを待つことができます。124行目、137行目で、setDummyState に await キーワードをつけており、これにより、盤面の再描画が終わるのを待っています。

なお、sleep 処理の実装についても、同様に await が必要になります。81〜83行目の関数 sleep は、指定の秒数(ミリ秒単位)後に完了する非同期処理を実装しており、たとえば、

await sleep(100);

とすると、100ミリ秒待ってから次に進む sleep 処理として使うことができます。

それでは、ロジックの中身にいきましょう。

84 目で、freeze.current = true にセットして、この後の処理が終わるまで、余計な操作ができないようにします。

次に、コマを置きたい場所 (x, y) から8方向について、相手のコマを挟んでいるかをチェックします。87, 88行目は、8方向を表すベクトルを定義しています。89, 90行目で用意している配列 allowed は、8方向それぞれのチェック結果を格納します。

94〜119行目の for ループで、8方向のそれぞれについてチェックします。チェック方法のロジックは・・・説明すると長くなるので省略させてください。チェック結果は、先ほどの配列 allowed に保存されていて、1方向でもOKであれば、121〜142行目の for ループで、コマをめくる処理を行います。

8方向のそれぞれについて、チェック結果がOKであれば、その方向について、相手のコマをめくっていきます。1枚めくるごとに、100ミリ秒のスリープをいれてから再描画することで(136, 137行目)、1枚ずつめくられていく様子が画面上で見えるようになります。最後に、手番を入れ替えて(141行目)、freeze.current を false に戻して(143行目)完了です。

これでついに、リバーシをゲームとして遊ぶことができるようになりました。ここまでの内容は、次のリポジトリ(v0.4 ブランチ)で確認できます。

github.com

アプリケーションを実行すると、こんな感じになります。

次回予告

ゲームとしてはほぼ完成ですが、手番表示がダサいので、ここを手直しします。また、現在のコマ数や最後の勝ち負け判定メッセージなども表示したいですよね。このあたりの情報をまとめて表示する Dashboard コンポーネントを追加することにしましょう。

パート8はこちらです。

enakai00.hatenablog.com

バックエンドエンジニアのための(かどうかは本当はよく分からないけど、とにかく書いてみる)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