レシピサービスのフロントエンドに CSS in JS を採用した話

こんにちは。技術部クックパッドサービス基盤グループのkaorun343です。我々のチームでは レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回はこの新システムの CSS の話 です。

背景

クックパッドのレシピサービスを Next.js と TypeScript で置き換えはじめた当初、CSS については Next.js に標準で組み込まれているCSS in JS ライブラリである styled-jsx を使っていました。プロジェクトが大きくなりはじめたタイミングで 「CSS の技術選定を考えなおしてもいいかもしれない」とチームの中で話し合い、改めて技術選定をしました。

技術選定

結論として、本システムでは CSS in JS ライブラリのemotion を採用しました。emotion には css prop を使う @emotion/react(旧 @emotion/core) と、 styled-components ライクな記法が使える @emotion/styled がありますが、css prop を使う @emotion/react を採用しました。記法については React の style prop のような Object Styles ではなく、通常の CSS と同じように書くことができる、タグ付きテンプレートリテラルを用いた String Styles を採用しました。

npmtrends を見ると、一番ダウンロード数が多いのは @emotion/react の昔の名前である @emotion/core となっています(技術選定時は @emotion/core だったので、こちらの名前で比較)。しかし @emotion/styled が依存するライブラリであるため、@emotion/react 単体で使うケースはそう多くありません。したがって @emotion/styled と styled-components が広く使われていることがわかります。

f:id:kaorun343:20210313093352p:plain
CSS in JS ライブラリのダウンロード数

まず、ダウンロード数が多くないにも関わらず @emotion/react による css prop を採用した理由を説明します。

1 点目はカプセル化ができるという点です。CSS はグローバルな名前空間であるため名前が衝突し、他の要素に意図せずスタイルを当ててしまう可能性があります。

<main class="main">
  <section class="section">
    <h2 class="title">タイトル</h2>
  </section>
</main>
/* スタイルA */
.main {
  .title {
    color: green;
  }
}

/* スタイルB */
.section {
  .title {
    color: orange;
  }
}

例えばこの例では、 スタイル A とスタイル B がどちらも .title を指定しているため、 h2.title の文字色は 2 つの記述する順番に依存します(この例の順番だとオレンジ色になります)。 BEM などの命名規則を採用すれば、名前の衝突を避けることができますが、スタイル名の記述が長くなってしまう別の問題があります。 一方で emotion はスタイルごとに一意な ID が割り振られるため、セレクタ名が競合することはありません。したがって上記のような問題を防ぐことができます。

2 点目は静的解析がしやすいという点です。CSS Modulesstyled-jsx では、通常の CSS と同様にセレクタを記述して className にクラス名を書いていくことになります。これらの手法の問題点は、クラス名を間違っていたとしても実行するまで気づくことができないこと、未使用のスタイルを静的解析により検出することができないことです(前者については、通常は Web ブラウザに画面を表示しながら作業するためそれほど大きな問題ではありませんが)。一方 emotion は、ESLint や TypeScript コンパイラといった JavaScript 向けの静的解析ツールにより、未使用の CSS を簡単に検出できます。

3 点目はコードレビューのしやすさです。これは styled-components の記法との比較です。styled-components の記法では、スタイルが当てられた DOM 要素や、スタイルが上書きされた既存のコンポーネントがどれもパスカルケースのコンポーネントとなります。

// styled-components の記法
export const StyledComponent: FC = () => {
  return (
    <Wrapper>
      <IconWrapper>
        <BellIcon />
      </IconWrapper>
      <MainText>お知らせ</MainText>
      <RightText>10</RightText>
    </Wrapper>
  );
};

// css propによる記法
export const CssProp: FC = () => {
  return (
    <div css={wrapperStyle}>
      <div css={iconWrapperStyle}>
        <BellIcon />
      </div>
      <div css={mainTextStyle}>お知らせ</div>
      <div css={rightTextStyle}>10</div>
    </div>
  );
};

この2つのコンポーネントを見ていただければわかると思いますが、パスカルケースの JSX タグを見たときに、それが「機能を持つコンポーネント」なのか、もしくは「スタイルが当てられたコンポーネント」なのか、ひと目で判断しにくいです。この理由から styled-components の記法は不採用となりました。

次に String Styles を採用した理由を説明します。

Object Styles では font-size などのケバブケースの CSS プロパティを fontSize のようにキャメルケースで記述しなければなりません。一方で String Styles は通常の CSS と同じように記述できます。通常の CSS の記述に慣れ親しんでいるメンバーが多いため、読みやすさや書きやすさの点から String Styles を採用しました。

const objectStyle = css({
  fontSize: "30px",
  color: "blue",
});

const stringStyle = css`
  font-size: 30px;
  color: blue;
`;

emotion の機能

emotion は stylis という CSS ライブラリが基盤となっており、以下の機能を提供してくれます。

  • ベンダープレフィックスの自動付与
  • セレクタのネスト
  • メディアクエリのサポート
  • テーマ機能のサポート

CSS を返す関数を定義することで、動的に スタイルを生成できます。

const getTextareaStyle = (isValid: boolean) => css`
  border: solid 1px ${isValid ? "green" : "red"};
`;

また別の CSS オブジェクトを元に新しいスタイルを定義できます。

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

/**
 * const buttonSpecialStyle = css`
 *   width: 100%;
 *   color: red;
 *   font-size: 16px;
 * `;
 */
const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

それ以外にも @emotion/babel-plugin により Babel のトランスパイル時にコードの最適化をおこない、不要な改行やインデントの削除などをおこなうことができます。

