ウェブサイトの中身を Nuxt UI に置き換える

投稿した日
更新した日
書いたひと
icon
ひらたけ

本ウェブサイト「Hiratake Web」の中身を Nuxt UI で書き直しました🚧 表面的にはデザインも何も変わっていないので、作り直したといってもただの自己満足でしかないところではありますが、変化の激しいウェブの世界では放って置くとすぐコードが古くなってしまうので、定期的なメンテナンスが欠かせません。

そんな Nuxt UI での再構築についての話を中心に、ウェブサイトのコードを書き直しているときのこだわりポイントだとか苦労したところなんかを色々書いていこうと思います。


今回の再構築についての Pull Request は以下。これまでは大半の Vue コンポーネントを自前で用意して、一部に Headless UI という Tailwind CSS と同じ Tailwind Labs が提供しているコンポーネントを使用してウェブサイトを構築していました。それを Nuxt UI という、この「Hiratake Web」でも使用しているフレームワークである Nuxt のオフィシャルなコンポーネントライブラリに置き換えています。

当然、独自のデザインがあるウェブサイトなので全て Nuxt UI のコンポーネントで実装するというのは不可能ではありますが(無理に全てを置き換える必要もない)、それでも Nuxt UI で実装できる部分はほぼほぼ置き換えができたのではないかと思います。

以前のコードでもウェブサイトのコンテンツの更新自体はできていたのですが、使用していた Headless UI は更新が止まっている(React 向けはメンテナンスされてるようだけれど Vue のほうは 2 年近く止まっている)ので変えたほうがいいだろうな…と思っていたのと、Tailwind CSS のバージョンが v3 から上げられない状態が続いていたのとで、「いつかどうにかしよう」と再構築の検討はずっと前からしていました。

そんな中、最近になって基盤となっているフレームワークの Nuxt のバージョンを上げると正しく表示できなくなるページが現れてしまい、さすがにマズいか…と重い腰を上げました。

環境

再構築にあたって使用した主なライブラリのバージョンは以下の通り。ウェブサイトのデプロイ先は Cloudflare Pages です(Cloudflare Workers ではない)。

@nuxt/ui は新規追加、あとは以前から使っていたパッケージとなります。@nuxtjs/seo については v3 から v5 へ、tailwindcss は v3 から v4 へとメジャーバージョンを更新しています。

Cloudflare Pages から Cloudflare Workers への切り替えも検討しましたが、サーバルートをほぼ使わないので機能的にオーバースペックだし、静的なブログサイトであれば事前に全コンテンツを生成しておく SSG の方が表示速度的に良いだろうということで実施しませんでした。お金もかかりそうだし。

Nuxt UI への置き換え

Nuxt UI は、Vue.js のフレームワークである Nuxt が公式に出しているコンポーネントライブラリ。以前は無料版のものと、有償の Nuxt UI Pro に分かれていましたが、Nuxt を開発している NuxtLabsNext.js などを開発する Vercel への合流を発表したことで、Nuxt UI v4 以降では誰でも無料で全てのコンポーネントが利用できる状態 になっています。

インストール方法としては他の Nuxt のモジュールと同様で、pnpm などのパッケージマネージャーを使用して @nuxt/ui をインストールし、nuxt.config.tsmodules 内に @nuxt/ui を追加。あとは、Nuxt UI では Tailwind CSS を使用するため、tailwindcss のインストールおよびインポートした CSS ファイルの読み込みが必要です。

pnpm
pnpm add -D @nuxt/ui tailwindcss
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@nuxtjs/seo', '@nuxt/content'],
  css: ['~/assets/css/main.css'],
})
app/assets/css/main.css
@import 'tailwindcss';
@import '@nuxt/ui';

新規 Nuxt アプリケーションを作成する pnpm create nuxt@latest <project-name> コマンドを実行すると、Nuxt UI の初期設定が済んでいる状態で開発を始めることができるので、こちらで作成するのが分かりやすくて安心でしょう。

モジュールのインストールが終われば、app/ ディレクトリ内の .vue ファイル内で Nuxt UI のコンポーネントを利用することができます。Nuxt UI のコンポーネントは、<UButton /><UInput /> のように U から始まる名前のもの。100 個以上もある様々なコンポーネントを駆使して、ウェブアプリケーションを構築することができます。

コンポーネントのカスタマイズ

