フロントエンド カラーミーショップ

Vue.jsとViteを活用したフロントエンドアプリケーションの漸進的な改善

フロントエンド カラーミーショップ

快適なショップ運営体験を目指して、カラーミーショップ上での商いを支援する「管理画面」のユーザー体験の改善を続けています。

このアプリケーションを長期間運用していく中で、開発体験に関する以下のような問題が出てきました。

  • DOM APIを直接利用した手続き的なUIの実装により、管理する状態が複雑になるにつれてコードの見通しが悪くなる
  • 適切にモジュール化されていないスクリプトによるテスタブルではない実装

ユーザー体験の向上のために多機能なUIを提供しつづけるためには、これらの課題も同時に解決する必要がありました。

管理画面はショップ運営において重要なWebアプリケーションであることから開発頻度も高く、日々機能が追加されていきます。 また、注文の処理や顧客管理、ECサイトのエディタなど、提供している機能も多岐に渡ります。

このため、ショップ運営や他の開発プロジェクトに影響を与えないように、徐々に、かつ継続的に取り組みを広げていくことが重要となります。 Nuxtをはじめとしたフロントエンドフレームワークベースのアプリケーションサーバに機能を移行すれば、統合されたフロントエンド開発環境を手にいれることができ、かつSSRをはじめとした機能によって快適なユーザー体験を提供できますが、作業量の観点から、現時点では全面的に移行する作戦を取ることは困難でした。

この前提のもとに、Vue.jsを活用し、様々な工夫によりフロントエンド関連技術を徐々に導入しているので、その事例を紹介します。

Vueコンポーネントをカスタム要素ライブラリ化して利用する

Vueコンポーネントは defineCustomElement() APIを介することで、Web Componentsにおけるカスタム要素として利用することができます。 HTML上に直接配置できるようになるメリットのほかに、スタイルやUIに必要なスクリプトをDOMの構造と一緒に配信できるので、UIコンポーネントのポータビリティを高めることができます。

以前からこのAPIを活用することで、サーバサイドのテンプレートエンジンで描画するHTMLのあらゆる箇所で、Vueコンポーネントを部分的に利用しています。

加えて、パッケージ構成を工夫することで、再利用性に配慮しています。

コンポーネントは、たとえばボタンやスナックバーローディングスピナーなどの「再利用性の高いコンポーネント」と「他のコンポーネント群で構成した具体性の高いコンポーネント」に大別できます。 前者の場合は他のVueコンポーネントから参照されることもあるので、カスタム要素化せずに利用する場合もあります。

このため、パッケージ構成を以下のように「再利用性の高いコンポーネント群」、「具体性の高いコンポーネント群」、「カスタム要素変換層」で構成しています。 コンポーネントは、カスタム要素変換層を経て選択的にカスタム要素化します。

汎用性の高いコンポーネントはより具体性の高いコンポーネントに依存される。両者のコンポーネントはカスタム要素変換層を経てHTMLに配置される。

カスタム要素ライブラリ内のコンポーネントには利用頻度の低いものも含まれるので、バンドルサイズを抑制するという目的から、ページに必要なカスタム要素をクライアント側で検索してDynamic Importによってコンポーネントを取得しています。

import { defineCustomElement } from 'vue'

load({
  "colorme-textbox": {
    // コンポーネントとスタイルはDynamic Importしておくことでロード時に解決しない
    // (バンドル時にこのスクリプトとは分割されたスクリプトが出力されるようになる)
    component: () => import("@colorme/admin-components/ColormeTextbox.vue"),
    style: () => import("@colorme/admin-components/ColormeTextbox.css?inline"),
  },
  ...
});

const load = (componentMap: {[tagName: string]: ComponentMap}) => {
  Object.keys(componentMap).forEach(
    async (key) => {
      // 読み込む必要のあるコンポーネントを検索する
      const el = document.getElementsByTagName(key)

      if (el.length > 0) {
        const componentLoader = componentMap[key].component
        const styleLoader = componentMap[key].style
        // 必要になった時点でコンポーネントを解決する (=ビルド時にチャンク分割されたコンポーネントがここではじめてリクエストされる)
        const [component, css] = await Promise.all([componentLoader(), styleLoader()])

        // defineCustomElementはカスタム要素に注入できる `styles` プロパティを受け入れる
        // `styles` のCSSはカスタム要素内にインラインCSSとして展開される
        const el = defineCustomElement({...component.default, styles: [css.default]})
        customElements.define(key, el)
      }
    }
  )
}

