Elm
css-modules
0

ElmにクリーンでハックのないCSS modulesを

この記事はElmでCSS modulesを使う新しい方法を提案します。
また、ページの多言語対応などにも便利な elm-html-with-context の使い方も一緒に紹介します。

CSS modules とは

CSS modules とは、CSSの再利用性を高める手法の1つです。
従来のCSSにはいわゆる「名前空間」が存在しません。
たとえばボタンに表示される文字の見た目を変える目的で text という名前のクラスに対してスタイルを宣言したとします。
別の日に入力欄に表示される入力文字列の見た目を変えようと思って、同じ text という名前のクラスを使ったとすると、入力欄にだけ適用しようと思った見た目の変化がボタンに表示される文字にも反映されてしまいます。

小さなプロジェクトであれば何ら問題ありませんが、プロジェクトが大きくなってくると「このクラス名は他で使われていないだろうか?」と細心の注意を払う必要があります。
特にCSSファイルが複数存在する場合、全ファイルをチェックしないといけないため、生産性が大きく低下してしまいます。
CSS modules を使うとこのような名前の衝突を防ぐことができます。

CSS modules がやることはとても単純で、CSSファイルを編集してクラス名に「識別子」をつけます。

たとえば button.css というCSSファイルの中で

.text {
  color: red;
}

というスタイルが宣言されているとき、これを

._text_xkpkl_5 {
  color: red;
}

のように自動的に書き換えてしまうのです。

もちろんCSSファイルのクラス名を書き換えるだけでは本来の text クラスを持つHTML要素にスタイルが適用されなくなってしまいますから、書き換えたクラス名の対応関係を JSON オブジェクトとして以下のように出力します。

{
  "text": "_text_xkpkl_5",
  "background": "_background_xkpkl_10"
}

HTML側がこの JSON オブジェクトを使って適切なクラス名を各要素に付与するようにすることで、無事に CSS で定義したスタイルがその要素に適用されます。

このようなクラス名の衝突をなくす工夫によって、アトミックデザインなどに使える再利用可能な CSS の記述が可能になります。

関連手法との比較

「CSS modules を使いたいから Elm で CSS modules を使えるようにした」みたいなのは無能な人がやることです。
いったん落ち着いて「本当に Elm で CSS modules を使う新しい手法を考える必要があるのか」「他にクラス名の衝突を回避する方法はないのか」について考えましょう。

せっかちな方は elm-css-modules-helper の使い方 まで読み飛ばしてください。

CSS in JS (elm-css) との比較

CSS modules とよく比較される手法として、CSS in JS と呼ばれるものがあります。
実際に Elm においても、rtfeldman/elm-css というツールが CSS in JS (この場合は CSS in Elm)の流儀を採用しています。

CSS in Elm である elm-css は、スタイルを Elm のプログラムとして記述し、インラインスタイルとして各要素に直接付与します。
インラインスタイルで付与するため、クラス名の衝突を気にする必要がありません。
ただし、elm-css には多くの課題が存在しており、特に日本の Elm コミュニティでは否定的な意見が多いです。

  1. インラインスタイルを採用しているため、メディアクエリーや擬似クラスなど CSS の便利な機能が使えない
  2. Elm プログラムとしてスタイルを記述するため、Sass や auto-prefixer、flexbugs-fixes などのツールチェーンが使えない
  3. 型安全であることのメリットよりもデメリットが大きすぎる

    型安全性は1年に1度しか実行されないような処理にバグが残るのを防ぐことができる強力な手法ですが、
    スタイルについてはふつう全ての場合/全ての要素についてブラウザ上で目視で確認しながら進めるはずです。
    テキストエディタの補完によって簡単な typo は防げる上に、仮に typo があっても目視で確認しているときに気づけます。
    このようにメリットがほとんどないにも関わらず、「型安全にするため」という名目で独自のプロパティ名を逐一調べないといけません。

  4. スタイルが Elm 内でしか共有できない

    Elm を段階的に導入しているプロジェクトを想像してください。
    一部を Elm で書いていて、他の部分を React や Vanilla JS で書いている場合、他の部分のスタイルと Elm で書かれている部分のスタイルを統一するためには、すでに書かれている CSS を Elm コードに修正したり、
    逆に Elm で書いたスタイルを CSS に書き直さなければいけません。

私自身もかつて elm-css の使い方を模索したことがあり、特に 1 と 2 の問題を解決するために elm-css で PostCSS も使える CSS in Elm という記事を書いたことがありますが、やはり 3 と 4 の問題は解決しなかった上に普通に CSS を書いたほうがシンプルだったため、現在では elm-css は使っていません。

CSS modules は基本的にただの CSS ファイルですから、名前衝突の問題を解消しつつ上記の全ての問題を解決することができます。

BEM 記法との比較

BEM 記法もクラス名の衝突を解消するための手法です。
とてもシンプルな方針で、手作業で block__element--modifier みたいなキモいクラス名を一定のルールにしたがってつけるだけの手法です。
キモさとめんどくささ以外は特にデメリットがないので、無駄にごたごた手間がかかることをするコストと比較した場合に、このような運用によるカバーの方が勝る場面もあります。

