ベーシック アドベントカレンダー 6日目。

こんにちは。@rigani_c です。今日も寒いですね。それでは本題です。

ファイルパスで View と CSS を付け合わせて薄いスコープを作る設計、ファイルパススコープについての記事の続編です。
CSS on Rails に治安が訪れます。

本記事は Rails 6.0.1 + Sprockets 4 な環境で動作検証しています。
タイトルで『こうするといいよ』とか言ってるけど、まだ運用してないので破綻したらごめんなさい。

📁 ファイルパススコープ

ファイルパススコープとは、ベーシックで考案・運用している CSS のスコープ設計です。
運用が容易な単純なルールと、書き直しやすさを重視しています。
次の手順で、View ファイルと CSS ファイルを対の関係にします。

  • View ファイルごとのルート要素に、自身の app/views/ 以降のファイルパス(拡張子抜き)を data-scope-path というデータ属性で設定。
  • 対となる CSS 側では [data-scope-path="..."] という属性セレクタで絞って参照。
    ファイルは app/assets/stylesheets/エントリー名( admin, client など)/scopes に格納。

ファイルパスの一意性を用いることでスコープ名の被りが無くなり、更に View ⇄ CSS で互いのファイルを探しやすくなります。

📄 app/views/articles/show.html.slim

article data-scope-path="articles/show"
  .title = @article.title

📄 app/assets/stylesheets/articles/show.scss

[data-scope-path="articles/show"] {
  .title {
    font-weight: bold;
  }
}

一年弱ほど運用していますが、大きな破綻も特になく、快適にスタイルを書くことができています。

🙄 これまでのファイルパススコープの欠点

巨大ファイル

ファイルサイズが スタイル規則 × 属性セレクタ分 肥大化します( CSS Nesting が各ブラウザで実装されるまでは根本的な解決はできません)。
これをエントリーファイルにまとめて配信しているので、キャッシュを持たずにサイトに訪れた人は巨大ファイルのレスポンスを待つことになります。これはたいへんよくない。
一度読み込んでしまえばファイル自体はブラウザがキャッシュしてくれますが、不要なスタイル規則は CSSOM 構築の妨げになります。

エントリーの名前空間

CSS ファイルの配信を Admin, Client などに分けるために、以下のような構成にしていました。

📂stylesheets
┣━ 📁variables
┣━ 📁mixins
┣━ 📂scopes
┃  ┣━ 📂layouts
┃  ┃  ┗━ 📄_header.scss
┃  ┣━ 📂articles
┃  ┃  ┗━ 📄show.scss
┃  ┗━ 📂authors
┃     ┗━ 📄show.scss
┣━ 📄application.scss
┣━ 📄reset.css
┃
┗━ 📂admin
   ┣━ 📁variables
   ┣━ 📁mixins
   ┣━ 📁scopes
   ┃  ┣━ 📂layouts
   ┃  ┃  ┗━ 📄_header.scss
   ┃  ┣━ 📂articles
   ┃  ┃  ┗━ 📄show.scss
   ┃  ┗━ 📂authors
   ┃     ┗━ 📄show.scss
   ┣━ 📄application.scss
   ┗━ 📄reset.css

これだと、filepath-scope="articles/show"admin のことなのか、トップレベルのことなのかが判別しづらくなります。

🧞‍♂️ 次世代のファイルパススコープ

ファイルパススコープごとに CSS を分割して配信することにしました。
HTTP2( + できれば EarlyHints )でドコドコ捌く方針です。

これにより、全ての CSS ファイルがエントリーファイルになるため、Admin, Client などの配信分割が必要なくなります。

📂stylesheets
┣━ 📁variables
┣━ 📁mixins
┣━ 📂scopes
┃  ┣━ 📂layoyts
┃  ┃  ┣━ 📄application.scss
┃  ┃  ┗━ 📄_header.scss
┃  ┣━ 📂articles
┃  ┃  ┗━ 📄show.scss
┃  ┣━ 📂authors
┃  ┃  ┗━ 📄show.scss
┃  ┗━ 📂admin
┃     ┣━ 📂articles
┃     ┃  ┗━ 📄show.scss
┃     ┗━ 📂authors
┃        ┗━ 📄show.scss
┗━ 📄reset.css

🛠 実装

