🪄

SCSSをパワーアップ:TypeScriptプロパティタイプの自動化

2024/09/13に公開
要約

コンポーネントのスタイリングプロパティを手動で定義するのはもううんざりですか? 動的な型生成でSCSSに頑張ってもらいましょう!

例えば、size--smsize--md のようなクラスがCSSにいくつかあるとします。 size: 'sm' | 'md' のようなプロパティを自動的に生成することができます。これは、常にscssファイルの内容と同期されます。

要点はこちら:

  1. SCSSの構造化: クラスには property-name--property-value の命名規則に従ってください(例: size--smvariant--primary)。

  2. typed-scss-modules のインストールと設定:

    yarn add -D typed-scss-modules sass 
    yarn typed-scss-modules src -w -n 'none' -e 'default' 
    
  3. ユーティリティタイプの作成 (utils.ts):

    type KebabToPascalCase<
       Str extends string,
       ToUpper = false,
     > = Str extends `${infer BeforeDash}-${infer AfterDash}`
       ? `${ToUpper extends true ? Capitalize<BeforeDash> : Uncapitalize<BeforeDash>}${KebabToPascalCase<AfterDash, true>}`
       : ToUpper extends true
         ? Capitalize<Str>
         : Uncapitalize<Str>;
     
     export type StyleProps<StyleDefinition> = {
       [Key in keyof StyleDefinition as Key extends `${infer Property}--${string}`
         ? KebabToPascalCase<Property>
         : never]: Key extends `${string}--${infer Value}` ? Value : never;
     };
    
  4. コンポーネントでの使用:

    import type { StyleProps } from "../utils/styles";
    import styles from "./myComponent.module.scss";
    import type { Styles } from "./myComponent.module.scss";
    
    type MyComponentProps = { children: string } & StyleProps<Styles>;
    
    export function MyComponent({ size, fontWeight, children }: MyComponentProps) {
      return <div className={styles[`size--${size}`]}>{children}</div>;
    }
    

これで、SCSSでクラスを追加または変更するたびに、TypeScriptのプロパティが自動的に更新されます!実装と高度なテクニックの詳細については、下記の記事を参照してください。

はじめに

フロントエンド開発の世界は、まさに目まぐるしく進化し続ける旋風のようです。「JavaScript疲れ」という言葉を発する間もなく、新しいフレームワークやライブラリが登場します!ワクワクする一方で、特に初心者の方にとっては圧倒されてしまうこともあるでしょう。

しかし、朗報があります。これらの新しい技術は、古くからのWeb開発の課題に、革新的かつ効率的な方法で取り組むために、常に登場しているのです。開発者としての私たちの生活を、より楽にしてくれる可能性も秘めています!しかし、どんなに素晴らしいツールにも、やはり欠点はつきものです。

CSSの現況

Web開発は比較的新しい分野かもしれませんが、飛躍的な進歩を遂げてきました! あの頃は、すべてのスタイルを純粋なCSSで苦労して作成していたことを覚えていますか? これらの基礎的なスキルは今でも重要ですが、業界はより効率的で強力なアプローチを採用するようになりました。

おそらく、すでにご存知の人気のあるスタイリングソリューションがいくつかあります。

  • Tailwind
  • プリプロセッサ (SCSS / Less)
  • モジュールCSS / モジュールSCSS
  • CSS-in-JS / スタイルコンポーネント

しかし、なぜこのような変化が起こったのでしょうか?これらのオプションは、従来のCSSと比べて何が優れているのでしょうか? これらのアプローチを詳しく見ていき、それぞれの長所と短所を探ってみましょう。

選択肢を比較する: メリットとデメリット

どのようなツールにも言えることですが、スタイリング手法にはそれぞれ長所と短所があります。 あなたのプロジェクトに最適なものを選ぶために、トレードオフを分解してみましょう:

Tailwind CSS

