LoginSignup
75
72

More than 3 years have passed since last update.

Vue.jsレベルを上げよう!○×ゲームを作ってTypeScript&Vue3のCompositionAPIと仲良くなる

Last updated at Posted at 2019-12-19

この記事はVue#2アドベントカレンダーの19日目です。昨日は@mgrさんのVue.jsのcomposition-apiにおける"ref"と"reactive"って何が違うの?でした。今日も引き続きComposition APIに絡む話題(今日は入門向け)です!

2020/2/24追記:Vue3版

もうすぐVue3が出るので、Vue3 + TypeScript版も作りました。
ほとんどVue2と同じですが、まだVue3用のCLIが出ていないのでWebpackの設定等参考になるかもしれません。

Vue3を一足早く試してたい方はクローンして動かしてみてください :relaxed:
https://github.com/yuneco/vue3-tic-tok-toe

前振り

こんにちは、SE→UX→コンサルときて、年明けからは晴れて?フロントエンドの人になる予定の「ゆき」です。

Vue界隈の今年はTypeScriptや来たるVue3に向けたCompositionAPIなど、根幹に関わる(=ちょっと難しい)話題が多い一年だったかと思います。みなさん、TypeScriptは使いこなしてますか?CompositionAPIはもうマスターしたでしょうか?

私は全然です:innocent:・・・っていうかこれ超難しくね?

対象読者とこの記事の内容

この記事は私と同じように、「今までの"普通"のVueはチョットワカル:relaxed:」でも「最近の新しいのはよくわからない:thinking:」「むしろ怖い:imp:」人のために、簡単な○×ゲームを作ってTypeScript + CompositionAPIを体験してみる内容です。1
○×ゲームくらいならなんとなくできそうな気がするじゃないですか?

流れとしては、

  1. まずは普通のVue + JavaScriptで○×ゲームを実装
  2. できるだけ原型を維持しながら、TypeScript + CompositionAPIに書き換え


という順で、TypeScript + CompositionAPIを使うと書き方がどう変わるのか + どこらへんが便利になるのかを、比較しながら実装して親しみを持つことがゴールです。ゆるくお付き合いくださいませ。単に「Vueで○×ゲーム作りたい!」って人ももちろんOK

まずは普通に○×ゲームを作る

仕様と方式

image.png

動くもの : https://yuneco.github.io/vue-tic-tok-toe/
ソース一式はこちら : https://github.com/yuneco/vue-tic-tok-toe

作りをできる限りシンプルにするため、今回作る○×ゲームは以下の単純な仕様とします:

  • 3x3のマスに○と×を交互に置いて、一列揃ったら勝ち。揃わずに全マス埋まってしまったら引き分け(普通のルール)
  • ローカルかつ人対人のみ(ネットワーク戦や対PC戦はなし)
  • ゲームオーバー後のリセット機能はなし(画面リロードしてね)

また、実装にあたってVueRouterやVuexは使わず、単純なコンポーネントの組み合わせのみで実現します。(Vuexを使うと難易度が上がるので。この記事を最後までやってみたらチャレンジしてもいいかも)

プロジェクトを作る

では実際に「普通のVue」でプロジェクトを作るところから始めましょう。このあたりは知ってる方の方が多いと思うので、適時飛ばしながら見てください。プロジェクト名はttt-js(ttt = Tic Tok Toe = ○×ゲームの英語名)としました。

vue create ttt-js

設定はデフォルトでも大丈夫だと思いますが、一応カスタム設定で、

Check the features... :
◉ Babel 
◉ CSS Pre-processor 
◉ Linter/Formatter

を選択しました。CSSやLintの話は今日はしないので、お好みで構いません。

プロジェクトができたら、npm run serveでテンプレの画面が出るところまで確認しましょう。確認できたら、サンプルで入っているHelloWorld等は消してしまってもOKです

ざっくり設計

今回はシンプルに3つのコンポーネントでゲームを作ります。
image.png

  • Appコンポーネント ... GameBoardを配置し、勝ち負けやターン等のメッセージを表示する
  • GameBoardコンポーネント ... ゲームの状態を管理保持し、GameCellを使って画面に表示する
  • GameCellコンポーネント ... ○×の一マスを表示する。データやロジックは持たない

