React チュートリアルをNext.js ✕ Typescript でやってみた

記事の目的

2度目の投稿になるダニーです。
今回は採用係長のフロントエンドの技術として、React + Next.js が使われており、私がフロントエンド周り苦手というのもあって、今回 React チュートリアルの三目並べゲームを題材に React + Next.js + Tyepscript で チュートリアルのゲームを作り、理解を深めようというのが今回のブログ記事の目的となります。
初歩的な内容となってますので、初学向けな記事となります。

React チュートリアルについて

この記事で取り扱う情報は React の公式のチュートリアルを題材としています。
細かい説明は公式を見ていただくのがいいと思いますので、詳細は省きます。
チュートリアル:React の導入 : https://ja.reactjs.org/tutorial/tutorial.html

今回題材にするのは、公式のチュートリアルで作成する三目並べゲームです。
仕様としては、

  • 3✕3のマスを用意し、O☓を交互に埋め縦横斜めのどれかの一列をOか☓で埋めれば勝ち
  • 機能として、
    • 三目並べゲーム
    • 現在のプレイヤーがわかること(Oか☓)
    • 勝利プレイヤーがわかること
    • 引き分けの判定
      • こちら公式のゲーム触ってて引き分け処理なかったので、追加しました。
    • 着手の表示
    • タイムトラベル(過去の着手の表示)

上記の仕様を満たしたものを作成していきます。

画面構成はこちらです。

Typescriptで書いてみた。

今回作成する環境はこちらです。

  • Node.js
  • npm
  • React.js
  • Typescript
  • エディタ:Visual Studio Code

それぞれローカルに準備できればOSに縛りはありません。
下記のNext.js でプロジェクト作成するときに、React / Typescript は一緒にインストールされます。

下準備 ~ Next.js 編

Next.js とは React.js をベースにしたjavascript フレームワークです。
公式URL:https://nextjs.org/

我社のサービスの採用係長のフロントエンドはこちらのフレームワークを利用してますので、今回採用しました。
それでは、まずローカルに Next.js プロジェクトを作成します。

# Typescript で今回プロジェクトを作成
npx create-next-app@latest --typescript
✔ What is your project named? … tutorial-app

.... 

Initialized a git repository.

Success! Created tutorial-app

上記でTypescriptでのプロジェクトが作成されます。
プロジェクトの中身のディレクトリ構造はこんな感じになります。

my-app
├── README.md
├── next-env.d.ts
├── next.config.js
├── node_modules
├── package.json
├── pages
├── public
├── styles
├── tsconfig.json
└── yarn.lock

今回細かくは説明を省きます。(私もまだまだ理解出来てない部分が多いので、また別の機会にブログのネタにしようと思います。)

あとは、プロジェクトで

npm run dev

を実行するとローカルサーバーが立ち上がります。

http://localhost:3000/

上記のリンクからこのようなページがブラウザで開ければ、成功です!

これで下準備が完了なので、早速ゲームを作成していきましょう。

本題 ~ React チュートリアル の三目並べゲームをTypescript で作る。

今回公式のチュートリアルにゲームのソースはあるのですが、それをもとにコンポーネント毎にファイルを分けて、作って行きます。

今回作成するものは

  • ./styles/game.css :CSS(公式のCSSを流用させていただきます。)
  • ./pages/game.tsx :ゲーム画面コンポーネント
  • ./components/game/board.tsx :3✕3マス ボードのコンポーネント
  • ./components/game/square.tsx:1マスのコンポーネント

こんな感じにコンポーネントとディレクトリに分けました。

では、それぞれのファイルの中身を紹介していきます。

game.css

body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol,
ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

こちらは公式のものをそのまま流用させてもらったので、特にこれといった変更は加えてません。
一点、このCSSを import をするときの注意点があります。
next.js は「./pages/_app.tsx」がどのページでも共通的に呼ばれるグローバルな性質を持っています。
そのため、アプリ全体で利用するCSSを設定する場合は「./pages/_app.tsx」に書かないとエラーで怒られます。
なので、今回は「./pages/_app.tsx」に game.css を追加する方法で対応してます。

_app.tsx