メリット デメリット
🚀 開発スピード: 定義済みのユーティリティクラスを使って、迅速なプロトタイピングと反復作業が可能になります。 🤨 可読性: HTMLに大量のクラスが含まれるため、可読性が低下する可能性があります。
📦 最適化されたCSS: 使用されるスタイルのみが最終的なバンドルに含まれるため、ファイルサイズが削減されます。 📦 JSバンドルサイズの増加: CSSは削減されますが、TailwindはJavaScriptバンドルサイズを増やす可能性があります。
🎯 Single Source of Truth: 一元化されたスタイリングにより、プロジェクト全体で一貫性が促進されます。 🧠 学習曲線: Tailwindのユーティリティファーストのアプローチに慣れる必要があります。
デザインシステム対応: すぐに使い始められるように、あらかじめ構築されたコンポーネントとデザインパターンが用意されています。

CSSプリプロセッサ (SCSS, Less)

メリット デメリット
🧱 構造化と再利用性: 変数、Mixin、関数を活用することで、コードの整理と重複の削減を実現します。 🧩 コンポーネントスタイリング: コンポーネントベースのアーキテクチャでスタイルを管理する場合、スタイルの重複が発生する可能性があります。
📈 保守性: 大規模なコードベースの管理と拡張が容易になります。 ⚙️ セットアップとツール: プリプロセッサのセットアップと、場合によってはビルドプロセスへの統合が必要になります。
🚶‍♂️ 段階的な導入: バニラCSSからの移行がスムーズで、初心者にも扱いやすいです。 📂 プロジェクト構造: スタイルファイルを効果的に整理するための綿密な計画が必要です。

CSS-in-JS (スタイルコンポーネント)

メリット デメリット
🤸‍♀️ 柔軟性: JavaScriptの能力を最大限に活用して、動的で複雑なスタイルを作成できます。 🐢 パフォーマンス: 特に大規模なアプリケーションでは、従来のCSSよりも動作が遅くなる可能性があります。
🎯 Single Source of Truth: 一元化されたスタイリングにより、プロジェクト全体で一貫性が促進されます。 📦 JSバンドルサイズの増加: スタイリングロジックがJavaScriptバンドルサイズに追加されます。

このように、「最良」のスタイリングアプローチは、万能なものが一つあるわけではありません。プロジェクトの固有のニーズ、チームの専門知識、パフォーマンス目標を考慮して、最適なツールを見つけることが重要です。

これらのスタイリングソリューションの人気がどれくらいあるのか、開発者の満足度はどれくらいなのか、気になりませんか? 最新の情報は、State Of CSS の調査結果をご覧ください。

読みやすいコードの重要性

Web開発において、コードの読みやすさはプロジェクトの成功を大きく左右する重要な要素だと私は考えています。開発者が読みやすさを重視することで、プロジェクトは拡張性が高く、メンテナンスしやすいものになります。 私が自分の技術スタックにSCSSを採用し続けているのも、まさにこのためです。

SCSSは、コードの品質とDXを大幅に向上させることができる、さまざまなメリットを提供してくれます。それでは、SCSSがもたらす主なメリットについて詳しく見ていきましょう。

ロジックとスタイリングの分離

SCSSの大きなメリットの一つに、ロジックとスタイリングを明確に分離できることが挙げられます。つまり、JavaScriptコードはアプリケーションのロジックと機能の処理に専念し、SCSSコードはスタイリングとレイアウトに集中させることができるのです。

このような関心の分離には、いくつかの利点があります。

  • メンテナンスの容易化: ロジックとスタイリングが分離されていると、互いに影響を与えることなく、どちらか一方を更新したり修正したりすることが容易になります。
  • 構成の改善: コードベースが整理され、開発者は必要なものを簡単に見つけることができます。
  • コラボレーションの促進: デザイナーと開発者が、それぞれの担当領域でより独立して作業できるようになります。

コードの再利用性

SCSSでは、変数、Mixin、関数を用いることで、コードの再利用が可能になります。これらの機能により、アプリケーション全体で再利用できるコードを記述することができ、重複を減らし、コードをより効率的にすることができます。

SCSSでコードを再利用する方法の例を以下に示します。

変数

色、フォントサイズなどに名前をつけて定義し、アプリケーション全体で利用することができます。

$primary-color: #4CAF50;

.button {
  background-color: $primary-color;
}

.link {
  color: $primary-color;
}

