【レガシー環境向け】PHP + ViteのWeb開発で実践しているTypeScript・SCSS・TSXの設計戦略

約28分

はじめに

弊社では、ポータルサイトを中心に、WebアプリケーションやWebサイトの開発を行っています。

主に以下のような技術構成を採用しています。

  • PHP
  • TypeScript / JavaScript
  • SCSS
  • Vite

ReactやVueなどのフロントエンドフレームワークを全面的に採用しているわけではなく、HTML・CSS・JavaScriptを中心にUIを実装する場面も多くあります。

本記事では、こうしたレガシー寄りの開発環境において、普段自分が意識しているTypeScript・SCSS・TSXの設計思想や構成について紹介します。

同じような技術構成で開発されている方にとって、少しでも設計の参考になれば幸いです。

なぜこの記事を書こうと思ったのか

CSS設計には、BEMやFLOCSSなど、さまざまな設計手法があり、参考になる記事も多く公開されています。

一方で、TypeScriptの設計について調べてみると、ReactやNext.jsなどのフレームワークを前提としたディレクトリ構成や設計手法の記事が多く、フレームワークに依存しない環境での設計については、あまり情報が見つからない印象がありました。

そこで今回は、普段の開発で自分が意識しているTypeScript・SCSSの設計思想や構成をまとめてみようと思いました。

同じような環境で開発されている方にとって、少しでも参考になればうれしいです。

また、あくまで自分なりの設計方針なので、改善できそうな点や、よりよい設計方法などがあれば、ぜひコメントでフィードバックやアドバイスをいただけると幸いです。

設計するうえで大切にしていること

ディレクトリ単位でCSS・Scriptを管理する

以前、プロジェクトにアサインされた際に、どのSCSSファイルやTypeScriptファイルを編集すればよいのかわからず、コードを探すところから始めなければならないことがありました。

そこで現在は、PHPのテンプレートディレクトリ名とSCSS・TypeScriptのファイル名を対応させることを基本方針としています。

templates/
└── shop/
    ├── detail.php
    └── coupon.php

対応するSCSS・TypeScriptは次のように管理します。

resources/
├── ts/
│   └── shop.ts
└── scss/
    └── shop.scss

このように命名規則を統一することで、shop配下のページを修正する際は、基本的にshop.scssとshop.tsを確認すればよいことがわかります。

責務をテンプレートディレクトリ単位で分ける

SCSSやTypeScriptの責務は、基本的にPHPのテンプレートディレクトリ単位で分けています。

例えば、detail.phpとcoupon.phpはどちらもshopに属するため、同じshop.scssとshop.tsで管理します。

もちろん、PHPファイルごとにCSS・Scriptを分割する方法もありますが、細かく分けすぎると管理するファイルが増え、かえって構成が複雑になることもあります。

そのため、現時点では細かく分割すること自体を目的にせず、管理しやすい単位で責務を分けることを意識しています。

共通処理や独立した機能は別のモジュールとして切り出し、エントリーポイントから必要に応じて読み込みます。ページ固有の処理が大きくなった場合には、個別のエントリーポイントを追加することもあります。

全体のディレクトリ構成

PHP・TypeScript・SCSSの対応関係

プロジェクトでは、PHPのテンプレートをtemplates、TypeScript・SCSSなどのフロントエンド資産をresourcesディレクトリで管理しています。

project/
├── templates/
│   ├── shop/
│   │   ├── detail.php
│   │   └── coupon.php
│   └── user/
│       ├── index.php
│       └── profile.php
├── resources/
│   ├── ts/
│   │   ├── modules/
│   │   ├── components/
│   │   ├── library/
│   │   ├── api/
│   │   ├── hooks/
│   │   ├── types/
│   │   ├── shop.ts
│   │   └── user.ts
│   └── scss/
│       ├── components/
│       ├── modules/
│       ├── shop.scss
│       └── user.scss
├── webroot/
│   ├── css/
│   └── js/
└── vite.config.ts

