Nuxt3 × Vuetify で Storybook 代替のUIカタログ「Histoire」を使う +自動生成ツール作りました

2024/09/09に公開

はじめに

開発者の皆様お疲れ様です。
普段からNuxt3×Vuetifyで開発をしているものです。
最近の案件でStorybookを導入する機会があったのですが、この環境にStorybookを導入するのは鬼のように難しかった、、、というのを前回記事にしました。

その途中でVite基準でVueとの相性が非常に良い「Histoire」というUIカタログを見つけましたので早速試してみました。今回はその導入方法と、またHistoireで使用する「.story」ファイルを自動生成するTSプログラムを作成いたしましたので、共有いたします。

UIカタログとは

各UIコンポーネント(UIのパーツ)をカタログのように表示、プロパティ(状態)を変更しての見た目及び動作の確認・テストができる。
各パーツごとの開発・テストや、クライアントに確認してもらう際に便利で、最近ではフロント開発の必須ツールになりつつある、かもしれない。

Storybookとは

UIカタログを作成するツールで、恐らく最も有名。最も普及している。というか他にUIカタログツールはほとんどない気がする。UIカタログと言えばまずStorybookのこと。
ざっと見たところ、2016年5月にv1.28.0が公開されている。少なくとも8年以上の歴史はあるようだ。
バージョンは現在8。

https://storybook.js.org/

Histoireとは

Histoire(イストワール)。新しめのUIカタログツールで、Viteネイティブのストーリービルダーである。現在VueとSvelteをサポートしており、Vueとの相性が良い。Viteを使用しているのでViteのプロジェクトで利用すると高速、らしい。
またTailwindを想定して構築されており、Tailwind CSS構成を検出して自動生成できるらしい。あと、検索機能が自動的に追加される。

https://histoire.dev/

比較メリット

Histoireを使ってみてStorybookより優れていると思ったこと。

● 軽量:Storybookは非常に巨大な構成で、また表示部分にReactを使用していることもあり、Vue等のプロジェクトに組み込むと500MB弱ものサイズになった。一方でHistoireは非常に軽量で、Nuxt3のプロジェクトに導入しても200MB程度であった。

● Nuxt3×Vuetifyの構築が比較的楽:Storybookの方は、少なくとも現状では、とても簡単とは言えない状態である。一方でHistoireはNuxt用のプラグインとVuetifyを使用したVue3のサンプルがあり、多少詰まったもののさほど苦労はしなかった。

● レイアウトが使いやすい:正直見た目のわかりやすさやかっこよさはStorybookの方が良いと感じたが、画像のようにUIコンポーネントとコントロールを縦に表示する為、プロパティが多い場合に、スクロールする必要があり、UIを確認しながらの変更ができなかった。一方Histoireは中央にUI、右にコントロールが来ており、UIを確認しながらの操作が可能である。

● Vueコンポーネントが使用できる
StorybookはあくまでJS・TSベースのツールであり、柔軟にコンポーネントを使用することはできなかった。HistoireはStoryに直接コンポーネントを記述することができ、柔軟な構成が実現できる。

● プロパティを自動で検出してくれる
Storybookはコンポーネントのプロパティを自動で検出する機能は無く、大量のプロパティを自身で書き出す必要があったが、その必要がない。

比較デメリット

● 不安定、若い:2022年頃に始まったプロジェクトでまだ歴史が浅く、バージョンも現状0.17.17で、機能等が十分に整った状況とは言えない。また、エラー等が多く、編集中に落ちたり、ホットリロードがうまく機能しないことがある。NuxtとVuetifyを合わせて用いる場合にもエラーが見られる。

● 情報が少ない:Reactで使用できないこともあり、ユーザが非常に少なく、記事、情報が少ない。また、ドキュメントの記載も不親切である。検索してヒットする日本語記事はほぼ2件のみである。なお、histoire自体が歴史という意味のフランス語?らしく、普通に検索すると該当の記事が出てこない。名前変えて欲しい。Histoire.vueとかにしてくれ。

● 継続性が不安:上記理由から、本プロジェクトがどこまで続けられていくのかは不安に感じる。

導入方法

Nuxt3×Vuetifyのプロジェクトを作成する

(Vuetifyページを参照)
https://vuetifyjs.com/en/getting-started/installation/#using-vite

histoireとVueプラグイン、Nuxtプラグインをインストールする

"histoire": "^0.17.17"
"@histoire/plugin-nuxt": "^0.17.17"
"@histoire/plugin-vue": "^0.17.17"

npm i -D histoire @histoire/plugin-vue @histoire/plugin-nuxt

プロジェクトルートに「histoire.config.ts」を作成し、プラグインを読み込むようにする。

histoire.config.ts
import {defineConfig} from 'histoire'
import {HstVue} from '@histoire/plugin-vue'
import {HstNuxt} from '@histoire/plugin-nuxt'

export default defineConfig({
    setupFile: '/histoire.setup.ts',
    plugins: [
        HstVue(),
        HstNuxt(),
    ],
    viteIgnorePlugins: [],
})

「histoire.setup.ts」追加

