🌵

ゲームを作ってpetite-vueを完全に理解する

2021/07/05に公開2

petite-vueは軽量・簡易版のVue.js

2021/7/2にVue.js作者のEvan Youさんの新作、petite-vueがリリースされました。

ざっくりした概要は『petite-vue 最速 使い方』など、早速日本語でまとめてくださっている方がいるので、そちらを参照いただくと良いと思います。

(ただし、当面は破壊的な変更や機能追加が頻繁に入ると思われるので、日本語の記事と併せて公式もチェックするのが良いと思います)

習うより慣れろ。ゲーム作って「完全に理解」してみた

公式のREADMEやサンプルが結構丁寧なので、眺めてみるだけでなんとなく理解できた気になってきます。まあでも多分わかった気になってるだけなので、実際にゲームを作って入門してみましょう。

手前から流れてくるサボテンをジャンプで避けるだけのゲームですね(ネットに繋がらない時にChromeで遊べる恐竜のゲームの簡易版です)。

ゲームそのものは大したものではないので、まあ一度遊んでもらえればOKです。ソースをみながら、petite-vueの使い方とポイントをみていきたいと思います。

なお、上のリポジトリをみるとわかる通り、petite-vueのアプリは素のhtml&js上で、ビルドなしに動かすのがメインの使い方です。package.jsonnpm_modulesも不要で、GitHub Pagesへのデプロイもファイルをプッシュすれば自動的に完了です。シンプルでいいですね😊

ローカルで開発中は、VS CodeのLive Serverあたりを使ってローカルサーバを動かすのが簡単です。

アプリの基本構造と初期化

↓は公式のREADMEに出てくる一番最初のサンプルです。こんな感じでindex.htmlにテンプレートを書けばそれだけで最低限のVueアプリになります。

index.html
<script src="https://unpkg.com/petite-vue" defer init></script>
<!-- anywhere on the page -->
<div v-scope="{ count: 0 }">
  {{ count }}
  <button @click="count++">inc</button>
</div>

とは言え、この記事を読まれる方の使い方だとテンプレートだけで完結するケースは少ないかと思います。これを<script>も使って書き直すと↓のようになります。

index.html
<div>
  {{ count }}
  <button @click="count++">inc</button>
</div>

<script src="main.js" type="module"></script>
main.js
import { createApp } from 'https://unpkg.com/petite-vue?module'

createApp({
  // 普通のVueのdata相当
  count: 0,
}).mount()

あとはここに好きなようにdatamethods相当のプロパティ・関数を生やしていくことができます。ただしcomputedをはじめとして、使えない機能もいろいろあるので注意。

index.html
<div>
  <div>りんご:{{ appleCount }}個 <button @click="appleCount++">追加</button></div>
  <div>みかん:{{ orangeCount }}個 <button @click="orangeCount++">追加</button></div>
  <button @click="resetCount">リセット</button>
</div>

<script src="main.js" type="module"></script>
main.js
import { createApp } from 'https://unpkg.com/petite-vue?module'

createApp({
  // data
  appleCount: 0,
  orangeCount: 0,

  // methods
  reset() {
    this.appleCount = 0
    this.orangeCount = 0
  }
}).mount()

簡単ですね🤗

コンポーネントを分割する

petite-vueもコンポーネントの概念を持っていますが、フル版のVue.jsと比べるとかなり簡略化されています。

今回作成したゲームではキャラクターを表示する「Charaコンポーネント」と「Cactusコンポーネント」の2つを作りました。

<div id="app" v-scope>
  <!-- Game main stage -->
  <div class="stage" :class="{gameOver: !store.game.isPlaying}" @mounted="onMounted">
    <!-- ↓Charaコンポーネントを利用している -->
    <div class="charaWrapper" v-scope="Chara()" @mounted="onMounted" @mousedown="onMousedown"></div>
    <!-- ↓Cactusコンポーネントを利用している(v-forと併用) -->
    <div class="cactusWrapper" v-for="cactus in store.cactuses" :key="cactus.id" v-scope="Cactus(cactus)"></div>
  </div>
</div>