※ 説明用に簡略化した構成です。api・hooks・typesなどは必要に応じて設けています。

基本的には、templates配下のディレクトリ名と、resources配下のTypeScript・SCSSのエントリーポイント名を一致させ、対応関係を把握しやすくしています。

ViteによるビルドとPHPからの読み込み

TypeScript・SCSSはresourcesディレクトリで管理し、vite.config.tsの設定に基づいてビルドしています。

ビルドによって生成されたCSS・JavaScriptファイルはwebroot配下に出力し、PHP側からlinkタグやscriptタグで読み込みます。Viteの具体的な役割については後ほど紹介します。

TypeScriptの設計

ディレクトリ構成と役割

TypeScriptでは、処理の責務に応じてディレクトリを分けています。

resources/
└── ts/
    ├── modules/
    │   └── TextCount.ts
    ├── library/
    │   ├── _common.ts
    │   └── _form.ts
    ├── components/
    │   └── KeepHistory/
    │       ├── index.tsx
    │       ├── KeepHistory.tsx
    │       ├── Skeleton.tsx
    │       └── Error.tsx
    ├── api/
    │   └── history.ts
    ├── hooks/
    │   └── useHistory.ts
    ├── types/
    │   └── history.ts
    ├── shop.ts
    └── index.ts

ディレクトリ

役割

modules/

複数ページで再利用する機能やクラスを管理

library/

共通機能の呼び出しや初期化処理を管理

components/

TSXを使用したReactコンポーネントを管理

api/

APIへの通信処理

hooks/

Reactのカスタムフック

types/

複数ファイルで使用する型定義

shop.tsなど

ページ単位のエントリーポイント

すべてのプロジェクトでこれらのディレクトリを設けているわけではなく、規模や機能に応じて必要な責務を切り出しています。

modulesの設計

modulesディレクトリでは、複数ページで再利用する機能をクラスとして管理しています。例えば、フォームの文字数をカウントするTextCountクラスなどです。

なぜクラスを採用しているのか

主な理由は、処理の共通化と、処理対象となるDOMのスコープを柔軟に指定できることです。

ページごとに同じ機能を実装すると、処理が重複したり、実装方法にばらつきが生まれたりする可能性があります。クラスとして共通化することで、実装を統一し、仕様変更にも対応しやすくなります。

また、コンストラクタでDOMを受け取れば、その要素内だけに処理を適用できます。

// modules/TextCount.ts
export class TextCount {
  elm: HTMLElement;
  input: HTMLInputElement | HTMLTextAreaElement | null;
  countElm: HTMLElement | null;
  maxLength: number;
  minLength: number;

  constructor(elm: HTMLElement) {
    this.elm = elm;
    this.input = elm.querySelector<HTMLInputElement | HTMLTextAreaElement>(
      'textarea, input[type="text"], input[type="password"]'
    );

    const countField = elm.querySelector<HTMLElement>('.js-countField');
    this.countElm = countField?.querySelector('span') ?? null;
    this.maxLength = Number(countField?.dataset.max ?? 0);
    this.minLength = Number(countField?.dataset.min ?? 0);
  }

  init() {
    if (!this.input || !this.countElm) return;

    this.countFunc();
    this.input.addEventListener('input', () => this.countFunc());
  }

  countFunc() {
    if (!this.input || !this.countElm) return;

    const count = this.input.value.length;
    this.countElm.textContent = `${count} / ${this.maxLength}`;

    const isError =
      count > this.maxLength ||
      (count < this.minLength && count > 0);

    this.countElm.classList.toggle('-error', isError);
  }
}

※ 実際のコードをもとに、Vanilla TypeScriptで書き直した説明用の実装です。

document.querySelectorAll<HTMLElement>('.js-count').forEach((elm) => {
  const textCount = new TextCount(elm);
  textCount.init();
});

