JSXで実装するUIライブラリに依存しないコンポーネント 第1回 JSX記法を用いた実装と利用場面

このシリーズではJSX記法でDOMツリーを生成し、UIライブラリに依存しないコンポーネントの実装方法を解説します。まずは、そのメリットとデメリット、利用場面と考慮すべきポイントを解説します。

発行

著者 森 大典 フロントエンド・エンジニア
JSXで実装するUIライブラリに依存しないコンポーネント シリーズの記事一覧

はじめに

このシリーズでは、JSX記法でDOMツリーを生成し、ReactのようなUIライブラリに依存せず、コンポーネントベースの実装を行う方法を紹介します。

開発言語にはTypeScript、CSSのカプセル化にはCSS Modules、複数のモジュールを単一のファイルにバンドルするビルド処理にはesbuildを利用することとし、これらの方法についても解説します。

補足:JSX記法、TypeScript、CSS Modules、esbuild

JSX記法、TypeScript、CSS Modules、esbuildの個々の技術については、このシリーズではあらためて解説しません。記事はコードを読んでいただければ、理解できるように構成されていますが、それぞれの要素技術について基礎を押さえたい方は、次の記事やシリーズを参考にしてください。

第1回では、JSXでDOMを生成できると、どのような実装が可能になるのかを確認します。また、コンポーネント構成で実装し、UIライブラリの代替として使用する場合のメリットとデメリットを考えます。

DOMツリー生成のシンタックスシュガー

JavaScriptでDOMツリーを生成する方法というと、createElementappendChildを使ったコードを思い浮かべるのではないでしょうか? たとえば、次のデモは、ボタンクリックでカウントアップするという単純なものです。

このデモを、createElementappendChildを使って実装してみます。

index.ts

const counterWidget = () => {
  let count = 0;

  const button = document.createElement('button');
  button.textContent = count.toString();
  button.addEventListener('click', (event: Event) => {
    count++;
    (event.target as HTMLButtonElement).textContent = count.toString();
  });

  const div = document.createElement('div');
  div.classList.add('counter-widget');
  div.appendChild(button);

  return div;
};

const dom = counterWidget();
document.querySelector('#counterWidget')?.appendChild(dom);

HTMLの要素階層としては、div > buttonと単純ですが、イベントハンドラをはじめとしたUIに必要な処置が混在していることもあり、UIの全体構造は読み取りづらくなっています。

対して、JSX記法でDOMを生成できるようになると、上記のコードを次のように書き換えることができます。

index.tsx

import { h } from '@/h';

const counterWidget = () => {
  let count = 0;
  return (
    <div class='counter-widget'>
      <button
        onClick={(event: Event) => {
          count++;
          (event.target as HTMLButtonElement).textContent = count.toString();
        }}
      >
        {count}
      </button>
    </div>
  );
};
const dom = counterWidget();
document.querySelector('#counterWidget')?.appendChild(dom);

煩雑でUIの構造が読み取りずらかったコードが、JSXにより、シンプルで見やすいコードになりました。このように、JSXでDOMを生成できるようになると、JSXがDOMツリーを生成するコードのシンタックスシュガーになるという効果をもたらします。

コンポーネントとしての利用

しかし、昨今のフロントエンドの開発おいて、createElementappendChildを使う機会は少なくなったのではないでしょうか。ピクセルグリッドにおいても案件のプロジェクトでは、React、Vue、Svelteなど、なにかしらのUIライブラリに依存し、そのライブラリの記法に沿ってコンポーネント構成で実装を組み立てていくことがほとんどです。

一方で、これらのUIライブラリの中には、JSXでコンポーネントを記述するものもあります。もし、先ほどのDOMを生成するJSXでも、コンポーネントの基本動作や実装構成を再現できたなら、JSXの知識のみで、UIライブラリに依存しないコンポーネントの実装が可能になるのではないでしょうか?

そこで、コンポーネントの基本動作や実装構成のポイントを考えてみます。筆者は次の2点ではないかと考えます。

スタイル・イベントハンドラのカプセル化

モジュールファイルが果たす役割の1つに、変数や関数のカプセル化があります。コンポーネントもモジュールファイルではありますが、コンポーネントの場合は、UIの構成要素に適用するスタイル、あるいは、イベントハンドラから参照される変数や関数もカプセル化された状態である必要があります。しかし、これらの処理は、ブラウザによるドキュメントツリーのパースを経て解釈されるため、カプセル化の実現にはなにかしらの工夫が必要になるでしょう。