数々のコンポーネントを備えた Nuxt UI ですが、そのままの状態で使うと「これ Nuxt の公式サイトか…?」って感じの、あまりにも「Nuxt 感」溢れる見た目の画面になってしまうので、カスタマイズは必須です。

Nuxt UI の見た目をカスタマイズする方法は大きく 2 種類。コンポーネントを使用する際に :ui Props を渡す方法と、app.config.ts ファイルの ui プロパティ内に設定を記述する方法です。どちらも Tailwind CSS のクラスを記述することで見た目をカスタマイズする仕組みになっていて、前者は個別に調整したい場合に、後者はまとめて調整したい場合に利用します。

たとえば、ボタンのコンポーネント <UButton /> のカスタマイズをしたい、とします。コンポーネントを使うときに「ここのボタンだけ文字を太字にしたいな」というときには、以下のようにクラスを渡します。

<UButton :ui="{ base: 'font-bold' }" />

base というのはクラスを付与する要素のことで、ブラウザのデベロッパーツールで DOM の様子を覗いてみると、data-slot="base" という属性が付いている要素に font-bold クラスが付与されていることが確認できます。<UButton /> では base の他にも、labelleadingIcon といった要素にクラスを付与することができるようになっています。

このように、:ui Props に Tailwind CSS のクラスを渡して個別に見た目をカスタマイズすることが可能となっていますが、たとえば「<UButton /> を使うときには必ず cursor-pointer を付けたい」みたいに、共通化したいということが多々あります。そのようなときには app.config.ts に設定を記述して対応します。

app.config.ts
export default defineAppConfig({
  ui: {
    button: {
      slots: { base: 'cursor-pointer' },
    },
  }
})

上記のコードを追加すると、<UButton /> を使ったときに base の要素に cursor-pointer が付与されるようになります。ボタンコンポーネントに最初から cursor-pointer が設定されてないのは謎ですが…。ボタンにカーソルを合わせたときに見た目を変化させたくない人が多数派なのか…?

適用されるクラスの優先順位は、「:ui Props で渡したクラス」が最も優先され、次に「app.config.ts で設定したクラス」、最後が「デフォルトのクラス」です。app.config.ts 側で bg-white を指定していても、:ui Props で bg-black を渡していたら黒色になります。

また、コンポーネントには :ui Props の他にも、動作を制御するための様々な Props が用意されています。先ほどの例で挙げた <UButton /> だと、ボタンのサイズを指定する size Props や、ボタン内にアイコンを表示するための icon Props などです。

app.config.ts で見た目をカスタマイズする場合には、特定の状態のときにのみ適用するクラスを設定することもできます。たとえば、sizexs の場合にのみ base 要素に font-bold を付与したい、という場合には、以下のように記述できます。

app.config.ts
export default defineAppConfig({
  ui: {
    button: {
      variants: {
        size: {
          xs: { base: 'font-bold' },
        }
      },
    },
  }
})

複雑な条件の場合も設定が可能で、たとえば「colorprimary」かつ「variantoutline」の場合にのみ font-bold を付与したい、という場合には、以下のようになります。

app.config.ts
export default defineAppConfig({
  ui: {
    button: {
      compoundVariants: [
        { color: 'primary', variant: 'outline', class: 'font-bold' },
      ],
    },
  }
})

これらの app.config.ts で設定できる項目は、Nuxt UI のドキュメント内の各コンポーネントのページの下の方に「Theme」として載っている(デフォルトの値も書いてある)ので、この部分をひたすらに読みながらカスタマイズをしていくことになります。

が、個人的には、いきなり app.config.ts に設定を書かなくても、とりあえず :ui Props に渡せば見た目を変更することはできるので、まずは個別に調整していって、2 回以上同じコンポーネントを使うようであればそのタイミングで「app.config.ts に設定を移して共通化できないかな?」と考えるのが良いのではないかな?と思いました。

苦労したところ

とりあえずざっくりと Nuxt UI の使い方について書きましたが、ここからは実際に Nuxt UI を使って「Hiratake Web」の中身を書き直したときに苦労した点について書き殴っていこうと思います。

デフォルトで付与されているクラスの癖が強すぎる

なんといってもコレ。Nuxt の公式サイトや、関連モジュールのドキュメントサイトなどを構築することに最適化されすぎている感がすごい。個人的に一番「それはあまりにもあんたらの都合すぎるだろ!」なスタイリングすぎて発狂したのは <UFooter /> で、ドキュメントに掲載されているデフォルトのテーマは以下の通り。