GameCellコンポーネント

まずは一番シンプルなGameCellコンポーネントから作ります。

GameCell.vue
<template>
  <div class="game-cell"
    @click="$emit('click')"
    :style="{
      backgroundImage: owner ? `url('/imgs/stone_${owner}.svg')` : 'none'
    }"
  >
  </div>
</template>

<script>
export default {
  props: {
    owner: { type: String }
  }
}
</script>

<style lang="scss" scoped>
// 略
</style>

シンプルなのでほとんど説明不要かと思います。

  • マスひとつをdivとして表現
  • マスの所有者を示すownerプロパティに従い、マスに背景画像を表示
('p1'...○の画像 / 'p2'...×の画像)
  • クリックされたらclickイベントを発行

○と×のsvg画像はpublic/imgsディレクトリに入れます。
image.png

マスのコンポーネント自身には状態やロジックは持たず、あくまでpropの指定に従い背景画像を表示しているだけのものです。

GameBoardコンポーネント

次にゲームの中心になるGameBoardコンポーネントです。
これもまずはコードを全部乗せてから、要所要所を説明していきます。

GameBoard.vue
<template>
  <div class="game-board">
    <game-cell class="game-cell"
      v-for="(cell, index) in cells" :key="index"
      :owner="cell.owner"
      @click="placeStone(index)"
    />
  </div>
</template>

<script>
import GameCell from './GameCell'

/** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P1 = 'p1'
/** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P2 = 'p2'
/** まだ石が置かれていないセル */
const BLANK = undefined
/** プレイヤー毎の置かれる石(文字) */
const STONES = { [P1]: '', [P2]: '×' }
/** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */
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]
]

export default {
  components: { GameCell },
  data () {
    return {
      cells: Array(9).fill(0).map(_ => ({ owner: BLANK })), // 全てのセルをowner = BLANKで初期化
      turn: P1
    }
  },
  computed: {
    /** 勝者(P1 or P2)。未決と引き分けの場合undefined */
    winner () {
      let winnerPName
      LINES.forEach(line => {
        const ownersInLine = line.map(index => this.cells[index].owner)
        const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2]
        if (isAllSame && ownersInLine[0] !== BLANK) {
          winnerPName = ownersInLine[0]
        }
      })
      return winnerPName
    },
    /** ゲームの勝敗が決まるか引き分けになっていればtrue */
    isGameEnded () {
      return this.winner || this.cells.filter(cell => cell.owner === BLANK).length === 0
    }
  },
  methods: {
    /** 指定のセルに現在のプレイヤーの石を置いて、ターンを交換。
     * すでにゲームが終了している場合や石が置けないセルの場合、何もしない。
     */
    placeStone (index) {
      if (this.isGameEnded) { return }
      if (this.cells[index].owner !== BLANK) { return }
      this.cells[index].owner = this.turn
      if (this.isGameEnded) {
        // どちらかの勝ち or 引き分け
        this.$emit('gameEnd', STONES[this.winner])
      } else {
        // ターン交代
        this.turn = this.turn === P1 ? P2 : P1
        this.$emit('nextTurn', STONES[this.turn])
      }
    }
  },
  mounted () {
    // 最初のターンを通知するためにイベントを発行
    this.$emit('nextTurn', STONES[this.turn])
  }
}
</script>

<style lang="scss" scoped>
// 略
</style>

テンプレート部

まずはテンプレート部分からです。

GameBoard.vue(テンプレ部)
<template>
  <div class="game-board">
    <game-cell class="game-cell"
      v-for="(cell, index) in cells" :key="index"
      :owner="cell.owner"
      @click="placeStone(index)"
    />
  </div>
</template>

先ほど作ったGameCellコンポーネントをv-forで並べています。
このあとのscript部分で出てきますがcellsは9マス分のオーナー情報(そのマスに石を置いたプレイヤーはどちらか?)を格納した配列です。見た目については今回は説明しませんが、この9マスをCSS Flexboxで3x3に配置してボードを表現しています。