その具体的な手段として、CSS Modulesのようなライブラリを使ったり、モジュール内で生成したDOMにaddEventListenerでイベントハンドラを割り当てる方法が考えられます。

HTML的なUI定義

UI構成をHTML的な表現で定義できるのは、ReactのようなUIライブラリの標準的な機能といえます。この機能は、単にJavaScript内にHTMLを記述できるだけでなく、コンポーネントをHTMLのタグのように表現でき、引数を受け渡すことができます。この記述ができなくてもコンポーネント構成の実装は可能ですが、コードの保守性の観点から必要な機能といえるでしょう。

このような実装については、JSX記法とJSXの変換処理を組み合わせることで実現できます。

コンポーネントの実装例

前節で述べたとおり、CSS ModulesとJSXのDOM変換で、コンポーネント構成の実装は実現できます。ではそれらを使いコンポーネントベースの実装をしたとき、どのようなコードになるのかを、先ほどのカウントアップ機能の実装をコンポーネント化して確認してみましょう。

エントリーポイント

まずは、アプリケーションのエントリーポイントとなる、index.tsから見てみます。

index.ts

import { h } from '@/h';
import { App } from './App';

document.querySelector('#counterWidget')?.appendChild(<App />);

メイン実装をAppコンポーネントに切り出し、<App/>というタグ形式でコンポーネントを呼び出しています。JSXであるこの記述はDOMに変換されるため、appendChildでページ内に挿入することができています。

Appコンポーネント

Appコンポーネントは、次のような実装になっています。

App.tsx

import styles from './App.module.css';
import { h } from '@/h';
import { CountButton } from './CountButton';

export const App = () => {
  let count = 0;
  return (
    <div class={styles.App}>
      <CountButton
        count={count}
        onClick={(event: Event) => {
          count++;
          (event.target as HTMLButtonElement).textContent = count.toString();
        }}
      />
    </div>
  );
};

もともとのコードでは、button要素に対してイベントハンドラを割り当てていましたが、このコードではCountButtonという名前でボタンUIをコンポーネント化し、カウント数とイベントハンドラを属性指定の引数で渡すようにしています。

また、ルート要素には<div class={styles.App}>という記述で、CSS Modulesで定義したスタイルを適用しています。Reactの場合、className属性を使ってスタイルを適用しますが、HTMLの属性名と同じclass属性でスタイルを適用できます。

スタイルを定義しているApp.module.cssは、次のようになっています。

App.module.css

.App {
  margin: 20px;
  padding: 20px;
  text-align: center;
  border: solid 1px silver;
}

普通のCSSファイルと同じようにスタイルを定義しているだけですが、CSS Modulesにより、内部的に適用されるクラス名が自動生成され、他のスタイルとの競合を避けることができます。

CountButtonコンポーネント

CountButtonコンポーネントは、次のような実装になっています。

CountButton.tsx

import { h } from '@/h';
import styles from './CountButton.module.css';

export const CountButton = ({
  count,
  onClick,
}: {
  count: number;
  onClick: (event: Event) => void;
}) => (
  <button class={styles.CountButton} onClick={onClick}>
    {count}
  </button>
);

引数で受け取ったカウント数とイベントハンドラを、ボタン要素に割り当てているだけのシンプルなコンポーネントです。スタイルはCountButton.module.cssで定義しています。

CountButton.module.css

.CountButton {
  background-color: #007bff;
  border: none;
  color: white;
  padding: 10px 20px;
  margin: 5px;
  border-radius: 5px;
  cursor: pointer;
  font-size: 30px;
  min-width: 100px;
}
.CountButton:focus {
  outline: thick double #32a1ce;
}

実装上のポイント

上記のようにコンポーネント化自体は、特に難しさはないことがわかります。しかし、実装上のポイントとなるのは、状態データの変更をいかにしてビューに反映させるか、という点です。

今回の例では、ボタンクリックがカウントデータの変更のきっかけであり、ビューの更新範囲はそのボタン自体のみで済んでいます。そのため、次のようにイベントハンドラ内にて更新対象であるDOMにアクセスすることができました。

ボタンクリック時のイベントハンドラ

<CountButton
  count={count}
  onClick={(event: Event) => {
    count++;
    // event.targetに更新対象であるボタン要素が格納されている
    (event.target as HTMLButtonElement).textContent = count.toString();
  }}
/>

