Vue.jsの自作カスタムブロックをViteと連携する便利な活用術
カスタムブロックとは
Vue.jsのSFC構文はtemplate
やscript
、style
の言語ブロックで構成されていますが、オプションでカスタムブロックと呼ばれる別名のブロックを追加することも可能です。
代表的な活用例ではvue-i18n
やvue-router
などで利用されています。例としてvue-i18n
(unplugin-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プラグインを作成してみます。
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-sfc
のparse
を実行することでカスタムブロックの内容を確認できます。カスタムブロックの情報としては以下の様な情報が渡ってきます。
-
type
: ブロック名 -
content
: ブロックの内容 -
attrs
: ブロックの属性(script
ブロックで言えばsetup
やlang
など)
// 一部省略
{
type: 'tab',
content: '\nlabel: tab1\nindex: 1\n',
attrs: { lang: 'yaml' },
...
}
Viteのプラグインではプラグインが解決された時にconfigResolved
が呼び出されるため初期処理として対象のファイルを探索してカスタムブロックを処理する以下の処理を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の慣例)、load
でresolvedVirtualModuleId
が読まれた際の処理を記述します)
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;
}
動作させると以下の様になります。タブの情報を各タブ画面側に集約した上で実現出来ました。
おわりに
今回はVueのカスタムブロックを利用したファイルベースのViteプラグインを作成してみました。Vite周りのエコシステムは扱いやすくてとても魅力的です。作成は少し大変ですが、利用側からすると便利に扱うことができるため様々な応用が出来そうだと思いました。
Discussion