マスをクリックしたらplaceStoneメソッドが呼ばれることもわかるかと思います。このメソッドについてもscript部分で説明します。

script冒頭部(定数宣言等)

次にscript部分を見ていきましょう

GameBoard(script部冒頭)
/** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P1 = 'p1'
/** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P2 = 'p2'
/** まだ石が置かれていないセル */
const BLANK = undefined
/** プレイヤー毎の置かれる石(文字) */
const STONES = { [P1]: '', [P2]: '×' }
/** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */
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]
]

GemeCellコンポーネントのimportは問題ないと思うので飛ばして、その次、定数をいくつか定義しています。よくあるテトリスやボードゲームのサンプルコードだと、「白石を1、黒石を2とする」みたいにコメントをつけてコードにはこの数字をベタ書きしているものも多いですが、できるだけ文字列やマジックナンバーはそのまま使わず、名前をつけてあげると良いと思います。

data部

次にコンポーネントのdata部分です

GameBoard(data部)
  data () {
    return {
      cells: Array(9).fill(0).map(_ => ({ owner: BLANK })), // 全てのセルをowner = BLANKで初期化
      turn: P1
    }
  }

cellsに9マス分の初期値を設定しています。ちょっと綺麗じゃ無い書き方ですが、これは

GameBoard(script部)
cells: [
  { owner: BLANK },
  { owner: BLANK },
  // ... 中略 ...
  { owner: BLANK }
]

と書くのと同じです。
turnには先手としてP1を設定しています。

computed部

つぎ、computedいきます

GameBoard(computed部)
  computed: {
    /** 勝者(P1 or P2)。未決と引き分けの場合undefined */
    winner () {
      let winnerPName
      LINES.forEach(line => {
        const ownersInLine = line.map(index => this.cells[index].owner)
        const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2]
        if (isAllSame && ownersInLine[0] !== BLANK) {
          winnerPName = ownersInLine[0]
        }
      })
      return winnerPName
    }

winnerは勝者を示す算出プロパティです。
勝敗が決まっている場合にはP1またはP2を、まだ決まっていない・引き分けになった場合はundefinedを返します。
○×ゲームでは現在の盤面さえ与えられれば勝ち負けを容易に求められる2ので、勝ち負け判定はメソッドではなくcomputedにしておくのが良いかと思います。

ロジックについては解説しませんが、冒頭の定数で宣言した8個の直線パターンを総当たりでチェックしているだけです。

GameBoard(computed部)
    isGameEnded () {
      return this.winner || this.cells.filter(cell => cell.owner === BLANK).length === 0
    }

isGameEndedはゲームが終了しているかどうかを示すcomputedです。
○×ゲームが終了するのは勝敗が決まった時と全てのマスに石が置かれた時なので、それをそのまま書いています。

methods部

次にmethodsです。マスの@clickで指定したplaceStoneの実装です

GameBoard(methods部)
    placeStone (index) {
      if (this.isGameEnded) { return }
      if (this.cells[index].owner !== BLANK) { return }
      this.cells[index].owner = this.turn
      if (this.isGameEnded) {
        // どちらかの勝ち or 引き分け
        this.$emit('gameEnd', STONES[this.winner])
      } else {
        // ターン交代
        this.turn = this.turn === P1 ? P2 : P1
        this.$emit('nextTurn', STONES[this.turn])
      }
    }

indexで指定されたマスに石を置けるかどうか判定し、置ける場合には置いた上でゲームが終了するかチェックしています。終了しなかったらターンを交代します。
ゲーム終了とターン交代それぞれでイベントを発行して親コンポーネントにゲームの状態を通知しています。

mounted(ライフサイクルフック)部

最後にライフサイクルフックのmountedです。

GameBoard(ライフサイクルフック部)
  mounted () {
    // 最初のターンを通知するためにイベントを発行
    this.$emit('nextTurn', STONES[this.turn])
  }

上で石を置いた時にターン交代をイベント通知していましたが、それだけだとゲーム開始時にどちらの手番かわからないので、最初に一発nextTurnイベントを発行しておきます。