Mixin

共通のスタイルのスニペットを定義し、アプリケーション全体で利用することができます。

@mixin flexbox {
  display: flex;
  justify-content: center;
  align-items: center;
}

.container {
  @include flexbox;
}

関数

複雑な計算を行う関数を定義し、アプリケーション全体で利用することができます。

@function modular-scale($base-font-size, $ratio, $increment: 1) {
  @return $base-font-size * pow($ratio, $increment);
}

h1 {
  font-size: modular-scale($base-font-size, $ratio-major-third, 3);
}

これらの機能により、以下の利点が得られます。

  • 効率性: コードの記述量を減らし、冗長性やエラーの可能性を減らすことができます。
  • 保守性: 共有スタイルの更新は一箇所で行うだけで済みます。

Single Source of Truthのジレンマ

SCSSは素晴らしいですが、もちろん完璧ではありません。特にTypeScriptと組み合わせて使用する場合によく生じる課題の一つに、スタイリングのSingle Source of Truthを維持することが挙げられます。CSSクラスとコンポーネントのプロパティの間で重複が発生する可能性があり、後々頭痛の種になることがあります。

例を挙げて説明しましょう。SCSSでサイズ関連のCSSクラスを定義する場合を考えてみましょう。

.size {
  &--medium {
    padding: $padding-medium;
    max-width: $max-width-medium;
  }
  &--large {
    padding: $padding-large;
    max-width: $max-width-large;
  }
}

次に、ReactコンポーネントのTypeScriptプロパティ内で、これらのサイズオプションを反映する必要があります。

type Props = {
    size: 'medium' | 'large';
    /* ... other props */
}

問題が分かりますか? 重複が発生しています。サイズ値を変更する必要がある場合は、両方の場所を更新する必要があることを覚えておく必要があります。これはエラーのリスクを高めるだけでなく、コードの保守性を損ないます。 この問題に対処するための戦略を見ていきましょう。

実装

目標の設定

コードに飛び込む前に、目標を明確にしましょう。私たちの目標は、SCSSクラスとTypeScriptプロパティ間の面倒な手動同期をなくすことです。TypeScriptコードがCSS定義を本質的に認識し、あらゆる変更に自動的に適応するようにしたいと考えています。

要するに、「動的に型付けされたCSS」のようなものを目指しています。これは、ある意味でSCSSが、TypeScriptコンポーネントで使用される型定義の情報源となることを意味します。

これをより具体的にするために、必要な入力(SCSS)と出力(TypeScript型)を定義してみましょう。これらはあくまでも例であり、プロジェクトの具体的な慣習やスタイルガイドラインに合わせて調整できることに注意してください。

入力

BEM命名規則を参考に、SCSSの構造を明確かつ一貫性のあるものにします。CSS Modulesを活用するため、クラス名は衝突を気にすることなく、簡潔で分かりやすいものにすることができます。

以下のSCSSの例を見てみましょう。

.size {
  &--sm {
    width: $size-sm;
    height: $size-sm;
  }
  &--md {
    width: $size-md;
    height: $size-md;
  }
  &--lg {
    width: $size-lg;
    height: $size-lg;
  }
}

.font-weight {
  &--regular {
    font-weight: 400;  
  }
  &--bold {
    font-weight: 700;  
  }
}

ご覧のとおり、クラス名には.property-name--property-valueというパターンを採用しています。この構造は、TypeScript型の生成を自動化する際に非常に役立ちます。

出力

SCSSの構造が決まったので、次は必要なTypeScriptの出力を定義しましょう。ここでは、以下のように、利用可能なCSSクラスを直接反映したプロパティ型を生成したいと考えています。

import type {Styles} from "./component-name.module.scss"

type StyleOptions = StyleProps<Styles>
/*
  期待されるStyleOptionsの型
  {
    size: 'sm' | 'md' | 'lg',
    fontWeight: 'regular' | 'bold'
  }
*/

想像してみてください。TypeScriptコンポーネントは、SCSSに基づいて、どのスタイルオプションが有効かを「ただちに」認識します。もう手動で同期する必要はありませんし、文字列リテラルを推測する必要もありません!