app.config.ts
export default defineAppConfig({
  ui: {
    footer: {
      slots: {
        root: '',
        top: 'py-8 lg:py-12',
        bottom: 'py-8 lg:py-12',
        container:
          'py-8 lg:py-4 lg:flex lg:items-center lg:justify-between lg:gap-x-3',
        left:
          'flex items-center justify-center lg:justify-start lg:flex-1 gap-x-1.5 mt-3 lg:mt-0 lg:order-1',
        center:
          'mt-3 lg:mt-0 lg:order-2 flex items-center justify-center',
        right:
          'lg:flex-1 flex items-center justify-center lg:justify-end gap-x-1.5 lg:order-3'
      }
    }
  }
})

どうしてデフォルトで lg:order-1 とか lg:order-2 とかが付いているんでしょう…?なんか並び順がうまいこといかないな…と思ってよく見たら order-* で指定されてました。

要素も rightleft の順に格納されていて、画面の幅が狭いときは right の要素が上、left の要素が下に表示されます。幅が広いときには order-* の指定が効いて、right が右へ、left が左へ、と正しい位置に配置されるようになっていました。

「Hiratake Web」では、md の幅で縦積みと横並びを切り替えたかったので、lg:flexmd:flex へ上書きしたのですが、すると mdlg の間でだけ left が右へ、right が左へ行ってしまい、意味不明すぎて時間を溶かしました。

カスタマイズをするときは、もう「デフォルトのクラスを全部上書きしてやるぜ!」くらいの勢いでやったほうがうまくいくかもしれません。

Nuxt Content との統合

Nuxt 公式のモジュールである Nuxt UI には、同じく公式から提供されている Git ベースのコンテンツ管理システム Nuxt Content 用のコンポーネントも内蔵。Nuxt Content にも存在している Prose コンポーネント(見出しや段落、引用やコードブロックなど、Markdown 形式に含まれる要素のコンポーネント)にそれぞれ Nuxt UI 独自のデフォルトスタイルが適用されているほか、AccordionBadge など独自のコンポーネントも追加されています。

これらは他の Nuxt UI のコンポーネントと同様に app.config.ts にてスタイルを変更することが可能です。

app.config.ts
export default defineAppConfig({
  ui: {
    prose: {
      h2: {
        slots: { base: '', leading: '' },
      },
      h3: {
        slots: { base: '', leading: '' },
      },
      // ...
    },
  }
})

私は Nuxt Content 自体はずっと使ってきていたので、当然 Prose コンポーネントも触ったことがあるし、スタイルを変更して使うということもやってきていたのですが、Nuxt UI での Prose コンポーネントのカスタマイズに少し苦戦しました。

標準の Nuxt Content では、app/components/content/ProseH2.vueProseA.vue のようなコンポーネントのファイルを作成するとデフォルトのコンポーネントが上書きされます。そしてそれは Nuxt UI と統合した場合でも同じなのですが、Nuxt UI の Prose コンポーネントは高機能で、たとえば <ProseImg /> という画像を表示するコンポーネントでは公式モジュールである Nuxt Image による最適化が行われます。

これまでであれば、ほとんどスタイルの当たっていないシンプルな Prose コンポーネントのソースコードをそのまま持ってきて必要な部分だけ書き換えて使っていたのですが、Nuxt UI の Prose コンポーネントの場合はそうもいきません。

app/components/content/ProseImg.vue
<script lang="ts" setup>
import type { ProseImgProps as UProseImgProps } from '#ui/components/prose/Img.vue'
import UProseImg from '#ui/components/prose/Img.vue'

type ProseImgProps = UProseImgProps

const props = withDefaults(defineProps<ProseImgProps>(), {
  width: 1536,
  height: 864,
  zoom: true,
})

const config = useRuntimeConfig()
const site = useSiteConfig()