今回ご紹介する elm-css-modules-helper を使った手法にもある程度の追加工数がかかるため、「手法のエレガントさ」にだまされずに客観的にどちらが良いかをよく考えた上で手法を選択してください。
私もあまり規模が大きくない場合は BEM 記法をもう少し簡素にした記法で対応することがあります。

css-modules-loader との比較

さて、CSS modules を採用するにしても、実はすでに cultureamp/elm-css-modules-loader というライブラリが存在します。
素直にこのライブラリを使うのではいけないのでしょうか?

実は elm-css-modules-loader には多くの問題があります。
むしろこのライブラリにおいて正しいと言えるのは CSS modules を使おうと思った動機だけで他の全ての選択を間違っています。

冒頭で述べた CSS modules の機能のうち

  • CSS ファイルを書き換えてクラス名に識別子を付与する
  • 変換されたクラス名と、変換前のクラス名の対応を示す JSON を出力する

これらの部分は Elm とは全く関係ないので従来通りに webpack の css-loadermodules オプションを付与するなり、postcss の postcss-modulesでJSONファイルを出力するなりすれば問題ありません。

重要なのは、どうやって Elm プログラム内で変換された後のクラス名を DOM に付与するかです。

elm-css-modules-loader は、Elm コンパイラーで生成された js コードを書き換えて、js コード内に存在する変換前のクラス名を変換後のクラス名に置き換える戦略をとっています。
このような Hacky な戦略を採用した結果

  • elm-hot-webpack-loader がエラーを吐いて動かなくなる
  • Elm コンパイラーの挙動が変わる度に動かなくなる

    実際に Elm 0.19 に対応するのに多くの作業が発生していたようですし、単純なバグフィックスでコンパイラーが新しくなっただけでも動かなくなる可能性があります。

などの具体的な問題が生じています。

さらにライブラリの API も正気の沙汰ではなく、Elm 側に事前に「CSS ファイルの中のこのクラス名を Elm 内で使います」と全部列挙しないといけません。
しかもその記法がすごいんです!

common :
    CssModules.Helpers
        { foo : String
        , bar : String
        , baz : String
        ...
        }
        msg
common =
    css "../../styles/common.scss"
        { foo = ""
        , bar = ""
        , baz = ""
        ...
        }

なんと、型レベルと値レベルの両方にクラス名を書かないといけません。

賢明な読者のみなさんの中には、「クラス名を列挙するのは、CSS 側にそのクラス名を定義しているのを忘れていないかチェックするためなのでは?」と思った方もいるかもしれません。
そんなことはありません。 CSS 側に存在しないクラス名を列挙してもコンパイルは通り、何の警告もされません。
型安全性もなにもなく、ただただ面倒な作業を強いられるだけなのです。
最高にクール!!

こんな面倒な列挙をしないでもよく、view 関数内で普通にクラス名を文字列で渡したらそのクラス名を自動的に変換後のものに置き換える設計だったら最高じゃないですか?
arowM/elm-css-modules-helper を使えば、ハックなしでそんな夢が叶います!

sakura-chan-under-the-bed.jpg

elm-css-modules-helper の使い方

全体の流れ

まず、arowM/elm-css-modules-helper を使った CSS modules との連携について、大まかな流れをご紹介します。

  1. クラス名の変換方法を示した JSON オブジェクトを生成する

    webpack を使っているのであれば css-loader の modules オプションをセットすれば、JS ファイル内で CSS ファイルを require した際にこの JSON オブジェクトを取得できます。
    postcss をお使いの方は postcss-modules で JSON ファイルを生成して、あとはなんかいい感じにがんばってください。

  2. 取得した JSON オブジェクトを flags として Elm に渡す

  3. flags として渡された JSON オブジェクトを CssClass に変換する

  4. view 関数の中で付与したいクラス名を指定する

    Html.Attributes.class の代わりに CssClass.class を使うことで、内部的にそのクラス名を CSS modules によって変換された名前に変更します。

サンプルプロジェクト examples/simple

では実際にサンプルプロジェクトの中身を見てみましょう。
examples/simple についてまず解説します。

クラス名の変換方法を示した JSON オブジェクトを生成する

index.js
var Elm = require('./Main').Elm;
require('./styles/reset.scss');

var layout = require('./styles/layout.scss');
var input = require('./styles/input.scss');
var label = require('./styles/label.scss');
var app = require('./styles/app.scss');

./styles/layout.scss のような CSS ファイル (実際には SCSS) を読み込み、クラス名の変換結果が示された JSON オブジェクトをそれぞれ layout input label app などの変数に格納しています。

取得した JSON オブジェクトを flags として Elm に渡す

index.js
var wrapper = document.createElement('div');
document.body.appendChild(wrapper);
var app = Elm.Main.init({
  node: wrapper,
  // Pass CSS class name JSON object to Elm via flags.
  flags: {
    layout: layout,
    input: input,
    label: label,
    app: app,
  },
});

