Single File Component

単一ファイルコンポーネント

単一ファイルコンポーネントとは、1つのファイルでHTML、JavaScript、CSSをセットにして1つの機能を持った部品(モジュール)として管理する仕組み、またはそのファイル(拡張子は.vue)のことを指す。英語でいうとSingle File Component、頭文字を取ってSFCと呼ばれることもある。

SFCは機能ごとにモジュール化されているため保守性も高く再利用性がしやすい。そのためコードが煩雑になりがちな規模の大きい開発では特に有用性が高いとされる。

単一ファイルコンポーネントの構成

<template>
  <div class="greeting">
    <p>Hello, {{ text }}!</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      text: '単一ファイルコンポーネント'
    }
  }
}
</script>

<style scoped>
.greeting {
  color: #4d7dc5;
}
</style>

<template>

表示されるHTMLの部分。これまで学習してきたコンポーネントのtemplateプロパティと同じ。1ファイルに1つ定義できる。

<script>

コンポーネントのロジック・データ・メソッドなどを記述する。
「export default」は、ES6のモジュール構文のひとつで、ざっくり言うと関数やオブジェクトを外部に書き出すための記述。1ファイル内で、1度しか使えないという特徴を持つ。

そして書き出された内容は「import」で別のモジュールで読み込むことができる。SFCではこの「export default」と「import」で部品同士を組み合わせてひとつのページを構築していく。

<script>
  import コンポーネント名 from "単一ファイルコンポーネントのファイルパス"
  export default {
    components: { コンポーネント名 }
  }
</script>

<script setup>

Composition APIを使う場合は、<script>にsetup属性を追加すると、コンポーネントのsetup()関数の中の処理として扱われる(ただしreturnは不要)。簡潔にComposition APIを使用できるためVue 3 推奨スタイルとなっている。また子コンポーネントをcomponentsオプションとして登録しなくても、インポートするだけで使用できる。

<script setup>
import { ref } from 'vue'

const text = ref('単一ファイルコンポーネント')
</script>

<style>

コンポーネントの見た目を調整する所謂cssの部分。<style>にscoped属性を設定すると、同ファイル内のテンプレートにのみ適用される。scoped属性のない<style>も記述はできるが、他のコンポーネントに影響してしまうことを考えると積極的には使えない気がする。

Vue開発環境の構築

SFCは専用の開発環境下でのみ実行することができる特殊な形式なので、サーバーに反映するときはブラウザ上でも動作するファイルに変換(ビルド)する必要がある。
このビルド機能を持ったVueの開発環境には「Vue CLI」が広く使用されていたが、2023年4月現在は「create-vue」が推奨されている。

公式の案内に従ってvue.js 最新版のインストールを始めると、

npm create vue@latest

どのフレームワークにするか聞かれるので「Vue」を選択する。

次に使用言語の選択をする。普通にJavaScriptでもいいけど、今回はTypeScriptも学習のためを入れてみた。

あと使用中の Node.js のバージョンが要求されたバージョンよりも低いと警告が出ているのでNode.js をアップデート(v20.14.0 → v22.17.0)。Volta使ってるとvolta install nodeだけで済むからホント楽。

create-vueのインストールが終わったら、インストールしたプロジェクトに移動(例だと「cd create-sfc」でOK)して、以下のコマンドを順に実行する。

npm install
npm run dev

表示されたURLにアクセスするとプロジェクトのTOPページが表示される。これでひとまず環境は整ったことになるので、思ったよりは手軽と言える。

ちなみにインストールされたのは、現時点での最新版なのでバージョンはv3。以降の記述はVue 3で進めていく。

ディレクトリ構成

作成したプロジェクトのデフォルトの構成は以下の通り。

ぱっと見で気になるのはルートディレクトリの「index.html」「vite.config.ts」と「src」配下の「App.vue」「main.ts」「vite-env.d.ts」。同じく「src」下層の「HelloWorld.vue」はディレクトリ名からコンポーネントだろうという予想。「/src/assets/vue.svg」「/public/vite.svg」は画像だけど、なぜ「src」と「public」で分かれているのかが謎。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

「index.html」のbody内では、id属性にappを持つdivタグとtype="module"を指定した<script>タグでモジュールとしてmain.tsを読み込んでいるだけで、コンテンツ自体の記述はない。
ただこれまでの学習の過程から、<div id="app"></div>があるので、これがHTMLテンプレートであることはわかる。試しにtitleを変更すると前述したTOPページのtitleも即座に更新されたので、現状ブラウザに表示されているのはこの「index.html」と考えていいと思う。

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()]
})

vite.configと付いているので、Viteの設定ファイルなのはわかる。Vite(ヴィート)とは、フロントエンド開発のための高速なビルドツールおよび開発サーバーのこと。
最初にインストールしたcreate-vueはこのViteを元にした Vueプロジェクトのひな形(テンプレート)を作るためのツール(スキャフォールディングツール)。create-vueには設定ファイルなどはなく、Vite 自体の設定ファイルでビルドや開発サーバーの挙動、プラグイン、エイリアス、環境変数などを定義する。
初期設定では、Vueのプラグインが読み込まれている状態。

/src/main.ts

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).mount('#app')

Vue.js本体を読み込み、アプリケーションインスタンスを生成し、DOM(HTML)の指定位置に「マウント」する役割を持つファイル。ちなみに拡張子が.tsなのは、環境構築の際に使用言語をTypeScriptにしているから。

style.cssはプロジェクト共通のデフォルトスタイルが記述されいるので、必要に応じてカスタマイズしていくものと思われる。