プロジェクトルートにセットアップファイル「histoire.setup.ts」を作成する
必要に応じてプラグインの読み込み、グローバルCSSの読み込みを行う。
Vuetifyのプラグインの読み込みもここで行っている。なお、pluginsディレクトリを自動で読み込んではくれず、また「defineNuxtPlugin」が使用できないことから、直接インポートすることもできない。

histoire.config.ts
import './assets/main.scss'
import "@mdi/font/css/materialdesignicons.min.css"
import 'vuetify/styles'
import {createVuetify} from 'vuetify';
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import colors from 'vuetify/util/colors'
import {defineSetupVue3} from '@histoire/plugin-vue'
import WrapperGlobal from './histoire/GlobalWrapper.vue'

export const lightTheme = {
    dark: false,
    colors: {
        primary: "#308040",
        secondary: "#CD4646",
        accent: "#e91e63",
        error: "#D02020",
        warning: "#ffc107",
        info: "#2196f3",
        success: "#4caf50"
    }
}

export const darkTheme = {
    dark: true,
    colors: {
        background: colors.grey.darken4,
        primary: colors.blue.darken2,
        accent: colors.grey.darken3,
        secondary: colors.amber.darken3,
        info: colors.teal.lighten1,
        warning: colors.amber.base,
        error: colors.deepOrange.accent4,
        success: colors.green.accent3
    }
}

export const defaults = {
    VBtn: {
        color: 'primary',
    },
}

const vuetify = createVuetify({
    defaults,
    // ssr: true,
    components,
    directives,
    theme: {
        defaultTheme: 'lightTheme',
        themes: {
            lightTheme,
            darkTheme
        }
    },
});

export const setupVue3 = defineSetupVue3(({app, addWrapper}) => {
    app.use(vuetify)
    addWrapper(WrapperGlobal)
})

GlobalWrapperを作成する

任意の場所に「GlobalWrapper.vue」ファイルを作成し、セットアップファイルで読み込む。
これは、Vuetifyのコンポーネントは<v-app></v-app>に囲まれている必要があるため、これを各UIコンポーネントに共通で提供するために行う。
またここでは、テーマの切り替えにも対応させている。
なお、このGlobalWrapper.vueは何故かコントロールにも適用されるため、テーマ切り替えボタンがコントロールにも表示されてしまうが、現状修正しようとすると他の不具合とかち合ってしまうためそのままにしている。

GlobalWrapper.vue
<script setup lang="ts">
let theme: Ref<string> = ref("lightTheme")
</script>

<template>
  <v-app :theme="theme">
    <div class="d-flex mb-2">
      <v-spacer/>
      <v-btn-toggle v-model="theme" density="compact" border mandatory>
        <v-btn icon="mdi-lightbulb" value="lightTheme" density="compact"/>
        <v-btn icon="mdi-weather-night" value="darkTheme" density="compact"/>
      </v-btn-toggle>
    </div>
    <v-main class="pa-3 border" rounded>
      <slot/>
    </v-main>
  </v-app>
</template>

<style scoped lang="scss">
:deep(.v-application__wrap) {
  min-height: 10px;
}
</style>

package.jsonにスクリプトを追加

package.json
{
  "scripts": {
    "story:dev": "histoire dev",
    "story:build": "histoire build",
    "story:preview": "histoire preview"
  }
}

起動する場合は「story:dev」を実行する。

.story.vue ファイルを作成

詳細は他記事や公式ドキュメントをご参照のこと。
https://histoire.dev/guide/vue3/stories.html

自動生成ツール

前回Storybookにはvueファイルからストーリーファイルを自動生成するBashスクリプトを作成された方がおられた。
https://qiita.com/Gityosan/items/e6570b8db8f90b351b3d

そこでHistoire版を作成しようと考えたが、私がBashに詳しくないため、記事を参考にしつつTypeScriptで作成した。
ファイル中のプロパティの抜き出しにおいて、JSONとして扱えると楽だったが、厳密にはJSON形式ではなく扱うことが難しかったため、TSでトランスパイルしてJSのプログラムとしてから、eval()で評価する形にした。

scripts/create-story.ts
import path from 'path'
import fs from 'fs'
import {transpile} from 'typescript'

// 作成する対象のファイルを含むパスを列挙する(/scriptsに配置した場合のルートからの位置)
const targetPaths: string[] = [
    'components',
    // 'layouts',
    // 'pages',
]

// Windowsパスの変換
const n = (a): string => {
    return a.replace(/\\/g, '/', a);
}

// ディレクトリ内ファイルの全列挙
const listFiles = (dir: string): string[] => {
    let files = fs.readdirSync(dir, {withFileTypes: true}).flatMap(dirent => {
        if (dirent.isFile()) {
            return [`${dir}/${dirent.name}`]
        } else {
            return listFiles(`${dir}/${dirent.name}`)
        }
    })
    let fls = []
    for (let f of files) {
        fls.push(n(f))
    }
    return fls
}

