emotion - フレームワークに依存しない洗練された CSS-in-JS

wakamsha
79

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2018 の投稿記事です。

emotion って何?

styled-components, glam, glamor, glamorous といった CSS-in-JS ライブラリにインスパイアされた比較的後発のライブラリです。後発なだけあって非常に多機能であり、他ライブラリの機能の多くを更に洗練させた上で備えています。下記はその一部。

  • CSS prop
    • いわゆる JSX ( TSX ) にインラインでスタイルを定義するというもの
  • Styled Components 記法のサポート
  • Composition
    • SCSS で言うところの mixin
  • Object Styles
    • <style></style> タグを CSS セレクタを生成するだけの機能
    • 今回メインでご紹介するのはこちら
  • Nested Selectors
    • SCSS のようなセレクタのネスト記述がそのまま使える
  • etc...

当エントリで注目したいのは Object Styles です。CSS セレクタ文字列(もちろんユニーク性は担保される)とそれに対応した <style></style> タグを生成して HTML head 内に埋め込むだけという純粋に CSS 定義に限定した機能です。つまり emotion は styled-components のように特定のフレームワークにロックインされない、広く導入が可能なライブラリということです。

今回はこの視点から emotion をご紹介します。

とりあえず動かしてみるところまで

前置きはこのくらいにして実際にコードを書いてみるとしましょう。

TypeScript のすゝめ

サンプルコードは全て TypeScript ( *.ts *.tsx ) で記述します。emotion はデフォルトで TypeScript をサポートしているため、TypeScript ベースのプロジェクトであれば CSS プロパティの入力補完といった恩恵が得られます

// emotion は各種 CSS プロパティ用に csstyle というライブラリに依存している
export type PositionProperty = Globals | "-webkit-sticky" | "absolute" | "fixed" | "relative" | "static" | "sticky";
...
// OK
const validStyle = css({
  position: 'absolute',  // => 🙆‍♀️
});

// NG
const invalidStyle = css({
  position: 'absoluteeeee', // => 🙅‍♀️ 型定義に無い文字列なのでコンパイルエラーとなる 
});

JSX ( TSX ) で emotion w/ create-react-app

とりあえず適当な React 製アプリケーションを立ち上げて JSX ( TSX ) に emotion でスタイルを適用するところまでやってみましょう。

# React プロジェクト作成
# 使用言語に TypeScript を指定します
$ create-react-app hello-emotion --scripts-version=react-scripts-ts

# emotion インストール
$ npm install --save emotion

今回は create-react-app で手軽にプロジェクトを作りましたので、 /src/App.tsx が既に用意されてます。これを下記のように編集します。

import { css } from 'emotion';  // css モジュールをインポート
import * as React from 'react';
import './App.css';

import logo from './logo.svg';

// Style 定義
// プロパティ名はキャメルケースとなる
const myStyle = css({
  color: 'hotpink',
  fontSize: '3rem',
  fontWeight: 'bold',
});

class App extends React.Component {
  public render() {
    return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h1 className="App-title">Welcome to React</h1>
        </header>
        <p className="App-intro">
          To get started, edit <code>MARKDOWN_HASH46ef2374da6211486f3a408db68b2bcbMARKDOWN_HASH</code> and save to reload.
        </p>
        {/* className に渡す */}
        <p className={myStyle}>Hello emotion 👩‍🎤</p>
      </div>
    );
  }
}

export default App;

yarn start でアプリケーションを起動すると以下のように表示されるはずです。

Hello emotion 👩‍🎤 という文字列にスタイルがあたってますね。HTML 上はどの様になってるのでしょうか。

<style data-emotion="css">
.css-qtg0sz{
  color: hotpink;
  font-size: 3rem;
  font-weight: bold;
}
</style>
…
<p class="css-qtg0sz">Hello emotion 👩‍🎤</p>

myStyle という変数名が .css-qtg0sz に変換されてます。 css- は emotion 側で付与される接頭辞で、 qtg0sz 部分は myStyle で定義したスタイルオブジェクトをハッシュ化したものです。仮に alternativeStyle という異なる変数名を定義しても内容のスタイル定義が全く同じであれば myStyle と同じセレクタ名に変換されて統合されるので、無駄なスタイル定義が追加されることはありません。

Cycle.js ( Snabbdom ) で emotion

エコシステムの少ない Cycle.js でも emotion であれば問題なく CSS-in-JS が実現出来ます。

# 必要な Node モジュールをインストール
$ npm install xstream @cycle/{run,dom}
$ npm install --save emotion

公式サイトのサンプルコードを下記のように編集して emotion を適用します。

import { run } from '@cycle/run'
import { div, label, input, hr, h1, makeDOMDriver, DOMSource, CycleDOMEvent } from '@cycle/dom'
import { css } from 'emotion';  // css モジュールをインポート

type So = {
  DOM: DOMSource;
};

const myStyle = css({
  color: 'hotpink',
  fontSize: '3rem',
  fontWeight: 'bold',
});