同じページに複数の.js-countが存在しても、それぞれ独立して処理できます。非同期通信によってDOMが追加された場合も、追加された要素を起点に初期化できます。

const newContent = document.querySelector<HTMLElement>('.js-async-content');

if (newContent) {
  newContent.querySelectorAll<HTMLElement>('.js-count').forEach((elm) => {
    const textCount = new TextCount(elm);
    textCount.init();
  });
}

もちろん関数でも同様の処理は実現できますが、対象要素や状態、イベント処理をひとつの単位として扱えるため、現在はクラスを採用しています。

なお、同じ要素に対してinit()を繰り返すとイベントが重複登録されるため、実運用では初期化済みの要素を管理するなどの対応も必要になります。

libraryの設計

libraryディレクトリでは、modulesで定義した共通機能を呼び出し、関連する初期化処理をまとめています。(命名あんまりな気がするので別の命名規則の方がいい気もしますが。。sharedとか?)

// library/_form.ts
import { TextCount } from '../modules/TextCount';

function textCountFunction() {
  document.querySelectorAll<HTMLElement>('.js-count').forEach((elm) => {
    const textCount = new TextCount(elm);
    textCount.init();
  });
}

export function form() {
  textCountFunction();
}
// shop.ts
import { form } from './library/_form';

form();

なぜこの構成にしているのか

各ページから直接modulesを呼び出すと、共通の初期化処理が重複することがあります。そこで、機能そのものの実装はmodules、機能をどのように初期化するかはlibraryという形で責務を分けています。

フォームの共通処理を追加・変更する場合も、基本的には_form.tsを修正するだけで済みます。

ページごとのエントリーポイント設計

各ページのエントリーポイントでは、libraryで定義した共通処理の呼び出しや、そのページ固有の処理を記述しています。

// shop.ts
import { common } from './library/_common';
import { form } from './library/_form';

common();
form();

function initShop() {
  // shopページ固有の処理
}

initShop();

ここで意識しているのは、各ページのTypeScriptファイルを見れば、そのページでどのような処理が実行されるのか、ある程度把握できることです。

共通機能はlibraryから呼び出し、ページ固有の処理はエントリーポイントに記述します。記述量が増えた場合は、適宜モジュール化することも検討します。

必要に応じたディレクトリの分離

基本となるmodules・library・componentsのほかに、必要に応じてtypes・api・hooksなどのディレクトリも設けています。

API通信を伴うReactコンポーネントでは、通信処理をapi、状態管理をhooks、型定義をtypesに分けることがあります。ただし、最初からすべてを細かく分割するわけではありません。

必要な責務が生まれた段階で切り出すことを意識しています。

TSXによる部分レンダリング

Reactを部分的に採用する理由

基本的にはPHPでHTMLを生成していますが、動的なUIや状態管理が必要な箇所ではReactを採用しています。

個人的にReactが好きという理由もありますが、状態に応じたUIの切り替えや、コンポーネント単位での責務の分離がしやすい点にメリットを感じています。

サイト全体をReact化するのではなく、必要な箇所だけReactで部分的にレンダリングする方針です。例えば、APIで取得した閲覧履歴のようなUIに利用しています。

React Rootを使った部分レンダリング

PHP側にReactを描画するRoot要素を配置し、tsx-〇〇という命名規則で管理します。

<div id="tsx-keepHistory" data-history-url="/api/history"></div>

API通信に必要なURLなどは、PHP側からdata-*属性で渡すこともあります。

// components/KeepHistory/index.tsx
import { createRoot } from 'react-dom/client';
import { KeepHistory } from './KeepHistory';

export function initKeepHistory() {
  const element = document.getElementById('tsx-keepHistory');
  const url = element?.dataset.historyUrl;

  if (!element || !url) return;

  createRoot(element).render(<KeepHistory url={url} />);
}

