はじめに
この記事はHRBrain Advent Calendar 2022カレンダー2の6日目の記事です。
こんにちは、フロントエンドエンジニアの村崎です。
Amazonのブラックフライデーでルンバを買いました。
この記事では、スタイルを上書きできるコンポーネントの説明と、なぜなるべく作らない方がいいのかを書きます。
前提
この記事中でのスタイリングにはstyled-componentsを用います。
CSS in JSを前提としますが、別ライブラリやCSS modulesによるスタイリングでも共通項があると思います。
スタイルを上書きできるコンポーネントとは?
styled-componentsでは以下のようにすることで、スタイルの上書きを許容するコンポーネントを作ることができます。
className
をpropから渡せるようにすると、styled-components
がstyleを注入できるようになりoverrideができるようになります。1
marginをコンポーネント自身に持たせたりせず、呼び出し側で管理したい場合などによくこのように実装されているのを見かけます。
import styled from "styled-components"
// 親コンポーネント.tsx
const ParentPage = () => {
return (
<div>
{...}
<Component />
</div>
)
}
const Component = styled(AnyComponent)`
margin-top: 16px;
`
// 子コンポーネント.tsx
type Props = {
className?: string
}
const AnyComponent = (props: Props) => {
return <div className={props.className}>{...}</div>
}
なぜ作らないほうがいいのか
margin以外にもあらゆるCSSが書けてしまうからです。
上記の例ではmargin-topを指定しました。
一見良さそうに見えますが、例えば以下のような子結合子を用いたセレクタによるoverrideを見てみます。
const Wrap = styled(AnyComponent)`
margin-top: 16px;
& > div:first-child {
text-align: center;
}
`
コンポーネントのがわだけではなく、中身のスタイルまで変更されてしまいました。
元のAnyComponent
は自身がどこで使われているかを知り得ないため、コンポーネントの改修などでdiv
ではなくspan
などに変える事がある場合、知らぬ所でスタイルが崩れます...。
さらに、AnyComponent
が必ずしもトップレベルの要素にclassName
を渡しているとは限りません。
なんかよくわからないけど消したら表示が崩れるかも...というCSSの完成です。
実際、親要素で余白を付与したいためだけにclassName
を受け取るようにしたのに、別の画面でも再利用され、その際にかなりスタイルをいじられてしまった...という場面に遭遇することがあります。
じゃあclassName
なんて受け取らずにmargin
を指定するpropにしたらいいじゃん!と思うかもしれませんが、全コンポーネントにそんなpropを生やすなんてことはしたくありません。
ではどうするか
スタイルに変化があるコンポーネントはpropsとして振る舞いを定義する
Polarisなどのデザインシステムのコンポーネントが参考になります。
コンポーネントのインターフェイスとして、スタイルの変化を制御させることで再利用性や保守性を高めることができます。
親要素でのmarginの付与にはレイアウトコンポーネントを使う
レイアウトコンポーネントとは、「パーツのレイアウトや間の余白」に責務を持つコンポーネントのことです。
ChakraUIやMantineといったUIライブラリでこの責務を持つコンポーネント達がレイアウトとグルーピングされているため、レイアウトコンポーネントとこの記事では呼ぶことにします。
これらのライブラリでは、Box
やFlex
、Stack
といったレイアウトコンポーネントを提供しています。
大変使い勝手が良いですが、これらのためにUIライブラリの導入はできない場合があります。
そのため、自前で実装してみます。
簡易的な実装例
import styled from "styled-components"
type StyleProps = {
gap?: 4 | 8 | 12 | 16 | 24
between?: boolean
wrap?: boolean
}
const Wrap = styled.div<StyleProps>`
display: flex;
align-items: center;
${(props) => props.gap && `gap: ${props.gap}px`};
${(props) => props.between && `justify-content: space-between`};
${(props) => props.wrap && `flex-wrap: wrap`};
`
type Props = {
children?: ReactNode
} & StyleProps
export const Stack = (props: Props) => {
return (
<Wrap
gap={props.gap}
between={props.between}
grow={props.grow}
>
{props.children}
</Wrap>
)
}
サンプル
See the Pen Untitled by Murasaki-1102 (@murasaki-1102) on CodePen.
レイアウトコンポーネントを使いやすくするためのtips
必要最低限のpropsにする
classNameを継承させないことにより、propsからでしかスタイルの変更ができないようにしています。
これにより、限られたパターンでのスタイリングを制限できます。
プロパティの値をユースケースで縛る
例えばgapの値はデザインシステムなどの値に合わせnumberのリテラル型にすることで、型レベルで使用を制限できます。
flex-wrap
のようなプロパティはwrap-reverse
のユースケースがない場合、trueならwrap
といったbooleanの型にしてしまうことでシンプルになり、他のプロパティとの衝突を避けることができます。
as propを用いてelementを指定できるようにする
サンプルでは実装していませんが、例えばli
要素でflexなどをしたかった場合に、as="li"
のようなas propを用いるとdomのネストが一段低くなります。2
レイアウトコンポーネントのメリットとデメリット
メリット
CSSを書かなくてよくなる
CSSの事を意識することなく、渡すpropsのことだけ考えればよくなります。
これは初学者やバックエンドエンジニアの方が多くフロントエンドを触るようなチームで有効です。
また、記述量が少なくなるため開発スピードも上がります。
スタイルの統一
サンプルではflexとgapで要素間の余白を実現しました。
このコンポーネントを使うだけで、意図しないgridやmarginの付与を防ぐことができます。治安が良い。
レビューもコンポーネントと渡しているpropsを見るだけなのでコストが低いです。
デメリット
要素が増える
divタグが増えることによるパフォーマンス懸念があります。これが最大のデメリットであり好き嫌いが別れる部分だと思います。
こちらはuhyoさんの記事の計測が参考になります。
DOMレベルでのパフォーマンスチューニングが必要なプロジェクトではレイアウトコンポーネントは採用できない可能性があります。
プロジェクトが大きくなると変更が大変
どのコンポーネントにも言えることですが、使われる箇所が多いほど影響範囲が大きくなります。
変更を感知するために自動テストの仕組みが必要になるでしょう。
終わりに
できるだけスタイルの上書きが可能なコンポーネントを作らないことでCSSの治安を守ることができます。
弊社HRBrainではコンポーネント設計やスタイリングの議論を共にできる仲間を募集しています。
興味を持った方がいればぜひこちらからご応募ください!