サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Create React App + React DnD 04: アプリケーションの処理を改善する


React DnDサイト「Tutorial」のでき上がりとして公開されている「Chessboard Tutorial」に合わせて、前回からこのシリーズの作例を書き替え始めました。前回つくったサンプル002で改めたのは、おもに構文やアプリケーションの組み立てです。今回は、アプリケーションの動作や処理を改善します。なお前回と同じく、公式作例と細かなコードやモジュールパスは異なるものの、基本的なポイントは変えていません。

01 ゲームロジックのモジュールを関数からクラスに書き替える

まず、ゲームロジックのモジュールsrc/Game.jsは、関数からクラスに改めます。クラスにした方が、インスタンスを取り回して複数のモジュールから参照・操作しやすくなるからです。処理の中身は変えません。変数はプロパティに、関数はメソッドに構文を書き替えてください。

src/Game.js

export class Game {
	constructor() {
		// let knightPosition = [1, 7];
		this.knightPosition = [1, 7];
		// let observer = null;
		this.observer = null;
	}
	// function emitChange() {
	emitChange() {
		// observer(knightPosition);
		this.observer(this.knightPosition);
	}
	// export function observe(o) {
	observe(o) {
		// if (observer) {
		if (this.observer) {
			throw new Error('Multiple observers not implemented.');
		}
		// observer = o;
		this.observer = o;
		// emitChange();
		this.emitChange();
	}
	// export function moveKnight(toX, toY) {
	moveKnight(toX, toY) {
		// knightPosition = [toX, toY];
		this.knightPosition = [toX, toY];
		// emitChange();
		this.emitChange();
	}
	// export function canMoveKnight(toX, toY) {
	canMoveKnight(toX, toY) {
		// const [x, y] = knightPosition;
		const [x, y] = this.knightPosition;

	}
}

Gameクラスのインスタンスをつくるのは、アプリケーションのモジュールsrc/index.jsの役割です。そのうえで、observe()メソッドを呼び出します。引数のコールバックに変わりはありません。違ってくるのは、子コンポーネント(Board)に与えるプロパティが駒の位置(knightPosition)でなく、Gameインスタンス(game)になることです。駒の位置はインスタンスから取り出せます。

src/index.js

// import { observe } from './Game';
import { Game } from './Game';

const game = new Game();
// observe((knightPosition) =>
game.observe((knightPosition) =>
	ReactDOM.render(
	<React.StrictMode>
		 {/* <Board knightPosition={knightPosition} /> */}
		<Board game={game} />
	</React.StrictMode>,
	root)
);

盤面のモジュールsrc/components/Board.jsは、親から受け取ったGameインスタンス(game)をさらにその子(BoardSquare)にバケツリレーです。そのためには、コンポーネントツリーをつくって返していた関数(renderSquare())は、Boardコンポーネントの中に取り込まなければなりません。前述のとおり、駒の位置(knightPosition)はGameインスタンスから取り出します。

src/components/Board.js

import { useCallback } from 'react';

/* function renderSquare(i, [knightX, knightY]) {

} */
// export const Board = ({ knightPosition }) => {
export const Board = ({ game }) => {
	const knightPosition = game.knightPosition;
	const renderSquare = useCallback((i, [knightX, knightY]) => {
		const x = i % 8;
		const y = Math.floor(i / 8);
		return (
			<div
				key={i}
				style={squareStyle}
			>
				{/* <BoardSquare x={x} y={y}> */}
				<BoardSquare x={x} y={y} game={game}>
					<Piece isKnight={knightX === x && knightY === y} />
				</BoardSquare>
			</div>
		);
	}, [game]);

};

モジュールsrc/components/BoardSquare.jsは、Gameインスタンスから参照したメソッドにより、駒の位置とマス目の処理を行います。

src/components/BoardSquare.js

// import { canMoveKnight, moveKnight } from '../Game';

// export const BoardSquare = ({ x, y, children }) => {
export const BoardSquare = ({ x, y, children, game }) => {

	const [{ isOver, canDrop }, drop] = useDrop(() => ({

		// drop: () => moveKnight(x, y),
		drop: () => game.moveKnight(x, y),
		// canDrop: () => canMoveKnight(x, y),
		canDrop: () => game.canMoveKnight(x, y),

	}), [x, y]);  

};

これで、ゲームロジックのモジュールsrc/Game.jsをクラスに改めることができました。処理の流れは、まだ基本的に変わっていません。Gameクラスとそれに対応して書き替えた他のモジュールの記述全体は、それぞれつぎのコード001のとおりです。

コード001■ゲームロジックのモジュールをクラスに書き替える

src/Game.js