話がうますぎると思うかもしれませんか? それでは実際に確認してみましょう! これから実装の詳細に入っていきます。

動的なCSSタイピング

無駄骨を折らずに

ありがたいことに、この旅を一人で始める必要はありません。オープンソースコミュニティの優秀な頭脳が、すでに動的なCSSタイピングの課題に取り組み、すぐに使えるソリューションを提供してくれています。

目標達成を支援するライブラリはいくつかあります。注目すべきオプションをいくつかご紹介します。

これらのライブラリを自由に検討し、プロジェクトのセットアップや好みに最適なものを選択してください。ここでは、typed-scss-modulesを使ってワークフローを簡素化する方法を見ていきましょう。

概要

typed-scss-modulesライブラリは、プロジェクト内のすべての.module.scssファイルに対して、.d.ts型の定義ファイルを自動的に生成してくれる頼もしい相棒です。さらに、SCSSファイルを監視し、変更を検知するたびに型を再生成してくれるという優れものです。

まずは、ライブラリとsass(まだインストールしていない場合)をインストールし、ターミナルで以下のコマンドを実行します。

# yarnを使う場合
yarn add -D typed-scss-modules

# npmを使う場合
npm i -D typed-scss-modules

インストール後、以下のコマンドを実行することで型の定義を生成できます。
(必要であれば src をSCSSファイルのパスに置き換えてください)

## yarnを使う場合
yarn typed-scss-modules src

## npmを使う場合
npx typed-scss-modules src

typed-scss-modulesは、初期設定のままでその威力を発揮しますが、好みの命名規則や型生成に合わせて動作を微調整することもできます。設定をカスタマイズする方法を見ていきましょう。

設定

typed-scss-modulesを微調整して、SCSSの構造とワークフローに完全に一致する型を生成するようにしましょう。

  1. ウォッチャーの有効化: SCSSと型の同期を保つために、組み込みのウォッチャーを有効にします。コマンドに --watch フラグ (または省略して -w) を追加します。

  2. 自動名前フォーマットの無効化: デフォルトでは、ライブラリは生成された型でケバブケースのクラス名をキャメルケースに変換します。ここでは、ダブルダッシュを使用したより複雑な命名パターンを使用しているため、 --nameFormat none (または -n 'none') を使用してこの動作を無効にします。

  3. エクスポート形式のカスタマイズ: default エクスポートタイプ ( --exportType default または -e 'default' を使用) を選択して、すべてのスタイルを Styles オブジェクト内にグループ化します。これにより、SCSSクラス名を反映して、オブジェクトキーにダッシュを使用できます。

これらの設定を組み合わせると、コマンドは次のようになります。

yarn typed-scss-modules src -w -n 'none' -e 'default'

便宜上、このコマンドを package.json ファイルに追加しましょう。

{
  "scripts": {
    "scss-types:watcher": "yarn typed-scss-modules src -w -n none -e default"
    // ...その他のスクリプト
  }
  // ...その他の設定
}

結果

設定が完了したので、前に定義した SCSSを確認し、typed-scss-modulesがどのような型を生成するかを見てみましょう。

export type Styles = {
  'font-weight--bold': string;
  'font-weight--regular': string;
  'size--lg': string;
  'size--md': string;
  'size--sm': string;
};

export type ClassNames = keyof Styles;

declare const styles: Styles;

export default styles;

ご覧のとおり、SCSSクラス名を完全に反映した Styles オブジェクトが作成されました。

型変換

長い道のりでしたね!これで、SCSSを反映したTypeScript型が自動的に生成されるようになりました。しかし、私たちの最終的な目標を覚えていますか?これらの型を、コンポーネントのプロパティにより便利な形式に変換することです。

ここで登場するのが、TypeScriptジェネリクスの力です。いくつかの強力な型操作テクニックと組み合わせることで、生成された型と必要なプロパティ型のギャップを埋めることができます。

プロパティ型のジェネリクス

生成された型を、コンポーネントのプロパティに必要な形式に変換するプロセスを詳しく見ていきましょう。この課題に段階的に取り組み、徐々に解決策を構築していきます。