これでゲームの主要な要素はできました。

Appコンポーネント

最後にゲームをAppコンポーネントに配置します。

App.vue
<template>
  <div id="app">
    <div class="msg">{{ gameMsg }}</div>
    <game-board
      @nextTurn="nextTurn"
      @gameEnd="gameEnd"
    />
  </div>
</template>

<script>
import GameBoard from '@/components/GameBoard'
export default {
  name: 'app',
  components: {
    GameBoard
  },
  data () {
    return {
      gameMsg: ''
    }
  },
  methods: {
    nextTurn (player) {
      this.gameMsg = `${player}の番です`
    },
    gameEnd (winner) {
      this.gameMsg = winner ? `${winner}の勝ちです` : '引き分けです'
    }
  }
}
</script>

<style lang="scss">
// 略
</style>

これも説明は不要かと思います。
配置したGameBoardコンポーネントからのイベントを拾って画面のメッセージを変えているだけですね。

TypeScript + Composition APIで○×ゲーム

さて、ここからが本題です。
この○×ゲームをTypeScript + Composition APIを使って書き直していきます。
今回はあくまで仲良くなるための導入なので「できるだけ元の形を維持したまま」書き直していきたいと思います。

プロジェクトの作成

新しくプロジェクトを作りましょう。名前はttt-tsにします

vue create ttt-ts

マニュアル設定で今度はTypeScriptを有効にします

Check the features... 
◉Babel 
◉TypeScript <<< ✨NEW!
◉CSS Pre-processor 
◉Linter/Formatter

TypeScriptをONにすると? Use class-style component syntax? (Y/n)と聞いてきますが、ここはnで答えてください。class-style component syntaxはComposition API以前に有力だった書き方です。これがダメなわけではないのですが、今回はComposition APIを使いたいのでOFFにします。

プロジェクトができたら、続けてComposition APIを利用できるようにします。
Composition APIは本来Vue3で提供予定のものですが、ほとんどの機能をVue2用に提供する@vue/composition-apiというパッケージがあるので、これをインストールして使います。

npm i -S @vue/composition-api

パッケージを入れたら、/src/main.tsを開いてComposition APIを読み込みます。

src/main.ts
import Vue from 'vue'
import App from './App.vue'
import VueCompositionApi from '@vue/composition-api' // ⭐︎追加

Vue.config.productionTip = false
Vue.use(VueCompositionApi) // ⭐︎追加

new Vue({
  render: h => h(App)
}).$mount('#app')

準備ができたので、次から標準版と同様にGameCellコンポーネント・Appコンポーネント・GameBoardコンポーネントをTypeScript + Composition APIで書いていきます。(説明しやすいよう、複雑なGameBoardコンポーネントは最後に説明します)

GameCellコンポーネント(TS + Composition API)

まずは一番簡単なGameCellコンポーネントからです。<template><style>は変わらないので、以下<script>部分のみ記載します。

GameCell.vue
<script lang="ts">
import { createComponent } from '@vue/composition-api'
export default createComponent({
  props: {
    owner: { type: String }
  }
})
</script>

JavaScript版との差分は3箇所だけです。

  • scriptタグにlang="ts"を追加してTypeScriptとしてコンパイルされるようにする
  • importでcomposition-apiを読み込む
  • export defaultするのが素のオブジェクトではなくcreateComponent関数で作ったコンポーネント

この3点は「お約束」だと思っても良いと思います。基本Composition APIで書く全てのコンポーネントで共通です。GameCellコンポーネントはもともとpropsの宣言くらいしかしていないので、これでおしまいです。

Appコンポーネント(TS + Composition API)

次にAppコンポーネントを書き換えましょう。

App.vue
<script lang="ts">
import GameBoard from '@/components/GameBoard.vue'
import { createComponent, ref } from '@vue/composition-api'

export default createComponent({
  name: 'app',
  components: {
    GameBoard
  },
  setup () {
    const gameMsg = ref('')
    const nextTurn = (player: string) => {
      gameMsg.value = `${player}の番です`
    }
    const gameEnd = (winner: string) => {
      gameMsg.value = winner ? `${winner}の勝ちです` : '引き分けです'
    }
    return {
      gameMsg,
      nextTurn,
      gameEnd
    }
  }
})
</script>