:classv-forなど見慣れた表現が並ぶ中で新しいのがv-scopeですね。petite-vueを書くのは普通のhtmlなので、コンポーネント名のタグの代わりにv-scopeディレクティブを使います。Chara()のような関数呼び出しの部分については後述します。

次にコンポーネントのテンプレート部分をみてみます。テンプレートも同じindex.htmlの中にあります。

<!-- Component template of Charactor -->
<template id="chara-template">
  <div class="chara" :class="{damaged: store.chara.damaged}" :style="{
    transform: `translate(${store.chara.x}px, ${-store.chara.y}px)`
  }">
  </div>
</template>

直接htmlに書かれていることを除けば、ほぼ見慣れたVueコンポーネントのテンプレートですね。

最後にスクリプト部分をみてみます。スクリプト部分はただの関数です。$templateでhtmlに書いたテンプレート要素のIDを指定します。
それ以外はcreateAppで作ったアプリのルート要素とおなじです。

export const Chara = () => {
  return {
    // テンプレート要素のIDを指定
    $template: '#chara-template',

    // data (今回は何もなし)

    // methods
    onMousedown(ev) {
      // ストアの処理を呼び出す(ストアについては後述)
      this.store.jump()
    },  
    onMounted() {},
  }
}

ここで宣言したdatamethods相当のプロパティーにはv-scope="Chara()"を指定した要素の下だけでアクセスすることができます。v-scopeの名前の通りですね。.vueファイルのようにコンポーネントごとにファイルを分けることはできなせんが、v-scopeを使うことでコンポーネントごとのスコープを持つことができます。

<div id="app" v-scope>
  <!-- ▼ ルート(createApp)のonMountedメソッドが呼ばれる -->
  <div @mounted="onMounted">
    <!-- ▼ CharaコンポーネントのonMountedメソッドが呼ばれる -->
    <div v-scope="Chara()" @mounted="onMounted"></div>

最後に、このコンポーネントをアプリのルートに登録します。

main.js
import { createApp } from 'https://unpkg.com/petite-vue?module'

const app = createApp({
  // ルートレベルのdataやmethodsの登録
  ...

  // コンポーネントの登録:components相当
  Chara,
  Cactus,
}).mount()

グローバルストアで状態を管理する

ここまでのコードでもちょくちょく出てきていますが、petite-vueでは簡易的なグローバルストアを使うことができます。コンポーネントごとに状態を持って親子でやりとりすることもできるようですが、個人的な感想としてはかなりやりづらいです(慣れもあるとは思います)。よほど再利用性やカプセル化を求めるのでなければ、グローバルのストアで管理してしまうのが楽で良いです。

ストア自体は単純なreactiveオブジェクトです。

store.js
import { reactive } from 'https://unpkg.com/petite-vue?module'

export const store = reactive({
  // store state
  game: {
    width: 400,
    score: 0,
    isPlaying: false,
    life: 0,
  },
  cactuses: [],
  chara: {
    x: 20,
    y: 0,
    vy: 0,
    jumpCount: 0,
    damaged: false,
  },
});

このストアも、コンポーネントと同様にmain.jsで登録します。

main.js
import { createApp } from 'https://unpkg.com/petite-vue?module'
import { store } from './store.js'

const app = createApp({
  // ルートレベルのdataやmethodsの登録
  ...

  // ストアの登録
  store,

  // コンポーネントの登録:components相当
  Chara,
  Cactus,
}).mount()

なお、ここまでの例ではなんとなくdatamethodsstorecomponentsなどと区別して順序があるかのような書き方をしていますが、実際には順不同です。

というか、ソースを見ると明らかなように、createApp()は引数で渡されたオブジェクトをreactive()でラップしてコンテキストにぶっ込んでるだけです。シンプルですね。

/src/app.ts
export const createApp = (initialData?: any) => {
  // root context
  const ctx = createContext()
  if (initialData) {
    ctx.scope = reactive(initialData)
  }
}

つまり、ストアで定義したデータもcreateApp()で定義したdatamethods相当のプロパティとアクセスの方法は一緒です。ストアとは言っていますが、VuexのようにFluxを強制するような仕組みは何もありません。良くも悪くもただのクローバル変数です。