各ページのエントリーポイントからマウント処理を呼び出します。

// index.ts
import { common } from './library/_common';
import { initKeepHistory } from './components/KeepHistory';

common();
initKeepHistory();

Rootが存在する場合のみマウントし、必要なページ・箇所にだけReactを適用します。

コンポーネントのディレクトリ構成

ReactコンポーネントはRoot単位でまとめ、必要な処理やUIを管理しています。

resources/
└── ts/
    ├── components/
    │   └── KeepHistory/
    │       ├── index.tsx
    │       ├── KeepHistory.tsx
    │       ├── Skeleton.tsx
    │       └── Error.tsx
    ├── api/
    │   └── history.ts
    ├── hooks/
    │   └── useHistory.ts
    ├── types/
    │   └── history.ts
    └── index.ts

ファイル

役割

index.tsx

React Rootへのマウント処理

KeepHistory.tsx

通常のUIと状態による表示切り替え

Skeleton.tsx

データ取得中の表示

Error.tsx

通信エラー時の表示

api/history.ts

API通信

hooks/useHistory.ts

データ取得と状態管理

types/history.ts

データの型定義

すべてのコンポーネントを同じ粒度で分割するのではなく、機能の規模に応じて必要なファイルだけを作成します。

API通信とHooksの分離

API通信が必要な場合は、通信そのものとReactの状態管理を分けています。以下は実装方針を説明するサンプルです。

// types/history.ts
export type HistoryItem = {
  id: string;
  title: string;
  url: string;
};
// api/history.ts
import type { HistoryItem } from '../types/history';

export async function fetchHistory(
  url: string,
  signal?: AbortSignal
): Promise<HistoryItem[]> {
  const response = await fetch(url, { signal });

  if (!response.ok) {
    throw new Error('Failed to fetch history');
  }

  return response.json();
}
// hooks/useHistory.ts
import { useEffect, useState } from 'react';
import { fetchHistory } from '../api/history';
import type { HistoryItem } from '../types/history';

export function useHistory(url: string) {
  const [data, setData] = useState<HistoryItem[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    setIsLoading(true);
    setError(null);

    async function load() {
      try {
        const result = await fetchHistory(url, controller.signal);
        if (!controller.signal.aborted) setData(result);
      } catch (err) {
        if (!controller.signal.aborted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
        }
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    }

    load();
    return () => controller.abort();
  }, [url]);

  return { data, isLoading, error };
}

これによりUIコンポーネント側では、表示処理に集中しやすくなります。

Skeleton・Errorの設計

非同期通信を伴うReactコンポーネントでは、通常表示・Skeleton・ErrorをRoot単位で管理することを意識しています。

// components/KeepHistory/KeepHistory.tsx
import { useHistory } from '../../hooks/useHistory';
import { Skeleton } from './Skeleton';
import { ErrorView } from './Error';

type Props = { url: string };

export function KeepHistory({ url }: Props) {
  const { data, isLoading, error } = useHistory(url);

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorView />;
  if (data.length === 0) return <p>閲覧履歴はありません。</p>;

  return (
    <div className="c-history">
      {data.map((item) => (
        <a href={item.url} key={item.id}>{item.title}</a>
      ))}
    </div>
  );
}
// Skeleton.tsx
export function Skeleton() {
  return (
    <div className="c-skeleton c-skeleton--history" aria-busy="true">
      <div className="c-skeleton__item" />
      <div className="c-skeleton__item" />
      <div className="c-skeleton__item" />
    </div>
  );
}
// Error.tsx
export function ErrorView() {
  return (
    <div className="c-error" role="alert">
      <p>閲覧履歴を取得できませんでした。</p>
    </div>
  );
}

c-skeletonやc-errorなどの共通クラスをベースに、コンポーネント固有のスタイルを組み合わせています。背景やアニメーションなどはSCSSのmixinで再利用します。