先ほど重なる部分は省略します。
標準版にあったdatamethodsがなくなって、setupという新しい関数の中に移動しています。Composition APIでは従来datacomputedmethodswatch等、仕組みの種類によって書く場所が別れていたものを全てsetupという単一の関数に統合しています。

dataにあったgameMsgref()に、methodsにあったnextTurngameEndは普通の関数として、setupの中に宣言されています。

そしてsetupの最後でこれらをまとめてreturnする、というのが基本的な流れです。returnしたものがテンプレート部分からアクセスできるようになります。


:thinking: 補足 「setup関数ってなんかごちゃごちゃしてて見辛くない?」

私は最初そう思いました。正直今でもちょっと思ってはいます。
仕組みごとに書く場所が決まっていた方が整理されたコンポーネントを書けそうな気がしませんか?なぜsetupにまとめたのでしょうか?

「テンプレートは、問題ではなく、技術を分けるだけだ」
“Templates separate technologies, not concerns.”

Reactを作ったFacebookの有名な言葉です。
元来VueのSFC(Single File Component = .vueのコンポーネント)もこの課題を解決するものでした(関心や責任でコンポーネントを分割して、その中にhtml・js・cssをセットで書く)。しかしそれでもなお、SFCでは実装の仕組み(computedやdata)ごとに書く場所が決められています。

実際にはコンポーネントを十分小さく分割することで多くの混乱は回避できる気もしますが、それでもコンポーネントが複雑になってくると、関連するデータやロジックがdataやcomputed、watch等に散在して依存関係が見えにくくなるのはVueのあるある、だと思います。

Composition APIのsetupはこれを一旦一箇所に全て混ぜられるようにした上で、仕組みではなく問題(関心事)で開発者が自由に再整理できるようにしたものです。

Composition API RFCより引用。新しい書き方の方が意味的に整理した書き方ができる)

実際setup関数の中身は(従来のmethodsやcomputedのような)Vueの特別な魔法が無いただのJavaScript(TypeScript)なので、分割したり別ファイルからimportしたり、JavaScript(TypeScript)でできることは基本的に全て許されます。そして、魔法のない普通のJavaScript(TypeScript)ということは、補完や静的解析等のエディタの恩恵も最大限に享受できるということでもあります。

この記事では「できるだけ元の形を維持して」Composition APIで書き直しているので分割等は行っていませんが、本来であれば(大きなコンポーネントは特に)setup関数をベタ書きせずに分割やimportを使って細かい粒度に切り出していくのが良いのだろうと思います。

:snake:蛇足ここまで:snake: (私も理解は十分でないので、間違っていたらご指摘ください)


GameBoardコンポーネント(TS + Composition API)

最後に一番大きなGameBoardコンポーネントです。
これも少し長いですが、まずはscript部分を全部のせます。

GameBoard.vue
<script lang="ts">
import { createComponent, ref, computed, onMounted } from '@vue/composition-api'
import GameCell from './GameCell.vue'

// 型・インタフェースの宣言
type Player = 'p1' | 'p2'
type Owner = Player | undefined
interface CellState {
  owner: Owner
}
interface GameState {
  cells: CellState[],
  turn: Player
}

/** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P1: Player = 'p1'
/** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */
const P2: Player = 'p2'
/** まだ石が置かれていないセル */
const BLANK: Owner = undefined
/** プレイヤー毎の置かれる石(文字) */
const STONES = { [P1]: '', [P2]: '×' }
/** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */
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]
]

