ベーシック アドベントカレンダー 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/header
と layouts/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/show
や partials/_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から作り直してます。