これを叶えるには、

  • 全ての CSS ファイルをエントリーファイルとしてコンパイル
  • 各ページで必要な CSS ファイルだけを呼び出す

をする必要があります。

全ての CSS ファイルをエントリーファイルとしてコンパイル

Sprockets は 4 からエントリーファイルとなる CSS は全て manifest.js で記述していくようになりました。
(基本的に Rails.application.config.assets.precompile には何も設定しません)

rails/sprockets UPGRADING
https://github.com/rails/sprockets/blob/master/UPGRADING.md#manifestjs

そこで、link_tree ディレクティブを使って scopes 以下のファイルを全て link(≒エントリーファイル化)します。

📄 app/assets/config/manifest.js

//= link_tree ../stylesheets/scopes .css
//= link ress.css

これで stylesheet_link_tag で各 CSS ファイルを呼び出すことが可能になりました。

stylesheet_link_tag(エントリーファイルパス, media: 'all')

各ページで必要な CSS ファイルだけを呼び出す

ファイルパススコープはその名の通りファイルパスを利用しているため、使われる View 全ての data-scope-path さえ分かれば、stylesheet_link_tag で CSS を呼び出すことができます。

使われる View 全ての data-scope-pathを取得する

必ず View のルート要素に data-scope-path を記述するという運用を利用し、

article data-scope-path="articles/show"

を Helper 化して、関数を使うごとに data-scope-path をインスタンス変数に蓄積されるようにします。

📄 app/helpers/application_helper.rb

module ApplicationHelper
  attr_reader :scope_paths

  def filepath_scope(tag_name, scope_path, **options)
    @scope_paths ||= [] # rubocop:disable Rails/HelperInstanceVariable
    scope_paths << scope_path

    options.deep_merge!(data: { scope_path: scope_path })
    tag.public_send(tag_name, options) { yield }
  end
end

使い方はこう👇

📄 app/views/articles/show.html.slim

= filepath_scope(:article, 'articles/show')
  .title = @article.title

この程度なら保守されそうですね。

(実は @virtual_path という filepath-scope と同等の値を保持しているインスタンス変数が各 View ファイルから参照できるのですが、Rails のアップデートでいつ仕様が変わるともわからないので、利用を見送っています。)

スコープパスごとに CSS を呼び出す

📄 app/views/layouts/application.html.slim

doctype html

= filepath_scope(:html, 'layouts/application', lang: 'ja') do
  head
    = stylesheet_link_tag('ress', media: 'all')
    - scope_paths.uniq.each do |scope_path|
      = stylesheet_link_tag(scope_path, media: 'all')
  body
    = render('layouts/header')
    = yield
    = render('layouts/footer')

このように head 内で scope_paths を使って stylesheet_link_tag を呼ぶだけ...。
と思いきや、これでは layouts/headerlayouts/footer の CSS が呼び出されません。

レイアウトは yield から実行され、その後、順に上から実行されていくためです。

そのため、scope_paths より先に body 内を実行する必要があります。

doctype html

- body_content = capture do
  = render('layouts/header')
  = yield
  = render('layouts/footer')

= filepath_scope(:html, 'layouts/application', lang: 'ja') do
  head
    = stylesheet_link_tag('ress', media: 'all')
    - scope_paths.uniq.each do |scope_path|
      = stylesheet_link_tag(scope_path, media: 'all')
  body
    = body_content

これで解決です。

読み込み順序の制御

レイアウトは yield から実行されるため、scope_paths はこのような順序で格納されています。

> scope_paths
=> ["articles/show",
 "partials/_viewer",
 "layouts/_header",
 "layouts/_footer",
 "layouts/application"]

この順序のまま CSS を読み込んでしまうと、articles/showpartials/_viewerのスタイルが layouts/application に上書きされてしまう形になります。

これを避けるため、少々ださいですが scope_paths.rotate(-1) をして layouts/application を先頭にずらします(実はこのために content_for の代わりにインスタンス変数を使ってた)。

- scope_paths.rotate(-1).each do |scope_path|
  = stylesheet_link_tag(scope_path, media: 'all')

ヘルパー内でインスタンス変数を使う罪を背負ったりしていますが、ひとまずはこれで形になりました。

おわりの言葉

お願い、破綻しないで......!!!!!!!!

今このエンジニアブログを次世代ファイルパススコープを導入しつつ0から作り直してます。