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

Vue.jsで作ったCustomElementの永続的な状態を包括的に管理する

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

カラーミーショップでは、Vue.jsのSingle File Componentで構成されるUIライブラリを、一部のページでカスタム要素化して利用しています。 本記事では、このようなカスタム要素に対して永続的な状態をどのように管理すればよいかを検討した事例をご紹介します。

保持した状態に基づきカスタム要素の挙動を変更したい

以下のような「閉じたらずっと隠したいキャンペーン要素」を開発したいとします。

閉じるボタンとともに表示されるキャンペーン

このとき、「閉じた」という状態をどこかに記録する必要があります。状態をクライアント側でのみ利用する場合は、クライアント側のストレージ領域(LocalStorageやSessionStorage)を活用することが候補の一つに挙げられることがあります。

しかし、このコンポーネントが汎用的であると考えた場合、以下のように異なるユースケースごとに永続化に関する処理を変更したい場合がありそうです。

  • キャンペーンは常に表示しておく必要があるので閉じるボタンは表示しない(ストレージ領域は不要)
  • 閉じたときにサーバ側で何らかの状態遷移を伴うようにするためにサーバ側のストレージ領域を使いたい

汎用的であるが故にユースケースにバリエーションがあるので、状態の管理はコンポーネントから分離できたほうが使い勝手がよさそうです。

また、LocalStorageを取り扱う場合には、例えば以下に示すような書き込みや削除に関する知識を集約すると見通しが良くなりそうです。

  • 状態のライフサイクル(いつ書き込み、いつ削除するか。LocalStorageそのものには状態の寿命を管理する機構はない。)
  • 状態のスコープ(同一ブラウザからのアクセスにおいても、ログインユーザー毎に状態を分離したいなどのケースがある。)

上記に挙げたように、永続化状態の保存に関する知識をUIコンポーネントから分離することでUIコンポーネントの汎用性を担保しつつ、ストレージ領域の読み書きに関する知識を集約することで処理の見通しを良くする必要がありました。

v-modelのインタフェースを通じて状態を管理する

Vue.jsのv-modelは、属性値(Props)に対応する更新イベントを発行(update:modelValueのEmit)することで、状態の更新を要求できます。 v-modelで状態をバインドしたときは、その状態は発行されたイベントのハンドリング処理によって変更されます。

また、 defineCustomElement() APIでカスタム要素化したコンポーネントは、VueコンポーネントでEmitしたイベントをカスタムイベントとしてカスタム要素の外側に発行します。

このインタフェースや仕組みを利用し、カスタム要素が発行するv-modelの値変更イベントを購読し、クライアント側のストレージ領域と同期する小さいアプリケーションを作りました。

アプリケーションのイメージ

まずは、予め状態を管理したいコンポーネントの名称および、Propsとストレージの簡易なスキーマ(属性名、属性の型)を宣言します。

import { vueCustomElementRule } from "./store/VueCustomElementRule"

// ユーザーIDごとに状態を分割するためのキーを生成する
const storageKeySuffix = `uid-${userId}`

// 状態管理の必要なコンポーネントの宣言
const config = {
  // (vueCustomElementRule() は `bindAll()` が必要なオブジェクトを生成する関数。実装は省略)
  'colorme-campaign': vueCustomElementRule({
    closed: {                       // closed Propsの状態を保存
      type: 'boolean',              // closed PropsはBoolean型
      initialValue: false           // 初期値(ストレージが空のとき)はfalse
    }
  }),
}

// ページ内の状態管理の必要な要素を探してバインドする
bindAll(config, storageKeySuffix)

以下のように、状態を管理したいカスタム要素に対して data-key 属性を付与しておきます。 ストレージのキーは storageKeySuffixdata-key 属性値によって決定されるので、ログインユーザー毎に状態を管理できます。

<colorme-campaign data-key="campaign-page">キャンペーン実施中!</colorme-campaign>

bindAll 関数が data-key 属性を持つ上記のカスタム要素を見つけ出して、以下のようにカスタム要素のPropsとアプリケーションが管理する状態を同期します。

  • クライアント側のストレージから値をリストアし、定義された状態の型と一致することをバリデーションした上でカスタム要素の属性値(=Vue Props)に反映
  • ストレージにリストアすべき値が存在しなければ initialValue を状態としてセットする
  • カスタム要素のイベント update:modelValue を購読し、イベントに含まれる更新値を自身の状態と同期するイベントハンドラを登録
  • イベントハンドラで状態をストレージに書き込み、かつカスタム要素の属性値を更新

v-modelをインタフェースに状態を管理することの副次的な効果として、UIコンポーネントのインタフェース設計にv-modelが活用されることで、Vueコンポーネントにおける「ふつうの状態のやりとり」を自然と実現できるようになると考えています。

ストレージとVue Propsの不一致に気づけるようにする

Vueコンポーネントの改修によって、ストレージの状態とPropsの定義が不整合な状態になるリスクがあります。

開発したアプリケーションでは、VueのProps型とストレージのスキーマが一致することをチェックしています。先ほどのスキーマの宣言にその差分を加えます。

  import { vueCustomElementRule } from "./store/VueCustomElementRule"
+ import type { ColormeCampaignProps } from "@colorme/components"
  
  const storageKeySuffix = `uid-${userId}`
  const config = {
-   'colorme-campaign': ({
+   'colorme-campaign': vueCustomElementRule<ColormeCampaignProps>({
      closed: {
        type: 'boolean',
        initialValue: false
      }
    }),
  }
  bindAll(config, storageKeySuffix)

vueCustomElementRule では attributeTypes に渡されるストレージのスキーマが VuePropsToAttributeSchema<T> 型で制約されています。

type AttributeSchemaType = 'string' | 'boolean' | 'number'

type VueProps = {
  [key: string]: ConvertSchemaToType<AttributeSchemaType>
}

export const vueCustomElementRule = <T extends VueProps>
(
  attributeTypes: VuePropsToAttributeSchema<T>
): Rule => ({
  // ... (Ruleに定義された bindAll() に必要な処理がここに入る) ...
})

VuePropsToAttributeSchema<T> は、 T 型(Vue Propsの型)をストレージのスキーマ型に変換する型です。

type VuePropsToAttributeSchema<T> = {
  [key in keyof T]:
    Exclude<T[key], undefined> extends string ? AttributeSchema<'string'> :
    Exclude<T[key], undefined> extends boolean ? AttributeSchema<'boolean'> :
    Exclude<T[key], undefined> extends number ? AttributeSchema<'number'> :
    never
}

type AttributeSchema<T extends AttributeSchemaType> = {
  type: T
  initialValue: ConvertSchemaToType<T>
}

type ConvertSchemaToType<T extends AttributeSchemaType> =
  T extends 'string' ? string :
  T extends 'boolean' ? boolean :
  T extends 'number' ? number :
  never

状態のスキーマから決定される型と、Vue Propsの型が一致していることが引数の制約になっているので、もしどちらかが変更されればTypeErrorとなり、ビルド時に気付くことができます。

まとめ

v-modelのインタフェースに則り状態を管理する小さなアプリケーションを作成したことで、UIコンポーネントライブラリから状態を切り離すことができました。 今後も可搬性の高いUIコンポーネントライブラリを目指すことで、結果としてカラーミーショップのユーザー体験をさらに向上できればと思っています。