状態管理はReact、共通の見た目はSCSS、個別のレイアウトはコンポーネント単位という役割分担です。

Root単位で設計するメリット

Reactを単に部分導入するだけでなく、各Rootがひとつの機能として独立して動作できることを意識しています。

PHPでRootを配置し、ページのTypeScriptからマウント処理を呼び出し、各コンポーネントが必要なUI・状態を管理します。一方、Rootを細かく分けすぎると状態共有や連携が複雑になるため、機能のまとまりを考えて適用範囲を決めています。

SCSSの設計

ディレクトリ構成と管理方針

SCSSについても、UIの粒度に応じてcomponents・modules・ページ固有のSCSSに分けて管理しています。

resources/
└── scss/
    ├── components/
    │   ├── _c-input.scss
    │   └── _c-select.scss
    ├── modules/
    │   ├── _tab.scss
    │   ├── _nav.scss
    │   ├── _header.scss
    │   └── _table.scss
    ├── shop.scss
    └── shopDetail.scss

ディレクトリ

役割

components/

入力欄やセレクトなど、小さなUIパーツ

modules/

UIパーツを組み合わせたタブやヘッダーなど

直下のSCSS

ページ単位のエントリーポイントと個別スタイル

共通スタイルとページ固有スタイルの分離

複数ページで再利用するスタイルはcomponentsやmodulesで管理し、各ページのSCSSから必要なものを読み込む構成です。

特定のページでのみ必要なスタイルは、ページ固有のSCSSに記述します。これにより共通部分と個別部分を区別し、修正時の影響範囲を把握しやすくしています。

UIの粒度に応じたコンポーネント設計

componentsでは入力欄やセレクトボックスなどの小さなUIを、modulesではそれらを組み合わせた大きなUIを管理します。

例えば、_c-input.scssは入力欄、_c-select.scssはセレクト、_tab.scssはタブ、_header.scssはヘッダーのスタイルを担当します。

ただし、すべてを細かくコンポーネント化するのではなく、共通化すること自体を目的にしないようにしています。

mixin・変数・命名規則

繰り返し使用するスタイルはSCSSのmixinなどを活用して共通化しています。例えばSkeletonの背景色やアニメーションです。

@mixin skeleton-bg {
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: skeleton-loading 1.5s infinite linear;
}

@keyframes skeleton-loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
.c-skeleton {
  &--history {
    display: grid;
    gap: 12px;
  }

  &__item {
    @include skeleton-bg;
    height: 80px;
    border-radius: 8px;
  }
}

※ 説明用のSCSSです。mixinの定義場所や読み込み設定は省略しています。

共通するデザインは再利用し、コンポーネント固有のレイアウトは個別に管理します。

ページ固有のスタイル管理

共通のコンポーネントやモジュールだけでは対応できないページ固有のレイアウトやデザインについては、ページに対応するSCSSで管理しています。

例えばshop.scssでショップ関連ページの共通スタイル、shopDetail.scssで詳細ページ固有のスタイルを管理する形です。

Viteのビルド構成

Viteをアセットビルドツールとして利用する

今回の構成では、ViteをSPAの開発環境としてではなく、TypeScript・TSX・SCSSをビルドするためのツールとして利用しています。

PHP側でHTMLを生成し、フロントエンドのアセット管理をViteに任せる形です。使い方としては、Webpackのようにエントリーポイントと出力先を指定する構成に近いと思います。

エントリーポイントと出力先の管理

vite.config.tsでresources配下のTypeScript・SCSSのエントリーポイントを指定し、ビルドしたファイルをwebroot配下に出力します。

resources/
├── ts/
│   ├── shop.ts
│   └── shopDetail.ts
└── scss/
    ├── shop.scss
    └── shopDetail.scss

webroot/
├── css/
│   ├── shop.css
│   └── shopDetail.css
└── js/
    ├── shop.js
    ├── shopDetail.js
    └── jsx-runtime.js