// 対象外のファイルと、既にstoryが存在するファイルを除外する
const extFiles = (files: string[]): string[] => {
    let _files = []
    for (let file of files) {
        let ext = path.extname(file).substring(1)
        let filename = path.basename(file)
        // console.log(filename)
        if (ext === "vue" && filename.slice(-7 - ext.length) !== '.story.' + ext) {
            if (files.includes(`${path.dirname(file)}/${filename.split(".")[0]}.story.${ext}`)) {
                console.log("既にStoryファイルが存在する為スキップされました:", file)
                continue
            }
            _files.push(file)
        } else {
            // console.log("sk", file)
        }
    }
    return _files;
}

// 以下の型でないプロパティは初期値を「null」とする
const types = ["string", "number", "boolean", "object", "array"]
const replacer = (k, v) => {
    if (!types.includes(typeof v)) return null
    return v;
}
// 抜き出したPropsの文字列をTSでトランスパイルしてJSのオブジェクトとして評価
const createPropsObj = (propsText: string): Dictionary => {
    let res = transpile(`(${propsText})`);
    return eval(res);
}

export const snakeToCamel = (snake: string): string => {
    return snake.replace(/[-_](.)/g, (match, group1) => {
        return group1.toUpperCase();
    });
};
const toUpperFirst = (str: string): string => {
    let s = snakeToCamel(str);
    return snakeToCamel(s).charAt(0).toUpperCase() + s.slice(1);
}
const ToUpperFirsts = (str: string): string => {
    let words = str.split("/")
    let _words = []
    for (let word of words) {
        _words.push(toUpperFirst(word))
    }
    return _words.join("/")
}

// Storyファイルを作成
const createStory = (file: string, tPath: string, props: string): string => {
    let filename: string = path.basename(file)
    let fileName: string = toUpperFirst(filename.split(".")[0])

    return `<script setup lang="ts">
import ${fileName} from './${filename}'

const initState = () => {
  return ${props}
}

const iconItems = [
  {label: 'Home', value: 'mdi-home'},
  {label: 'Account', value: 'mdi-account'},
  {label: 'AccountBox', value: 'mdi-account-box'},
  {label: 'AccountBoxOutline', value: 'mdi-account-box-outline'}
]
</script>

<template>
  <Story title="${ToUpperFirsts(tPath)}/${fileName}" :layout="{ type: 'grid', width: 400 }">
    <Variant title="Default" :init-state="initState">
      <template #controls="{state}">
        <!--        <HstText v-model="state.title" title="title"/>-->
        <!--        <HstSelect v-model="state.icon" :options="iconItems" title="icon"/>-->
        <!--        <HstColorSelect v-model="state.color" title="color"/>-->
      </template>

      <template #default="{state}">
        <${fileName}/>
      </template>
    </Variant>

    <Variant title="Sub">
      <template #default="{state}">
        <${fileName}/>
      </template>
    </Variant>
  </Story>
</template>

<style scoped lang="scss">

</style>

<docs lang="md">

</docs>`
}

for (let tPath of targetPaths) {
    let files = extFiles(listFiles(path.join(__dirname, '../', tPath)))
    console.log("対象ファイル", files)

    for (let file of files) {
        let text = fs.readFileSync(file, {encoding: 'utf-8'});

        // "props: {"を見つける その位置を記録する
        let type: number = 0 // 0: none, 1:OptionsAPI, 2:CompositionAPI, 3:CompositionAPI(withDefaults)
        let index: number = text.search(/props: ?{/)
        if (index > 0) type = 1 //OptionsAPI
        if (type === 0) {
            index = text.search(/defineProps.*\({/)
            if (index > 0) type = 2 //withDefaultを使わないCompositionAPI
        }
        if (type === 0) {
            index = text.search(/withDefaults\(/)
            if (index > 0) type = 3 //withDefaultを使うCompositionAPI
        }

        let nestLevel: number = 1
        let propsText: string = ""
        if (type > 0) {
            let start: number = text.indexOf("{", index) + 1
            let now: number = start
            while (nestLevel > 0) {
                if (text[now] === "{") nestLevel++
                if (text[now] === "}") nestLevel--
                now++;
            }
            propsText = text.substring(start - 1, now)
        } else {
            // console.log("no props")
        }

        let props: Dictionary = {}
        switch (type) {
            case 1: {
                let _props = createPropsObj(propsText)
                for (let key in _props) {
                    props[key] = _props[key].default ?? ""
                }
                break
            }
            case 2: {
                let _props = createPropsObj(propsText)
                for (let key in _props) {
                    props[key] = _props[key].default ?? ""
                }
                break
            }
            case 3: {
                let _props = createPropsObj(propsText)
                for (let key in _props) {
                    props[key] = _props[key] ?? ""
                }
                break
            }
        }

        let filename: string = path.dirname(file) + "/" + path.basename(file).split(".")[0] + ".story.vue"
// ファイル保存
        try {
            fs.writeFileSync(filename, createStory(file, tPath, JSON.stringify(props, replacer, 2)))
        } catch (e) {
            console.log("ファイルの作成に失敗しました:", filename)
        }
    }
}

かなり決め打ちで作成している部分もありますので、調整しながらご利用ください。

Discussion