/** 画像のURL */
const imgUrl = computed(() => {
  const baseUrl = `${site.url}/cdn-cgi/imagedelivery/${config.public.cloudflareImageHash}/${props.src}`
  const generatedSrcset = {
    320: `${baseUrl}/w=320`,
    640: `${baseUrl}/w=640`,
    768: `${baseUrl}/w=768`,
    1024: `${baseUrl}/w=1024`,
    1280: `${baseUrl}/w=1280`,
    1536: `${baseUrl}/w=1536`,
  }
  return {
    src: generatedSrcset[1536],
    srcset: `
      ${generatedSrcset[320]} 320w,
      ${generatedSrcset[640]} 640w,
      ${generatedSrcset[768]} 768w,
      ${generatedSrcset[1024]} 1024w,
      ${generatedSrcset[1280]} 1280w,
      ${generatedSrcset[1536]} 1536w,
    `,
  }
})
</script>

<template>
  <UProseImg
    :alt="props.alt"
    :class="props.class"
    :height="props.height"
    :src="imgUrl.src"
    :srcset="imgUrl.srcset"
    :ui="props.ui"
    :width="props.width"
    :zoom="props.zoom"
    decoding="async"
    loading="lazy"
    sizes="(max-width: 768px) calc(100vw - 48px), 768px"
  />
</template>

色々調べた結果、Nuxt UI の Prose コンポーネントは #ui/components/prose/ からそれぞれインポートすることができるようだったので、それを利用することで動作を変更することができました。上のコードは画像を表示する <ProseImg /> コンポーネントで、#ui/components/prose/Img.vue から Nuxt UI のコンポーネントと Props の型情報をインポートして使用しています。

「Hiratake Web」では画像の配信に Cloudflare Images というサービスを利用していて、Markdown ファイル側には画像の ID のみを記述するという使い方をしています。上書きした <ProseImg /> コンポーネントで正しい画像の URL に変換する処理を挟むなどの変更を加えています。

また、コードブロックを表示する <ProsePre /> ではコードをコピーするためのボタンが表示されているのですが、app.config.ts からではボタンのカスタマイズが難しい(コピーボタンには <UButton /> が使われているが、app.config.ts からではボタンコンポーネントの Props を設定できない)ので、こちらも上書き用のファイルを作成し、#ui/components/prose/Pre.vue からベースとなる Nuxt UI 側のコンポーネントをインポートして使用しました。

app/components/content/ProsePre.vue
<script lang="ts" setup>
import type {
  ProsePreProps as UProsePreProps,
  ProsePreSlots as UProsePreSlots,
} from '#ui/components/prose/Pre.vue'
import UProsePre from '#ui/components/prose/Pre.vue'

type ProsePreProps = UProsePreProps
type ProsePreSlots = UProsePreSlots

const props = defineProps<ProsePreProps>()
defineSlots<ProsePreSlots>()

/** コピーボタンをカスタマイズするための設定 */
const customizedCopy = computed<ProsePreProps['copy']>(
  () => props.copy || { color: 'neutral', variant: 'soft' },
)
</script>

<template>
  <UProsePre
    :icon="props.icon"
    :code="props.code"
    :language="props.language"
    :filename="props.filename"
    :highlights="props.highlights"
    :hide-header="props.hideHeader"
    :meta="props.meta"
    :copy="customizedCopy"
    :class="props.class"
    :ui="props.ui"
  >
    <slot />
  </UProsePre>
</template>

アイコンの設定

Nuxt UI では、ダークモードの切り替えボタン(<UColorModeButton />)など、いくつかのコンポーネントでデフォルトのアイコンが設定されているものがあります。デフォルトでは Lucide のアイコンが使用されていますが、これらのアイコンの上書きも app.config.ts で行うことができます。

ダークモードの切り替えボタンの のアイコンだけでなく、見出しの横の マークやコードブロックのコピーボタンのアイコンなども全て app.config.ts から指定できます。このことに全然気付いていなくて「どうやってアイコンを別のものに変えるんだ…?」と結構悩んでました(これは私がアホなだけ)。


頻繁にコンテンツを更新しているウェブサイトなので、裏で新しいリポジトリを作ってちょっとずつ進めていた再構築プロジェクトですが、このウェブサイトで使用している Nuxt というフレームワークのスタンダードな構成に近づけられたり、非推奨なコードを一掃できたりしたのはとても良かったです。

そのまま使うと「Nuxt 感」がめちゃくちゃ出てしまう UI ライブラリをいじくり回して、なんだかんだで愛着がある既存のデザインをほぼ変えずに移行できたのも、ひと仕事やり遂げたという達成感があって大満足。今後とも「Hiratake Web」を何卒どうぞよろしくおねがいいたします🙇