😶‍🌫️

【tailwind】そのmerge、本当に安全ですか?

2023/07/24に公開

https://www.canva.com/design/DAFnA7U5WMY/hCuKTR54DSCPT4OeWS8uiQ/view?utm_content=DAFnA7U5WMY&utm_campaign=designshare&utm_medium=link&utm_source=publishsharelink

結論

  • コンポーネント内部で 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「propsclassName を受け取ってそれを twMerge() で安全に上書きして...。これで汎用的なコンポーネントになったな!(ヨシ!」

common-component.tsx
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 の見た目
CommonComponent の見た目

その後 開発者B さんが CommonComponent を使おうとしたとき、スタイルを上書きする必要が生じました。

B「縦並びになってるけど横並びにしたいなぁ」
B「よくわかんないけど block とか flex とか使ってそうだなぁ」
B「なんか flex-row にしたら横並びになったから、きっと内部で flex を使ってるんだなぁ (ヨシ!」

parent-component.tsx
const ParentComponent = () => (
  <CommonComponent className="flex-row" />
);

ParentComponent の見た目
ParentComponent の見た目

それからしばらくして、開発者A さんが CommonComponent に以下のような変更を加えました。

A「あーやっぱここ block にしたいなぁ」
A「実装完了!見た目は変わってないし、buildlint も通った!(ヨシ!」

common-component.tsx
-  <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 の見た目
CommonComponent の見た目

何 を み て ヨ シ ! っ て 言 っ た ん で す か

ParentComponent の見た目
ParentComponent の見た目

上図からわかるように、CommonComponentdisplayflex から block になったことにより、ParentComponent の見た目が明らかに変わってしまいました。[2]
これを修正するには、ParentComponent に以下のような変更を加える必要があります。

B「明示的に display: flex にして、space-y-2 を消すために space-y-0 をして、元の余白と同じになるように gap-2 にして...。これで元通り!(ヨシ!」

parent-component.tsx
const ParentComponent = () => (
-   <CommonComponent className="flex-row" />
+   <CommonComponent className="flex flex-row gap-2 space-y-0" />
);

ParentComponent の見た目
ParentComponent の見た目

何 を み て ヨ シ ! っ て 言 っ た ん で す か

確かにこれで、見た目上は元通りになりました。
しかし、ここには致命的な問題が潜在しています。
space-y-2gap-2 です。
このように、スタイルを注入する側とされる側とで余白を別々のプロパティで表現したことにより、今後 CommonComponentspace-y-4 として余白を 16px としたとしても、ParentComponent では余白は gap-2 で指定しているので 8px のまま変わりません。

ParentComponentCommonComponent の方向以外のスタイルを継承したかったのに、これでは継承の意味がほとんどなくなってしまいました。

A「ただ flexblock にしただけなのに...」
B「ただ要素を横並びにしたかっただけなのに...」

スタイルを上書きすることの問題点

さて、以上のストーリーを踏まえて、ここでの問題点をまとめましょう。

  • 注入される側のコンポーネントの変更で、注入する側のコンポーネントのスタイルは容易に変わってしまう
    • これを防ぐためには、注入する側は注入される側にどんなスタイルがあるのかを知る必要があるし、注入される側は注入する側がどんなスタイルを注入しているのかを知る必要がある
  • build でも lint でもエラーが発生しないため、静的解析だけではスタイルの意図しない変更を検知できない[3]

こういった諸々の問題の根底には コンポーネントを隠蔽できていない といった問題があると自分は考えています。

コンポーネントを隠蔽するには

以前のセクションでは、twMerge() を使用すると柔軟性や汎用性の向上を享受できる反面、隠蔽度が低下するといった問題があることを説明しました。
では、この問題を解決するにはどうすればいいのでしょうか。
このセクションでは、具体的に 2つ の手法を紹介します。

条件分岐で隠蔽する

twMerge() が登場する以前、我々はある手法でコンポーネントの汎用性を高めていたはずです。
そう、ユニオン型による条件分岐です。

このような手法を用いて先ほどのコードを書くとすれば、以下のようになるでしょう。

common-component.tsx
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 にしたいと思ったとしても、影響範囲はコンポーネント内部に留められます。

common-component.tsx
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]
この手法を用いると、コードは以下のようになります。

common-component.tsx
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 とする場合のコードはこうです。

common-component.tsx
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~!

参考

本記事の主題について自分の考えをまとめる際に参考にさせていただいた記事を簡単に一覧させていただきます。
素敵な記事をありがとうございました 🙏

https://zenn.dev/link/comments/3d0b06946c5cb1

https://zenn.dev/nyatinte/articles/083ebbe8ab2457#2.-tailwind-mergeを使う

https://qiita.com/mrskiro/items/1d8c4264be2b35a428b1#fnref-1

脚注
  1. この現象については simonswiss さんが動画でとてもわかりやすく解説してくださっています: https://youtu.be/tfgLd5ZSNPc ↩︎

  2. CommonComponent と同じ見た目になったのはたまたまです。 ↩︎

  3. これを防ぐ方法として網羅的にビジュアルリグレッションテストを設けるといったことが挙げられますが、それは今回の問題の本質ではないため割愛します。 ↩︎

  4. tailwind-variants の詳細については YEND さんによる記事をご覧ください: https://zenn.dev/yend724/articles/20230603-wgnqrgmj8kymzpev ↩︎

Discussion