export default createComponent({
  components: { GameCell },
  setup (props, ctx) {
    // 1. 元 data = reactive(), ref()でラップする
    const cells = ref<CellState[]>(Array(9).fill(0).map(_ => ({ owner: BLANK })))
    const turn = ref<Player>(P1)

    // 2. 元 computedの要素 = computed()でラップする
    const winner = computed<Player | undefined>(() => {
      let winnerPName: Player | undefined
      LINES.forEach(line => {
        const ownersInLine = line.map(index => cells.value[index].owner)
        const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2]
        if (isAllSame && ownersInLine[0] !== BLANK) {
          winnerPName = ownersInLine[0]
        }
      })
      return winnerPName
    })
    const isGameEnded = computed<boolean>(() => {
      return !!(winner.value || cells.value.filter(cell => cell.owner === BLANK).length === 0)
    })

    // 3. 元 methodsの要素 = そのまま(素の関数)
    const placeStone = (index: number) => {
      if (isGameEnded.value) { return }
      if (cells.value[index].owner !== BLANK) { return }
      cells.value[index].owner = turn.value
      if (isGameEnded.value) {
        // どちらかの勝ち or 引き分け
        ctx.emit('gameEnd', winner.value ? STONES[winner.value] : undefined)
      } else {
        // ターン交代
        turn.value = turn.value === P1 ? P2 : P1
        ctx.emit('nextTurn', STONES[turn.value])
      }
    }

    // 4. 元 ライフサイクルフック = on + フック名の関数でラップ
    onMounted(() => {
      // 最初のターンを通知するためにイベントを発行
      ctx.emit('nextTurn', STONES[turn.value])
    })

    // 最後にテンプレートから参照される物を全て返す
    return {
      cells,
      turn,
      winner,
      isGameEnded,
      placeStone
    }
  }
})
</script>

ポイントになる箇所を見ていきます。

型・インタフェースの宣言

冒頭で主な型とインタフェースを宣言しています。
JavaScriptでは定数程度でしたが、TypeScriptでは型を使ってより強力・明確に変数の内容を縛れることがわかると思います。これで例えば「現在のターン(turn)にnullが入る」と言ったバグは回避できますね。

GameBoard(スクリプト冒頭)
// 型・インタフェースの宣言
type Player = 'p1' | 'p2'
type Owner = Player | undefined
interface CellState {
  owner: Owner
}
interface GameState {
  cells: CellState[],
  turn: Player
}

元data部

setup関数の中を見ていきまししょう。
まずは元dataだった部分です。先ほどのAppコンポーネントと同様、ref()を使ってラップすることでリアクティブな変数を作ります。ref()には型を明示することもできます(省略・型推測もできますが、たとえばこの例でturn<Player>を消すとP1型と解釈されP2が代入できなくなります)。

GameBoard(元data部)
    // 1. 元 data = ref(), reactive()でラップする
    const cells = ref<CellState[]>(Array(9).fill(0).map(_ => ({ owner: BLANK })))
    const turn = ref<Player>(P1)

このref()でラップした変数の値を参照するときは(ラップされてしまっているので).valueをつけてアンラップする必要があることに留意してください。

const num = ref(123) // ラップ(リアクティブな変数になる)
console.log(num.value) // アンラップ(中身の普通の値を取り出す)
num.value = 456 // 中身を書き換え

また、複数の変数をまとめてリアクティブにしたい場合、refの代わりにreactiveを使うこともできます

    // 別解: reactiveを使ってオブジェクトを丸ごとラップすることもできる
    // この場合は'data'の塊ごとreturnするので、アクセスするときもdata.cells[0]のようになる
    // (reactive内の要素にアクセスする際は".value"は不要)
    const data = reactive<GameState>({
       cells: Array(9)...
       turn: P1
     })

元Computed部

続いてComputedです。
これもComposition APIでComputed()というそのまんまの名前の関数が提供されているので、これでラップするだけです。

GameBoard(元computed部)
    const winner = computed<Player | undefined>(() => {
      let winnerPName: Player | undefined
      LINES.forEach(line => {
        const ownersInLine = line.map(index => cells.value[index].owner)
        const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2]
        if (isAllSame && ownersInLine[0] !== BLANK) {
          winnerPName = ownersInLine[0]
        }
      })
      return winnerPName
    })

ref()の場合と同様、型を明示することもできます。
winnerはPlayer型ですが、勝敗が決まっていない・引き分けの場合はundefinedを返すとしているので、それを明示しています。(Owner型と書いても同じです)

isGameEndedも同様です。一点、!!をつけて戻り値がbooleanになるようにしました。