一方で、どのコンポーネントを利用するにしても、エントリポイントとなるスクリプトとカスタム要素コンポーネントで必ず2回以上のリクエストが必要となってしまいます。 現状は、利用頻度の高いコンポーネントは一つにバンドルして配信するようにして最適化を図っています。

このように適切な単位でモジュール化を実施することで、VueコンポーネントをVueアプリケーション上でも、カスタム要素としてHTML上でも利用でき、再利用性に配慮しています。

フォーム要素のカスタム要素化

Vue 3.5 以前においては、VueコンポーネントはShadow DOMでラップされて提供されていたことにより、前述したカスタム要素の「スタイルをコンポーネントと一緒に配布できる」という利点がありました。 一方で、HTMLフォームの取り扱いが難しいという問題がありました。

たとえば、<colorme-textarea> といったフォーム要素をカスタム要素として利用しようとしても、Shadow DOMによってDOMツリーが分断されていることから、テキストボックスの入力内容は送信されません。

<form action="https://colorme.test/path/to/submit">
  <colorme-textarea value="サンプルテキスト"></colorme-textarea>

  <!-- このボタンを押下しても colorme-textarea のvalueは送信されない -->
  <button type="submit">送信</button>
</form>

<colorme-button> のようなボタン要素を作成した場合も同様に、ボタンを押下してもフォームが送信されることはありません。

<form action="https://colorme.test/path/to/submit">
  <colorme-textarea></colorme-textarea>

  <!-- このボタンを押下しても何も起きない -->
  <colorme-button type="submit">送信</colorme-button>
</form>

Shadow DOMによって分断されたInput要素やボタンをフォームに「参加」させるには、たとえば HTMLElementのattachInternals() メソッドを利用できますが、Vue.jsのカスタム要素サポートにおいて、このDOM APIにアクセスする術はありませんでした。

Vue.js 3.5 以降は、Shadow DOMの利用を選択的にできるようになり、DOMツリーを分断しないという選択ができるようになりました。 このおかげでフォーム要素をカスタム要素化しやすくなりました。 (ただし、CSSをカスタム要素に同梱することはできないので、ビルド時に出力されるCSSを別途 <link> タグなどで読み込む必要があります。)

import { defineCustomElement } from 'vue'
import ColormeTextarea from './Components/ColormeTextarea.vue'
import ColormeButton from './Components/ColormeButton.vue'

customElements.define(
  'colorme-textarea',
  defineCustomElement(ColormeTextarea, {
    shadowRoot: false  // Shadow DOMを利用しない
  })
)

進化するVue.jsのカスタム要素サポートをフルに活用し、開発したVueコンポーネントをあらゆる場所で利用できるようにしています。

機能毎にパッケージを開発する

複数のUIコンポーネントが協調して動作したり、入力バリデーションの実施やWeb APIリクエストなどの機能固有のスクリプトを実装する必要がある場合は、前述したカスタム要素ライブラリとは独立したパッケージとして、機能ごとにフロントエンドアプリケーションを開発する手法を採用しています。

パッケージの構成

機能ごとに、共有すべき状態やコンポーネントをひとまとめにしたパッケージを作成します。

たとえば、サイドナビゲーションには現在表示しているページを登録するショートカット機能があり、ページ内の登録ボタンを押下することでショートカットリストに追加されます。

ショートカット登録ボタンを押すとサイドナビゲーション内のショートカットリストにアイテムが追加される

このような動作を実現するには、サイドナビゲーションとショートカット登録ボタンの間でリアクティブな状態を共有している必要があるため、両者のコンポーネントは同一のパッケージで提供します。

これらのパッケージは、以下のようなmonorepoの構成の下で開発をしています。 新しい機能を開発する際には、以下のディレクトリに対して新しいパッケージを追加することで対応します。

