RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

フレームワークに依存しない UI コンポーネントライブラリを Stencil.js で構築した話

はじめに

ラクスルの印刷事業部でエンジニアをしている西元です。

私の所属するチームでは少し前に Canva のデザインを使ってラクスルで印刷する機能 をリリースしました。

同じチームの取り組みとして、すでに公開されている「ラクスルのデザイン制作基盤における技術選定と開発の裏側」という記事がありますので、ぜひご覧ください。

今回はその中でも「フレームワークに依存しない UI コンポーネントライブラリをどう作ったか」をテーマに紹介します。

ラクスルの現状と課題

ラクスルでは複数の EC サイトを展開しており、それぞれが Vue や React といった異なる技術スタックで開発・運用されています。 Canva のデザインを使って注文を作成する機能をさまざまな商材に展開していくために、プロジェクト初期から各 EC での導入を簡単にする UI コンポーネントの提供を考えていました。

当初は「まず今一番使われている Vue で社内用の Web UI コンポーネントライブラリ を作って配布しよう」という方針で検討していました。

社内には Web UI コンポーネントライブラリ を 「UIKit」 として npm package で配布している例が複数あり、各プロダクトで活用されています。 しかし、ある機能を追加しようとするとフレームワークごとに実装する必要があるという課題がありました。

さらに、ラクスルでは自社のプロダクトを育てながらシナジーのある会社の M&A も行うという方針のため、利用技術の幅は今後も広がっていくことが予想されます。

日頃から複数の技術スタックを跨いで開発・保守しているメンバーからすると、フレームワークが増えるごとに各フレームワークの対応・メンテナンスをしていくのが現実的なのか、という不安がありました。

そこで視点を変えて、「そもそもフレームワークに依存しない、Web 標準技術で UI コンポーネントを作れないか」 という方向で検討を進めることにしました。

Web Components と Stencil.js の採用

チーム内で検討したところ、「Web Components はどうだろう」という案が出ました。

Web Components は、Web 標準技術の仕組みだけで動作する フレームワーク非依存のコンポーネント を作成できる技術です。

社内で利用されていない技術でしたが、フレームワークに依存しないという点が魅力的に感じ、まずは調査してみることにしました。

Stencil.js

Stencil.js は、Ionic Framework の開発チームが作った Web Components のコンパイラです。TypeScript + JSX でモダンに書けて、最終的には純粋な Web Components として出力されます。

@Component({
  tag: 'base-button',
  shadow: true
})
export class BaseButton {
  /**
   * Button variant
   */
  @Prop() variant: 'primary' | 'secondary' = 'primary';

  render() {
    return (
      <button class={`button button--${this.variant}`}>
        <slot />
      </button>
    );
  }
}

このコンポーネントは、どのフレームワークからも同じように呼び出して使うことができます。

<base-button variant="primary">クリック</base-button>

採用の決め手

  • TypeScript + JSX で書けるため、学習コストが低い
  • 最終成果物は純粋な Web Components で、フレームワークに依存しない
  • Ionic Framework での実績があり、公式ドキュメントも充実している

調査を通して上記の利点が見えてきました。慎重に進めるべく、チーム外のフロントエンドメンバーとも検討を重ね、採用を決めました。

アーキテクチャと実装

今回のプロジェクトでは、「各導入先 EC に用意されたボタンが押されたらモーダルを開き、モーダル内で Canva との連携を完結させる」というユースケースだったため、次のような階層構造をとりました。

Canva 連携用モーダル
  └── 各種ビューコンポーネント
        ├── 連携導入画面
        ├── デザイン一覧画面
        └── ページ選択画面

導入先 EC では「Canva 連携用モーダル」を UIKit から呼び出すことで、簡単にコンポーネントを利用できます。

実際の利用例

Vanilla JavaScript / TypeScript から使う

まず、UIKit のスクリプトを読み込みます。

<script type="module" src="/path/to/components.js"></script>

その後、通常の DOM 操作と同じようにコンポーネントを操作できます。

const modal = document.querySelector('canva-modal') as any;

// モーダルを開く
await modal.open();

// イベントリスナー登録
modal.addEventListener('design-selected', (event: CustomEvent) => {
  console.log('Design selected:', event.detail);
});

Vue から使う

<template>
  <div>
    <button @click="openModal">Canva デザインを選択</button>

    <canva-modal
      ref="canvaModal"
      @design-selected="handleDesignSelected"
    />
  </div>
</template>

<script lang="ts">
export default {
  methods: {
    async openModal() {
      await this.$refs.canvaModal?.open();
    },
    handleDesignSelected(event) {
      console.log('Design selected:', event.detail);
    }
  }
}
</script>