export class Game {
	constructor() {
		this.knightPosition = [1, 7];
		this.observer = null;
	}
	emitChange() {
		this.observer(this.knightPosition);
	}
	observe(o) {
		if (this.observer) {
			throw new Error('Multiple observers not implemented.');
		}
		this.observer = o;
		this.emitChange();
	}
	moveKnight(toX, toY) {
		this.knightPosition = [toX, toY];
		this.emitChange();
	}
	canMoveKnight(toX, toY) {
		const [x, y] = this.knightPosition;
		const dx = toX - x;
		const dy = toY - y;
		return (
				(Math.abs(dx) === 2 && Math.abs(dy) === 1) ||
				(Math.abs(dx) === 1 && Math.abs(dy) === 2)
		);
	}
}

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Game } from './Game';
import { Board } from './components/Board';

const root = document.getElementById('root');
const game = new Game();
game.observe((knightPosition) =>
	ReactDOM.render(
	<React.StrictMode>
		<Board game={game} />
	</React.StrictMode>,
	root)
);

src/components/Board.js

import { useCallback } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'
import { BoardSquare } from './BoardSquare';
import { Piece } from './Piece';

const boardStyle = {
	width: 500,
	height: 500,
	border: '1px solid gray',
	display: 'flex',
	flexWrap: 'wrap'
};
const squareStyle = { width: '12.5%', height: '12.5%'};
export const Board = ({ game }) => {
	const knightPosition = game.knightPosition;
	const renderSquare = useCallback((i, [knightX, knightY]) => {
		const x = i % 8;
		const y = Math.floor(i / 8);
		return (
			<div
				key={i}
				style={squareStyle}
			>
				<BoardSquare x={x} y={y} game={game}>
					<Piece isKnight={knightX === x && knightY === y} />
				</BoardSquare>
			</div>
		);
	}, [game]);
	const squares = Array.from(new Array(64), (_, i) => renderSquare(i, knightPosition));
	return (
		<DndProvider backend={HTML5Backend}>
			<div style={boardStyle}>
				{squares}
			</div>
		</DndProvider>
	);
};

src/components/BoardSquare.js

import { useDrop } from 'react-dnd';
import { ItemTypes } from '../ItemTypes';
import { Square } from './Square';
import { Overlay } from './Overlay';

const boardSquareStyle = {
	position: 'relative',
	width: '100%',
	height: '100%',
};
export const BoardSquare = ({ x, y, children, game }) => {
	const black = (x + y) % 2 === 1;
	const [{ isOver, canDrop }, drop] = useDrop(() => ({
		accept: ItemTypes.KNIGHT,
		drop: () => game.moveKnight(x, y),
		canDrop: () => game.canMoveKnight(x, y),
		collect: (monitor) => ({
			isOver: !!monitor.isOver(),
			canDrop: !!monitor.canDrop(),
		}),
	}), [x, y]);  
	return (
		<div
			ref={drop}
			style={boardSquareStyle}
		>
			<Square black={black}>{children}</Square>
			{isOver && !canDrop && <Overlay color="red" />}
			{!isOver && canDrop && <Overlay color="yellow" />}
			{isOver && canDrop && <Overlay color="green" />}
		</div>
	);
};

02 描画の更新をアプリケーション全体から盤面のコンポーネントに下げる

つぎに考えるのが、無駄な処理はないかです。ドラッグ&ドロップで駒を動かすためのコールバック関数は、アプリケーションモジュールsrc/index.jsGameインスタンスのobserve()メソッドに渡していました。そして、コールバックが呼ばれるたびに、ReactDOM.render()がアプリケーション全体を描画しなおすことになります。けれど、「実際には大抵のReactアプリケーションはReactDOM.render()を一度しか呼び出しません」(「レンダーされた要素の更新」)。

もちろん、Reactはすべてのコンポーネントを再描画するわけではありません。どこが更新されたか確かめたうえで、必要な要素をレンダーしなおすのです。とはいえ、更新の対象となるコンポーネントはできるだけ絞り込むことが、描画の最適化につながります。

src/index.js

game.observe((knightPosition) =>
	ReactDOM.render(
	<React.StrictMode>
		<Board game={game} />
	</React.StrictMode>,
	root)
);

そこで、Gameクラスのobserve()メソッドを呼び出すのは、アプリケーションモジュールsrc/index.jsからでなく、盤面のコンポーネントBoardに移しましょう。その準備として、ふたつのモジュールの間に、アプリケーションのルートとなる新たなコンポーネント(TutorialApp)を差し込みます。逆に、DndProviderコンポーネントは、Boardからもらい受けることにしました。ルートコンポーネントを包んでしまってよいだろう、という考えからです。

src/index.js

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'
// import { Game } from './Game';
// import { Board } from './components/Board';
import { TutorialApp } from './TutorialApp';

