HTML5テクニカルノート
Create React App + React DnD 04: アプリケーションの処理を改善する
- ID: FN2105002
- Technique: ECMAScript 2015
- Library: React 17.0.2 / React DnD 14.0.2
React DnDサイト「Tutorial」のでき上がりとして公開されている「Chessboard Tutorial」に合わせて、前回からこのシリーズの作例を書き替え始めました。前回つくったサンプル002で改めたのは、おもに構文やアプリケーションの組み立てです。今回は、アプリケーションの動作や処理を改善します。なお前回と同じく、公式作例と細かなコードやモジュールパスは異なるものの、基本的なポイントは変えていません。
01 ゲームロジックのモジュールを関数からクラスに書き替える
まず、ゲームロジックのモジュールsrc/Game.js
は、関数からクラスに改めます。クラスにした方が、インスタンスを取り回して複数のモジュールから参照・操作しやすくなるからです。処理の中身は変えません。変数はプロパティに、関数はメソッドに構文を書き替えてください。
src/Game.jsexport 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.jsimport { 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)
);
}
}
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)
);
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>
);
};
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.js
でGame
インスタンスのobserve()
メソッドに渡していました。そして、コールバックが呼ばれるたびに、ReactDOM.render()
がアプリケーション全体を描画しなおすことになります。けれど、「実際には大抵のReactアプリケーションはReactDOM.render()
を一度しか呼び出しません」(「レンダーされた要素の更新」)。
もちろん、Reactはすべてのコンポーネントを再描画するわけではありません。どこが更新されたか確かめたうえで、必要な要素をレンダーしなおすのです。とはいえ、更新の対象となるコンポーネントはできるだけ絞り込むことが、描画の最適化につながります。
src/index.jsgame.observe((knightPosition) => ReactDOM.render( <React.StrictMode> <Board game={game} /> </React.StrictMode>, root) );
そこで、Game
クラスのobserve()
メソッドを呼び出すのは、アプリケーションモジュールsrc/index.js
からでなく、盤面のコンポーネントBoard
に移しましょう。その準備として、ふたつのモジュールの間に、アプリケーションのルートとなる新たなコンポーネント(TutorialApp
)を差し込みます。逆に、DndProvider
コンポーネントは、Board
からもらい受けることにしました。ルートコンポーネントを包んでしまってよいだろう、という考えからです。
src/index.jssrc/components/Board.jsimport { 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); // );
// 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.jsconst 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);
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.jsexport 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.jsexport const Board = ({ game }) => { // useEffect(() => game.observe(setKnightPosition), [game]); useEffect(() => game.observe(setKnightPosition)); };
それだけではありません。Game
クラスでコールバックは配列(observers
)に収めました。すると、駒を動かすたびに、同じコールバック関数が配列に加えられてしまうのです(図001)。もっとも、位置を定めるコールバックが何度も呼ばれるだけで、見た目からはわからず、エラーにはなりません。依存配列は正しく与えなければならないのです。
src/Game.jsexport class Game { observe(o) { this.observers = [...this.observers, o]; console.log('observe:', this.observers); // 確認用 } }
図001■同じコールバック関数が駒を動かすたびにGameクラスの配列プロパティに加わる
もちろん、公式作例にはコールバックの重複が起こらない処理は加えられています。Game
クラスのobserve()
が関数を返していることです。useEffect
フックの第1引数に渡したコールバックが関数を返すと、クリーンアップとして扱われます。そして、useEffect
がコールバックを呼び出す前に実行されるのです。公式作例のクリーンアップ関数は、コールバックの配列(observers
)からすでに加えられていた関数(o
)を除いています。こうすれば、コールバックの重複は避けられます。useEffect
フックの構文は、以下の表001のとおりです。
src/Game.jsexport class Game { observe(o) { return () => { this.observers = this.observers.filter((t) => t !== o); }; } }
表001■useEffectフック
useEffect |
|
引数 | 第1引数は、副作用があるかもしれない命令型の処理を行う関数。レンダリングの結果が画面に反映された後に動作する。第2引数に、副作用が依存する値の配列を与えられる。
|
副作用関数 |
つぎの変化に対して実行される。
戻り値に後処理の関数を定めることができる。つぎのときに実行される。
|
戻り値 | なし。 |
とはいえ、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
- Create React App + React DnD 01: ドラッグ&ドロップの前に ー クリックで動かす
- Create React App + React DnD 02: ドラッグ&ドロップで動かす
- Create React App + React DnD 03: アプリケーションを組み立て直す
- Create React App + React DnD 04: アプリケーションの処理を改善する
作成者: 野中文雄
作成日: 2021年05月09日
Copyright © 2001-2020 Fumio Nonaka. All rights reserved.