styled-jsx から emotion への置き換え

この 2 つのライブラリは共存させることが可能だったため少しずつ emotion へ置き換えることができました。移行の過程で CSS の記述を大きく変える必要がなく、プロジェクトが始まって間もない頃で変更箇所が少なかったことから、数回のプルリクエストで移行を完了しました。

emotion を用いた React コンポーネントの書き方

こちらがコンポーネントのコード例です。

import { css } from "@emotion/react";
import { FC, useEffect } from "react";
import { StepListItem } from "./StepListItem";
import { Step } from "./Step";

type Props = {
  steps: readonly Step[];
};

export const StepList: FC<Props> = ({ steps }) => {
  useEffect(() => {
    //
  }, []);

  return (
    <section css={rootStyle}>
      <h2 css={sectionTitleStyle}>手順</h2>
      {stpes.map((step) => (
        <StepListItem key={step.id} step={step} />
      ))}
    </section>
  );
};

const rootStyle = css`
  padding: 8px;
`;

const sectionTitleStyle = css`
  font-size: 16px;
  &:hover {
    text-decoratoion: underline;
  }
`;

CSS in JS では基本的に 1 つのファイルにコンポーネントの HTML、CSS、JavaScript をすべて詰め込みますが、それらを記述する順番が読みやすさに強く影響します。そこで本システムでは CSS をファイルの一番下に配置しました。

CSS は JavaScript よりも HTML (JSX) と密接に関連しています。また React の関数コンポーネントは関数の前半に React フックを書き、後半に JSX タグを書く構成がほとんどです。もし CSS の定義が関数コンポーネントの前に置かれていると、 React フックの行数が増えるほど CSS と JSX が離れてしまい、コードが見にくくなってしまいます。その点今回の配置では、CSS の変数の使用箇所が定義よりも前にくる違和感はありますが、 CSS と JSX が近い位置に配置されてコードが読みやすくなりました。 Vue.js のスタイルガイドにおいても <style></style> を一番下に配置するように推奨しています。

stylelint の導入

CSS の宣言そのものに対する静的解析ツールの stylelint もあわせて導入しました。存在しないプロパティや誤った値の指定を検出してくれます。stylelint 単体では emotion のコードを検証してくれないため、emotion の CSS 宣言をチェックするために、 stylelint-processor-styled-components を利用しています。こちらは名前に styled-components とある通り、もとより styled-components 向けのライブラリです。しかしemotion に対しても問題なく使えます。

Emotion には Next.js の SSR 環境 に起因する問題がありました。これは SSR 環境において:nth-child などの *-childが使えないという問題です( https://github.com/emotion-js/emotion/issues/1178 )。SSR 環境では <style></style> タグが途中に挿入される場合があり、正しくスタイルを当てられない可能性があるためです。

そこで、 *-child の代わりに *-of-type を使うように stylelint にプラグインを追加することで対処しました。

const stylelint = require("stylelint");

const ruleName = "local/unsafe-pseudo-classes";
const messages = stylelint.utils.ruleMessages(ruleName, {
  expected: (unsafePseudoClass) => {
    return `The pseudo class "${unsafePseudoClass}" is potentially unsafe when doing server-side rendering. Try changing it to "${unsafePseudoClass.replace(
      "-child",
      "-of-type"
    )}".`;
  },
});

module.exports = stylelint.createPlugin(ruleName, function (primaryOption) {
  return function (postcssRoot, postcssResult) {
    const validOptions = stylelint.utils.validateOptions(
      postcssResult,
      ruleName,
      {
        actual: primaryOption,
        possible: [true],
      }
    );

    if (!validOptions) {
      return;
    }

    postcssRoot.walkRules((rule) => {
      const unsafePseudoClass = /:(?:first|nth|nth-last)-child/g.exec(
        rule.selector
      )?.[0];

      if (unsafePseudoClass) {
        stylelint.utils.report({
          message: messages.expected(unsafePseudoClass),
          node: rule,
          result: postcssResult,
          ruleName,
        });
      }
    });
  };
});

module.exports.ruleName = ruleName;
module.exports.messages = messages;

テストについて

本システムでは JestReact Testing Library によるコンポーネントのテストをおこなっており、スナップショットテストも活用しています。

@emotion/babel-preset-css-prop を利用することで、CSS の記述を展開してくれます。そのためスナップショットで CSS に変更がないか確認できます。以下にコンポーネントのスナップショットテストの例を示します。

/** @jest-environment jsdom */
/** @jsx jsx */
import { css, jsx } from "@emotion/react";
import { render } from "@testing-library/react";
import { FC } from "react";

const MyComponent: FC = () => {
  return (
    <div>
      <button type="button" css={buttonBaseStyle}>
        Base Style
      </button>
      <button type="button" css={buttonSpecialStyle}>
        Special Style
      </button>
    </div>
  );
};

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

it("renders component", () => {
  const { container } = render(<MyComponent />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    .emotion-0 {
      width: 100%;
      color: red;
    }

    .emotion-1 {
      width: 100%;
      color: red;
      font-size: 16px;
    }

    <div>
      <button
        class="emotion-0"
        type="button"
      >
        Base Style
      </button>
      <button
        class="emotion-1"
        type="button"
      >
        Special Style
      </button>
    </div>
  `);
});

将来的には画像回帰テストも導入して、より堅牢なシステムの構築を検討しています。

さいごに

今回はレシピサービスの新システムにおける CSS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご気軽にご連絡ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/