const root = document.getElementById('root');
// const game = new Game();
// game.observe((knightPosition) =>
ReactDOM.render(
	<React.StrictMode>
		<DndProvider backend={HTML5Backend}>
			{/* <Board game={game} /> */}
			<TutorialApp />
		</DndProvider>
	</React.StrictMode>,
root);
// );

src/components/Board.js

// import { DndProvider } from 'react-dnd';
// import { HTML5Backend } from 'react-dnd-html5-backend';

export const Board = ({ game }) => {

	return (
		// <DndProvider backend={HTML5Backend}>
		<div style={boardStyle}>
			{squares}
		</div>
		// </DndProvider>
	);
};

新しいルートコンポーネントのモジュールsrc/TutorialApp.jsxの記述は、以下のコード002にまとめたとおりです。Boardコンポーネントのスタイル(boardStyle)から一部を移しました。

src/components/Board.js

const boardStyle = {
	width: '100%',  // 500,
	height: '100%',  // 500,
	// border: '1px solid gray',

};

コード002■ルートコンポーネントのモジュール

src/TutorialApp.jsx

import { useMemo } from 'react';
import { Game } from './Game';
import { Board } from './components/Board';

const containerStyle = {
	width: 500,
	height: 500,
	border: '1px solid gray',
};
export const TutorialApp = () => {
	const game = useMemo(() => new Game(), []);
	return (
		<div style={containerStyle}>
			<Board game={game} />
		</div>
	);
};

そうしたら、Boardコンポーネントは、プロパティに受け取ったインスタンス(game)に対してobserve()メソッドを呼び出します。渡したコールバックは、駒の位置の状態設定関数(setKnightPosition)です。observe()の呼び出しは、useEffectフックに加えました。第1引数はコールバック関数です。第2引数の配列に含めた参照が更新されると、コールバックは呼び出されます。つまり、コンポーネントが再描画されるたびに実行される無駄を防ぐのです。なお、第1引数は「副作用関数」、第2引数は「依存配列」と呼ばれます。

src/components/Board.js

// import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';

export const Board = ({ game }) => {
	const [knightPosition, setKnightPosition] = useState(game.knightPosition);
	useEffect(() => game.observe(setKnightPosition), [game]);
	// const knightPosition = game.knightPosition;

};

ノート01■副作用とは

Reactの公式ドキュメントには、つぎのように説明されています(「副作用フック」)。

これまでに React コンポーネントの内部から、外部データの取得や購読 (subscription)、あるいは手動での DOM 更新を行ったことがおありでしょう。これらの操作は他のコンポーネントに影響することがあり、またレンダーの最中に実行することができないので、われわれはこのような操作を “副作用 (side-effects)“、あるいは省略して “作用 (effects)” と呼んでいます。

これで、アプリケーションモジュールsrc/index.jsにおけるReactDOM.render()の呼び出しは1回で済み、Gameインスタンスのobserve()メソッドに渡したコールバックが無駄に実行されることも避けられました。アプリケーションと盤面のモジュールの記述全体は、つぎのコード003に掲げたとおりです。

コード003■アプリケーションと盤面のモジュール

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'
import { TutorialApp } from './TutorialApp';

const root = document.getElementById('root');
ReactDOM.render(
	<React.StrictMode>
		<DndProvider backend={HTML5Backend}>
			<TutorialApp />
		</DndProvider>
	</React.StrictMode>,
root);

src/components/Board.js

import { useCallback, useEffect, useState } from 'react';
import { BoardSquare } from './BoardSquare';
import { Piece } from './Piece';

const boardStyle = {
	width: '100%',
	height: '100%',
	display: 'flex',
	flexWrap: 'wrap'
};
const squareStyle = { width: '12.5%', height: '12.5%'};
export const Board = ({ game }) => {
	const [knightPosition, setKnightPosition] = useState(game.knightPosition);
	useEffect(() => game.observe(setKnightPosition), [game]);
	const renderSquare = useCallback((i, [knightX, knightY]) => {
		const x = i % 8;
		const y = Math.floor(i / 8);
		return (
			<div
				key={i}
				style={squareStyle}
			>
				<BoardSquare x={x} y={y} game={game}>
					<Piece isKnight={knightX === x && knightY === y} />
				</BoardSquare>
			</div>
		);
	}, [game]);
	const squares = Array.from(new Array(64), (_, i) => renderSquare(i, knightPosition));
	return (
		<div style={boardStyle}>
			{squares}
		</div>
	);
};

03 Gameクラスに複数のコールバック関数を加えられるようにする

さらに、Gameインスタンスにobserve()メソッドで複数のコールバックが加えられるようにします。そのため、コールバックのプロパティ(observers)は配列に改めました。すると、コールバックを呼び出すメソッド(emitChange())も配列要素を取り出さなければなりません。

src/Game.js