しかし、もし、カウントデータの参照が他のコンポーネントにまで及んでいた場合はどうでしょう? そのような場合、カウントデータの更新後に、関係するコンポーネントを再描画するなど実装上の工夫が必要になるでしょう*。

*補足:状態データの管理とビューの更新

保守性を考慮した状態データの管理とビュー更新は、リアクティブが使えるReactやVueなどのUIライブラリにおいても共通の課題です。次の記事でも、この課題を解決するための設計思想について解説しているので参考にしてみてください。

想定する利用場面と考慮すべきポイント

前節で述べたようにJSXを使えるUIライブラリが存在するなかで、あえてそれらを使わずに、独自にJSXをDOMに変換する実装を選択する理由は何でしょうか。

理由の1つとして、カレンダーやギャラリーのような特定のUIに特化したライブラリを、ReactのようなUIライブラリに依存せずにコンポーネント構成で実装したい、といったケースなどが考えられるでしょう。

筆者が採用したケース

筆者の場合であれば、担当案件において次のような課題を解決するために、UIライブラリを使わず、JSXをDOMに変換する方法を採用しました。

  • バンドルファイルのサイズを極力小さくしたい
  • 依存するUIライブラリのアップデートに追われたくない(コラム参照)
  • 既存サイトに存在するUIライブラリとの競合リスクを避けたい

この案件は、サービスの契約先の既成サイトに対し、ピクセルグリッドが開発した複数のウィジェットを選択・挿入できるようにするというもので、その用途の特性上、上記のような課題が生じました。そこでUIライブラリの依存をやめることで、上記課題を解決するというメリットを享受しつつ、JSXのDOM変換とCSS Modulesでコンポーネント構成の実装を可能にしました。

また、この案件ではウィジェットを数多く作る必要があるものの、1つ1つはそこまで複雑な実装にはならないという判断があったことも、UIライブラリの依存をやめる選択をした大きな理由の1つでした。

UIライブラリのアップデートの追随問題

UIライブラリには、学習コストを払う対価として、そのライブラリが提供する機能を利用できるというメリットがあります。しかし、パラダイムシフトを背景としたライブラリの開発停止や、プロジェクト開発の長期停滞時に破壊的変更を含むライブラリのアップデートが発生することもあります。

このような自体に遭遇したとき、新たなUIライブラリへの切り替えの検討や、アップデートの追随とそれに伴う変更作業が生じることを、考慮しておく必要があるでしょう。無論、これらの対応にはそれなりのコストがかかります。そのため、ピクセルグリッドで扱う案件でも実装的には対応可能であっても、プロジェクトのさまざまな背景から当時このコストをかけられず、最新環境でのプロジェクト進行が困難になっている事例もあります。

UIライブラリに限った話ではないですが、外部パッケージへの依存は常にこのようなリスクと抱き合わせであると認識し、取捨選択を適切に行うことが大切であると言えるでしょう。

リアクティブの利用を諦めるデメリットの考慮

しかし、UIライブラリに依存しないということは同時に、リアクティブの利用を諦めデータ変更のビュー反映を自前で行う必要があるということを意味します。データ変更をビューに反映する実装アイディアについては、シリーズ中でも紹介しますが、UIライブラリを用いた実装と比べたら手間が増えることには変わりありません。

また、JSX自体の記述はUIライブラリ利用時と外見上は変わりませんが、内部的にはDOMに変換されている点を開発者は留意しておく必要があります。リアクティブなアプローチでビューの更新ができないため、場合によっては特定のDOMを直接操作する必要もでてくるでしょう。これは、内部の動作を詳細に理解せずとも利用できるUIライブラリとは、良くも悪くも異なるアプローチが求められる点です。

つまりは、上記で述べたようなメリットとデメリットをトレードオフできるかが、利用場面の選定として重要であるといえるでしょう。

まとめ

今回は、JSXをDOMツリー生成のシンタックスシュガーとして利用できること、UIライブラリの代替とすることのメリットとデメリットについて紹介しました。

次回以降、具体的なビルド環境の構築方法や、CSS Modulesの導入方法、JSXをDOMに変換する実装について解説していきますが、これらは基本的に、既成のライブラリと簡単な実装の組み合わせで構成されたもので、特別なものではありません。

そのため、UIライブラリの代替とするには、UIライブラリが提供する機能を補う実装アイディアや、実装設計上の工夫を自身で行う必要があります。シリーズでは筆者が採用している実装アイディアも併せて紹介していく予定ですが、それらを参考にしつつ、自身のプロジェクトに適した実装方法を見つけていただければと思います。