Skip to content

Sass 響應式 mixin 實作:respond-to 從零寫起

Sass 響應式 mixin 的實作

斷點 map 解決了數值集中的問題,但每次還是要寫 @media (min-width: bp(md))。把它包成 混入,元件裡就只剩斷點名稱。

最小可用版本

scss
// abstracts/_breakpoints.scss
@use "sass:map";

$breakpoints: (sm: 576px, md: 768px, lg: 992px, xl: 1200px) !default;

@mixin respond-to($name) {
  @if not map.has-key($breakpoints, $name) {
    @error "斷點 #{$name} 不存在。可用的有:#{map.keys($breakpoints)}。";
  }

  @media (min-width: map.get($breakpoints, $name)) {
    @content;
  }
}
scss
@use "../abstracts/breakpoints" as *;

.card {
  padding: 16px;

  @include respond-to(md) { padding: 24px; }
  @include respond-to(lg) { padding: 32px; }
}
css
.card { padding: 16px; }
@media (min-width: 768px) { .card { padding: 24px; } }
@media (min-width: 992px) { .card { padding: 32px; } }

三十行不到,就把整個專案的斷點管起來了。關鍵是 @content,呼叫者給的樣式會被放進媒體查詢裡。

這是 as * 少數合理的場合

respond-to 幾乎每個元件檔都會用到,寫成 bp.respond-to(md) 很囉嗦。前提是名稱夠特別、由自己維護,見 @use

加上區間

有時只想在某個範圍內套用:

scss
@mixin respond-to($from: null, $until: null) {
  $query: "";

  @if $from {
    $query: "(min-width: #{bp($from)})";
  }

  @if $until {
    $max: bp($until) - 0.02px;
    $query: if($query == "", "(max-width: #{$max})", "#{$query} and (max-width: #{$max})");
  }

  @if $query == "" {
    @error "respond-to() 至少要給 $from 或 $until 其中一個。";
  }

  @media #{$query} {
    @content;
  }
}
scss
.nav {
  @include respond-to($until: md)          { display: none; }   // 未滿 768
  @include respond-to($from: md)           { display: flex; }   // 768 以上
  @include respond-to($from: md, $until: lg) { gap: 8px; }      // 768 ~ 992
}

$until 減去 0.02px 是為了避開邊界重疊,理由見 斷點管理

這裡的插值不能省

$query 是字串,@media #{$query} 必須用 插值 展開。

其他媒體特性

寬度以外的查詢也值得包起來:

scss
@mixin dark {
  @media (prefers-color-scheme: dark) { @content; }
}

@mixin reduced-motion {
  @media (prefers-reduced-motion: reduce) { @content; }
}

@mixin hover {
  @media (hover: hover) and (pointer: fine) {
    &:hover { @content; }
  }
}
scss
.btn {
  transition: background 0.2s;

  @include hover { background: #1b4dc1; }

  @include reduced-motion { transition: none; }
}

hover 那個特別實用,觸控裝置不會套用,就不會出現「點一下之後卡在 hover 狀態」。

完整的檔案

scss
// abstracts/_breakpoints.scss
@use "sass:map";

$breakpoints: (
  sm: 576px,
  md: 768px,
  lg: 992px,
  xl: 1200px,
) !default;

@function bp($name) {
  @if not map.has-key($breakpoints, $name) {
    @error "斷點 #{$name} 不存在。可用的有:#{map.keys($breakpoints)}。";
  }
  @return map.get($breakpoints, $name);
}

@mixin respond-to($from: null, $until: null) { /* 如上 */ }

@mixin dark { @media (prefers-color-scheme: dark) { @content; } }
@mixin hover { @media (hover: hover) and (pointer: fine) { &:hover { @content; } } }

放進 abstracts 目錄,用 @forward 收進 _index.scss,結構見 abstracts 該放什麼

產出會分散,這沒關係

把媒體查詢寫在元件裡,同一個斷點會在產出的 CSS 裡出現幾十次:

css
@media (min-width: 768px) { .card { } }
/* ... */
@media (min-width: 768px) { .btn { } }
/* ... */
@media (min-width: 768px) { .nav { } }

看起來很浪費,但:

  • 重複的字串正是 gzip 最擅長壓的東西。
  • 瀏覽器解析多個相同條件的 @media 開銷可以忽略。
  • 換來的是「元件的所有樣式都在同一個地方」,這個維護價值高得多。

真的在意的話有工具

PostCSS 的 postcss-sort-media-queries 這類外掛能把相同條件的區塊合併,接在 Sass 編譯之後跑即可,見 效能與產出品質

常見問題

把媒體查詢寫在元件裡,產出會不會很零散?

會,同一個斷點會出現很多次。但重複的媒體查詢字串壓縮後成本很低,而且瀏覽器解析它們的開銷可以忽略。真的在意的話可以用 PostCSS 的外掛把相同條件的區塊合併起來。

respond-to 可以支援區間嗎?

可以,多加一個上限參數,同時輸出 min-widthmax-width。上限要記得減去一個極小值,避免與下一個斷點的規則在邊界上重疊。

斷點混入該不該用 as 星號省略命名空間?

這是少數合理的例外。它幾乎在每個元件檔都會出現,加前綴很囉嗦。前提是名稱夠特別、由自己維護,且整個專案有共識。

為什麼傳錯斷點名稱時樣式會整段消失?

因為取不到值會得到 null,組出來的媒體查詢條件無效,整個區塊就不生效。在混入裡先檢查斷點是否存在並用 @error 中斷,就能在編譯期抓到。

延伸閱讀