export class Game {
	constructor() {

		// this.observer = null;
		this.observers = [];
	}
	emitChange() {
		// this.observer(this.knightPosition);
		const pos = this.knightPosition;
		this.observers.forEach((o) => o && o(pos));
	}
	observe(o) {
		/* if (this.observer) {
			throw new Error('Multiple observers not implemented.');
		}
		this.observer = o; */
		this.observers = [...this.observers, o];
		this.emitChange();
	}
}

これで、動作に問題はありません。ただ、公式サイトの作例「Chessboard Tutorial」のsrc/components/Board.jsモジュールを見ると、useEffectフックに第2引数がありません。この場合はデフォルトで、コンポーネントが再描画fされるたびにコールバックは実行されます。つまり、フックを使う意味がないということです。

src/components/Board.js

export const Board = ({ game }) => {

	// useEffect(() => game.observe(setKnightPosition), [game]);
	useEffect(() => game.observe(setKnightPosition));

};

それだけではありません。Gameクラスでコールバックは配列(observers)に収めました。すると、駒を動かすたびに、同じコールバック関数が配列に加えられてしまうのです(図001)。もっとも、位置を定めるコールバックが何度も呼ばれるだけで、見た目からはわからず、エラーにはなりません。依存配列は正しく与えなければならないのです。

src/Game.js

export class Game {

	observe(o) {

		this.observers = [...this.observers, o];
		console.log('observe:', this.observers);  // 確認用

	}

}

図001■同じコールバック関数が駒を動かすたびにGameクラスの配列プロパティに加わる

図001

もちろん、公式作例にはコールバックの重複が起こらない処理は加えられています。Gameクラスのobserve()が関数を返していることです。useEffectフックの第1引数に渡したコールバックが関数を返すと、クリーンアップとして扱われます。そして、useEffectがコールバックを呼び出す前に実行されるのです。公式作例のクリーンアップ関数は、コールバックの配列(observers)からすでに加えられていた関数(o)を除いています。こうすれば、コールバックの重複は避けられます。useEffectフックの構文は、以下の表001のとおりです。

src/Game.js

export class Game {

	observe(o) {

		return () => {
			this.observers = this.observers.filter((t) => t !== o);
		};
	}

}

表001■useEffectフック

useEffect
引数 第1引数は、副作用があるかもしれない命令型の処理を行う関数。レンダリングの結果が画面に反映された後に動作する。第2引数に、副作用が依存する値の配列を与えられる。
  1. 副作用関数: デフォルトではレンダーが済むたびに動作する。
  2. 依存配列: 副作用が依存するプロパティの配列。指定にしたがって、副作用関数が実行される。
    • 指定しないと、再描画のたびに実行される。
    • 配列要素のプロパティが変わったときに実行される。
    • 空の配列[]を渡すと、はじめのマウント時にのみ実行される。
副作用関数

つぎの変化に対して実行される。

  • 親コンポーネントの再描画。
  • propsの値。
  • ステートフックのstate変数の値。

戻り値に後処理の関数を定めることができる。つぎのときに実行される。

  • コンポーネントが破棄されるとき。
  • つぎの副作用関数を実行する前。
戻り値 なし。

とはいえ、useEffectフックの依存配列を正しく定めれば、この作例ではコールバックは最初に一度しか呼び出されません。無駄な呼び出しは省くべきでしょう。他方で、useEffectの処理がほかに何か加われば、複数回呼び出されることもありえます。クリーンアップ関数もやはり必要です。それらの手直しを加えたゲームロジックのモジュールsrc/Game.jsの記述全体は、つぎのコード004のとおりです。でき上がった作例は、以下のサンプル001としてCodeSandboxに公開しましたので、具体的な動きやコードはこちらでお確かめください。

コード004■ゲームロジックのモジュール

src/Game.js

export class Game {
	constructor() {
		this.knightPosition = [1, 7];
		this.observers = [];
	}
	emitChange() {
		const pos = this.knightPosition;
		this.observers.forEach((o) => o && o(pos));
	}
	observe(o) {
		this.observers = [...this.observers, o];
		this.emitChange();
		return () => {
			this.observers = this.observers.filter((t) => t !== o);
		};
	}
	moveKnight(toX, toY) {
		this.knightPosition = [toX, toY];
		this.emitChange();
	}
	canMoveKnight(toX, toY) {
		const [x, y] = this.knightPosition;
		const dx = toX - x;
		const dy = toY - y;
		return (
				(Math.abs(dx) === 2 && Math.abs(dy) === 1) ||
				(Math.abs(dx) === 1 && Math.abs(dy) === 2)
		);
	}
}

サンプル001■React DnD 04: Chess board and lonely Knight

Create React App + React DnD


作成者: 野中文雄
作成日: 2021年05月09日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.