Vue.jsとReactの勉強のため,Reactのチュートリアルの「Tic Tac Toe」をVue.jsで実装してみる。
vue-cliを使ってプロジェクトを作成(省略)。
バージョンはVue2。
components/フォルダ以下に次のファイルを作成。
- Square.vue
- ゲームの1マスを管理
- Board.vue
- ゲームボード(9マス)を管理
- Game.vue
- ゲーム全体の状態(手番,履歴)を管理
ちなみにcssもチュートリアルと同じになるように移植した。
Square.vue
<template>
<button class="square" @click="onClick">{{value}}</button>
</template>
<style scoped>
.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;
background: #ddd;
}
</style>
<script>
export default {
name: 'Square',
props: {
value: String,
onClick: Function,
},
}
</script>
Squareコンポーネントはゲームの1マスにあたるボタンを表示する。
同時にボタンクリックのイベントを発火してゲームが進行するのだが,ゲームの状態は上位のGameコンポーネントが管理している。
通常はemitを使うところなのだろうが,ここではbutton@clickに上位コンポーネントからプロパティとして引き渡されたonClick(名前は何でもよい)関数を当ててみたら上手く動いた。(この方法が正しいのかどうか,どなたかご教示ください。)
Board.vue
<template>
<div>
<div class="board-row" v-for="r in [0,1,2]" :key="r">
<square v-for="c in [0,1,2]" :key="c"
:value="squares[r*3+c]"
:onClick="() => onClick(r*3+c)"
/>
</div>
</div>
</template>
<style scoped>
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
</style>
<script>
import Square from '@/components/Square'
export default {
name: 'Board',
components: {
Square,
},
props: {
squares: Array,
onClick: Function,
},
}
</script>
Boardコンポーネントはゲームの盤面である9つのマスを管理する。
Reactのチュートリアルでは,Square 1つのレンダリングを関数にして,それを9回呼び出す方式をとっている。
Reactではレンダリングのテンプレート自体がJavaScript(の拡張)なのでそういったことができるが,Vue.jsで相当する方法が判らなかったので,ここではv-forによる二重ループで実装した。
onClickは「上から渡されたハンドラ関数をマス番号の引数付きで呼び出す」アロー関数に設定する。
Game.vue
<template>
<div class="game">
<div class="game-board">
<board
:squares="current.squares"
:onClick="handleClick"
/>
</div>
<div class="game-info">
<div>{{status}}</div>
<ol>
<li v-for="(step, move) in history" :key="move">
<button @click="() => jumpTo(move)">{{move ? 'Go to move #'+move : 'Go to game start'}}</button>
</li>
</ol>
</div>
</div>
</template>
<style scoped>
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
ol, ul {
padding-left: 30px;
}
</style>
<script>
import Board from '@/components/Board'
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
}
export default {
name: 'Game',
components: {
Board,
},
data() {
return {
history: [{
squares: Array(9).fill(null)
}],
stepNumber: 0,
xIsNext: true,
}
},
computed: {
current() {
return this.history[this.stepNumber]
},
winner() {
return calculateWinner(this.current.squares)
},
status() {
return this.winner ? 'Winner: ' + this.winner : 'Next player: ' + (this.xIsNext ? 'X' : 'O')
},
},
methods: {
handleClick(i) {
const history = this.history.slice(0, this.stepNumber + 1)
const current = history[history.length - 1]
const squares = current.squares.slice()
if (calculateWinner(squares) || squares[i]) {
return
}
squares[i] = this.xIsNext ? 'X' : 'O'
this.history = history.concat([{ squares: squares }])
this.stepNumber = history.length
this.xIsNext = !this.xIsNext
},
jumpTo(step) {
this.stepNumber = step
this.xIsNext = (step % 2) === 0
},
}
}
</script>
Gameコンポーネントはゲーム全体の状態を管理し,履歴を遡るための機能を提供している。
やはりレンダリングテンプレートを関数にできないことから,<template>の中がReactに比べて無理をしがち。履歴制御ボタンの@clickやテキストにそのあたりが表れている。
Vue.jsの流儀であれば,ここもコンポーネントにするべきなのだろうが,今回はReactチュートリアルとの比較しやすさをとった。
App.vue
<template>
<div id="app">
<game />
</div>
</template>
<style lang="scss">
# app {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
</style>
<script>
import Game from '@/components/Game'
export default {
name: 'App',
components: {
Game,
},
}
</script>
最後にGameコンポーネントを呼び出すようにApp.vueを修正。
今回の気付き
- ReactではレンダリングテンプレートがJavaScriptであることにより,関数化などの柔軟な実装が可能。
- 下位コンポーネントで発生したイベントを,上位コンポーネントのハンドラで処理するためにプロパティが使える(要調査)。
- v-forで生成される要素に固有のハンドラを割り当てるために,
@clickにアロー関数を設定する方法が使える。