frontends
├── side-navigation-app (ショートカット機能付きサイドナビゲーション)
│   └── package.json
├── admin-components (管理画面Vueコンポーネントライブラリ)
│   └── package.json
├── inhouse-vue-components (再利用性の高いUIコンポーネントライブラリ)
│   └── package.json
└── vue-custom-elements-proxy (カスタム要素変換層 + (後述する) ページ毎のスクリプト配信パッケージ)
    └── package.json

(inhouse-vue-components という名前は、ペパボ共通基盤デザインシステム Inhouse から名前を借りています)

パッケージの単位をなるべく小さく、かつ共有すべき状態のスコープにあわせて決定することで、コードのメンテナンス性にも寄与すると考えています。

パッケージ間の依存関係は以下のようになっています。

カスタム要素変換層およびページごとのスクリプト配信パッケージが各パッケージを公開する

それぞれのパッケージは、前述したカスタム要素変換層に加え、後述するページごとのスクリプトをビルドする仕組みをもった vue-custom-elements-proxy パッケージを経由して公開します。 このパッケージが公開するJavaScriptを必要に応じて <script> タグでロードします。

離れたUIコンポーネント間の状態の共有

例に取り上げたショートカット機能つきサイドナビゲーションは、以下のように配置されています。

ショートカット登録ボタンとサイドナビゲーションがひとつのアプリケーションとして開発されているが、離れた位置に存在できている。また、カスタム要素変換層を経て提供されているコンポーネントも引き続き利用している。

これらのコンポーネントは以下のように記述すれば、状態を共有することができそうですが、<SideNavigation><ShortcutToggle> コンポーネントは同じDOMツリーの枝の中に存在しなければならないので、両者を離れた場所に設置することができません。

<script setup lang="ts">
import SideNavigation from './Components/SideNavigation.vue'
import ShortcutToggle from './Components/ShortcutToggle.vue'
import {
  useShortcut,
  shortcutStateKey,
  currentShortcutKey,
  updateShortcutKey
} from './Composables/useShortcut'
import { provide } from 'vue'
</script>

<template>
  <div>
    <SideNavigation :shortcuts="shortcutState" />
    <ShortcutToggle :current-shortcut="currentShortcut" :updator="updateShortcut"/>
  </div>
</template>

この問題を解決するために、<SideNavigation><ShortcutToggle> はそれぞれカスタム要素として公開しつつ、カスタム要素間の状態の共有は Provide/Inject を用いて、Vueのrefオブジェクトを共有して実現しました。

以下のように、サイドナビゲーションにはショートカットの一覧表示に必要なリストを、ショートカット登録解除ボタンには現在ページのショートカット登録状況および更新処理をProvideします。

import SideNavigation from './Components/SideNavigation.vue'
import ShortcutToggle from './Components/ShortcutToggle.vue'
import {
  useShortcut,
  shortcutStateKey,
  currentShortcutKey,
  updateShortcutKey
} from './Composables/useShortcut'

const currentPage = getCurrentPage()

// ショートカット機能を提供するVueコンポーザブル
const { shortcutState, currentShortcut, updateShortcut } = useShortcut(currentPage)

customElements.define(
  'side-navigation',
  defineCustomElement(SideNavigation, {
    configureApp(app) {
      // ショートカット一覧を描画するためにショートカットの状態を提供
      // Vueのrefオブジェクトを提供することで、一覧はショートカットの登録解除によって再レンダリングされる
      app.provide(shortcutStateKey, shortcutState)
    }
  })
)

customElements.define(
  'shortcut-toggle',
  defineCustomElement(ShortcutToggle, {
    configureApp(app) {
      // ショートカット登録解除ボタンは、いま表示しているページが登録済かどうかを注入する
      app.provide(currentShortcutKey, currentShortcut)
      app.provide(updateShortcutKey, updateShortcut)
    }
  })
)

なお、カスタム要素化したVueコンポーネントがProvideするコンテキストは、他のカスタム要素化したVueコンポーネントがInjectすることもできます。 たとえば、上記のコードは以下のように書き直すこともできます。