まず、SCSSクラス名からプロパティ名と値を抽出し、効果的にグループ化することに焦点を当てます。TypeScriptジェネリクスを使用して、この関数を異なるスタイルセットに対して再利用できるようにします。

type StyleProps<StyleDefinition> = {
  [Key in keyof StyleDefinition as Key extends `${infer Property}--${string}`
    ? Property
    : never]: Key extends `${string}--${infer Value}` ? Value : never;
};

一見すると、TypeScriptの黒魔術を呼び出したかのように見えるかもしれません!高度なTypeScriptの機能を理解することで、下記の記事で解明できます。

仕組み

型定義を一つずつ分解してみましょう。

  1. Key in keyof StyleDefinition: StyleDefinition オブジェクト (生成された Styles 型) 内の各キーを反復処理します。

  2. Key extends `${infer Property}--${string}`? Property : never: 現在の Key (クラス名) が property-name--property-value のパターンに一致するかどうかをチェックします。一致する場合、infer Property を使用して property-name 部分を抽出し、新しいキーとして使用します。パターンが一致しない場合は、never を使用してキーを除外します。

  3. Key extends `${string}--${infer Value}` ? Value : never: 手順2と同様に、パターンを再度チェックします (推論された Value にアクセスするには、これを別途行う必要があります)。パターンが一致する場合、infer Value を使用して property-value を抽出し、新しいキーの型として使用します。

パスカルケースのためのジェネリクス

あと少しです!パズルの最後のピースは、kebab-caseのプロパティ名をPascalCaseに変換することです。一見単純そうですが、この変換にはTypeScriptの工夫が必要です。

kebab-caseの文字列をPascalCaseに変換するアルゴリズムの概要を以下に示します。

  1. 文字列内の最初のダッシュ(-)を見つけます。
    • ダッシュが見つからない場合は、これで完了です!
  2. ダッシュの位置で文字列を2つの部分に分割します。
  3. ダッシュのの部分の最初の文字を大文字にします。
  4. ダッシュの後の部分に手順1〜3を再帰的に適用します。

このアルゴリズムを、再帰的なTypeScriptジェネリクスに変換してみましょう。

type KebabToPascalCase<
  Str extends string,
  ToUpper = false,
> = Str extends `${infer BeforeDash}-${infer AfterDash}`
  ? `${ToUpper extends true ? Capitalize<BeforeDash> : Uncapitalize<BeforeDash>}${KebabToPascalCase<AfterDash, true>}`
  : ToUpper extends true
    ? Capitalize<Str>
    : Uncapitalize<Str>;

確かに、長いですね!しかし、恐れることはありません。これを分解して説明します。重要なポイントは、文字列変換の反復プロセスを模倣するために、型定義内で再帰を使用していることです。

仕組み
  1. KebabToPascalCase<Str extends string, ToUpper = false>: このジェネリックは、2つの型パラメータを取ります。

    • Str: 変換する文字列。
    • ToUpper: 現在の部分が大文字化されるべきかどうかを示すフラグ(デフォルトは false)。
  2. Str extends `${infer BeforeDash}-${infer AfterDash}` ? ... : ...: Str にダッシュが含まれているかどうかをチェックします。

    • ダッシュが見つかった場合:

      • infer を使用して、ダッシュの前後の部分を抽出します。
      • AfterDash を使用して KebabToPascalCase を再帰的に呼び出し、ToUppertrue に設定します(次の部分を大文字にするため)。
    • ダッシュが見つからない場合:

      • ToUpper フラグに基づいて、Str を大文字または小文字にします。

この再帰的な処理は、ダッシュが見つからなくなるまで続き、kebab-case の文字列を効果的に PascalCase に変換します。


TSプレーグラウンドのリンクをチェックしてください。SampleStylesにホバーすると、生成された型の定義を確認することができます。

