CSS

Sassの@importを@use, @forwordに移行するメモ

最近、CSSの自作のライブラリ(のようなもの)を更新しようと思っていろいろ調べていたら、近い将来にSassの@importが使えなくなることを今更ながらに知りました。世捨て人の私にとっては寝耳に水です。

前置き

新しいモジュールシステムなど詳しい説明は他のプロにまかせるとして、とりあえず@importを新しく@use@forwordに書き換えるだけではダメなんじゃいってことでググっているうちにいろいろ知識は得ることはできたけど、具体的な解決策がまだまだ少ないなと感じました。

Sassの新しいモジュールシステム - シフトブレイン/スタンダードデザインユニット

@importの廃止について要約すると、これまでの@importが、読み込んだファイルの変数やミックスインすべてがグローバルな状態(読み込んだ後に別ファイルでいくらでも書き換えられてしまうもの)だったのに対し、@useではファイルの内容がカプセル化され、読み込み先では名前空間(ネームスペース)を使って参照する必要がある、といった大きな違いがあります。20年前のPerlのCGIみたいにグローバルな変数のままでは危なっかしいのでやめましょうってことですね。

ちなみに、うちではコンパイルにお手軽なPreProsというUI付きのアプリを使っているので、LibSassかDart Sassか、とかバージョンがどうか、という環境周りの面倒なことは一切考えないようにしています。そのへんのツッコミはご遠慮ください。

@useで読み込んだモジュールの内容は名前空間を追加して参照します。

@use 'variables';
@use 'mixins';

body {
  color: variables.$text-color;
  @include mixins.bg-color();
}

名前空間は基本的にファイル名で、@use ~ as ~ によって違う名前空間にしたり、アスタリスクでなしにすることもできます。

@use 'variables' as *;
@use 'mixins' as mixin;

body {
  color: $text-color;
  @include mixin.bg-color();
}

アスタリスクで名前空間をなくしてもこれまでの@importと同じようには動作しません。上の例であれば、2行目で読み込んだmixins.scssは、1行目のvariables.scssの中身を参照できないので、改めて変数ファイルをロードする必要があります。

// > mixins.scss
@use 'variables' as var;

@mixin bg-color () {
  background-color: var.$body-bg-color;
}

で、Bootstrapであれ自作であれ、ライブラリやフレームワークのデフォルト変数(!default付き)をプロジェクトごとに上書きして使うなんてときのために使えるのがwithで、配列で変数を渡してデフォルト変数を変更することができます。

@use 'variables' as var with(
  $text-color: #555,
  $body-bg-color: #eee
);
...

ただ、たいていの場合これだけでは解決策にならないです。海外のフォーラムでも見かけましたが、後で読み込んだ別ファイルにはwithで変更した値が反映されないし、変数の数が多ければ大変過ぎます。

「野郎ども!ベストプラクティスよこせ!」と、しばらく調べたり考えたり寝っ転がったりした結果、思いついた方法が下記です。

@useに加えて@forword ~ withを使う

以下のようなファイル構成を想定します。
includes/には、モジュールとして利用するファイルを、includes/library/には他でも使い回すファイル群(以下、ライブラリ)を入れています。

┣ style.scss
┣ includes/
  ┣ _variables.scss
  ┣ library/
    ┣ _index.scss
    ┣ _layout.scss
    ┣ _mixins.scss
    ┣ _variables.scss
    ┣ layout/
      ┗ _container.scss
    ┣ mixins/
      ┗ _breakpoints.scss
    ┣ ....

ライブラリのデフォルト変数は、プロジェクト用の変数(includes/variables.scss)で上書きします。これまでなら以下のように@importしてやれば変数を上書きして利用することができました。

