⚡️

Vue.jsの自作カスタムブロックをViteと連携する便利な活用術

2025/01/04に公開

カスタムブロックとは

Vue.jsのSFC構文はtemplatescriptstyleの言語ブロックで構成されていますが、オプションでカスタムブロックと呼ばれる別名のブロックを追加することも可能です。

代表的な活用例ではvue-i18nvue-routerなどで利用されています。例としてvue-i18nunplugin-vue-i18n)のカスタムブロックではi18nというカスタムブロックで以下の様な記述をすることにより言語設定をSFC内に記載することが可能です。

<i18n>
{
  "en": {
    "language": "Language",
    "hello": "hello, world!"
  },
  "ja": {
    "language": "言語",
    "hello": "こんにちは、世界!"
  }
}
</i18n>

カスタムブロックを使用することでSFC内に設定などを直接記述できるため、コードの分離やメンテナンス性が向上します。本記事ではViteと連携したカスタムブロックの活用方法をご紹介します。

カスタムブロック Hello World

まずはカスタムブロックを作成してみましょう。SFC内に適当な名前で作成したブロックを用意します。

<hoge>
label: value
</hoge>

上記のブロックをSFC内に記述するとエラーとなります。カスタムブロックはViteまたはwebpackなどのビルドツールでJavaScriptに変換する必要があるためです。公式のサンプルを元にvite.config.tsにViteプラグインを作成してみます。

vite.config.ts
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'

const CustomBlockPlugin: Plugin = {
  name: 'my-custom-block-plugin',
  transform(code, id) {
    // vueファイルのhogeブロックのみを処理
    if (!/vue&type=hoge/.test(id)) {
      return
    }
    
    // ブロック内のyamlをパースする
    code = JSON.stringify(parse(code.trim()))

    // コンポーネントにカスタムブロックの内容をプロパティとして渡す
    return `export default Comp => {
      Comp.fuga = ${code}
    }`
  },
}

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    CustomBlockPlugin // 作成したプラグインを設定する
  ],
})

上記のコードではVite(rollup)のtransformの処理においてhogeブロックで定義された内容をコンポーネントのfugaに渡しています。最終的にはComp.fuga = { label: 'value' }の様になります。

定義した内容を確認するには以下のコードとなります。valueと表示されるはずです。

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

<template>
  {{ HelloWorld.fuga.label }}
</template>

Viteとの連携

上記の例ではカスタムブロックを利用せずとも良いと思うかもしれません。カスタムブロックはViteと連携してプロジェクト全体の設定やファイルベースの処理を行うと真価を発揮します。今回はtabs以下のVueファイルの構成に基づき自動でタブ情報を作成するプラグインを作成していきます(Nuxtなどのファイルベースルーティングのタブ版の様なもの)。

フォルダ構成

- tabs
  - tab1.vue
  - tab2.vue
  - tab3.vue

カスタムブロック(タブに関する情報を付与するために利用)

<tab>
label: tab1
index: 1
</tab>

<template>
  <div>screen 1</div>
</template>

ファイルベースのカスタムブロック処理

先程の処理ではtransformでファイルが呼ばれた際にカスタムブロックの処理をしていましたが、ロードされない限り発火されないため起動時に処理したい時などには利用できません。任意のタイミングでカスタムブロックの処理をする場合は本家Vue.jsと同様に@vue/compiler-sfcで処理を行うことが可能です。

import { parse } from '@vue/compiler-sfc';
import { parse as yamlParse } from 'yaml'

const fileContent = ...                    // Vueファイル
const { descriptor } = parse(fileContent); // SFCのパース
if (descriptor.customBlocks.length > 0) {  // カスタムブロックの情報がdescriptor.customBlocksに入る
  const blockContent = yamlParse(tabBlock.content.trim()) // カスタムブロックの内容(yaml)をパース
}

ファイルの内容を@vue/compiler-sfcparseを実行することでカスタムブロックの内容を確認できます。カスタムブロックの情報としては以下の様な情報が渡ってきます。

  • type: ブロック名
  • content: ブロックの内容
  • attrs: ブロックの属性(scriptブロックで言えばsetuplangなど)
// 一部省略
{
  type: 'tab',
  content: '\nlabel: tab1\nindex: 1\n',
  attrs: { lang: 'yaml' },
  ...
}

Viteのプラグインではプラグインが解決された時にconfigResolvedが呼び出されるため初期処理として対象のファイルを探索してカスタムブロックを処理する以下の処理をvite.config.tsに記載します。

vite.config.ts
import { join, resolve } from 'node:path'
import process from 'node:process'
import { readFileSync, readdirSync } from 'fs'
import { parse } from '@vue/compiler-sfc';
import { parse as yamlParse } from 'yaml'
import type { Plugin } from 'vite'

