![見出し画像](https://assets.st-note.com/production/uploads/images/39532789/rectangle_large_type_2_b870ef5a1692b90f013332493aaa481e.png?width=1200)
SassからCSS Modules、そしてstyled-componentsに乗り換えた話
この記事は食べログアドベントカレンダー2020の2日目の記事です。
こんにちは。食べログFE(フロントエンド)チームの金野です。
以前の記事でもご紹介しました通り、現在食べログは、jQuery+Railsだったフロントエンド環境をReact/TypeScriptに置き換えるリプレースを進めています。
CSSもSassからCSS Modulesを経てstyled-componentsに移行中です。
今回は、「どうしてその技術を選んだか」という技術選定の経緯や、どのような規約で運用しているかをご紹介します!
なぜリプレースを始めたのか
まず、CSSの技術選定について触れる前に、リプレースの目的について話さなくてはいけません。
食べログは今年で開設から15年となるサービスです。システムも組織も巨大で、且つ複雑な機能が多くあります。
特にフロントエンドは場当たり的な実装も多く、技術的負債が開発時のボトルネックになっていました。
これまでも「ファイルをモジュール化する」「ECMAScript5をECMAScript6+に置き換える」などの地道なリファクタリングは継続的に続けていましたが、その速度よりもずっと早く、世間のフロントエンドのデファクトはどんどん変わっていきます。
このまま放置していては、
「何もかもが古くてバージョンアップができなくなる」
「ユーザーが求める時代にあったUIを作れない」
「パフォーマンス要件やSEO要件が満たせなくて、検索順位が下がる」
「フロントエンドにたいしてモチベーション高い人が辞めちゃう」
「スキルが有る人がやめた結果品質を保てない」
など、サービスや組織全体にとって深刻な状況につながってしまいます。
そのため、以下を目的にリプレースを始めた次第です。
・改修時や機能追加時に迅速にリリースができるアプリケーションにしたい
・大人数で運用しても壊れにくいアプリケーションにしたい
・継続的に、世間の標準的なフロントエンド技術に追従できるつくりにしたい
速やかにこの理想の状態に近づけることはFEチームの急務でした。
技術選定で大事にする観点
リプレースの目的も考慮して、CSSの技術選定で重視する観点をチームで洗い出し、重要度が高い順にリストアップしました。
1. 堅牢性があるもの。つまり大人数で触っても壊れにくいもの。
2. 3年後も生き残りそうなもの。もしくは置き換えしやすいもの。
3. Sassからの置き換えコストが比較的少なそうなもの。
4. マークアップを担当するデザイナーが触りやすいもの。
5. FEとデザイナーで作業分担・並行作業がしやすいもの。
CSS Modulesを選定するまでの経緯
Sass+jQueryからReactに置き換えるにあたって、CSSの技術選定を始めたのが2019年11月頃でした。
当時はstyled-componentsとemotionがCSS in JSの中では人気でしたが、CSS Modulesもまだまだ現役、といった時勢だったかと思います。
(画像はnpmtrendsでstyled-componentsとcss-loaderとemotionを比較したもの)
チームが取る選択肢としては以下の3つがありました。
A. 一旦現状のまま移行しない(.cssファイルをRailsのviewで読み込む。)
B. CSS Modules
C. emotion, styled-componentsなどのCSS in JS
先程のそれぞれの観点を、最も満たしているのはどの方法なのかを検討しました。
1. 堅牢性があるもの。つまり大人数で触っても壊れにくいものはどれか?
・「A. 一旦現状のまま移行しない」は、つまりCSSをグローバルに置いたままにするということなので、スタイルをローカルスコープに閉じることができない。
BEM/FLOCSSである程度壊れにくい設計にはなっているが、予めすべての汎用モジュールのCSSをグローバルで読み込んでおくことが前提のため、アセットのバンドルサイズを削りたい時に非常に困る。
「使いたい時にモジュール(HTML/CSS/JSのセット)を都度読み込む」という状態にしたい。
よって「A. 一旦現状のまま移行しない」という選択肢はこの時点でNG。
・CSS ModulesもしくはCSS in JSなら、どちらもスタイルをローカルスコープに閉じることができる。
・ただし、CSS ModulesもCSS in JSも「セレクタの書き方次第で子コンポーネントのスタイルの汚染ができる」ので依然として注意は必要。
➡「C. CSS in JS」も「B. CSS Modules」も両方よさそう。
2. 3年後も生き残りそうなもの。もしくは置き換えしやすいものはどれか?
・CSS in JSはどんどん普及していきそう。
・ただ、CSS in JSのどのライブラリが勝利するかはまだわからない。
・また、CSS in JSをプロダクションで採用している他社事例をまだあまり聞かない。
・CSS Modulesは登場してかなり時間が経っている技術だが、とはいえ3年くらいはまだ生き残るのでは…?
・CSSのネイティブからあまり離れていない、という意味ではCSS Modulesは他への移植性は高そう。
つまり、捨てやすく剥がしやすいのでは。
➡「C. CSS in JS」は気になるが、「B. CSS Modules」も悪くなさそう。
3. Sassからの置き換えコストが比較的少なそうなものはどれか?
・FEとしては、下記のリプレースの目的を達成するという部分に注力したい。
・改修時や機能追加時に迅速にリリースができるアプリケーションにしたい
・大人数で運用しても壊れにくいアプリケーションにしたい
・継続的に、世間の標準的なフロントエンド技術に追従できるつくりにしたい
React + TypeScript + ローカルスコープのCSSにする時点で、この目的は達成できるのでは?
・FEが置き換えるなら、移行作業のコストについてはCSS in JSのほうがCSS Modulesよりも若干かかりそう。デザイナーだと、もっとコストがかかりそう。
・リプレースはモジュールごと/ページごとに段階的に行っていくため、同じ機能でも「レガシー版」「モダン版」が食べログ内に共存する期間が発生してしまう。
二重管理せざるを得ないのなら、CSS in JSよりもCSS Modulesのほうが従来のSassにノリが近いため同期コストが低いのでは?
・最もコストが掛からないのは「A. 一旦現状のまま移行しない」だが「1. 堅牢性があるもの。つまり大人数で触っても壊れにくいもの。」という観点を満たしてないので論外。
➡「B. CSS Modules」の方がコストが低そう。
4. マークアップを担当するデザイナーさんが触りやすいものはどれか?
・UIのlook and feelに責任を持っているのはデザイナーのため、UIの見た目はデザイナーがある程度触れるようにしておきたい。
また、CSSバリバリ書けるデザイナーさんが食べログには多い。(5人以上はいる)
・CSS in JSは詳細度でコントロールしていた従来のCSSとは勝手が違う部分が多いため、初期の学習コストはかかりそう。
たとえば、styled-componentsはstyled()でのコンポーネント拡張時に、プロパティを二重で出力して上書きする点など。
・スタイルの中で、propsの値を見てCSSプロパティを出し分けるなど、動的な処理を書くことが多くなりそう。もはやCSSというよりJSの実装に近くなる。
➡「B. CSS Modules」の方が良さそう。
5. FEとデザイナーで作業分担・並行作業がしやすいのはどれか?
・CSS in JSは4.の通りスタイルの中でロジックをもりもり書くことも多くなるので、コンフリクトしないように注意する必要がある。
・とはいえ、ロジックはContainerやPresenterなど別ファイルなどに書くようにすればある程度運用でもカバーできる…?
➡「B. CSS Modules」の方が良さそう。
ということで、CSS in JSとどちらにするか悩みつつも、CSS Modulesでいくという結論になりました。
「若干レガシーな技術を使うのはどうなのか?」
という懸念はありつつも、リプレースの目的である「大人数で触っても壊れにくく、次の技術にも移行しやすいアプリケーションにする」ということが達成できれば、CSSの技術がモダンかどうかは些末な問題では…?という考えでした。
もちろん、CSS in JSの技術調査やCSS Modulesの動向のウォッチは都度行っていこう、ということになりました。
しかし2020年3月、転機が訪れます。
CSS Modulesがメンテナンスオンリーに
webpackのcss-loaderではCSS Modulesはdeprecatedとしてメンテナンスステージになることが判明しました。
食べログのリプレース後のアプリケーションではwebpackを使用しているため、これは無視できない出来事です。
In the near future we want to deprecate CSS modules
CSS modules is maintenance stage (only fixes), it is really old technology and very controversial.
引用元:https://github.com/webpack-contrib/css-loader/issues/1050#issuecomment-592541379
これでは、「3年後も生き残りそうなもの。もしくは置き換えしやすいもの」つまり、「継続的に、世間の標準的なフロントエンド技術に追従できる状態」とはとても言えません。
CSS in JSの中でどれを選ぶか
やはり「3年後も生き残りそうなもの」ということで、CSS in JSが良さそうです。
そしてその中のemotionとstyled-componentsでどちらがよいか再度検討し、styled-componentsを使用することにしました。
styled-componentsを選定した理由
・Reactにもwebpackにも依存せず、スタンドアローンで動く。
・他社の採用実績がある。
・技術顧問のhiroppyさんも経験がある技術なのでアドバイスがもらえる。
・polished等の外部サポートがある。
特に「依存packageが少ない」というのは大きなアドバンテージでした。
ライブラリのアップデートなどのメンテナンスもしやすくなりますし、次の技術に乗り換える上でもメリットがあります。
どのような規約で運用しているか
ここからは、実際にどのような規約でどのようなコードを書いているかご紹介します。
サンプル:
import styled from "styled-components";
import { glyph } from "~/src/main/styles/utils/glyph";
import { palettes } from "~/src/main/styles/constants/theme";
export const StyledLinkArrow = styled.a`
position: relative;
display: inline-block;
padding-left: 15px;
cursor: pointer;
&:hover {
text-decoration: none;
}
&::before {
position: absolute;
top: 0;
left: 0;
color: ${palettes.core.hover};
${glyph("arrowright")};
}
> span:hover {
text-decoration: underline;
}
`;
・CSSはstyle.tsに記載
・StyledComponentを定義したら接頭語として「Styled」をつける。
・クラスセレクタは使わない。
・combinator(半角スペース、>、~、+など)や要素セレクタはなるべく使わない。
・ただし、「> span」のように要素セレクタと子要素コンビネータ「>」とセットにして一階層までならOK。
・別のファイルのコンポーネントを拡張するときはstyled(Component)を使う。
なぜそうしているのか、詳しくご説明します。
3層に分かれたファイル構成
コンポーネントのファイルは以下のような構成にしています。
まずcomponentsディレクトリ配下はAtomic Designのレイヤーごとにディレクトリを分けています。
Atomic Designの運用については以下の記事で詳しく紹介しています。
コンポーネントごとに切ったディレクトリの中にはindex.tsx, presenter.tsx, style.tsを設置し、それぞれContainer層、Presenter層、Style層として責務を分けています。
こうすることで、jsxやスタイルだけ触るデザイナーがロジック壊す心配がないように、また、FEとデザイナーの分業がしやすいようにしています。
別のコンポーネントを拡張するときはstyled()を使う
別のコンポーネントを拡張するときは、もちろんstyled-componentsのAPIであるstyled()を使用します。
import { TabelogButton } from "~/src/main/components/molecules/TabelogButton";
const StyledRedBtn = styled(TabelogButton)`
color: red;
`;
なんらかのセレクタを使って別のAtomic Designコンポーネントのスタイルを上書きすることは禁止しています。
なので下記のようなコードはNGです。
<StyledP>
{/* moleculesである別のコンポーネント。ルート要素がbuttonタグになっている */}
<TabelogButton>ボタン</TabelogButton>
</StyledP>
const StyledP = styled.p`
padding: 15px;
> button {
color: red;
}
`;
このように他のコンポーネントのスタイル汚染を防ぐため、combinator(半角スペース、>、~、+など)や要素セレクタはなるべく使わないようにしています。
例外として、同じコンポーネント内であれば「> span」のように要素セレクタと子要素コンビネータ「>」とセットにして一階層までなら書いてOKにしています。
毎回const StyleHogeと宣言してexportするのも煩雑なためです。
stylelintを導入していますので、規定以上のcombinatorや要素セレクタが書かれたらエラーになるようにしています。
.stylelintrc.json
"rules": {
"selector-max-type": 1,
"selector-max-combinators": 1,
}
また、「一番上の要素だけ色を変えたい、偶数の要素だけスタイルを変えたい」などJSだと実装が複雑になりそうな場合も、disabledコメント付きでuniversal selectorを使用してOKとしています。
/* stylelint-disable-next-line selector-max-universal */
> *:not(:first-child) {
margin-top: 15px;
}
なぜセレクタを使って上書きしてはだめなのか
「なぜセレクタを使って別のコンポーネントを上書きしてはだめなの?」
「従来のSassの運用ではそうやってたけど?」
という点はマークアップを担当するデザイナーとしては当然疑問に思うポイントだったかと思います。
ですのでstyled-componentsの仕組みについては勉強会で詳しく共有しました。
styled-componentsは、Automatic critical CSSという仕組みを採用しており、ページにレンダリングされたコンポーネントを追跡し、必要なスタイルだけを自動的に挿入します。よって別々のファイルで管理されているコンポーネント間のスタイルの順番は保証されていません。
例えば、下記のコードはTabelogAnchorという別のAtomic Designコンポーネントの文字色をセレクタで上書きをしようとする例です。
<StyledP>
<TabelogAnchor>リンク</TabelogAnchor>
</StyledP>
const StyledP = styled.p`
span {
color: blue; /* TabelogAnchor内の子孫要素のspanを上書きする意図 */
}
`;
TabelogAnchor側のコード:
<StyledAnchor>
<span>{children}</span>
</StyledAnchor>
const StyledAnchor = styled.a`
span {
color: red;
}
`;
spanに当たっているセレクタの詳細度はStyledPとStyledAnchorで同じなので、スタイルが出力される順序によって文字色が変わってしまいます。
「とある画面では意図通り青だったのに、別の導線から来たら色が赤に変わってた」ということも起こり得ます。
優先したい側のスタイルの詳細度を上げるようにすれば常に上書くこともできますが、「上書きたい別のファイルのスタイルより詳細度が高くなっているか」を人間がレビュー時に漏れなくチェックするのは正直困難です…。
一方で、styled-componentsのstyled()であればプロパティを二重に出力して元の値を上書きしてくれるため、コンポーネント単位のスタイル順を気にしなくて良くなります。
クラスセレクタは使わない
クラスセレクタは使わず、原則HTML要素ごとにStyledComponentを宣言するようにしています。
ルールがシンプルになり、「クラスセレクタをいつ使うべきか迷う必要がなくなる」ためです。
懸念としては、presenter.tsxですべての要素がStyledHogeとなるため、どのようなDOM構造をしているか若干わかりづらくなる点があります。
これはStyledの後はタグ名にするなど適切な命名をつけることでカバーしています。
presenter.tsx:
<StyledRadio type="radio" id="radioId" value="1" name="radio" />
<StyledRadioLabel htmlFor="radioId">{children}</StyledRadioLabel>
style.ts:
export const StyledRadio = styled(Input).attrs({ type: "radio" })`
...
`;
export const StyledRadioLabel = styled.label`
...
`;
ただし前述したとおり、同じAtomic Designコンポーネント内であれば、「> span」のように一階層までなら要素セレクタと子要素コンビネータ「>」のセットを使ってOKです。
OK:
export const StyledLinkArrow = styled.a`
> span:hover {
text-decoration: underline;
}
`;
stylelintでもクラスセレクタを使用したらエラーが出るように設定しています。
.stylelintrc.json
"rules": {
"selector-max-class": 0,
}
jest-styled-componentsを使用したUnit Test
CSS in JSを導入するメリットの一つとして、Unit Testで見た目に関するロジックの担保がしやすいというものがあります。
従来でも、getComputedStyleを使用すれば以下のようにstyleの読み取りができますが、これでは擬似要素の検証は難しかったです。
expect(window.getComputedStyle(buttonElement).borderColor).toEqual(
"transparent"
);
ですがjest-styled-componentsのtoHaveStyleRule()を使用すれば、疑似要素や:hoverなど擬似クラスを使用したスタイルも簡単に検証できます。
下記はicon={true}というpropsを渡したら矢印アイコンを出すというサンプルです。
export const StyledArrowSpan = styled.span<{ icon: boolean }>`
display: inline-block;
vertical-align: middle;
${({ icon }): FlattenSimpleInterpolation | false =>
icon &&
css`
&::before {
display: block;
width: 25px;
color: black;
text-align: center;
${glyph("thinnext")};
}
`}
`;
it("矢印のグリフが表示されること", () => {
const { getByTestId } = render(<ContentsArrow icon />);
expect(getByTestId("modal-contents-separating-arrow")).toHaveStyleRule(
"content",
`"\\f6b9"`,
{
modifier: `::before`,
}
);
});
また、食べログではスナップショットにもスタイルを出力するようにしているため、スタイル変更時に意図しないファイルやコンポーネントに影響が出た際もすぐに検知できるようになっています。
導入してみてどうたったか
CSS in JSは従来のスタイリングとだいぶノリが異なるため、デザイナーを含めた開発体制の確立には時間をかけました。
すでに数名のデザイナーはstyled-componentsでの開発実績はありますが、他のデザイナーや新規参入者もスムーズに開発に入れるよう、今後も勉強会をしたりペアプロをしてサポートする予定です。
しかしキャッチアップに少し時間はかかるものの、スタイリングをJSで制御できることでより柔軟に実装できますし、なによりUnit Testでの品質担保もしやすくなり全体的には従来より効率的な運用にできそうです。
また、Sass→CSS Modules→styled-componentsにくるまで紆余曲折がありましたが、「技術選定のとき大事にする観点はなにか」を予め明確にしておいたおかげで、スムーズにチームでの意思決定ができたように思います。
最後に
食べログFEチームでは、一緒にリプレースに取り組んでくれる仲間を大募集中です!
・難しい課題にチーム一丸となって取り組みたい
・柔軟に働ける環境で自分のスキルを活かしたい
・React/TypeScriptでバリバリ開発したい
・レガシーなシステムのリファクタリングがしたい
・アーキテクチャについて探求したい
・食べログというプロダクトに貢献したい
どれかに当てはまった方は以下のリンクを是非御覧ください!
明日は食べログアドベントカレンダー2020の3日目の記事が公開されるのでご期待ください!