【tailwind】そのmerge、本当に安全ですか?
結論
- コンポーネント内部で CSS を出し分けてスタイルを完全に隠蔽しようぜ
- この実現には
tailwind-variants
を使うといいのかもしれない
- この実現には
はじめに
こんにちは、shio 🧂 だったりディーノ 🦖 だったりする人です。最近は shio 率が多い気がします。
さて、今年の 5月 ごろ (うろ覚え) に tailwind 関連の議論があちらこちらで起こっていたのも久しく、7月 になってこのところはあまり見かけないようになりました。
みんな tailwind に飽きてしまったのでしょうか?それとも議論するネタが尽きてしまったのでしょうか?
そんな方に朗報です。
この記事では tailwind におけるスタイルの注入の安全性とその代替案 について扱います。
筆者の tailwind 知識は大したものではありませんが、それでもコメント等々大歓迎です。
もちろんサジェスチョンや間違いの指摘も大歓迎です。
気軽に意見を交換できたらなぁと思っています。
スタイルの上書きは正か
twMerge()
、便利ですよね。
通常、JIT mode を使用した tailwind では props.className
等で競合するスタイルをコンポーネントの外部から注入したとき、注入したスタイルが優先されたりされなかったりします。[1]
しかし、tailwind-merge
が提供する twMerge()
を使用することで、競合する class を事前に消去して安全なスタイルの注入と上書きを提供してくれます。
さらにメタ的な視点では、このことはコンポーネントの汎用性が向上することを意味します。
これにより、何人かの開発者は twMerge()
を使うことによる幸せスパイラルを感じることすらあるかもしれません。
かく言う私もその一人でした。
しかし、そもそも スタイルを外部から上書きするという行為は果たして安全なのでしょうか?
スタイルの上書きが負なケース
具体例で考えましょう。
このセクションでは、スタイルの上書きが開発者にどのような悪影響を与えるかについて、ストーリー形式の例で説明します。
開発者A さんは、以下のようななんの変哲もない CommonComponent
を実装しました。
A「
props
でclassName
を受け取ってそれをtwMerge()
で安全に上書きして...。これで汎用的なコンポーネントになったな!(ヨシ!」
type Props = Omit<ComponentPropsWithoutRef<'div'>, 'children'>;
export const CommonComponent = ({ className, ...props }: Props) => (
<div className={twMerge('flex flex-col gap-2 bg-blue-500 p-10', className)} {...props}>
<p>HogeHoge</p>
<p>FugaFuga</p>
</div>
);
CommonComponent
の見た目
その後 開発者B さんが CommonComponent
を使おうとしたとき、スタイルを上書きする必要が生じました。
B「縦並びになってるけど横並びにしたいなぁ」
B「よくわかんないけどblock
とかflex
とか使ってそうだなぁ」
B「なんかflex-row
にしたら横並びになったから、きっと内部でflex
を使ってるんだなぁ (ヨシ!」
const ParentComponent = () => (
<CommonComponent className="flex-row" />
);
ParentComponent
の見た目
それからしばらくして、開発者A さんが CommonComponent
に以下のような変更を加えました。
A「あーやっぱここ
block
にしたいなぁ」
A「実装完了!見た目は変わってないし、build
もlint
も通った!(ヨシ!」
- <div className={twMerge('flex flex-col gap-2 bg-blue-500 p-10', className)} {...props}>
+ <div className={twMerge('block space-y-2 bg-blue-500 p-10', className)} {...props}>
CommonComponent
の見た目
何 を み て ヨ シ ! っ て 言 っ た ん で す か
ParentComponent
の見た目
上図からわかるように、CommonComponent
の display
が flex
から block
になったことにより、ParentComponent
の見た目が明らかに変わってしまいました。[2]
これを修正するには、ParentComponent
に以下のような変更を加える必要があります。
B「明示的に
display: flex
にして、space-y-2
を消すためにspace-y-0
をして、元の余白と同じになるようにgap-2
にして...。これで元通り!(ヨシ!」
const ParentComponent = () => (
- <CommonComponent className="flex-row" />
+ <CommonComponent className="flex flex-row gap-2 space-y-0" />
);
ParentComponent
の見た目
何 を み て ヨ シ ! っ て 言 っ た ん で す か
確かにこれで、見た目上は元通りになりました。
しかし、ここには致命的な問題が潜在しています。
space-y-2
と gap-2
です。
このように、スタイルを注入する側とされる側とで余白を別々のプロパティで表現したことにより、今後 CommonComponent
が space-y-4
として余白を 16px としたとしても、ParentComponent
では余白は gap-2
で指定しているので 8px のまま変わりません。
ParentComponent
は CommonComponent
の方向以外のスタイルを継承したかったのに、これでは継承の意味がほとんどなくなってしまいました。
A「ただ
flex
をblock
にしただけなのに...」
B「ただ要素を横並びにしたかっただけなのに...」
スタイルを上書きすることの問題点
さて、以上のストーリーを踏まえて、ここでの問題点をまとめましょう。
- 注入される側のコンポーネントの変更で、注入する側のコンポーネントのスタイルは容易に変わってしまう
- これを防ぐためには、注入する側は注入される側にどんなスタイルがあるのかを知る必要があるし、注入される側は注入する側がどんなスタイルを注入しているのかを知る必要がある
-
build
でもlint
でもエラーが発生しないため、静的解析だけではスタイルの意図しない変更を検知できない[3]
こういった諸々の問題の根底には コンポーネントを隠蔽できていない といった問題があると自分は考えています。
コンポーネントを隠蔽するには
以前のセクションでは、twMerge()
を使用すると柔軟性や汎用性の向上を享受できる反面、隠蔽度が低下するといった問題があることを説明しました。
では、この問題を解決するにはどうすればいいのでしょうか。
このセクションでは、具体的に 2つ の手法を紹介します。
条件分岐で隠蔽する
twMerge()
が登場する以前、我々はある手法でコンポーネントの汎用性を高めていたはずです。
そう、ユニオン型による条件分岐です。
このような手法を用いて先ほどのコードを書くとすれば、以下のようになるでしょう。
type Props = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'className'> {
direction?: 'column' | 'row'
};
export const CommonComponent = ({ direction = 'column', ...props }: Props) => (
<div
className={twMerge(
'flex gap-2 bg-blue-500 p-10',
direction === 'column' ? 'flex-col' :
direction === 'row' ? 'flex-row' :
'',
)}
{...props}
>
<p>HogeHoge</p>
<p>FugaFuga</p>
</div>
);
ここで、もし display: block
にしたいと思ったとしても、影響範囲はコンポーネント内部に留められます。
type Props = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'className'> {
direction?: 'column' | 'row'
};
export const CommonComponent = ({ direction = 'column', ...props }: Props) => (
<div
className={twMerge(
'bg-blue-500 p-10',
direction === 'column' ? 'block space-y-2' :
direction === 'row' ? 'flex gap-2' :
'',
)}
{...props}
>
<p>HogeHoge</p>
<p>FugaFuga</p>
</div>
);
Variant API で隠蔽する
今回の例のように条件が少ない場合であれば、三項演算子やヌル合体演算子によるスタイルの出し分けは妥当な手法に思えます。
しかし、例えば条件の数が 10 や 20 だとしたらどうでしょうか。
ネストがとんでもなく深くなってしまうことは想像に難くありません。
属人化待ったなしです。
そのような場合の代替案として、tailwind-variants
を使用することを提案します。[4]
この手法を用いると、コードは以下のようになります。
const style = tv({
base: 'flex gap-2 bg-blue-500 p-10',
variants: {
direction: {
column: 'flex-col',
row: 'flex-row',
},
},
});
type Props = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'className'> {
direction?: 'column' | 'row'
};
export const CommonComponent = ({ direction = 'column', ...props }: Props) => (
<div className={style({ direction })} {...props}>
<p>HogeHoge</p>
<p>FugaFuga</p>
</div>
);
display: block
とする場合のコードはこうです。
const style = tv({
base: 'bg-blue-500 p-10',
variants: {
direction: {
column: 'block space-y-2',
row: 'flex gap-2',
},
},
});
type Props = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'className'> {
direction?: 'column' | 'row'
};
export const CommonComponent = ({ direction = 'column', ...props }: Props) => (
<div className={style({ direction })} {...props}>
<p>HogeHoge</p>
<p>FugaFuga</p>
</div>
);
この場合でも影響範囲はコンポーネント内部に留まっていますね。
さらに、条件 (variant) とそれに対応するスタイルが構造化されているので、可読性や拡張性も十分に高いと言えるでしょう。
結論 (再掲)
- コンポーネント内部で CSS を出し分けてスタイルを完全に隠蔽しようぜ
- この実現には
tailwind-variants
を使うといいのかもしれない
- この実現には
おわりに
結構長々と語ってしまいましたが、正直なところ自分も tailwind-variants
の手法はまだあまり経験できていません。
そのため「使ってみた!こう思ったわー」みたいな感想でも、めちゃ参考になりますしめちゃ嬉しいです!
Have a nice tailwind day~!
参考
本記事の主題について自分の考えをまとめる際に参考にさせていただいた記事を簡単に一覧させていただきます。
素敵な記事をありがとうございました 🙏
-
この現象については simonswiss さんが動画でとてもわかりやすく解説してくださっています: https://youtu.be/tfgLd5ZSNPc ↩︎
-
CommonComponent
と同じ見た目になったのはたまたまです。 ↩︎ -
これを防ぐ方法として網羅的にビジュアルリグレッションテストを設けるといったことが挙げられますが、それは今回の問題の本質ではないため割愛します。 ↩︎
-
tailwind-variants
の詳細については YEND さんによる記事をご覧ください: https://zenn.dev/yend724/articles/20230603-wgnqrgmj8kymzpev ↩︎
Discussion