ビルドなしのHTML1枚でVueのComposition APIを使う
たまに、1枚のHTMLだけで簡単なwebアプリを作りたくなるときがあります。
ファイル1つで完結する規模のものを書くためにわざわざプロジェクトを生成するのは、たとえそれがコマンド一発で終わるとしてもダルいと感じてしまいます。しかし、jQueryにしろバニラJSにしろ、2024年にもなってDOM操作を手動でやるようなコードを書くのはもっとダルいです。
Vueはこういった需要にぴったり合うフレームワークです。Vueはフロントエンドのビルドツールなしでも導入できますし、ユーザーの入力を取得するようなコードをv-model
で簡潔に書けるなど、ボイラープレートコードが少なくて済むようになっています。
公式ドキュメントにも以下のように明記されています。
私たちは、Web に「どんな場合にも通用する」ストーリーはないと考えています。このため、Vue は柔軟で段階的に採用できるよう設計されています。ユースケースに応じて、Vue はスタックの複雑さ、開発者体験、およびエンドパフォーマンスの最適なバランスを取るために、さまざまな方法で使用できます。
しかし、ビルドツールなしのVue導入を解説する記事の多くはOptions APIを前提にしています。私はVue3から入門した人間で、初めからComposition APIしか使ったことがありません。ビルドツールを使わない手軽さは得たいけれど、書き慣れたComposition APIも使いたいのです。
幸い、ビルドツールなしで、すなわちSFC(単一ファイルコンポーネント)を使わない場合でも、setupフックでComposition APIを使うことができます。
Composition API: setup() | Vue.js
ビルドツールなしでVueを読み込む
ビルドツールなしでVueを使う場合、CDNから読み込むのが一番ストレートな方法です。
<!-- グローバルビルドを使う場合 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
// 以下コード
</script>
<!-- または -->
<!-- ES moduleからimportする場合 -->
<script type="module">
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
// 以下コード
</script>
どちらのコードでも同じように動きます[1]。以降の記事ではES moduleを使った書き方を採用します。
なお、もし実際にアプリケーションとして配信するなら、vue.global.js
の代わりにvue.global.prod.js
を、vue.esm-browser.js
の代わりにvue.esm-browser.prod.js
を使うことが推奨されます。
ルートコンポーネント単独で実装する
ボタンを100回クリックしたら送信フォームが開く、謎のカウンターアプリを実装してみます。
(記事ではZennのシンタックスハイライトの都合上HTMLとJSを別のファイルに分けていますが、全てインラインに書いても問題ありません)
<div id="app">
<div>クリック数:{{ count }}</div>
<div>残り回数: {{ Math.max(100 - count, 0) }}</div>
<button type="button" @click="onButtonClick">増やす</button>
<div v-if="open">
<h2>100回に到達しました!</h2>
<label>名前: <input type="text" ref="inputElem" /></label>
<button type="button">送信</button>
</div>
</div>
<script src="main.js" type="module"></script>
import {
createApp,
ref,
watch,
nextTick,
} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
createApp({
setup() {
const count = ref(0);
const open = ref(false);
const inputElem = ref(null);
watch(count, async () => {
if (count.value === 100) {
open.value = true;
// input要素がDOMに追加されinputElemに値が入るまで待つ
await nextTick();
inputElem.value.focus();
}
});
return {
count,
open,
inputElem,
onButtonClick: () => count.value++,
};
},
}).mount('#app');
今回は手軽さを重視して、マウント先のdiv要素の中に直接テンプレートを書いてみました。SFCと全く変わらない書き方がSFCなしで実現できているのが分かります。
ただし、テンプレートからアクセスできるのは、setup
関数の中でreturn
したオブジェクトに含まれている値だけです。<script setup>
では全ての変数が自動的にアクセス可能になるので、SFCに慣れていると挙動の差を忘れてしまいがちです。注意しましょう。
コンポーネントを定義・使用する
今までは全てを単一のルートコンポーネントで実装していました。しかし、場合によってはコンポーネントを切り分けたくなることもあるでしょう[2]。
今回はカウンター部分を別のコンポーネントに切り出してみます。
<div id="app">
<counter :goal="100" @goal-reached="onGoalReached"></counter>
<div v-if="open">
<h2>{{goal}}回に到達しました!</h2>
<label>名前: <input type="text" ref="inputElem" /></label>
<button type="button">送信</button>
</div>
</div>
<script src="main.js"></script>
import {
createApp,
ref,
watchEffect,
nextTick,
} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
const Counter = {
props: {
goal: Number,
},
emits: ['goalReached'],
setup(props, context) {
const count = ref(0);
watchEffect(() => {
if (props.goal === count.value) {
context.emit('goalReached');
}
});
return { count };
},
template: `
<div>クリック数: {{count}}</div>
<div>残り回数: {{ Math.max(goal - count, 0) }}</div>
<button type="button" @click="count++">増やす</button>
`,
};
createApp({
components: { Counter },
setup() {
const goal = 100;
const open = ref(false);
const inputElem = ref(null);
const onGoalReached = async () => {
open.value = true;
await nextTick();
inputElem.value.focus();
};
return {
open,
inputElem,
onGoalReached,
goal,
};
},
}).mount('#app');
コンポーネントの内部で別のコンポーネントを子として使うために、components
オプションを使います。また、カスタムコンポーネントにおいてはSFCのdefineProps
やdefineEmits
の代わりにprops
やemits
オプションを、そしてテンプレートはtemplate
オプションを利用します。
このあたりはOptions APIを使ったことのある人なら馴染みのある書き方だと思うのですが、逆にSFCとComposition APIしか使ったことがない人にとっては少々戸惑うポイントになりそうです。
コンポーネントのテンプレートは上記のように文字列で与えることもできるのですが、template
要素を使うこともできます。その場合、コンポーネントのtemplate
オプションにテンプレートとして使うtemplate
要素のIDを指定します。
<div id="app">
<!-- 略 -->
</div>
<template id="counter-template">
<div>クリック数: {{count}}</div>
<div>残り回数: {{ Math.max(goal - count, 0) }}</div>
<button type="button" @click="count++">増やす</button>
</template>
// 上略
const Counter = {
// 中略
template: '#counter-template',
};
また、テンプレートをHTMLに直接書く場合(DOM内テンプレート)、カスタムコンポーネントを使うときにいくつか注意すべきことがあります。詳細は以下のページに載っているのですが、要約すれば「Vueより先にブラウザがテンプレートをパースしてしまうので、HTMLのルールに従わなければならない」ということです。これはtemplate
要素の中に書くときも同様です。
自己終了タグは使えないし、コンポーネントの名前や属性はケバブケースで書く必要があります。そしてul
やtable
など子孫要素の種類が制限されている要素の下でカスタムコンポーネントを使うときはis
属性を使わなければなりません。
たとえば、SFCなら<TodoItems @itemAdded="" />
と書くところを、DOM内テンプレートでは<todo-items @item-added=""></todo-items>
と書かなければなりません。
テンプレートをどこに書くか
DOM内テンプレートはシンプルで分かりやすいのですが、カスタムコンポーネントを使おうとすると前述のトラップが発動します。SFCと違い「このテンプレートはHTMLとしてどう解釈されるか」を常に意識しなければなりません。
一方、さっき実装したCounter
コンポーネントのようにテンプレートをJSの中で直接文字列として書いた場合は、SFCと同じ挙動になります。
<div id="app"></div>
<script src="main.js" type="module"></script>
// 上略
createApp({
components: { Counter },
setup() {
// 略
},
template: `
<!-- SFC内と同様に(lower)CamelCaseやself-closing tagが使える -->
<Counter :goal="100" @goalReached="onGoalReached" />
<div v-if="open">
<h2>{{goal}}回に到達しました!</h2>
<label>名前: <input type="text" ref="inputElem" /></label>
<button type="button">送信</button>
</div>
`,
}).mount('#app');
余談:petite-vueとAlpine.js
HTML1枚でちょっとしたインタラクションを実装するユースケースにおいて、petite-vueやAlpine.jsという競合ライブラリがあります。
petite-vueは直接テンプレートを書くユースケースに最適化されたVue実装です。通常のVueと比べて非常に軽量ですが、普通のVueとは少々勝手が違うので、いっそ別のライブラリと考えた方がいいかもしれません。
ディレクティブにコードをインラインで書くだけでなく、JavaScript側で定義したオブジェクトなどにアクセスすることもできるため、Options APIに似た書き味です。
Alpine.jsもpetite-vueの類似ライブラリです。というより、Alpine.jsの方が先で、petite-vueがAlpine.jsにインスパイアされて誕生したという経緯があります。petite-vueよりは若干サイズが大きいものの、ディレクティブでそのままawait
が書けるなど非同期処理に強そうです(APIを少し見ただけの感想ですが)。
-
HTTPサーバを使わずHTMLを直接ブラウザに表示させる場合は挙動の違いが生まれます。
file://
プロトコルのURLを持つJavaScriptファイルは、HTMLから<script src="main.js"></script>
のように読み込むことはできますが、JSのimport
文やHTMLの<script type="module">
タグからES moduleとして読み込むことはできません。ES moduleをやめるか、上のサンプルコードのようにインラインでJavaScriptを書くか、HTTP経由でlocalhostからアクセスするか、いずれかの対策が必要です。 ↩︎ -
もちろん、規模が大きくなれば普通にVueのプロジェクトを作ってSFCを書いた方がいいです。 ↩︎
Discussion