上記の例は、どちらも「モーダルを開いてデザイン選択イベントを受け取る」という同じ処理を、Vanilla JS と Vue でそれぞれ実装したものです。フレームワークが異なっても、ほぼ同じ書き方でコンポーネントを操作できています。

アセット管理

Stencil.js でアセット (画像・アイコンなど) を扱う際、いくつかポイントがあったので紹介します。

コンポーネント配下にアセットを配置

ロゴやアイコンなどのコンポーネント固有アセットは、コンポーネント配下の assets/ ディレクトリに集約しました。「このコンポーネントを削除すると、このアセットも一緒に消える」というスコープにしておくことで、不要ファイルが残りにくくなります。

assetsDirs でビルド対象を明示

@Component デコレーターの assetsDirs オプションで、ビルド対象のアセットディレクトリを明示します。

@Component({
  tag: 'base-modal',
  styleUrl: 'base-modal.css',
  shadow: true,
  assetsDirs: ['assets']
})
export class BaseModal {}

これを書いておくことで、Stencil.js が distdist-custom-elements へビルドする際に、assets/ 配下のファイルも一緒にコピーしてくれます。

getAssetPath でパスを解決

実際の <img> 参照は素のパスではなく、getAssetPath を経由します。

import { getAssetPath } from '@stencil/core'

<img
  src={getAssetPath('./assets/icon-close.svg')}
  alt=""
  class="close-icon"
/>

開発環境では ./assets/... という相対パスでも動作しますが、UIKit として別アプリから読み込んだときにパスがずれてしまい、アイコンだけ 404 になるという問題が起きました。

公式ドキュメントにも記載がありますが、getAssetPath を使うことで、Stencil.js が「今のビルドターゲットに応じた正しい URL」を計算してくれるようになりました。

良かった点

モダンなフレームワーク経験をそのまま活かせた

TypeScript + JSX のスタイルは React や Vue に慣れているエンジニアにとって読みやすく、キャッチアップがスムーズでした。

Shadow DOM によるスタイルの分離

Shadow DOM を使っているので、コンポーネント内のクラス名や CSS は導入側へ影響を及ぼすことがありません。そのため、既存のアプリケーションへ組み込んでいてもスタイル衝突を気にせず済みました。

コンポーネント名だけはグローバルなので、実際には UIKit 特有のプレフィックスをつけ、導入側と干渉することのないようにしています。

Vanilla JavaScript 環境にも素直に導入できた

最終的に出てくるのは単なるカスタム要素なので、スクリプトを読み込んでタグを書くだけで、Vanilla JS 上にも載せることができました。また、必要なコンポーネントだけを指定して読み込むことができる点も魅力です。

「モーダル内でロジック完結」という設計と相性が良かった

今回のユースケースでは、次のような一連のフローを モーダルの中で完結させたい という要件がありました。

  • Canva との連携状態の確認
  • デザイン一覧の取得
  • ページ選択やエクスポート

Stencil.js のコンポーネントはライフサイクルと状態管理が備わっているので、このような「1 コンポーネントの中にロジックを閉じ込める」設計と非常に相性が良かったです。

ドキュメントが充実していて AI フレンドリーだった

Stencil.js の公式ドキュメントはサンプルコードやベストプラクティスが整理されており、人間が読むのはもちろん、AI に依頼したときにも一貫したコードを生成しやすいというメリットがありました。

困った点

今回のプロジェクトでは Web Components、Stencil.js を使ったことでの大きなデメリットはありませんでした。 一部ビルド周りの環境が古いところでは想定していた方法でそのまま導入は難しい点がありましたが、読み込み部分を調整すればモーダルの導入自体は他と変わらず簡単に行えました。

どんなプロダクトに向いているか

今回のようにモーダルなど小さなコンポーネントの中でロジックが完結できる場合は、Web Components は良い選択肢の 1 つです。

ラクスルのように Vue、React など複数のフレームワークを使っていて、同じ小さいコンポーネントをさまざまな箇所で使いたいという場合、Web Components の良さが発揮されます。

大きなコンポーネントだとコンポーネント間の状態共有が複雑になったり、フレームワークを利用した方が良い場合もあるので、今後の機能拡張の予測も踏まえて導入を検討されると良いのではないかと考えています。

まとめ

当初自分自身は「まずは Vue で作って、必要に応じてフレームワーク版も実装していこう」と考えていました。しかし、チーム内外で相談することで、より良い方法が見つかり、長期的な目的にあった技術選定ができました。

また、初めて UIKit のリポジトリ設計を議論したり、Web Components の利点、フレームワークの恩恵を感じることができ、とても学びの多いプロジェクトとなりました。 今後もチームで協力しながらより良い方法でお客様に価値を届けていきたいです。

小さい共通コンポーネントを作りたいけれど複数フレームワークの保守が発生しそうという場合には、一度 Web Components を検討してみてください。

参考リンク