はじめに
React を基礎から勉強する。最近の React は Hooks を用いることでクラスコンポーネントを扱わなくても開発ができるという耳障りのいい言葉を受け取った状態で React 公式チュートリアルを眺めてみると、なんとクラスコンポーネントを使っている。(チュートリアルのソースコードは数年以上更新されていないらしい。)
クラスコンポーネントなんて触りたくないとチュートリアルを敬遠していたのだが、基礎が疎かで目も当てられない状態だったので割り切って一通り試してみた。たしかにわかりやすかったが、流行りの Hooks などは使用されていないので、TypeScript + Hooks という流行りについていけるようにリファクタリングを行った。
Hooks + TypeScriptでReact公式チュートリアルをリファクタ、Reactのチュートリアルをhooks + TypeScriptでモダンな仕様にリファクタしてみたを参考にした。
React + TypeScript 環境の導入
ローカルに開発環境を入れたくないので Docker コンテナで実行環境を作成する。概要はDocker 開発環境構築 (WSL2 + VSCode)を参照。VSCode が提供している Node.js & TypeScript のサンプルを元に作成した devcontainer.json および Dockerfile は以下。
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node
{
"name": "Node.js & TypeScript",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 12, 14, 16
"args": {
"VARIANT": "16"
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.wordWrap": "on",
// "terminal.integrated.shell.linux": "/bin/bash"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"formulahendry.auto-close-tag",
"formulahendry.auto-rename-tag",
"mrmlnc.vscode-duplicate",
"dsznajder.es7-react-js-snippets",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"xabikos.javascriptsnippets",
"esbenp.prettier-vscode",
"shardulm94.trailing-spaces",
"msjsdiag.debugger-for-chrome",
"visualstudioexptteam.vscodeintellicode",
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
}
ARG VARIANT="16-buster"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
公式チュートリアルのコード準備
上記をビルドして VSCode 上で接続する。問題なく接続できたら、VSCode 上のターミナルにて、npx create-react-app my-app --typescript
を実行。作成された my-app/src 内のファイルをすべて削除して新たに index.css と index.tsx を作成し、それぞれに公式チュートリアルに載っている以下コードをコピペする。
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;
}
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
/>
);
}
render() {
return (
<div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null)
}
],
stepNumber: 0,
xIsNext: true
};
}
handleClick(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
this.setState({
history: history.concat([
{
squares: squares
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
render() {
const history = this.state.history;
const current = history[this.state.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={() => this.jumpTo(move)}>{desc}</button>
</li>
);
});
let status;
if (winner) {
status = "Winner: " + winner;
} else {
status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}
return (
<div className="game">
<div className="game-board">
<Board
squares={current.squares}
onClick={i => this.handleClick(i)}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
</div>
</div>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
function calculateWinner(squares) {
const lines = [
[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[c]) {
return squares[a];
}
}
return null;
}
TypeScript 形式へのリファクタリング
上記の準備を終えた状態だと、index.tsx にて多くのエラーが吐かれているので順に修正していく。
typescript, @types/react
まず yarn add typescript
で typescript をインストールする。次に import React from "react";
で以下のようなエラーが出ているのを修正する。
Could not find a declaration file for module ‘react’. ...
TypeScript に対応した React となっていないことが(たぶん)原因と思われる。エラーコードに書いてあるように npm i --save-dev @types/react
を実行することで解消できる。(コンテナのビルド時にあらかじめこのあたりを解消できるように Dockerfile を修正したかったが、パッとできなかったのでわかる方教えて下さい。)
リファクタリング
「はじめに」に載せたサイトを参考にリファクタリングをした。TypeScript の特徴である「静的型付け」や 「React Hooks」 の基本的な知識は、TypeScript: Documentation - Everyday Types や React.js&Next.js超入門 第2版で事前に勉強した。
以下にリファクタリング後のコードを載せた。もともと1つのファイルで構成されていたものをコンポーネントごとに分け、以下のようなディレクトリ構成になっている。
src
├── components
│ ├── Board.tsx
│ ├── Game.tsx
│ └── Square.tsx
├── index.css
├── index.tsx
└── interface.ts
各ファイルのコードは以下。(index.css は変更していないため省略。)
import ReactDOM from "react-dom";
import './index.css';
import Game from "./components/Game";
ReactDOM.render(<Game />, document.getElementById("root"));
export type ISquare = "X" | "O" | null;
export type History = {
squares: ISquare[];
position: number;
};
import React from "react";
import {ISquare} from "../interface";
interface SquareProps {
value: ISquare;
onClick: () => void;
}
const Square: React.FC<SquareProps> = ({value, onClick}) => {
return (
<button className="square" onClick={onClick}>
{value}
</button>
);
}
export default Square;
import React from "react";
import {ISquare} from "../interface";
import Square from "./Square";
interface BoardProps {
squares: ISquare[];
onClick: (i: number) => void;
}
const Board: React.FC<BoardProps> = ({squares, onClick}) => {
const renderSquare = (i: number) => {
return (
<Square value={squares[i]} onClick={() => 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;
import React, {useState} from "react";
import {ISquare, History} from "../interface";
import Board from "./Board";
const Game: React.FC = () => {
const [history, setHistory] = useState<History[]>([{squares: Array(9).fill(null), position: -1}]);
const [stepNumber, setStepNumber] = useState<number>(0);
const [xIsNext, setXIsNext] = useState<boolean>(true);
const handleClick = (i: number) => {
const _history = history.slice(0, stepNumber + 1);
const current = _history[_history.length - 1];
const squares = current.squares.slice();
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = xIsNext ? "X" : "O";
setHistory(_history.concat([{squares: squares, position: i}]));
setStepNumber(_history.length)
setXIsNext(!xIsNext);
}
const jumpTo = (step: number) => {
setStepNumber(step);
setXIsNext((step % 2) === 0);
}
const current = history[stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map(({squares, position}, move) => {
const x = position % 3 + 1;
const y = Math.floor(position / 3) + 1;
const desc = move ?
'Go to move #' + move + ` (${x}, ${y})`:
'Go to game start';
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{desc}</button>
</li>
);
});
let status;
if (winner) {
status = "Winner: " + winner;
} else {
status = "Next player: " + (xIsNext ? "X" : "O");
}
return (
<div className="game">
<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>
);
}
const calculateWinner = (squares: Array<ISquare>) => {
const lines = [
[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[c]) {
return squares[a];
}
}
return null;
}
export default Game;
上記で問題なく、チュートリアルアプリが動く。
おわりに
TypeScript + Hooks でのリファクタリングをした。かなり参考サイトにおんぶにだっこな状態で進めたので、自身の理解を深めるべくチュートリアル末尾に記載されている改良のアイデアを試そうと思う。