※ ビルド後の出力構成は説明用のイメージです。

PHP側では生成されたCSS・JavaScriptをlinkタグやscriptタグで読み込みます。共通の依存関係は別のJavaScriptチャンクとして出力される場合があります。

ビルド時の不要ファイルのクリーンアップ

複数のTypeScript・SCSSをエントリーポイントとして管理すると、設定によっては不要なファイルが生成されたり、過去のビルド成果物が残ったりすることがあります。

そのため、ビルド時に不要なファイルをクリーンアップし、出力先を整理するようにしています。ただし、ほかのエントリーポイントが参照する共通チャンクまで削除しないよう注意しています。

この設計のメリット・デメリット

ここまで普段の設計を紹介してきましたが、保守性や実装方法の統一というメリットを感じる一方、共通化による課題も感じています。

メリット

実装方法を統一し、保守しやすい

この設計で最もメリットを感じているのは、実装方法を統一しやすく、保守性を高められることです。

複数ページで横断的に使う機能はexportして再利用できる形で管理しています。共通の実装を利用することで、コードの書き方や動作を統一しやすく、仕様変更や不具合修正も共通処理から複数ページに反映できます。

修正対象のファイルを把握しやすい

PHPのテンプレートディレクトリとTypeScript・SCSSのファイル名を対応させているため、修正対象を探しやすくなります。また、ページのTypeScriptを確認すれば、どの共通機能を使っているのか、ある程度把握できます。

デメリット

共通機能の拡張が難しくなる場合がある

共通モジュールの根本的な仕様変更や機能拡張では、既存の呼び出し元にも影響が及ぶ可能性があります。

場合によっては派生した機能を別に用意したり、すべての呼び出し箇所を確認して修正したりする必要があります。修正箇所を集約できる反面、共通機能の変更には慎重な対応が必要だと感じています。

共通機能を把握していないと重複実装が生まれる

もうひとつ大きな課題は属人化です。

modulesやlibraryに共通機能を用意していても、開発者がその存在を把握していなければ、再利用するという発想に至らないことがあります。その結果、似た機能を別の場所に実装してしまうこともあります。

共通化するだけでは不十分で、その機能が存在することを開発者が認識できる仕組みも必要だと考えています。

今後改善していきたいこと

今後は、AIを活用したコーディングをより積極的に取り入れていくためにも、設計ルールの明文化や開発環境の整備を進めていきたいと考えています。

現状では、共通機能の存在や使い方を開発者自身が把握している必要があり、どうしても属人化しやすい部分があります。

そこで、READMEなどのドキュメントを整備し、ディレクトリごとの役割や共通モジュールの使用方法、命名規則などを明確にしていきたいです。

また、コーディングルールの統一や、共通機能の利用、定型的な実装についても、可能な範囲で自動化できる仕組みを整えていきたいと考えています。

人によって実装方法が変わらず、AIも既存の設計思想を理解したうえでコードを生成・修正できる環境を目指して、今後も改善していきたいと思っています。

まとめ

今回は、PHP + Viteを中心とした開発環境で、普段自分が意識しているTypeScript・SCSS・TSXの設計思想について紹介しました。

個人的には、保守性や実装方法の統一を重視してこのような構成にしていますが、実際に運用していく中で、共通化による属人化や機能拡張の難しさなど、まだまだ課題も感じています。

今後は、READMEなどのドキュメント整備やAIを活用したコーディングの自動化も進めながら、より保守しやすく、誰でも扱いやすい構成を目指していきたいと思っています。

もちろん、今回紹介した設計が正解というわけではなく、あくまで自分なりに試行錯誤している現時点での設計方針です。

「こういう構成の方が管理しやすい」「ここはこう改善した方がいい」など、アドバイスやご意見があれば、ぜひコメントで教えていただけるとうれしいです。

最後まで読んでいただき、ありがとうございました。