先ほど取得した JSON オブジェクトをまとめて flags として Elm に渡しています。

flags として渡された JSON オブジェクトを CssClass に変換する

flags として与えられた以下のオブジェクトに対応する型 Style を宣言し、Model に保持できるようにしておきます。

{
  layout: layout,
  input: input,
  label: label,
  app: app,
}
Main.elm
type alias Model =
    { style : Style
    , ...
    , ...
    }


{-| This type represents dictionaries for CSS class names generated by CSS modules.
-}
type alias Style =
    { layout : CssClass
    , label : CssClass
    , input : CssClass
    , app : CssClass
    }

実際に Style 型にデコードするために decoder 関数を定義しましょう。

Main.elm
decoder : Decoder Style
decoder =
    Decode.map4 Style
        (Decode.field "layout" CssClass.decode)
        (Decode.field "label" CssClass.decode)
        (Decode.field "input" CssClass.decode)
        (Decode.field "app" CssClass.decode)

重要なのは CssClass.decode で、これが各 JSON オブジェクトを CssClass 型としてデコードします。

CssClass.decode : Decoder CssClass

CssClass 型の実態は Dict String String ですが、Opaque type として提供しているため、今後ライブラリ側の都合で Dict String String 以外に変更したくなっても互換性を壊さないようになっています。

init 関数内で実際にデコードする際には以下のようにしています。

init : Value -> ( Model, Cmd Msg )
init v =
    ( { style =
            Decode.decodeValue decoder v
                |> Result.toMaybe
                |> Maybe.withDefault
                    { layout = CssClass.empty
                    , label = CssClass.empty
                    , input = CssClass.empty
                    , app = CssClass.empty
                    }
      , ...
      , ...
      }
    , ...
    )

デコードに失敗したときの初期値として使っている CssClass.empty の実態は Dict.empty です。

view 関数の中で付与したいクラス名を指定する

ここまでで準備が整ったので、実際に DOM にクラスをつけてみましょう。

import CssClass exposing (CssClass, class)

label : Style -> String -> Html Msg
label style str =
    wrap style
        [ Html.lazy2 label_ style str
        ]


label_ : Style -> String -> Html Msg
label_ style str =
    Html.div
        [ class style.label "default"
        ]
        [ Html.text str
        ]

このように、通常使う Html.Attributes.class の代わりに CssClass.class を使うだけです。

CssClass.class : CssClass -> String -> Attribute msg

サンプルプロジェクト examples/real-world

さて、勘のいい方はお気づきになったことでしょう。
すべての View 関数に引数として Style を渡さないと CssClass.class が使えません。
そのためには Style をバケツリレーのように引き回し続けないといけません。

inputRow : Style -> String -> (String -> Msg) -> Maybe String -> Html Msg
inputRow style name onInput val =
    wrap style
        [ Html.div
            [ class style.app "inputRow"
            , class style.layout "row"
            , class style.layout "alignCenter"
            ]
            [ label style name
            , expanded style
                [ input style onInput val
                ]
            ]
        ]

何回 style って書かせるねん!
そこまで規模が大きくなければこれでもいいのですが、規模が大きなプロジェクトで View を細分化するとさすがに堪えられなくなります...

そんなあなたに朗報です!
なんと arowM/elm-html-with-context を使えばこのようなコンテクスト(ここでは Style)を明示的に関数に渡さなくても下記のようにすっきり書けます。

view : Model -> Html Msg
view model =
    WithContext.toHtml (toContext model) <|
        wrap
            [ inputRow "item 1" OnInputInputBox1 model.inputBox1
            , inputRow "item 2" OnInputInputBox2 model.inputBox2
            ]


inputRow : String -> (String -> Msg) -> Maybe String -> Html_ Msg
inputRow name onInput val =
    wrap
        [ WithContext.node
            (\{ style } ->
                -- 明示的に `Style` を渡してないのに、なんと `Style` を関数内で使える!!
                -- めっちゃマジカル!!
                Html.div
                    [ class style.app "inputRow"
                    , class style.layout "row"
                    , class style.layout "alignCenter"
                    ]
            )
            [ label name
            , expanded
                [ input onInput val
                ]
            ]
        ]

なんと、CSS modules を使えるライブラリを作っただけでなく、こんなライブラリまで用意してあるなんて、なんと気が利くのでしょう!
しかも、elm-html-with-context は CSS modules を使いたい時以外にも、たとえば多言語対応で選択中の言語を各Viewに渡したいときなどにも大活躍します。

elm-html-with-context を使った全コードは、examples/real-worldで見ることができます。
Elm におけるアトミックデザインの簡易な利用例にもなっているので、興味のある方はぜひチェックしてみてください。

またもや Elm の発展に貢献してしまったので、さくらちゃんへのおやつもお待ちしております!

さくらちゃんにご飯をあげる
sakura-chan-eating-corn.jpg