/src/vite-env.d.ts

/// <reference types="vite/client" />

Viteプロジェクトで環境変数に型を定義するためのTypeScriptの型定義ファイル、らしい。正直まだよくわからない。とりあえず、TypeScriptのためのファイルということらしい。

/src/App.vue

<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <a href="https://vite.dev" target="_blank">
      <img src="/vite.svg" class="logo" alt="Vite logo" />
    </a>
    <a href="https://vuejs.org/" target="_blank">
      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
    </a>
  </div>
  <HelloWorld msg="Vite + Vue" />
</template>

<style scoped>
.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
  filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

「App.vue」はコンテンツ部分の大枠になるコンポーネント(ルートコンポーネント)。importで別のコンポーネント(HelloWorld.vue)を読み込んでいて、自身のtemplateでそれを設置している。lang="ts"はTypeScriptを使うことを明示。.vueファイル内で型安全なコードが書ける。

/src/components/HelloWorld.vue

<script setup lang="ts">
import { ref } from 'vue'

defineProps<{ msg: string }>()

const count = ref(0)
</script>

<template>
  <h1>{{ msg }}</h1>

  <div class="card">
    <button type="button" @click="count++">count is {{ count }}</button>
    <p>Edit<code>components/HelloWorld.vue</code> to test HMR</p>
  </div>

  <p>Check out<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>, the official Vue + Vite starter</p>
  <p>Learn more about IDE Support for Vue in the<a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank">Vue Docs Scaling up Guide</a>.</p>
  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>

<style scoped>
.read-the-docs {
  color: #888;
}
</style>

App.vue に読み込まれている子コンポーネント。

ここで初見なのは以下の一行。

defineProps<{ msg: string }>()

まずdefineProps()は、<script setup>内で、親コンポーネントから渡されたプロパティを受け取るための関数。つまりprops: ['msg']と同義。これをTypeScriptで記述すると上記のような型付きの定義になる。

// 複数のプロパティの場合
defineProps<{
  msg: string,
  count: number
}>()

// 型定義なし
const props = defineProps([msg, count])

その他は、特に気になる記述はない。countは0が初期値で、<button @click="count++">でボタンをクリックするたびにカウントが増えるようになっている。

ちなみに、子から親にイベントをemitしたい場合は、defineEmits関数を使ってイベントを定義する。

// イベント定義
const emit = defineEmits(['notify'])

// 型付きイベント定義
const emit = defineEmits<{
  (e: 'notify', message: string): void
}>()

// イベント発火
function handleClick() {
  emit('notify', '子からのメッセージです')
}

ビルド

だいたいのファイルの構成がわかったところで、これをサーバー上でも動作するファイル形式に変換(ビルド)してみる。

npm run build

無事に「dist」ディレクトリにビルドされたファイルが格納された。

スクリプトは/assets/index-DA1xjUjD.jsに、cssは/assets/index-BYiJVlY3.cssにそれぞれまとめられた様子。/public/vite.svgは、/dist/vite.svgと同じものだったのでそのまま出力された感じ。/src/assets/vue.svgはどこへいったのか…。ビルド前のルート直下index.htmlは、/dist/index.htmlに出力され内容も少し変化した。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
    <script type="module" crossorigin src="/assets/index-DA1xjUjD.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BYiJVlY3.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

ビルド前と後をブラウザで見比べてみると、

ビルド前↑は<style>要素でcss設定がされているのに対して、ビルド後↓はきちんとcssが分離され外部ファイルとして読み込まれている。

あと画像に関して。
/src/assets/vue.svg/public/vite.svgで同じようなロゴ画像なのに、ディレクトリが違うのが謎だったけど、ビルドしたら以下のような違いがあった。

viteのロゴ画像は、App.vueのテンプレートでは<img src="/vite.svg" class="logo" alt="Vite logo" />と記述されていて、ビルド後のソースと同じ。一方、Vueのロゴ画像は<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />から、ビルド後はインラインSVGとして変換されている。
調べてみたら、Vite では 小さな画像ファイル(デフォルトは4KB未満) はビルド時に自動でインライン化されるそうで。4KB以上の画像の場合は、/dist/assets/にコピー(圧縮なし)されるとのこと。
この時、ディレクトリ構造は破棄され、/dist/assets/に、「ファイル名.ハッシュ.拡張子」の形式で出力される。プラグインを使って構造を保持することも可能なので、必要な時は検索すること。
また画像の圧縮処理をしたい場合は、別途プラグイン(vite-plugin-imagemin)が必要になる。

npm install vite-plugin-imagemin --save-dev
// vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    vue(),
    viteImagemin({
      gifsicle: { optimizationLevel: 7 },
      optipng: { optimizationLevel: 7 },
      mozjpeg: { quality: 75 },
      svgo: {
        plugins: [
          { name: 'removeViewBox' },
          { name: 'removeEmptyAttrs', active: false }
        ]
      }
    })
  ]
})

ちなみに/src/assets/に、Vueとは別の.jsや.cssファイルがあった場合、結合され、圧縮(minify)されて出力されるらしい。つまりこのディレクトリ配下にあるファイルは、画像以外でもビルド処理の対象(未使用ファイルは対象外)になる。

対して/public/フォルダの役割は、ビルド対象外のファイルの格納場所で、ディレクトリ構造を保ったまま/dist/にそのままコピーされる。

なんでこんな複雑なのかはわからないが、とりあえずそんな構成が分かったところで、あとは実践しながら覚えていくしかないかな。