<!-- カスタム要素の配置 -->
<navigation-app-context>
  <side-navigation></side-navigation>
  <shortcut-toggle></shortcut-toggle>
</navigation-app-context>

このときのnavigation-app-contextに対応するVueコンポーネントは、以下のようになります。

<template>
  <div>
    <slot />
  </div>
</template>

<script setup lang="ts">
import { provide } from 'vue'
const currentPage = getCurrentPage()

// ショートカット機能を提供するVueコンポーザブル
const { shortcutState, currentShortcut, updateShortcut } = useShortcut(currentPage)

// side-navigation, shortcut-toggle が依存する状態を注入
provide(currentShortcutKey, currentShortcut)
provide(updateShortcutKey, updateShortcut)
</script>

カスタム要素化する際に各コンポーネントに依存を注入(Provide)することで、全体がVueコンポーネントとして実装されていなくても、DOMツリー上の離れた場所にあるコンポーネント間で協調した動作を実現できます。 また、上記の例の場合は useShortcut() コンポーザブルに機能を集約し、UIコンポーネントがそれを利用するという構造にしておくことで、UIに依存しないような機能の単体テストを記述しやすくなりました。

一方で、Provide / Inject による依存関係の注入はPropsによる状態のセットと比較して依存関係が見えにくく、かつ子孫コンポーネントも注入された状態を参照できるため、濫用すると依存関係の見通しが悪くなりそうです。 まだこの問題は顕在化していないものの、いずれは何らかの対策を考慮する必要性はありそうなので、今後の課題として対応を検討する必要性を感じています。

Viteを活用したパッケージのページへの埋め込みの自動化

開発した機能パッケージは、規約に基づきファイルを配置することで自動的にWebページにロードするようにしています。

前述のカスタム要素変換層パッケージのpathディレクトリ配下に、ページのパスに一致するディレクトリを準備し、さらにスクリプトのエントリポイントとなる index.ts を設置します。 たとえば、 /orders でルーティングされたページで提供するスクリプトは、以下のファイルツリーにおける frontends/vue-custom-elements-proxy/src/pages/path/ordersindex.ts を配置します。

(説明を簡単にするために、パスは架空のものを用いて説明しています)