// import '../styles/globals.css'
import "../styles/game.css";
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

export default MyApp

コンポーネント毎にCSSを追加したい場合は、CSS Modules がサポートされているので、「[name].module.css」 の命名規則に則ることで、import することが出来ます。
その編の詳しい説明は公式ドキュメントにあるので、説明は省きます。
公式ドキュメント: Adding Component-Level CSS

game.tsx

import type { NextPage } from "next";
import Head from 'next/head';
import { useState } from "react";
import Board, { SquaresState } from "../components/game/board";

type History = {
  readonly squares: SquaresState;
}

type FieldState = {
  readonly history: readonly History[];
  readonly stepNumber: number;
  readonly xIsNext: boolean;
};

const Game: NextPage = () => {

  // GAME 初期値
  const [field, setField] = useState<FieldState>({
    history: [{ squares: Array(9).fill(null) }],
    stepNumber: 0,
    xIsNext: true
  })

  // マスをクリックされたときのイベント
  const handleClick = (i: number) => {
    const history = field.history.slice(0, field.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice()

    // 勝利判定確認もしくはすでに選択済みなら、クリックを無効にする。
    if (calculateWinner(squares) || squares[i]) return;

    squares[i] = field.xIsNext ? "X" : "O";

    setField({
      history: history.concat([{ squares: squares }]),
      stepNumber: history.length,
      xIsNext: !field.xIsNext,
    });
  };

  const jumpTo = (step: number) => setField({ ...field, stepNumber: step, xIsNext: step % 2 === 0 });

  // 最新の状態を確認
  const history = field.history;
  const current = history[field.stepNumber];
  const winner = calculateWinner(current.squares);

  const moves = history.map((step, move) => {
    const desc = move ? "Go to move #" + move : "Go to game start";
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{desc}</button>
      </li>
    );
  });

  let status: string = winner ? "Winner: " + winner : "Next player: " + (field.xIsNext ? "X" : "O");
  if (!current.squares.includes(null)) status = "Draw";

  return (
    <div className="game">
      <Head>
        <title>GAME</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={(i:number) => handleClick(i)}
        />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  )
}

export default Game;

// 勝利判定
const calculateWinner = (squares: SquaresState) => {
  // 勝利パターン
  const lines: number[][] = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];

  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (
      squares[a]
      && squares[a] === squares[b]
      && squares[a] === squares
    )
      return squares[a];
  }

  return null;
}

こちらは三目並べゲームのページに関するコンポーネントとなります。
コンポーネントとしては、最上位のコンポーネントです。

ここでは、

  • ゲームの現在の状態
  • ゲームの過去の履歴
  • プレイヤーのアクションによってのデータの更新(handleClick())

などの処理がここに該当しています。
ここから、Boardコンポーネントへ現在の状態(squares)とクリックイベント(onClick)をPropsで引き渡します。
そしてさらに Board → Square へ Props 経由で引き渡され、Square(マス)をクリックされると、イベントが発火し、最終Gameコンポーネントで定義された handleClick() で状態の更新と履歴の挿入がされ、 useState 経由で状態が管理される仕組みとなっています。

今回 Typescript ということで型付けですが、

  • FieldState : ゲーム全般の状態管理の親の型(GAMEコンポーネントで定義)
    • History: ゲームの履歴を管理する型(GAMEコンポーネントで定義)
      • SquaresState:9マス分の配列を管理する型(Boardコンポーネントで定義)
        • SquareValue:マス情報( “X” | “O” | null )の型(Squareコンポーネントで定義)

このようなネストした関係で型付けしました。
そして今回の試みとしてクラスコンポーネントから関数コンポーネントに書き換えも行いました。
理由としては 月並みですが、React 16.8 で追加された Hooks を利用する為です。
フックの導入 : https://ja.reactjs.org/docs/hooks-intro.html

State の利用を useState()を使うことで 、クラスコンポーネント同様に関数コンポーネントでも利用ができるようになってる点や、単純に関数コンポーネントのほうが記述がスッキリ書けるとか this.state のような this アクセスをしなくても良くなるなどのメリットがあったためです。

board.tsx