https://www.typescriptlang.org/play/?#code/C4TwDgpgBA0hBGBDeAVA9gBUQZwMaIBsBhHCAHgCgooBlYAJyggA9gIA7AE2ymwYEt2AcwA0VKOgCqYSIwC8UAGaFsEMQD4oCuoxZsuPAAYASAN6DFERgCEIitPQgARHAAsAvgFozFq1ACCimz0LtgehuIA-FAmplIyfnoc3FAMAK7Q0SRg-MCE-ABe5Lb2jqGumgBcUJLs+Dl5BIXFdg7OburuZnBIqJg4+MSkZIHB5SKp9BmdEdTV8bJMrMk86RDi1FmIDflFZDrqG1DVtfW5u+QHANwUFKCQtKAEEBj0aGDY+0-Odhy5-Gh2JoFKZxABtOAgKCCKAAawgIDQikeIGeTl+7H+gKgOFgCKW+hSsV8jFe7ysoC83lMfHogiE7lm1Cg0R6yHQWDwhBIqjIZNkoEOzOOUHYEAAblYALrVSEElYxMy0+lUnzsSyMABqhAyjJZUG1BAyIrFkvoN3cNzu4GgdFREB4IPEAHJsM1PJ5sABbZ3VZXCG7UV3uzxezi+3gCAMut1FD0EIQR-1CK1B+zsYCeADuEH4Qlcmc8jiEaQIiHoSajKZd6czObzBY98DQBHDfqrFqt91tiC9YGeduejpRz35n0HDsOQA


これで TypeScript のツールが完成しました。次は、これらのツールを組み合わせましょう! KebabToPascalCase 変換を組み込んで、StyleProps 型を強化してみましょう。

type StyleProps<StyleDefenition> = {
  [Key in keyof StyleDefenition as Key extends `${infer Property}--${string}`
-    ? Property
+    ? KebabToPascalCase<Property>
    : never]: Key extends `${string}--${infer Value}` ? Value : never;
};

この最後の仕上げで、私たちのジェネリックは、目指していた通りのプロパティ型を生成するようになります!

最終結果を見る前に、コードを整理して見やすくしておきましょう。

src
└───📂utils
    │   📄styles.ts   // StyleProps と KebabToPascalCase を含む
└───📂components
    │   ⚛️myComponent.tsx
    │   💅myComponent.module.scss  // SCSS スタイルを含む
    │   📦myComponent.module.scss.d.ts  // 自動生成された型定義

いよいよ真実の瞬間です! 動的に型付けされた CSS の動作を見てみましょう。

結果

すべての準備が整いました。myComponent.tsx ファイル内で、私たちのソリューションがどのように動作するのか見てみましょう。

import type { StyleProps } from "../utils/styles";

import styles from "./myComponent.module.scss";
import type { Styles } from "./myComponent.module.scss";

type MyComponentProps = {
  children: string;
} & StyleProps<Styles>;

export function MyComponent({ size, fontWeight, children }: MyComponentProps) {
  return <div className={styles[`size--${size}`]}>{children}</div>;
}

ついに頂上に到達しました! SCSS をコンポーネントスタイルの唯一の真実の源泉とするシステムの実装に成功しました。SCSS に新しいサイズ、バリエーション、またはプロパティを追加すると、TypeScript の型が自動的に適応します。

完成された実装を添付のCodeSandboxでご覧ください。
https://codesandbox.io/p/devbox/typed-scss-modules-ky4rxl

重要なポイント:

  • 保守性: CSSとTypeScriptの間で手動で同期する必要がなくなり、エラーを減らし、コードの整合性を向上させます。
  • 拡張性: TypeScriptコードを壊す心配なく、スタイルを簡単に追加または変更できます。
  • 共同作業: SCSSに精通しているデザイナーは、TypeScript を詳しく知らなくても、スタイリングに貢献できます。

注意点:

  • 命名規則: このソリューションは、特定のクラス命名パターンに依存しています。 正確な型生成のために、SCSS がこのパターンに準拠していることを確認してください。
  • 逆変換のための正規表現: 動的なクラス名構築のために、PascalCase の props を kebab-case に変換するユーティリティ関数を実装します。

この記事が、フロントエンド開発ワークフローを強化するための強力なテクニックを身につけるのに役立ったことを願っています。 考え、質問、または代替アプローチがあれば、下のコメント欄で共有してください。

💻 良いコーディングを!

Sun* Developers

Discussion