↓こんな感じでActionっぽいものを書くこともできます。もちろんこれも、ただの関数です。

store.js
export const store = reactive({
  // store state
  game: {...},
  cactuses: [],
  chara: {...},

  // actions
  /** ゲーム開始 */
  startGame() {
    this.game.isPlaying = true
    this.game.life = MAX_LIFE
    this.game.score = 0
    this.cactuses = []
  },

  /** ゲーム終了 */
  gameOver() {
    this.game.isPlaying = false
  },

  /** キャラのジャンプ */
  jump() {...}
});

このストアをテンプレートから使う部分をみてみましょう。何も説明はいらないと思います。

<div class="title" v-if="!store.game.isPlaying">
  <div class="result" v-if="store.game.score">
    <div class="gameOverMsg">GAME OVER</div>
    <div class="score">Your Score: {{store.game.score}}</div>
  </div>
  <div class="start">
    <button @click="store.startGame">START!!</button>
  </div>
</div>

ストア(というかAppのルートコンテキスト)にはコンポーネントのスクリプト部分からもアクセスできます。

Chara.js
export const Chara = () => {
  return {
    $template: '#chara-template',

    onMousedown(ev) {
      // ストアのjumpメソットを呼び出し
      this.store.jump()
    },  
  }
}

なお、この挙動はルートのストアに限ったものではありません。
コンポーネントは親コンポーネントやルートのcreateApp()のコンテキストを暗黙に継承するようで、これらのプロパティやメソッドを自由に参照したり書き換えたりできます。つまり、子コンポーネントから親のdataを書き換えたりmethodsを呼んだりすることができます。ちょっと怖いですね😬

この挙動は流石にアレなので、今後何かしらの機能強化が入るかもしれません。。

petite-vueの使い所は?

※ ここからは完全に個人の感想です

公式READMEの冒頭に「petite-vue is an alternative distribution of Vue optimized for progressive enhancement.」とある通り、petite-vueはそれ自体がVue.jsのprogressive enhancementを実現するための入り口と位置付けられているようです。

つまり、ReactやVueのようなかっちりしたフレームワークを組み込むよりもっと手前の、「静的なHTMLにちょっと動的な要素を足したい」と言うときのとっかかりになるものです。実際にはフルスペックのVue.jsでもCDNを使い、ビルドレスでHTMLに組み込むことはできます。しかし、実際にはそういう使い方をしている人は少ないようです。

↓は少し前にEvan Youさんが行ったアンケートの結果です。「もう最初からSFC(.vueファイル)でよくね?」って意見が多いですね。

Vue.jsはもともと徐々にステップアップしながら導入していける「progressive enhancement」がコンセプトだったわけですが、その点ではあまりうまくいっていないのかもしれません。

petite-vueは機能を絞って単純化することで既存の静的なページに組み込んだり、CMSのテンプレートのような他のシステムの一部として動かしたり、色々な展開が期待できます。「webアプリを作るぞ!」って意気込んで環境構築から始めるのに比べればハードルが低くて気軽に体験できるのはいいですね。

なお正直な感想としては、今回のシンプルなゲームでも開発には若干の辛さがあったので、これ単体でかっちりしたSPAやwebアプリを作るのはかなりしんどいと思います。割り切って適材適所で使っていけると良さそうです。

Discussion

jay-esjay-es

README の Not Supported のところに computed() があるのを見て、最初「Vue の computed にあたる機能がない」のかと勘違いしていました。。。
(実際には「Composition API のような ref, computed 関数がない」という内容)

「算出プロパティ」が必要な場合は createApp のオプションにゲッター関数を指定すればいいんですね https://github.com/vuejs/petite-vue#root-scope

yuki matsumotoyuki matsumoto

おっしゃる通り、基本的にgetterを使ってね、と言うことのようですね(私もちょっと理解できてませんでした)。キャッシュされないのでパフォーマンスがシビアな場面では使えないかもですが、そもそもそこまで重いことはしないのが前提でしょうか...