Sass 響應式 mixin 實作:respond-to 從零寫起
斷點 map 解決了數值集中的問題,但每次還是要寫 @media (min-width: bp(md))。把它包成 混入,元件裡就只剩斷點名稱。
最小可用版本
// 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;
}
}@use "../abstracts/breakpoints" as *;
.card {
padding: 16px;
@include respond-to(md) { padding: 24px; }
@include respond-to(lg) { padding: 32px; }
}.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。
加上區間
有時只想在某個範圍內套用:
@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;
}
}.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} 必須用 插值 展開。
其他媒體特性
寬度以外的查詢也值得包起來:
@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; }
}
}.btn {
transition: background 0.2s;
@include hover { background: #1b4dc1; }
@include reduced-motion { transition: none; }
}hover 那個特別實用,觸控裝置不會套用,就不會出現「點一下之後卡在 hover 狀態」。
完整的檔案
// 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 裡出現幾十次:
@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-width 與 max-width。上限要記得減去一個極小值,避免與下一個斷點的規則在邊界上重疊。
斷點混入該不該用 as 星號省略命名空間?
這是少數合理的例外。它幾乎在每個元件檔都會出現,加前綴很囉嗦。前提是名稱夠特別、由自己維護,且整個專案有共識。
為什麼傳錯斷點名稱時樣式會整段消失?
因為取不到值會得到 null,組出來的媒體查詢條件無效,整個區塊就不生效。在混入裡先檢查斷點是否存在並用 @error 中斷,就能在編譯期抓到。