function main(sources: So) {
  const input$ = sources.DOM.select('.field').events('input')

  const name$ = input$
    .map((ev: CycleDOMEvent) => (ev.target as HTMLInputElement).value)
    .startWith('');

  const vdom$ = name$.map(name =>
    div([
      label('Name:'),
      input('.field', { attrs: { type: 'text' } }),
      hr(),
      h1(`.${myStyle}`, ['Hello ' + name + ' × emotion 👩‍🎤'])
    ])
  );

  return { DOM: vdom$ }
}

run(main, { DOM: makeDOMDriver('#app-container') })

Snabbdom の場合ですと CSS セレクタとして指定するため、 `.${myStyle}`. を頭に付け足して記述します。

webpack なりで TypeScript をコンパイルしてブラウザから動作確認してみます。

念の為 HTML 上はどうなっているのか見てみましょう。

<style data-emotion="css">
.css-qtg0sz {
  color: hotpink;
  font-size: 3rem;
  font-weight: bold;
}
</style>
...
<h1 class="css-qtg0sz">Hello  × emotion 👩‍🎤</h1>

こちらも問題なくスタイルが適用されていますね。React の時と同様、ハッシュ化された CSS セレクタ名に変換されているのが分かります。Cycle.js で念願の CSS-in-JS が実現出来ました 🎉

emotion ( Object Styles ) の特徴について

CSS プロパティはキャメルケースとなる ※ 一部例外あり

CSS の場合、プロパティ名は margin-left, font-size のようなケバブケース(単語をハイフンで繋ぐ)ですが、emotion は JavaScript ベースなのでキャメルケースとなります。

.myStyle {
  color: hotpink;
  font-size: 3rem;
  font-weight: bold;
}
const myStyle = css({
  color: 'hotpink',
  fontSize: '3rem',
  fontWeight: 'bold',
});

ただし、例外として -webkit-appearance のようなベンダープレフィックス付きのプロパティは、キャメルケースではなくパスカルケース(先頭が大文字から始まるキャメルケース)となります。

const resetStyle = css({
  WebkitAppearance: 'button',
  WebkitTextSizeAdjust: '100%',
  MozAppearance: 'none',
  '-ms-progress-appearance': 'none',
});

さらに -ms- に関してはなぜか TypeScript の型定義がされていないため、IE 対応が必要な場合は上記のようにプロパティ名も文字列としてすることになります。こういった細かな違いにつきましてはご利用のテキストエディタや IDE の補完機能に頼りながら書いていくと良いでしょう。

AltCSS ( SCSS とか Stylus ) のような記法がそのまま使える

SCSS や Stylus1)最近は PostCSS の導入事例も着実に増えていますね。 のような AltCSS といえば nest 記法や mixin ですよね。emotion であれば AltCSS と全く同じ感覚で記述可能です。

nest 記法

const nestedStyle = css({
  position: 'relative',
  '&:after': {
    display: 'block',
    content: `''`,
    width: '30px',
    height: '30px',
  },
});

プロパティ名には変数も利用可能ですので、emotion で定義したスタイルを他の定義の中にネストして書くことでスタイルを上書く事も出来ます。

const childStyle = css({
  padding: '8px 12px',
  ...
});

const parentStyle = css({
  ...
  [`.${childStyle}`]: {
    padding: '16px 20px',
    ...
  },
});

mixin

emotion の css() 関数の引数は『可変長引数』となっており、各引数の値を合成した結果を最終的なスタイルとして返します。

const baseStyle = css({
  display: 'flex',
  background: 'transparent',
  borderBottom: '1px solid transparent',
});

const selectedStyle = css(
  baseStyle,
  {
    background: 'hotpink',
    borderColor: 'red',
  },
);
<style data-emotion="css">
.css-xxxxxx {
  display: flex;
  background: transparent;
  border-bottom: 1px solid transparent;
}
</style>

<style data-emotion="css">
.css-yyyyyyy {
  display: flex;
  background: transparent;
  border-bottom: 1px solid transparent;
  background: hotpink;
  borderColor: 'red';
}
</style>

詳細は後述しますが、この機能を駆使することで emotion でもある程度スタイルの上書き ( Cascading ) を制御することが出来ます。

変数や関数といった JavaScript のロジックがそのまま使える

『色情報』『文字サイズ』『余白の基準値』『Transition の値』など複数箇所で使い回すような値は変数として定義しておくと便利です。TypeScript であれば enum として定義しておくと更に良いでしょう。

変数(enum)

export enum Color {
  ...
  // Text
  TextDefault = '#474A5E',
  TextSub = '#808d96',
  ...
}

export enum FontSize {
  Tiny = '12px',
  Small = '14px',
  Regular = '16px',
  Large = '20px',
}

const titleStyle = css({
  color: Color.TextDefault,
  fontSize: FontSize.Large,
});

関数

mixin と似ていますが、プロパティの組み合わせのみを共通化したい場合や引数に応じて部分的に変更された値が欲しい場合は関数として定義しておくと便利です。