変更前(@import

// > style.scss
@import 'includes/variables';
@import 'includes/library/index';

body {
  color: $text-color;
  background: $body-bg-color;
}

@include media-breakpoint-up (s) {
  @if( mixin-exists("layout-up-small") ) {
    @include layout-up-small;
  }
}
// > includes/_variables.scss
$text-color: #555;
$body-bg-color: #eee;
// > includes/library/_index.scss
@import "variables";
@import "mixins";
@import "layout";
....
// > includes/library/_mixins.scss
@import "mixins/breakpoints";
// > includes/library/_variables.scss
$text-color: #000 !default;
$body-bg-color: #fff !default;
$breakpoints: ( s: 600px, m: 768px, l: 992px, xl: 1200px) !default;

ライブラリはプロジェクトによる変数の上書きがあるかどうかを問わないので、ライブラリ内のモジュールではデフォルト変数のファイルだけを読み込んで上書きに対応させないといけません。前述のように@use ~ with ~による上書きは記述したファイルでしか有効にならず、重複させることもできないのでデフォルト変数の読み込みに利用できません。代わりに@forword ~ with ~を使ったらうまく行きました。

変更後(@use, @forword

// > style.scss
@use 'sass:meta';
@use 'includes/variables' as var;
@use 'includes/library/index' as lib;

body {
  color: var.$text-color;
  background: var.$body-bg-color;
}

@include lib.media-breakpoint-up (s) {
  @if( meta.mixin-exists("layout-up-small", lib) ) {
    @include lib.layout-up-small;
  }
}
// > includes/_variables.scss
@forward "library/variables" with(
  $text-color: #555,
  $body-bg-color: #eee,
);
// > includes/library/_index.scss
@forward "variables";
@forward "mixins";
@forward "layout";
....
// > includes/library/_mixins.scss
@forward "mixins/breakpoints";
// > includes/library/_variables.scss(変更なし)
$text-color: #000 !default;
$body-bg-color: #fff !default;
$breakpoints: ( s: 600px, m: 768px, l: 992px, xl: 1200px) !default;

この方法であれば、下のようにライブラリ内のモジュールでデフォルト変数のファイルを読み込んでも、プロジェクトによる上書きがうまく反映されるようになりました。

// > includes/library/mixins/_breakpoints.scss
@use "../variables" as var; // デフォルト変数のファイル

@mixin media-breakpoint-up ( $name, $breakpoints: var.$breakpoints ) {
  $min: map-get($breakpoints, $name);
  @media (min-width: $min) {
    @content;
  }
}

それと、名前空間付きで使用するミックスインはmixin-existsで存在のチェックができなくなります。モジュールのミックスインが定義済みかどうか調べるには組込モジュールのsass:metaが必要なようです。

// > style.scss
@use 'sass:meta';

@include lib.media-breakpoint-up (s) {
  @if( meta.mixin-exists("layout-up-small", lib) ) {
    @include lib.layout-up-small;
  }
}

Sass sass meta

もうひとつ。ライブラリの中では@forwordばっかり使ってればいいじゃないかと思ってたらそうはいきません。下記のようにライブラリの中で、読み込んだ先のミックスインを展開(@include)するにはやっぱり@useしないといけません。

// > includes/library/_layout.scss
@use "sass:meta";
@use "layout/container";

@mixin layout-up-small {
  @if( meta.mixin-exists("layout-container-up-small", container) ) {
    @include container.layout-container-up-small;
  }
}

プロジェクトの変数ファイルがもう一つあったら

ライブラリの変数を上書きする話に戻すと、複数のファイルを使って上書きを繰り返したい場合があります。例えばマルチサイトでの「プロジェクト変数 > ネットワーク変数 > ライブラリ変数(優先順)」といった感じに。いろいろ試した結果、最終的にライブラリの変数を上書きするまでそれぞれのプライベートな変数をとり扱うことで対処できました。

// > includes/_variables.scss
// プロジェクト変数はネットワーク変数を上書きする
@forward "network-variables" with(
  $network-text-color: #555,
  $network-body-bg-color: #eee,
  // ネットワーク変数が未定義の場合はそのまま上書きできるみたい【2021年9月3日追記】
  $primary: #369
);
// > includes/_network-variables.scss
// ネットワーク変数がライブラリ変数を上書きする
$network-text-color: #333 !default;
$network-body-bg-color: #f0f0f0 !default;

@forward "library/variables" with(
  $text-color: $network-text-color,
  $body-bg-color: $network-body-bg-color,
);

ここまでで、これまで使っていた構成をだいぶカバーできるようになってきました。

変数をマップにまとめてアクセサを作れないか

こちらはまた別の方法。実は、最初に思いついたのはこちらでした。
カプセル化と謳っているのだから、プログラム言語のクラスやパッケージのようにプロパティのアクセサを作ってやればいいと思いました。結果的には今のところうまく動きません。

たくさんの変数はマップにまとめて、外部からはゲッター(プロパティ取得)とセッター(上書き)を介してアクセスできるようにすればええではないか。海外のフォーラムでも同じような案をみつけました。

css - The @use feature of Sass - Stack Overflow

下記のようなものを実際に試してみるとマップの上書き(組込モジュールのsass:mapを使ったmap.set)の方がうまく機能しませんでした。つまり、ゲッターは機能しますが、セッターが機能せず使い物になりません(map-setでも同じ)。

// > variables.scss
@use 'sass:map';

$colors: (
  text: #000,
  body-bg: #fff,
) ! default;

// Getter: OK
@function get-color( $key ) {
  $color: map.get( $colors, $key );
  @return $color;
}

// Setter: Doesn't Work. 思ったとおりに動作しません
@function set-color( $key, $val ) {
  $colors: map.set( $colors, $key, $val );
  @return $colors;
}
// > style.scss
@use 'sass:map';
@use 'variables' as var;

body {
  color: var.get-color(text);
  // 上書きを試みる
  $updated-colors: var.set-color( 'body-bg', #ccc );
  background: map.get( $updated-colors, 'body-bg' );
  background: var.get-color('body-bg');
}
// > style.css
body {
  color: #000;
  background: #ccc;
  background: #fff; // <- 元のマップは上書きされていない
}

関数set-colorの中で代入した変数$colorsはレキシカル変数なんでしょうか。Sassってよくわかりません。いっちょ前にカプセル化とか言うんならデフォルト変数の書き換えに対して効率的な代替案が提示されてほしいです。

アクセサがうまく機能しさえすれば、カプセル化に対してはこちらの方がベターな方法ではないでしょうか。自作ライブラリとか総作り変えになっちゃってハッピーです。とりあえず、原因究明するほどこの問題にかまってられないのでこちらは保留にして終わりにします。