GameBoard(元computed部)
    const isGameEnded = computed<boolean>(() => {
      return !!(winner.value || cells.value.filter(cell => cell.owner === BLANK).length === 0)
    })

そう、実は最初に書いたJavaScript版のisGameEndedは戻り値がbooleanじゃなかったんです。。

image.png
試しにこの!!<boolean>を外して関数の戻り値型を見てみると... 実はbooleanの他に文字列も返る実装にになってしまっていることがわかります。

空では無い文字列は真偽値として評価するとtrue扱いになるため大抵のケースでは問題は起こりませんが、その分見つかりにくいバグを埋め込むことにも繋がります。TypeScriptを使うとこういう気づきにくい間違いを事前に拾えるのでありがたい限りです:angel:

元methods部と$emit

methodsの書き換えはAppコンポーネントでも出てきたので簡単に。

GameBoard(元methods部)
    // 3. 元 methodsの要素 = そのまま(素の関数)
    const placeStone = (index: number) => {
      if (isGameEnded.value) { return }
      if (cells.value[index].owner !== BLANK) { return }
      cells.value[index].owner = turn.value
      if (isGameEnded.value) {
        // どちらかの勝ち or 引き分け
        ctx.emit('gameEnd', winner.value ? STONES[winner.value] : undefined)
      } else {
        // ターン交代
        turn.value = turn.value === P1 ? P2 : P1
        ctx.emit('nextTurn', STONES[turn.value])
      }
    }

イベントの発行はsetup関数の第二引数として受け取れるctxオブジェクトのemitを使います。

setup (props, ctx) {

元mounted(ライフサイクルフック)部

次はライフサイクルフックです。
ライフサイクルフックは単純にonXXXという名前の関数がComposition APIで提供されているのでそれを使ってコールバックを登録するだけです。

GameBoard(元ライフサイクルフック部)
    // 4. 元 ライフサイクルフック = on + フック名の関数でラップ
    onMounted(() => {
      // 最初のターンを通知するためにイベントを発行
      ctx.emit('nextTurn', STONES[turn.value])
    })

テンプレート部からの利用

setup関数内で作った色々なもののうち、テンプレートから利用するものをsetup関数からreturnします。


    // 最後にテンプレートから参照される物を全て返す
    return {
      cells,
      turn,
      winner,
      isGameEnded,
      placeStone
    }

これでテンプレートからは従来通りcomputedやmethodを扱えるようになります。

GameBoard(テンプレート部)
    <game-cell class="game-cell"
      v-for="(cell, index) in cells" :key="index"
      :owner="cell.owner"
      @click="placeStone(index)"
    />

テンプレート部分は今まで通りVueの魔法が効いている部分なので、computedにアクセスするときに.valueをつけてアンラップする必要はありません。(Vueがやってくれます)
その代わりテンプレートの中では型や補完の恩恵はあまり受けられないことに注意する必要があります。

まとめ

この記事では3つのコンポーネントで構成されるシンプルな○×ゲームを従来のJavaScriptの記法と、新しいTypeScript + Composition APIの記法の2つで実装しました。

小規模ゆえに新しい書き方のメリットは理解しにくい部分もあるかと思いますが、これらのメリットをまとめている解説はいくつもあるので、まずはこの記事が新しい書き方にステップアップするための補助輪になれば幸いです。

まとめのまとめ

  • Vue3で導入されるComposition APIはVue2でももうほとんど使えるよ
  • 従来の書き方の大部分はComposition APIでもそこそこ簡単に書き直せるよ
  • TypeScript + Composition APIを使うと補完やエラーチェックが効いて嬉しいよ
  • Composition APIではコンポーネント内のロジックの書き方が従来よりも自由に。その分正しい分割や切り出しのスキルも必要になりそう

明日は、@amakawa_ さんです!


  1. コンパクトでアプリとして完結したサンプルで従来と新しい書き方の比較をしたい、というニーズを満たすサンプルがあまりなかった、というのがもう少し正確な動機です。 

  2. 勝ち負けが決まった後は追加で石を置けない、という制約は必要 

75
72
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
75
72