export function square(val: string | 0) {
  return {
    width: val,
    height: val,
  };
}

const regularStyle = css({
  ...square('24px'),
});

const largeStyle = css({
  ...square('48px'),
});
<style data-emotion="css">
/** regularStyle */
.css-xxxxxx {
  width: 24px;
  height: 24px;
}
</style>

<style data-emotion="css">
/** largeStyle */
.css-yyyyyyy {
  width: 48px;
  height: 48px;
}
</style>

原則として『!important』を使ってはいけない

CSS はその名の通りスタイル定義を上から順に評価(Cascading)して、セレクタの後勝ちルールや優位性に応じて順次上書きしていく仕様です。そのため、コーダーがスタイル定義の記述順序を意識することである程度スタイルの上書きを制御出来ます。

一方 emotion は、内容の重複したスタイル定義をマージするなどの処理が入り込むためスタイルの定義順序が担保されません。そのためピュアな CSS と同じ感覚でコーディングしていると思い通りにスタイルが適用されないといった事が発生しがちです。

「それなら !important を使えば良いじゃん」 と思いがちですが、あいにく emotion は !important の使用を良しとしていません。使えないこともないですが、使用すると console.warn() で指摘されてしまいます。またプロパティによっては『string literal』として型定義されているため、これに対して !important をつけるとコンパイルエラーとなります。

// 型定義に無い文字列なのでコンパイルエラーとなる 🙅‍♀️
const invalidStyle = css({
  position: 'absolute !important',
});

// as を使えばコンパイルは通るがお行儀が悪い 🤔
const outOfStyle = css({
  position: 'absolute !important' as PositionProperty, 
});

そもそも !important はスタイルの本来の優位性を無視した定義であるため、ブラウザレンダリングエンジンの効率的な解析を阻害する要因です。そのためパフォーマンスに振り切った AMP では !important の利用が禁止されているくらいです。

emotion においては mixin を駆使してスタイルの上書きを制御しましょう

emotion を他の util 系 modules と組み合わせてみる

SCSS や Stylus の関数の多くはカラーコードをよしなに管理してくれます。例えば HEX ( #ff0000 ) と RGB ( 255, 0, 0 ) との相互変換等ですが、あいにく emotion にそういった機能はありません。

そこで csx というカラーコード管理に特化したライブラリの登場です。もともとは TypeStyle というライブラリの機能の一つですが2)TypeStyle は emotion の Object Styles に非常によく似たライブラリです。、独立した Node モジュールとして提供されているため、簡単に emotion と組み合わせることが出来ます。

今回は簡単な例として『HEX ( #ff0000 ) で enum 定義したカラーを半透明にしたい』ケースをご紹介します。色の半透明指定は RGBA でなくてはならないため、 HEX そのままでは使えません。ビット演算による変換処理が必要ですが、 csx はそういった処理を担う util 系ライブラリです。

# インストール
$ npm install --save csx

それではカラーコード #ff0000rgba(255, 0, 0, 0.8) に変換してみましょう。

import { css } from 'emotion';
import { color } from 'csx';

enum Color {
  Primary = '#ff0000',
}

const filledStyle = css({
  background: `${color(Color.Primary).fade(0.8)}`, 
});

csx にある css というモジュール関数を使います。これにカラーコードを渡すと ColorHelper なるオブジェクトが返され、ここから様々な関数を実行して任意の値を求めます。ここでは fade という関数を呼び出して RGBA に変換しています。

ちなみに fade 関数の戻り値もまた ColorHelper 型であり、 background プロパティは BackgroundProperty<TLength> | BackgroundProperty<TLength>[] という型なので、このままでは型不一致エラーとなります。ですが fade の戻り値をテンプレートリテラルで文字列変換すると rgba(255, 0, 0, 0.8) という文字列が返されるので、無事にコンパイルが通るようになります。便利ですね。

<style data-emotion="css">
.css-zzzzzzz {
  background: rgba(255, 0, 0, 0.8);
}
</style>

問題なく動作しています。これでますます emotion の利便性が向上することでしょう。

締め - CSS-in-JS なら JavaScript ( TypeScript ) の恩恵をフルに受けられる

CSS のグローバル名前空間という問題を解消するというのが CSS-in-JS の強みですが、これ自体は『CSS Modules』でも解消出来ます。むしろこちらは正真正銘 CSS ( SCSS, Stylus 等含む ) をそのまま記述出来るので、こちらの方が好みという方も大勢いるでしょう。既存のノウハウがそっくりそのまま活かせるというのは確かに強みです。そこは僕も同意です。

一方 CSS-in-JS は JavaScript そのままなので、JS の記法やテクニックがそのまま応用できるという強みがあります。そして何よりエディタや IDE による入力補完も最大限活用出来るというのは無視できないでしょう。プロジェクトが大規模になってくればなおさらです。

当記事が皆さまのお役に立てれば幸いです。

脚注   [ + ]

1. 最近は PostCSS の導入事例も着実に増えていますね。
2. TypeStyle は emotion の Object Styles に非常によく似たライブラリです。