import Square, { SquareValue } from "./square"

export type SquaresState = SquareValue[];

type BoardProps = {
  squares: SquaresState
  onClick: (i:number) => void
}

const Board = (props: BoardProps) => {
  const renderSquare = (i: number) => {
    return (
      <Square
        value={props.squares[i]}
        onClick={() => props.onClick(i)}
      />
    )
  }

  return (
    <div>
      <div className="board-row">
        { renderSquare(0) }
        { renderSquare(1) }
        { renderSquare(2) }
      </div>
      <div className="board-row">
        { renderSquare(3) }
        { renderSquare(4) }
        { renderSquare(5) }
      </div>
      <div className="board-row">
        { renderSquare(6) }
        { renderSquare(7) }
        { renderSquare(8) }
      </div>
    </div>
  )
}

export default Board

Board コンポーネントは3✕3マスのゲームの盤コンポーネントです。
ここでは、Gameコンポーネントから Props 経由で受け取った状態やイベントを3✕3の9マスにそれぞれ割り当てていきます。
そうすることで、各マスがクリックされた時のイベントの定義やクリック後のマスの○✗表示などの状態を記録していく役目があります。
Gameコンポーネントから Props 経由で来る値があるので、Propsの型を定義してます。

ここでは、

  • squares:最新の9マスの状態の配列情報
  • onClick:クリックイベントの関数

で型を定義しています。

square.tsx

export type SquareValue = "X" | "O" | null;

type SquareProps = {
  value: SquareValue;
  onClick: () => void;
}

const Square = (props: SquareProps) => {
  return (
    <button
      className="square"
      onClick={ props.onClick }
    >
      { props.value }
    </button>
  )
}

export default Square

最後はマス(正方形)のコンポーネントです。
これはシンプルで、盤上の9マスの「マス」を定義するコンポーネントとなります。
次にBoardコンポーネント から 各マス毎の値が Props 経由で値が来るので、ここでもPropsの型を定義します。

ここでは、

  • value:マスに表示する文字列「”X” | “O”」かまだ選択されてないときの初期値「null」 だけを許容する型
  • onClick:Boardコンポーネントでマス毎に 0~8 を定義付けたクリックイベントの関数

で型を定義しました。
以上で、チュートリアルのゲームが完成です 🎉

http://localhost:3000/game

上記のURLで三目並べゲームの画面にアクセス出来るようになっているはずです。
game.tsx が画面としてのコンポーネントも兼ねているので、pagesのディレクトリに配置したことで、Next.js のルートに関連付けられるようになりファイル名がそのままURLスキームとなります。
無事エラーもなく動いてくれる所まで完成して一安心です。

まとめ

今回やってみた感想として、本当は純粋に React チュートリアル やってみてのブログを書くつもりだったんですが、
リーダー:「せっかくだし、Typescript で書いてみたら?」
という話をリーダーとしたことがきっかけで、ついでに Next.js のフレームワーク上で作ってみるかーっと盛ってみました。
結果として、フロントエンド開発の難しさを知れた点は良かったと思います。
記述の自由度もさることながら、今回Reactのクラスコンポーネントとしても関数コンポーネントとしても別に古いか新しいかの違いで、どちらも間違ってはいないので、React に精通したエンジニアの方からの指摘がないと自分で気づけてなかったか、学習の過程で後に気付いたって感じだっただろうなと思います。
Typescript も 型付けを中心に書いてみた感想として、型付けをすることでコードを書いてる時点でエラーに気づけるので、効率的に修正に移れるのが良かったです。
そして、型設計の難しさも合わせて感じました。
今回シンプルな型付けしか書いてないので、ジェネリクスなどの柔軟な記述方法なども存在するし、まだまだ学習不足だなと痛感しています。
そして調べていた過程で主流やトレンドの変化が激しいので、キャッチアップを日々追いかけないと、気付いたらその情報もう古いよ?ってことになってるだろうなと感じました。
このブログでの記述も近いうちに古くなってる可能性は十分あると思います。(すでに古いかも・・・(^_^;))

今回のブログは以上となります。
お付き合い頂きありがとうございました。m(_ _)m