frontends
├── side-navigation-app
├── colrome-admin-components
├── inhouse-vue-components
└── vue-custom-elements-proxy (カスタム要素変換層 + ページ毎のスクリプト配信パッケージ)
    ├── package.json
    └── src
        ├── pages
        │   └── path
        │       ├── orders (パスに対応したディレクトリ名)
        │       │   └── index.ts (ex. https://example.test/orders に対するスクリプト)
        │       ├── customers
        │       │   └── index.ts (ex. https://example.test/customers に対するスクリプト)
        │       └── shoppages
        │           └── index.ts (ex. https://example.test/shoppages に対するスクリプト)

path ディレクトリに対して、ページごとのエントリポイントをglob importで収集し、ViteのJavaScript APIを利用したスクリプトを実行することでビルドしています。

また、サーバアプリケーションとバンドルした成果物を統合するために、Viteのマニフェストファイル生成機能 を用いて作成し、さらにこれらの結果を統合した files.json を生成します。

import { readFileSync, writeFileSync } from 'fs'
import { glob } from 'glob'
import { join } from 'path'
import { build, type InlineConfig, type Manifest } from 'vite'
import vue from '@vitejs/plugin-vue'

const baseConfig = (entry: string, name: string): InlineConfig => ({
  configFile: false,
  mode: 'production',
  build: {
    outDir: `./dist/pages/paths/${name}`,
    manifest: true,
    lib: {
      entry,
      name: 'page',
      formats: ['umd']
    },
    rollupOptions: {
      output: {
        entryFileNames: '[name]-[hash].js'
      },
    }
  }
})

async function main() {
  const getEntries = async (): Promise<Record<string, string>> => {
    // (簡略化のためにパスの深さは1と仮定する)
    const files = await glob('src/pages/paths/*/index.ts')
    return files.reduce((acc: Record<string, string>, file) => {
      const name = file.split('/').slice(-2)[0]
      acc[name] = file
      return acc
    }, {})
  }

  const entries = await getEntries()
  
  for (const [name, entry] of Object.entries(entries)) {
    // ViteのJavaScript APIを用いてビルドする
    await build(baseConfig(entry, name))
  }

  // バンドルされた結果のファイル名とページのパスの対応関係を示すkey-valueを作成
  const fileMap = Object
    .keys(entries)
    .map(name => [name, `dist/pages/paths/${name}/.vite/manifest.json`])
    .reduce((acc, [name, manifestPath]) => {
      const content = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Manifest

      const entry = Object.values(content).filter(c => c.name === 'index')[0]

      acc[name] = {
        file: join(name, entry.file)
      }

      return acc
    }, {})

  writeFileSync('dist/files.json', JSON.stringify(fileMap))
}

main()

上記のビルドスクリプトを実行することで、次のようなファイルが出力されます。

frontends
├── side-navigation-app
├── colrome-admin-components
├── inhouse-vue-components
└── vue-custom-elements-proxy (カスタム要素変換層 + ページ毎のスクリプト配信パッケージ)
    └── dist 
        ├── files.json
        ├── pages
        │   └── path
        │       ├── orders
        │       │   └── index-a0b1c2.js
        │       ├── customers
        │       │   └── index-d3e4f5.js
        │       └── shoppages
        │           └── index-1f2d3a.js

これらのファイルの中から、URLのパスに対応するディレクトリ名にある index.js をロードします。

<!-- /orders にアクセスしたときに埋め込まれるスクリプトタグ -->
<script src="/path/to/dist/pages/path/orders/index-a0b1c2.js"></script>

このとき、ファイル名に含まれるハッシュ値 -a0b1c2 はスクリプトの内容に応じて変化します。 先ほど作成しておいた files.json を用いてサーバサイドで対応関係を確認し、src 属性の値を動的に決定します。

この files.json の内容は以下のようになっています。

{
  "orders": {
      "file":"orders/index-a0b1c2.js"
  },
  "customers": {
      "file":"customers/index-d3e4f5.js"
  },
  "shoppages": {
      "file":"shoppages/index-1f2d3a.js"
  }
}

ファイルのパス (たとえば /orders) をキーにして上記のJSONから index-a0b1c2.js を得ることができます。

このようなビルド環境を整備することで、規約で決めたディレクトリにファイルを配置しておけば、自動的にスクリプトをページに公開できるようになりました。

これらの方法の利点 / 欠点

これらの方法には、以下のような利点があると考えています。

  • Vue.jsのWeb Componentsサポートを活用することで、必要な箇所だけを段階的にVue.jsで実装できる
  • 機能ごとにパッケージ化することで、状態などのスコープが明確でコードをリーダブルにしやすい
  • コンポーネント間の依存関係が package.json を見れば明確になるので、管理しやすい

特に、必要に応じてVue.jsを選択的に利用できるので、継続的に実施しやすいところが私たちにとってメリットになっています。

一方で、以下のような欠点もあります。

  • カスタム要素間を連動させるための Provide / Inject による状態の注入は、状態のスコープを不必要に広げてしまうので何らかのガイドレールが必要
  • カスタム要素の変換層や、それをビルドするための複雑な仕組みが必要

前提として、すべての機能をVue.js上に載せるような開発が難しいということから、欠点を利点が補っていると現状では判断し、このような構成を試行しています。 仮に将来的にNuxt等に載せる決断をしたとしても、パッケージを機能ごとに分割していることでコードの見通しがよくなり、これまで開発したVueコンポーネント資産も活かしやすいのではないかと考えています。

まとめ

これらの手法により、冒頭に挙げた課題の解決(Vue.jsの活用によって得られるUIのコンポーネント化、宣言的UIによる状態の取り扱いの改善、Composablesを活用したロジックのテスタビリティ向上)をしやすくなりました。 かつ、これらは漸進的に取り組めるものであり、日々の開発に無理なく適用できています。

一方で、この取り組みは引き続き試行錯誤しながら方向性を模索中でもあります。

これらの取り組みが皆様の参考になればうれしいです。