Railsでページごとに読み込むCSSを限定する

Railsで以下の課題があり、解決のためのコードを試し書きしたので概要を記載する

- ページ単位でCSSのスコープを限定したい
- 不要なCSSファイルの読込みを削減したい



### Disclaimer
この記事は、以下の記事を参考に自分用にカスタマイズしたもの。
[Rails の CSS にスコープを持たせてファイル分割する](https://tech.basicinc.jp/articles/166)

意図通りに動くのは確認したが、実運用に耐えるのかは不明。
ページ単位は細かすぎて割に合わないので、普通にController単位のスコープ限定/呼び出しにしたほうが良さげ。

### 課題
railsで以下を実現したい。

#### ページ単位でCSSのスコープを限定したい
常にビューテンプレートのトップレベルに一定のクラス名を設定すれば実現できるが、
もう少し強制力をもたせたい。

#### 不要なCSSファイルの読みこみを削減したい
現状では、「全てのページで全てのCSSファイルを読み込んでいる」ので、
ページ単位で必要なものだけ読み込む形にしたい。

現状はこういう感じ。

```application.scss
@import 'bases/_variable';
@import 'bulma/bulma';

@import 'bases/_base';
@import 'partials/*';
@import 'pages/*';

```

```application.html.slim

doctype html
html
  head
    /...省略
    = stylesheet_link_tag 'application', media: 'all'
    = javascript_pack_tag 'application'

   /...省略

  body
    /省略
```

### 解決策

#### 前提

- SCSS, bulmaを利用
- rails 6.1.3.2
- sprockets 4.0.2
- sprockets-rails 3.2.2

#### 具体的な実装方針/規約
簡単に言うと、`"#{controller_name}/#{method_name}"`に対応した`css-scope`属性を
ページのトップレベル要素として記述し、対応する名前のSCSSをファイルのみを呼び出す。

以下詳細。

- ビューテンプレート(例: app/views/articles/run_melos.html.scss)
    - `css-scope`属性を各ページテンプレートの最上位の要素に記載
    - `#content_for` で`css-scope`をlayoutsファイルに渡す
- layoutsファイル(例: app/assets/layouts/application.html.slim)
    - `= stylesheet_link_tag yield(:css_scope)` でビューテンプレートに対応するSCSSファイルを呼び出す
- SCSSファイル(構成)
  - ビューテンプレートに対して、1:1でSCSSファイル(以下「ページSCSS」)を作成
  - ページSCSSのfullpathは、`app/assets/stylesheets/pages/{#controller_name}/#{method_name}.scss`
  - パーシャルに対して、N:1でSCSSファイル(以下パーシャルSCSS)を作成
- SCSSファイル(記載内容)
  - ページSCSSから、必要なSCSSファイル(パーシャルSCSS含む)を都度importする
  - ページSCSSのトップレベルに、対応するビューテンプレートの`css-scope`を記載
- アセットパイプラインの設定ファイル(config/initializers/assets.rb)
    - `app/assets/stylesheets/pages/`以下のファイルをprecompile対象にするよう設定
 

#### 実装のポイント

```application.html.slim
html 
  head
    / 既存のSCSS体系からの移行用
    - if content_for?(:css_scope)
      / baseでbulma/bulmaを読み込んでいる
      = stylesheet_link_tag 'base', media: 'all'
      = stylesheet_link_tag yield(:css_scope),  media: 'all'
      = stylesheet_link_tag 'partials/_header', media: 'all'
      = stylesheet_link_tag 'partials/_footer', media: 'all'
    - else
      = stylesheet_link_tag 'application', media: 'all'

```

```application_helper.rb
# content_forでcss_scopeをapplication.html.slimに渡しつつ、
# data-css-scopeを設定した(ビューテンプレート内の)トップレベルのHTML要素を作成する
def with_css_scope(tag_name: :div, css_scope:, **options)
  content_for :css_scope, css_scope

  tag_options = options.deep_merge(data: { 'css-scope': css_scope })
  tag.public_send(tag_name, tag_options) { yield }
end
```

```app/views/articles/run_melos.html.slim
= with_css_scope(css_scope: 'pages/articles/run_melos')
  h1.title なぜメロスは怒ったのか
  / 以下普通にHTMLを記述
```

```app/assets/stylesheets/pages/articles/run_melos.scss
// 適宜このSCSSで利用しているSCSSファイルを呼ぶ
@import 'bases/_variable';
@import 'bases/_base';
@import 'partials/_helpers';
@import 'partials/_button';
@import 'partials/_qa';

[data-css-scope='pages/articles/run_melos'] {
  .title {
    font-weight: bold;
  }
  // 以下省略
}
```

```config/initializers/assets.rb
# 省略

Rails.application.config.assets.precompile += %w( 
  application.css 
  partials/_header.css 
  partials/_footer.css 
)

stylesheets_path = 'app/assets/stylesheets/'
Pathname(stylesheets_path + 'pages').
  glob('**/**.scss').
  map  { |e| e.relative_path_from(stylesheets_path).sub('scss', 'css').to_s }.
  each { |path| Rails.application.config.assets.precompile << path }

# 省略
# 注記: https://railsguides.jp/asset_pipeline.htmlにはProcを...assets.precompileに渡す方法が書かれているが
# 仕様が変わったのか受け付けてくれないのでPathnameで個別にファイル名を拾って渡している
```

#### 実装のポイント(＋α)
各View Templateから`@virtual_path`が参照できるので活用する。

```articles/run_melos.html.slim
/ @virtual_pathの値は articles/run_melos
= with_css_scope(css_scope: "pages/#{@virtual_path}")
  h1.title 何故メロスは怒りやすいのか
  / 以下普通にHTMLを記述
```


SCSSファイルからbulmaのクラスや変数を呼び出している場合は、該当するbulmaファイルを個別に読み込む。

```sample.scss
// 変数
@import 'bulma/sass/utilities/initial-variables';


// ユーティリティっぽいクラス
@import 'bulma/sass/utilities/_all';
@import 'bulma/sass/base/_all';

```