const tabs: {label: string, index: number, path: string}[] = []

const TabPlugin: Plugin = {
  ...,
  transform(_, id) {
    if (!/vue&type=tab/.test(id)) {
      return
    }
    return `export default {}` // transformでは何もしない
  },
  configResolved() {
    const dirPath = join(process.cwd(), 'src/tabs')    // タブのフォルダパス
    const files = readdirSync(dirPath)                 // フォルダを読み込む
    for (const file of files) {
      const filePath = resolve(dirPath, file);
      const fileContent = readFileSync(filePath, 'utf-8'); // タブとなるVueファイルを取得(今回はVueファイル以外想定しない)
        
      const { descriptor } = parse(fileContent);           // SFCのパース
      if (descriptor.customBlocks.length > 0) {        // カスタムブロックの情報がdescriptor.customBlocksに入る
        // カスタムブロックの処理
        const blockContent = yamlParse(tabBlock.content.trim())
        // タブの情報とコンポーネントのファイルパスを設定しておく
        tabs.push({
          label: blockContent.label,
          index: blockContent.index,
          path: filePath
        })
      }
    }
  }
}

これで特定のディレクトリ以下のVueファイルに記載してあるカスタムブロックを処理することが可能です。また、属性を付与すればブロック内をJSONやyamlなど複数の記法に対応することや他の付帯情報を設定することも可能となります。

設定のロード

仮想モジュールを作成して画面からタブの設定情報を呼び出せるようにします。仮想モジュールとは以下の様に生成したデータをJavaScriptのモジュールとして扱う仕組みとなります。

import tabs from 'virtual:tab-generated'

console.log(tabs) // [{label: 'tab1', ...}]

Vite(rollup)ではガイドに記載があるように仮想モジュールを利用する際には他のエコシステムの影響を及ぼさない様に対応する必要があります。vite.config.tsに以下を仮想モジュール解決処理を追記します。(下記例ではvirtualModuleIdが読み込まれた際にresolvedVirtualModuleIdとしてIDの解決を行い(\0付与はrollupの慣例)、loadresolvedVirtualModuleIdが読まれた際の処理を記述します)

vite.config.ts
const virtualModuleId = 'virtual:tab-generated'
const resolvedVirtualModuleId = '\0' + virtualModuleId

{
  ...
  resolveId(id) {
    if (id === virtualModuleId) {
      return resolvedVirtualModuleId
    }
    return null
  },
  load(id) {
    if (id === resolvedVirtualModuleId) {
      // カスタムブロックから作成したタブの情報を設定する
      const tabCode = tabs.map(tab => `
        {
          label: '${tab.label}',
          component: () => import('${tab.path}')
        }
      `).join(',')

      return {
        code: `export default [${tabCode}];`, // importされた際に返されるコード
      }
    }
    return null
  }
}

loadで返す文字列はJavaScriptとして評価されるため上記のlabel: '${tab.label}'の様に文字列として設定する必要があります。他のライブラリの読み込みやソースマップの作成など高度な処理をする場合はmagic-stringを利用するのが良いかもしれません。

Viteのプラグインの設定が出来たので実際に仮想モジュールを読んでみます。パスはvirtual:tab-generatedで設定したため以下の形式でimportしてタブの情報を取得します。実際のコンポーネントもパスを設定済みのためAsyncComponentとして呼び出します。

<script setup lang="ts">
import { shallowRef, defineAsyncComponent, type Component } from 'vue'
import tabs from 'virtual:tab-generated' // カスタムブロックから作成した情報

const DynamicComp = shallowRef<null | ReturnType<typeof defineAsyncComponent>>(null)

// 動的コンポーネントの切り替え
function changeComp(component: () => Promise<Component>) {
  DynamicComp.value = defineAsyncComponent(component)
}
</script>

<template>
    <div v-for="(tab, idx) in tabs" :key="idx">
      <div @click="changeComp(tab.component)">{{ tab.label }}</div>
    </div>
    <div v-if="DynamicComp != null">
      <DynamicComp></DynamicComp> 
    </div>
    <div v-else>
      not select
    </div>
  </div>
</template>

仮想モジュールであるvirtual:tab-generatedは型が付いていないためTypeScriptの場合はd.tsファイルなどに以下の様な記載をすれば対応可能です。

declare module 'virtual:tab-generated' {
  const tabs: {label: string, component: () => Promise<Component>}[];
  export default tabs;
}

動作させると以下の様になります。タブの情報を各タブ画面側に集約した上で実現出来ました。

tab

おわりに

今回はVueのカスタムブロックを利用したファイルベースのViteプラグインを作成してみました。Vite周りのエコシステムは扱いやすくてとても魅力的です。作成は少し大変ですが、利用側からすると便利に扱うことができるため様々な応用が出来そうだと思いました。

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion