1. ホーム
  2. Sass

【Sass】@extend と %プレースホルダーセレクタの使い方|共通スタイルをひとつにまとめる

Share

Sass でスタイルを使い回す手段には @mixin と @extend の2つがあります。どちらを使っても見た目の結果は同じにできますが、出力される CSS の形はまったく違います。@extend は宣言をコピーせず、セレクタのほうをカンマでまとめるのが特徴です。この記事では、その仕組みをコンパイル後の CSS と並べて確認しながら、@extend 専用の継承元である % プレースホルダーセレクタの使い方、出力位置によるカスケードの落とし穴、メディアクエリをまたげないという制約、!optional フラグまでを解説します。記法は SCSS、動作は Dart Sass を前提にしています。

@extend は「このセレクタを、あのセレクタの仲間に加える」指示

@extend を一言で言うと、「今書いているセレクタを、継承元のセレクタが持っているルールの適用対象に加えてください」という指示です。スタイルを自分のところへコピーしてくるのではなく、自分のセレクタを相手のセレクタリストへ登録しに行く、というイメージです。この向きの違いが、そのまま出力の違いになって表れます。

まず mixin で3つのセレクタに同じ枠線スタイルを当ててみます。

style.scss
@mixin box-base {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.card {
  @include box-base;
}

.panel {
  @include box-base;
}

.alert {
  @include box-base;
}
style.css
.card {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.panel {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.alert {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

同じ3行が3回書き出されました。mixin は呼び出した場所に中身を展開する仕組みなので、呼び出した回数だけ宣言が複製されます。同じことを @extend で書くと、出力はこうなります。

style.scss
%box-base {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.card {
  @extend %box-base;
}

.panel {
  @extend %box-base;
}

.alert {
  @extend %box-base;
}
style.css
.alert, .panel, .card {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

宣言は1回しか出力されず、代わりに3つのセレクタがカンマでまとめられました。継承する場所が増えるほど差は広がるので、共通の土台が多いプロジェクトでは CSS のファイルサイズがはっきり小さくなります。なお、カンマで並ぶセレクタの順番は Sass のバージョンによって変わることがありますが、セレクタリストの中の並び順は適用結果に影響しないので気にする必要はありません。

基本の書き方|クラスを @extend する

継承元には、ふつうのクラスセレクタも指定できます。書き方は @extend .クラス名; です。.btn という土台のクラスを用意して、.btn-primary をその仲間に加えてみます。

style.scss
.btn {
  display: inline-block;
  padding: 12px 24px;
  border-radius: 4px;
}

.btn-primary {
  @extend .btn; // .btn の仲間に加わる
  background-color: #0d6efd;
}
style.css
.btn, .btn-primary {
  display: inline-block;
  padding: 12px 24px;
  border-radius: 4px;
}

.btn-primary {
  background-color: #0d6efd;
}

これで HTML 側は class="btn-primary" と書くだけで、.btn の見た目も一緒に適用されます。class="btn btn-primary" のように2つ並べて書く必要がなくなる、というのが @extend のうれしいところです。

ただしこの書き方には無視できない副作用があります。継承元の .btn は、実際には HTML のどこにも使っていないのに CSS へ出力され続けます。「土台として用意しただけで、単体では使わないクラス」が増えてくると、使われないセレクタが CSS に溜まっていきます。これを解決するのが次のプレースホルダーセレクタです。

%プレースホルダーセレクタ|継承されなければ出力されない

クラスの . の代わりに % で始めたセレクタを、プレースホルダーセレクタと呼びます。%button-base { ... } のように書くもので、@extend されなければ CSS にいっさい出力されません。継承専用の、CSS には姿を現さない土台だと考えてください。

style.scss
// % で始めると、これ自体は CSS に出力されない
%button-base {
  display: inline-block;
  padding: 12px 24px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-primary {
  @extend %button-base;
  background-color: #0d6efd;
  color: #fff;
}

.btn-secondary {
  @extend %button-base;
  background-color: #6c757d;
  color: #fff;
}

.card__more {
  @extend %button-base;
  background-color: transparent;
}
style.css
.card__more, .btn-secondary, .btn-primary {
  display: inline-block;
  padding: 12px 24px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-primary {
  background-color: #0d6efd;
  color: #fff;
}

.btn-secondary {
  background-color: #6c757d;
  color: #fff;
}

.card__more {
  background-color: transparent;
}

出力された CSS のどこにも %button-base という文字は現れません。継承した3つのセレクタがそのまま並び、使わないクラスは1つも増えていません。継承する場所が増えれば、このカンマ区切りのセレクタリストが伸びていくだけです。

もし %button-base をどこからも @extend しなければ、この6行はまるごと CSS に出力されません。「あとで使うかもしれない土台」を書き溜めておいても、実際に使ったものだけが CSS になる、というわけです。@extend を使うときは、実クラスではなくプレースホルダーを継承元にする、と覚えておけばまず間違いありません。

プレースホルダーは入れ子の中からも継承できる

継承する側は、入れ子(ネスト)の内側にあってもかまいません。Sass はセレクタを組み立てた結果をセレクタリストに加えます。

style.scss
%button-base {
  padding: 12px 24px;
}

.btn-primary {
  @extend %button-base;
}

.card {
  .btn-secondary {
    @extend %button-base; // 入れ子の中から継承
  }
}
style.css
.card .btn-secondary, .btn-primary {
  padding: 12px 24px;
}

組み立て後の .card .btn-secondary がセレクタリストに加わっています。ここで気をつけたいのは、加わったセレクタの詳細度は元のセレクタのものになる、という点です。.btn-primary と .card .btn-secondary は同じルールを共有していますが、詳細度は異なります。ほかのスタイルとの競合を考えるときは、まとめられたあとのセレクタそれぞれで判断する必要があります。

@extend されたスタイルは「継承元が書かれた場所」に出力される

@extend でいちばんつまずきやすいのがここです。継承したスタイルは、@extend と書いた場所ではなく、継承元のルールが書かれている場所に出力されます。つまり、ソース上での CSS の並び順が書いたとおりにならないことがあります。

詳細度が同じルールどうしがぶつかったとき、CSS は「あとに書かれたほう」を採用します。並び順が変わるということは、この勝ち負けが変わるということです。次の例を見てください。

style.scss
%link-reset {
  color: inherit;
  text-decoration: none;
}

.card a {
  color: #0d6efd;
}

// カード内のサムネイルのリンクだけ色を消したい
.card__thumb a {
  @extend %link-reset;
}
style.css
.card__thumb a {
  color: inherit;
  text-decoration: none;
}

.card a {
  color: #0d6efd;
}

SCSS では .card__thumb a をあとに書いたのに、出力では先に来てしまいました。%link-reset がファイルの先頭にあるからです。.card a と .card__thumb a は詳細度が同じなので、あとに書かれた .card a が勝ち、リンクの色は青のままになります。意図した打ち消しが効かない、という結果です。

同じことを mixin で書くと、宣言は @include と書いた場所に展開されるので、順番は SCSS に書いたとおりになり、色は inherit が勝ちます。打ち消し目的のスタイルや、上書きの順序が結果を左右する場面では、@extend ではなく mixin を選んだほうが読みどおりに動きます。

この性質があるため、プレースホルダーには「共通の土台」だけを書き、個別の調整を打ち消すようなスタイルは入れないのが安全です。

@extend が通らない・想定外の CSS になるとき

You may not @extend selectors across media queries

メディアクエリの中から、外側にあるプレースホルダーやクラスを継承することはできません。次のコードはコンパイルエラーになります。

style.scss
%box {
  padding: 16px;
}

.card {
  @extend %box;
}

@media (min-width: 768px) {
  .card-lg {
    @extend %box; // ここでエラーになる
  }
}
ターミナル
Error: From line 1, column 1 of style.scss:
  ╷
1 │ %box {
  │ ^^^^^
  ╵
You may not @extend selectors across media queries.
   ╷
11 │     @extend %box;
   │     ^^^^^^^^^^^^
   ╵

理由は @extend の仕組みを考えると納得できます。@extend は継承元のセレクタリストに自分を書き足すだけで、宣言をコピーしません。しかし .card-lg は「画面幅 768px 以上のときだけ」有効にしたいセレクタです。外側のルールにカンマで書き足してしまうと、条件なしで適用されることになってしまいます。Sass はそれを黙ってやるのではなく、エラーで知らせてくれるわけです。

メディアクエリをまたいでスタイルを共有したいときは、宣言そのものを複製する mixin を使います。

style.scss
@mixin box-base {
  padding: 16px;
  border-radius: 4px;
}

.card {
  @include box-base;
}

@media (min-width: 768px) {
  .card-lg {
    @include box-base; // mixin なら問題なく使える
  }
}
style.css
.card {
  padding: 16px;
  border-radius: 4px;
}

@media (min-width: 768px) {
  .card-lg {
    padding: 16px;
    border-radius: 4px;
  }
}

なお、同じメディアクエリの内側で完結している @extend であれば問題なく動きます。制約があるのは「メディアクエリをまたぐ場合」だけです。

The target selector was not found と !optional

継承元が存在しないと、Dart Sass は次のエラーで止まります。プレースホルダー名の打ち間違いや、定義を書いたパーシャルの @use 忘れが主な原因です。

ターミナル
Error: The target selector was not found.
Use "@extend %button-base !optional" to avoid this error.
  ╷
2 │   @extend %button-base;
  │   ^^^^^^^^^^^^^^^^^^^^
  ╵

エラー文が案内しているとおり、!optional を付けると「継承元が見つからなくても、そのまま黙って続行する」という意味になります。

style.scss
.btn-primary {
  @extend %button-base !optional; // 無くてもエラーにしない
  background-color: #0d6efd;
}

この場合、出力は background-color だけを持つ .btn-primary になります。!optional は、読み込むパーシャルの組み合わせによって継承元があったりなかったりするライブラリやテーマで役に立ちますが、ふだんの制作で安易に付けるのはおすすめしません。typo や @use の書き忘れに気づけなくなり、「なぜかスタイルが当たらない」という原因の分かりにくい不具合になってしまいます。まずはエラーの原因を潰し、本当に「無くてもよい」と判断できるときだけ付けてください。

子孫セレクタを継承するとセレクタが増えすぎる

.card .title のような子孫セレクタを継承すると、出力されるセレクタの数が一気に増えます。Sass が「起こりうる組み合わせ」をすべて書き出すからです。

style.scss
.card .title {
  font-weight: bold;
}

.news .headline {
  @extend .title;
}
style.css
.card .title, .card .news .headline, .news .card .headline {
  font-weight: bold;
}

.card の中に .news がある場合と、.news の中に .card がある場合の両方がありうるため、Sass は念のため両方を出力します。入れ子が深くなるほどこの組み合わせは増え、数十行のセレクタリストになることもあります。これがよく「セレクタの爆発」と呼ばれる現象です。

対策はシンプルで、継承元は入れ子のない単純なセレクタにすることです。トップレベルに置いたプレースホルダーを継承していれば、この問題はまず起きません。逆に、入れ子の中で定義したセレクタや子孫セレクタを継承したくなったときは、設計を見直す合図だと考えるとよいでしょう。

@extend と @mixin の使い分け

ここまで見てきた性質を表にまとめます。

観点@extend@mixin / @include
出力のされ方セレクタをカンマでまとめ、宣言は1回だけ出力する呼び出した場所に宣言を複製する
出力サイズ使う場所が増えても宣言は増えない使った回数だけ宣言が増える(gzip 後の差は小さい)
引数渡せない。土台をそのまま共有するだけ渡せる。色やサイズを変えて使い回せる
メディアクエリまたいで継承できない(エラー)問題なく使える。@content でブロックも渡せる
出力される位置継承元が書かれた場所。並び順が変わりカスケードに影響しうる呼び出した場所。書いたとおりの順序になる
セレクタの巻き込み継承元のセレクタリストに加わるため、詳細度や組み合わせが増えることがあるセレクタには一切影響しない

判断に迷ったときは、mixin を既定の選択肢にしておくのが安全です。引数を渡したい、メディアクエリを含む、順序どおりに上書きしたい、という要求のどれかがあれば、その時点で mixin しか選べません。

そのうえで @extend が向いているのは、「引数のいらない共通の土台を、同じ種類の要素どうしで共有する」場面です。ボタン、見出し、視覚的に隠すユーティリティなど、中身が固定されていて、それを使う要素が本当に同じ仲間だと言い切れるときに限って使う、という方針にしておくと事故が起きにくくなります。逆に、単に「同じ宣言が並んでいるから」という理由だけで継承させると、無関係な要素が同じセレクタリストに同居し、あとで片方だけ変更したいときに困ります。

まとめ

@extend は、書いているセレクタを継承元のセレクタリストに加える指示です。宣言はコピーされず1回だけ出力されるため、mixin のように同じ記述が繰り返されません。継承元には % で始まるプレースホルダーセレクタを使うのが基本で、これは @extend されなければ CSS に出力されないので、土台のために使わないクラスが増えることがありません。注意すべきなのは、継承したスタイルが継承元の書かれた場所に出力される点です。CSS の並び順が変わり、詳細度が同じルールの上書き結果が入れ替わることがあります。また、メディアクエリをまたいだ継承は You may not @extend selectors across media queries. でエラーになり、継承元が見つからないときは The target selector was not found. が出ます(!optional で抑えられますが、原因を潰すほうが先です)。子孫セレクタを継承するとセレクタの組み合わせが増えるので、継承元はトップレベルの単純なプレースホルダーに保ちましょう。引数やメディアクエリが絡むなら mixin、引数のいらない共通の土台を同じ仲間で共有するなら @extend、という基準で選び分けてみてください。

参考ページ