この記事は株式会社カオナビ Advent Calendar 2024の9日目の記事です。
はじめに
自社で開発しているプロダクトデザインシステム「sugao」の開発中に私が遭遇した課題と、Symbolを用いた解決の経緯をまとめてみました。
課題
自社開発のプロダクトデザインシステムであるsugao
に、新しいコンポーネントを追加したいという要望があり、私が担当することになりました。このコンポーネントの開発にはすでにSugao
に実装されているコンポーネントButton
を利用するのですが、このButton
にも新しいデザインパターンを追加することになりました。
Button
は以下のような形で利用することができます。kindという必須プロパティに種別を指定することでボタンのデザインが変わる、とざっくり考えてもらえれば大丈夫です。
import { Button } from 'sugao';
// kindに特定の種別を指定することでデザインの切り替えが可能
<Button kind="normal">text</Button>
sugao内(モジュール内)でのみ利用可能なデザインの追加
このボタンの改修だが、以下のルールが科されることになりました。
- 新コンポーネントで利用する
Button
のデザインとしてSugao内でのみ利用可能で、sugao外からの利用は不可とする - sugao外での利用の制限はデザインルール/コーディングルールのどちらの面から制限してもかまわない
自分がSymbolで実装
まで辿り着くまでの変遷
この課題に対して当初私が考えていたアプローチは以下の三つになります。
1. 既存のButtonコンポーネントを丸々コピーした、外部にexportしないSpecialButton
的なコンポーネントを新たにsugao内に追加し、デザインを書き換えて利用する
2. Button
のkindプロパティに指定可能なパラメータを追加する
3. 特別なデザインパターンを利用することを示すフラグプロパティをButton
に追加する
まず、案1. はデザイン分岐以外のロジックが全く同じコンポーネントがsugao内に複製されることで、Button
というコンポーネントの動作仕様が変わるたびに二つのコンポーネントを修正しなければならないという保守性の悪さから不採用としました。
案2. については最も変更が容易ですが、kindは必須パラメータでよく設定されるものであり、そこに利用可能なパラメータを追加しておいて 使えるけど、使わないでね
的なルールを設定しても、ルールの絶対尊守は難しいと考えたため、検討段階でいったん不採用としています。(もちろん、コードレビューなどで弾くなどの対応はとれますが)
ButtonKind = "normal" | ... | "specialDesign"; // specialはデザインシステム外では利用しないこと!
// 指定はできるから、コードレビュー等で弾く
<Button kind="specialDesign">text</Button>
残った案3. については、新デザインを利用するにはフラグの指定が必要なため、実装時に不用意に利用されることはないだろうという考えから、いい案なのではと最初は思っていました。
<Button useSpecialDesign>text</Button>
が、結局この案もあまりイケてる案ではないねという気持ちになり、アプローチを再考することになります。
デザインを指定する
プロパティが二つになる
Button
には元々デザインを指定するために kind
という必須プロパティがすでに設定されています。案3.ではここにさらに useSpecialDesign
のような、特別なデザインを使いますよ、という意味あいのフラグを追加して利用することになる想定です。
そのため、Button
を特別なデザインで使いたい場合、実際には以下のコードを書く必要があります。
<Button kind="normal" useSpecialDesign>text</Button>
kind
でデザインを指定しているのに、さらにそれをuseSpecialDesignのフラグで上書きする形になってしまっています。新デザインのButtonを使いたい場合、kindの指定は完全に無駄なものになります。
ならば排他的な型制御をいれて、kind
かuseSpecialDesign
のどちらかのみを指定可能にしよう!と思ったものの、Button
の実装内ではすでにkindに応じたデザインの切り替えロジックが点在しているため、さらにここに useSpecialDesign
によるデザイン切り替えの分岐まで増やすことになると、これもまた煩雑になってしまうなぁという気持ちに。
じゃあどうすればよいだろうとなってしまい、他のエンジニア諸兄に相談したところ、Symbol
を使って実装してはどうか?というアドバイスをもらうことができました。
JavaScriptのSymbol is 何?
Symbolとはなんぞや?ということでこの辺りのドキュメントを読んだり、先輩エンジニアに相談したりしました。
今回の課題はSymbolの以下の点を押さえておくだけで解決できます
- SymbolはJavaScriptの組み込み関数である
- Symbolコンストラクターを利用することで、一意であることが保証されているシンボル値を取得することができる
気になるのは一意であることが保証されているシンボル値を返す
、とはどういうことか?という点ですが、これはmozillaに掲載されている例がわかりやすいでしょう。
// この三つはすべて違うシンボル値を返す
const sym1 = Symbol();
const sym2 = Symbol("foo");
const sym3 = Symbol("foo");
// コンストラクターに同じキーを指定しても、生成されるシンボル値は異なる
Symbol("foo") === Symbol("foo"); // false
Symbolコンストラクターには文字列をキーとして渡して値を生成することもできますが、やはり生成される値は一意であることが保証されています。ランタイム上でまったく同一のシンボル値を利用するためには、そのシンボル値を直接参照するなにかしらの手段を取る必要があります(わかりやすい手段としては定義したシンボル値をexport/importして利用することでしょう)
もし反対に、あなたがランタイム上のあらゆる箇所からある特定キー名に紐づくSymbol値を使いたいのであれば、Symbol.for('キー名')を使うことでキー名に対応するシンボル値を生成し、あらゆる箇所で引き出して利用することができます。
Symbolを利用した特定のプロパティの利用制限
kindに指定可能なパラメータとしてシンボル値を利用する
Symbol()によって生成されるシンボル値がランタイム上で一意になることはわかりました。あとはこれをどう課題に利用するかという話ですが、今回はButton
コンポーネントの定義内にuseSpecialDesign
と同じ意味を持つシンボル値を定義して利用することにしました。
// sugao内のButton定義
const specialDesignSymbol = Symbol("specialDesign");
// kindとして指定可能なものの中にsymbol値を混ぜる
type ButtonKind = 'normal' | ... | typeof specialDesignSymbol;
// Buttonを使いたいどこか
<Button kind={specialDesignSymbol}>text</Button>
kind
に指定可能なパターンが増えているため、すでにあるkindに応じたデザイン切り替えのロジックも修正します。ざっくりとしたイメージはこう
// 修正前
const getButtonColor = (kind: ButtonKind): Color => {
switch (kind){
case 'normal':
return 'green';
case 'warning':
return 'red';
...;
}
}
// 修正後
const getButtonColor = (kind: ButtonKind): Color => {
switch (kind){
case 'normal':
return 'green';
case 'warning':
return 'red';
...,
// 追加
case specialDesignSymbol:
return 'specialColor';
}
}
これでButton
に対してkind: specialDesignSymbol
に対応した実装ができました。
このままでは 案2. のkindに種別をただ追加するパターンと同じではないのか?と思われるかもしれません。しかしそこは心配無用です。なぜならspecialDesingSymbol
はSymbol()
によって生成されたシンボル値だから。
シンボル値のexport範囲を限定し、外部からの利用を制限する
kind
に指定できるspecialDesignSymbol
はButton
に定義されているspecialDesignSymbol
というシンボル値を指しています。外部から同じ値を持つシンボル値を生成することはできません。したがって、新デザインをButton
を利用するには、sugaoのButton
定義からspecialDesignSymbol
もimportして利用する必要があります。
// sugao/Button
// シンボル値そのものをexportして、Button以外のSugaoのコンポーネント定義からもimport可能にする
export const specialDesignSymbol = Symbol('specialDesignSymbol');
つまりspeicialDesignSymbol
をSugaoというデザインシステムのモジュールの外にexportさえしなければ、Sugao外のプロダクトからは利用できないという制限がかけられるのです。
// sugaoのmodule exportリスト
// ここでexport宣言されていないものはsugaoのモジュールからexportされない
...,
export {
Button,
type ButtonKind,
...,
// specialDesignSymbol, //このシンボル値はsugaoモジュールの外にexportしない
} from './Button';
// sugaoを使いたい外部プロダクト
// specialDesignSymbolはsugao外にexportしていないのでimportできない
import { Button } from 'sugao';
// 同じキー名でSymbol()を利用しても、返されるシンボル値はランタイム上で必ず一意であるため、
// sugao/Button内に定義されているspecialDesignSymbolとは別のシンボル値となる
const useSpecialDesign = Symbol('specialDesignSymbol');
// シンボル値が異なるためkindに指定できない
// Type 'unique symbol' is not assignable to type 'ButtonKind'.ts(2322)
<Button kind={useSpecialDesign}>text</Button>
もちろんこのシンボル値がモジュール外からも参照可能な状態になってしまうと元も子もないため、コメントなどは付け加えておくのがよいでしょう
// ugao内でのみ利用するため、モジュール外へのexport禁止
const specialDesignSymbol = Symbol('specialDesignSymbol');
おわりに
実ははじめSymbolをつかってみてはどうか?とアドバイスいただいた時は「一意な値を返すって、それいったいなんの旨味があるんだ?」と悩んだりもしたのですが、先輩エンジニアのサポートもあり無事課題解決まで辿り着くことができました。(ちょっとしたAHA体験でした)
今回紹介した課題はあまり頻繁に遭遇するものではないかもしれませんが、もし同じ課題に悩んでいる方、あるいはSymbol
について学びはじめた方にとって、この記事が解決の一助となってくれればうれしいです。