Mixin, Function, @extend ve Control Flow: @if, @each, @for ile Tekrarı Kaldırmak
Parametre, media query ya da selector üretmek gerekiyorsa mixin, gerçekten aynı olan kurallar için placeholder ile @extend, değer hesaplamak için @function kullan; map ve @each ile tekrar eden kuralları veriden üret. Bookstore'a breakpoint mixin'i ve stok badge'leri ekliyor, slash division'ın neden 12px/2 gibi geçersiz CSS ürettiğini, @extend'in media query'de kırıldığını ve her şeyi mixin yapmanın gzip öncesi ve sonrası maliyetini ölçüyoruz.
Sürüm notu: Bu yazıdaki örnekler Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; Angular CLI'ın içindeki Sass 1.104.1) ve Playwright 1.65'in Chromium build'iyle denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yazılmış eski bir stil dosyasında fiyat etiketinin iç boşluğu şöyle hesaplanmış:
$gap: 12px;
.price-tag {
padding: 12px / 2;
padding-block: $gap / 2;
}
İki satır da "12'nin yarısı" gibi görünüyor. Derlenmiş CSS'te padding ve padding-block'un değerleri ne olur? Derleme sırasında terminalde bir şey yazar mı? Cevap Sass'ın bölme işaretine yıllardır neden iki anlam yüklediğini ve Dart Sass 2.0'da neden değişeceğini gösteriyor.
Önceki yazıda değişkenleri, nesting'i ve interpolation'ı gördük. Bu yazıda Sass'ın tekrar eden kodu kaldıran araçlarına geçiyoruz: mixin, function, map ve döngüler. Sonunda Bookstore'un Angular istemcisine (bookstore-web) bir breakpoint mixin'i, döngüyle üretilen spacing utility'leri ve arka plan rengine göre yazı rengini seçen stok badge'leri ekleyeceğiz.
@mixin ve @include: Argüman, Varsayılan Değer ve @content
Mixin, adı olan ve parametre alabilen bir declaration bloğu. @include edildiği her yerde içeriği yeniden yazılır. Bookstore'un katalog sayfasında en çok tekrar eden şey media query'lerdi; mixin'e çevirdim:
@use "sass:map";
$breakpoints: (
"sm": 36rem,
"md": 48rem,
"lg": 64rem,
);
@mixin from($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint '#{$name}'. Expected one of: #{map.keys($breakpoints)}";
}
@media (width >= map.get($breakpoints, $name)) {
@content;
}
}
// varsayılan değerli argümanlar
@mixin focus-ring($color: var(--color-brand), $offset: 2px) {
&:focus-visible {
outline: 2px solid $color;
outline-offset: $offset;
}
}
.book-list {
display: grid;
gap: 16px;
@include from("md") {
grid-template-columns: repeat(2, 1fr);
}
@include from("lg") {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
}
.book-card__link {
@include focus-ring;
}
.cart-button {
@include focus-ring($offset: 4px);
}
.book-list {
display: grid;
gap: 16px;
}
@media (width >= 48rem) {
.book-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width >= 64rem) {
.book-list {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
}
.book-card__link:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.cart-button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 4px;
}
@content, mixin'i çağıran yerin süslü parantez içindeki bloğunu mixin'in içinde istediğin yere koyuyor. from() media query'yi kuruyor, içine ne yazılacağına çağıran karar veriyor. Media query kuralın içinde yazıldığı için Sass onu dışarı çıkarıp selector'ü içine taşıyor; çıktıda her breakpoint için ayrı bir @media bloğu var.
focus-ring iki şeyi gösteriyor. Birincisi varsayılan değerli argümanlar: @include focus-ring; parantezsiz çağrılabiliyor, $offset: 4px gibi isimli argümanla yalnız değiştirmek istediğin değeri veriyorsun. İkincisi mixin içinde & kullanmak: &, mixin'in include edildiği kuralın selector'ü oluyor. Varsayılan değerin bir custom property (var(--color-brand)) olması da bilinçli: Sass değeri olduğu gibi yazıyor, tema değişince outline rengi de değişiyor.
Breakpoint map'inin bir faydası daha var: hata mesajı. Native CSS'te media query'de var() kullanılamadığı için breakpoint değerleri her dosyaya elle yazılır ve biri 48em yerine 46em yazınca kimse fark etmez. Burada geçersiz bir isim build'i kırıyor.
@function ve sass:math
Function bir değer döndürür, mixin ise CSS üretir. Bookstore'da tasarımcıdan gelen ölçüler piksel; tarayıcıya rem göndermek istiyoruz ki kullanıcının yazı boyutu ayarı layout'a yansısın:
@use "sass:math";
$root-font-size: 16px;
@function rem($px) {
@if math.unit($px) != "px" {
@error "rem() expects px, got #{$px}";
}
@return math.div($px, $root-font-size) * 1rem;
}
.book-card__title {
font-size: rem(18px);
margin-block-end: rem(6px);
}
$gap: 12px;
.price-tag {
// iki sabit sayı: Sass eğik çizgiyi korur
padding: 12px / 2;
// bir tarafta değişken: bölme yapar ve deprecation uyarısı verir
padding-block: $gap / 2;
// modern yol
margin: math.div(12px, 2);
// calc() içinde eğik çizgi her zaman bölmedir
inset-inline-end: calc(100% / 3);
// gerçek bir CSS eğik çizgisi: aspect-ratio ve grid-area
aspect-ratio: 2 / 3;
}
npx sass 03/functions.scss
DEPRECATION WARNING [slash-div]: Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.
Recommendation: math.div($gap, 2) or calc($gap / 2)
More info and automated migrator: https://sass-lang.com/d/slash-div
╷
23 │ padding-block: $gap / 2;
│ ^^^^^^^^
╵
03/functions.scss 23:20 root stylesheet
.book-card__title {
font-size: 1.125rem;
margin-block-end: 0.375rem;
}
.price-tag {
padding: 12px/2;
padding-block: 6px;
margin: 6px;
inset-inline-end: 33.3333333333%;
aspect-ratio: 2/3;
}
Tahminin cevabı burada. padding: 12px / 2 derlenmiş CSS'e 12px/2 olarak gidiyor; bu padding için geçersiz bir değer ve tarayıcı declaration'ı atıyor. padding-block: $gap / 2 ise 6px oluyor ama Sass bir deprecation uyarısı basıyor. Sebep CSS'in kendisi: eğik çizgi CSS'te bölme değil, ayraç. font: 16px/1.5, grid-area: 1 / 2, aspect-ratio: 2 / 3 gibi yerlerde iki değeri ayırıyor. Sass yıllarca "iki tarafı da sabit sayıysa ayraç, değişken ya da fonksiyon sonucu varsa bölme" diye bir sezgi kullandı. Bu sezgi hem yukarıdaki gibi sürprizlere yol açıyor hem de yeni CSS syntax'larıyla çakışıyor. Dart Sass 2.0'da / her yerde CSS'teki gibi ayraç olacak; bölme için math.div() ya da calc() kalacak.
Uyarı mesajı iki çözüm öneriyor ve ikisi farklı şey yapıyor. math.div($gap, 2) bölmeyi derleme anında yapıp 6px yazıyor. calc($gap / 2) ise Dart Sass'ta sadeleştiriliyor ve yine 6px çıkıyor; ama bir tarafta 100% gibi derleme anında bilinemeyen bir değer varsa bölmeyi tarayıcıya bırakıyor. calc(100% / 3)'ün 33.3333333333% olmasının sebebi de bu sadeleştirme: iki taraf da bilinen değerler.
rem() fonksiyonundaki @error da önemli. Biri rem(1.5rem) yazarsa build şu mesajla duruyor: Error: "rem() expects px, got 1.5rem". Function'ların içinde tip ve birim kontrolü yapmak, yanlış birimin sessizce yanlış bir sayıya dönüşmesini engelliyor.
Map'ler, @each, @for ve @if ile Kural Üretmek
Sass'ın native CSS'te hâlâ karşılığı olmayan en güçlü tarafı veriden kural üretmek. Bookstore'un spacing ölçeği beş adım; her biri için margin utility'lerini elle yazmak yerine iki map ve iç içe bir @each kullandım:
@use "sass:color";
@use "sass:map";
$spacers: (
0: 0,
1: 4px,
2: 8px,
3: 16px,
4: 24px,
);
$sides: (
"": null,
"t": "block-start",
"b": "block-end",
"x": "inline",
"y": "block",
);
// .m-0 … .my-4: 5 ölçü × 5 yön
@each $suffix, $side in $sides {
@each $key, $size in $spacers {
.m#{$suffix}-#{$key} {
@if $side {
margin-#{$side}: $size;
} @else {
margin: $size;
}
}
}
}
// .cols-1 … .cols-4
@for $i from 1 through 4 {
.cols-#{$i} {
grid-template-columns: repeat($i, minmax(0, 1fr));
}
}
$stock-states: (
"in-stock": #15803d,
"low": #fbbf24,
"preorder": #1d4ed8,
"out": #e7e5e4,
);
// yazı rengi arka plana göre: açık badge'de koyu yazı
@each $state, $bg in $stock-states {
.stock-badge--#{$state} {
background-color: $bg;
@if color.channel($bg, "lightness", $space: oklch) > 70% {
color: #1c1917;
} @else {
color: #ffffff;
}
}
}
Çıktıda 33 kural var: 25 margin utility'si, 4 grid kolonu ve 4 badge. Birkaç örnek:
.m-0 {
margin: 0;
}
...
.mx-3 {
margin-inline: 16px;
}
...
.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
...
.stock-badge--low {
background-color: #fbbf24;
color: #1c1917;
}
...
.stock-badge--preorder {
background-color: #1d4ed8;
color: #ffffff;
}
Map'in key'i boş string olduğunda ("": null) interpolation hiçbir şey eklemiyor; .m-0 bu sayede ayrı bir döngü gerektirmiyor. @if $side'da null'ın false sayılması da aynı hilenin ikinci yarısı. Sass'ta yalnız false ve null false sayılıyor; 0 ve boş string true.
Badge'lerdeki @if, arka plan renginin OKLCH (algıya dayalı bir renk uzayı; lightness değeri gözün gördüğü parlaklığa yakın) lightness kanalına bakıyor. Sarının lightness'ı yaklaşık %84, yeşilinki %53 çıktı; bu yüzden sarı badge koyu, yeşil badge beyaz yazı aldı. Aynı kararı tarayıcıda verecek contrast-color() CSS3 serisinin renk yazısında anlatıldı ama henüz her tarayıcıda yok; renkler derleme anında bilindiği sürece Sass bu kararı bugün her tarayıcı için veriyor. Bu bir erişilebilirlik kontrolü yerine geçmiyor; gerçek kontrast oranını Accessibility (a11y) serisinde anlatılan araçlarla ölçmek gerekiyor.
Bookstore'da: Breakpoint Mixin'i ve Stok Badge'leri
bookstore-web'de breakpoint map'i ve from() mixin'i src/styles/_breakpoints.scss'e, spacing utility'leri src/styles/_utilities.scss'e taşındı. Alt çizgiyle başlayan dosyalar partial: tek başına derlenmez, yalnız başka bir dosyadan yüklenir. Katalog component'i mixin'i şöyle kullanıyor:
@use "../styles/breakpoints" as bp;
.site-header {
padding: var(--space) calc(var(--space) * 1.5);
border-bottom: 1px solid var(--color-border);
}
.book-list {
display: grid;
gap: var(--space);
padding: calc(var(--space) * 1.5);
@include bp.from("sm") {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include bp.from("lg") {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@use satırı dosyayı yüklüyor ve içindeki her şeye bp. önekiyle erişmemizi sağlıyor. Göreli yol ve namespace'in ayrıntıları bir sonraki yazının konusu; orada bu ../styles/ yollarını da kaldıracağız. Stok badge'leri BookCard'ın stil dosyasında, yukarıdaki @each ile üretiliyor (Bookstore'da şimdilik ön sipariş durumu yok, üç renk var). Üç genişlikte katalog grid'ini ve badge renklerini ölçtüm:
400px .book-list grid-template-columns=352px
700px .book-list grid-template-columns=318px 318px
1280px .book-list grid-template-columns=296px 296px 296px 296px
in-stock background-color=rgb(21, 128, 61) color=rgb(255, 255, 255)
low background-color=rgb(251, 191, 36) color=rgb(28, 25, 23)
out background-color=rgb(231, 229, 228) color=rgb(28, 25, 23)
in-stock background-color=rgb(21, 128, 61) color=rgb(255, 255, 255)
Mixin mi @extend mi?
Önceki yazıda placeholder ve @extend'i gördük: declaration'ları kopyalamak yerine selector'leri tek bir kurala ekliyor. Kâğıt üstünde daha küçük CSS demek. Pratikte iki sınırı var. Birincisi media query:
%card-surface {
border: 1px solid #d6d3d1;
border-radius: 8px;
}
.order-summary {
@media (width >= 48rem) {
@extend %card-surface;
}
}
Error: You may not @extend selectors across media queries.
╷
1 │ %card-surface {
│ ━━━━━━━━━━━━━ extended selector
... │
7 │ @media (width >= 48rem) {
│ ━━━━━━━━━━━━━━━━ extension @media
8 │ @extend %card-surface;
│ ^^^^^^^^^^^^^^^^^^^^^ extension
╵
03/extend-media.scss 8:9 root stylesheet
@extend selector'ü placeholder'ın kuralına ekliyor; o kural media query'nin dışında, extend eden ise içinde. Sass ikisini birleştiremiyor çünkü sonuç ya media query'yi yok sayan ya da kuralı kopyalayan bir şey olurdu. Mixin'de bu sorun yok.
İkincisi selector patlaması. @extend bir class'ı extend ettiğinde, o class'ın geçtiği her selector'e yeni selector'ü ekliyor:
.price {
font-variant-numeric: tabular-nums;
}
.book-card .price,
.cart .price,
.order-summary .price {
font-weight: 700;
}
.checkout-total .price-label {
color: #57534e;
}
// "toplam da bir fiyattır": class'ı extend et
.checkout-total {
@extend .price;
}
.order-row__total {
@extend .price;
}
.price, .order-row__total, .checkout-total {
font-variant-numeric: tabular-nums;
}
.book-card .price, .book-card .order-row__total, .book-card .checkout-total,
.cart .price,
.cart .order-row__total,
.cart .checkout-total,
.order-summary .price,
.order-summary .order-row__total,
.order-summary .checkout-total {
font-weight: 700;
}
.checkout-total .price-label {
color: #57534e;
}
Üç bağlam ve iki extend, dokuz selector'lük bir listeye dönüştü. Kimse .cart .checkout-total diye bir kural yazmadı ama artık var ve sepet içindeki bir toplam satırı kalın görünecek. Gerçek bir projede .price onlarca dosyada geçer ve bu liste yüzlerce selector'e çıkabilir. Bu davranış dosya sınırında da durmuyor: extend eden dosyanın @use ile yüklediği dosyalarda .price içeren yeni bir kural yazan geliştirici, farkında olmadan extend eden selector'leri de oraya taşımış oluyor.
Bookstore'daki kuralım: @extend yalnız placeholder'la, yalnız media query dışında ve yalnız gerçekten "aynı şey" olan kurallar için (%visually-hidden gibi). Class extend etmiyoruz. Parametre gerekiyorsa, media query içindeysek ya da şüphedeysek mixin. Angular'da bir şey daha var: her component'in stil dosyası ayrı derlendiği için @extend component sınırını zaten geçemiyor; iki component'in ortak stili ya mixin'le ya da global bir class'la paylaşılıyor.
Üretimde Dikkat
Deprecation uyarılarını say. slash-div eski codebase'lerde en sık görülenlerden biri. Sass aynı türden uyarının yalnız ilk beşini gösteriyor, sonra WARNING: 7 repetitive deprecation warnings omitted. gibi bir satırla kaç tanesini gizlediğini yazıyor; hepsini görmek için --verbose gerekiyor. Bu sayı neyle uğraştığını gösteren en iyi ölçü. sass-migrator division komutu bunların çoğunu math.div()'e otomatik çeviriyor; serinin 07. yazısında kullanacağız.
Döngülerin ürettiğini ölç. Spacing ölçeğine bir adım, $sides'a iki yön eklemek 25 kuralı 42'ye çıkarıyor; renk paletine her renk eklemek her utility ailesinde bir kural daha demek. Utility class'larını kullanılmasa da üreten bir döngü, Tailwind'in yalnız kullanılan class'ları üretmesinin tam tersi. Bookstore'da utility'leri yalnız gerçekten kullandığımız ölçü ve yönlerle sınırlı tutuyoruz.
Function ve mixin'lere doğrulama koy. @error ve @warn derleme zamanında çalışıyor, runtime'a hiçbir maliyeti yok. Paylaşılan bir mixin'e yanlış argüman verildiğinde build'in kırılması, yanlış CSS'in production'a gitmesinden her zaman ucuz.
Sık Düşülen Tuzaklar
Her tekrarı mixin yapmak. Bookstore'da kart görünümündeki on iki component'in her biri aynı altı declaration'ı bir mixin'le alıyordu. Aynı şeyi placeholder ile yazıp iki çıktıyı karşılaştırdım:
node scripts/03-sizes.mjs
surface-mixin 1717 bytes, gzip 223 bytes
surface-extend 298 bytes, gzip 212 bytes
Ham boyutta fark altı kat, gzip sonrası ise yalnız 11 byte. Sıkıştırma tekrar eden metni çok iyi küçültüyor, bu yüzden ağ üzerinde aktarılan boyut neredeyse aynı. Ama tarayıcı sıkıştırılmamış CSS'i parse ediyor ve mixin'li sürümde on iki ayrı kural tutuyor; değişiklik gerektiğinde de DevTools'ta on iki yerde aynı değeri görüyorsun. İkisinin de daha iyi bir alternatifi var: bu on iki component aynı görünümü paylaşıyorsa ortak bir .surface class'ı ya da custom property'ler (--surface-border gibi) hem daha az CSS üretiyor hem de runtime'da değiştirilebiliyor. Mixin'i parametre gerektiğinde, media query ya da selector üretmek gerektiğinde kullan.
Literal bölmenin geçersiz CSS üretmesi. Bu yazının başındaki padding: 12px / 2 hiçbir uyarı vermeden 12px/2'ye dönüştü. Sass'ın buna uyarı vermemesinin sebebi bu yazımın font: 12px/1.5 gibi geçerli CSS'te de kullanılması. Bölme yapmak istediğin her yerde math.div() ya da calc() yaz; Dart Sass 2.0'dan sonra zaten tek yol bu.
Mixin içinde &'ı unutmak. focus-ring'i &:focus-visible yerine :focus-visible ile yazarsan çıktı .cart-button :focus-visible olur; arada bir boşluk var ve kural butonun kendisini değil içindeki odaklanan element'i hedefliyor.
Küçük Bir Deneme
★☆☆ 03/utilities.scss'teki $spacers'a 5: 32px ekle ve npx sass 03/utilities.scss | grep -c '^\.' ile kural sayısının nasıl değiştiğini kontrol et.
★★☆ from()'un yanına iki breakpoint arasını hedefleyen bir between($min, $max) mixin'i yaz. $min'in $max'tan küçük olmadığı durumda @error ver ve media query'yi range syntax'ıyla (48rem <= width < 64rem) üret.
★★★ surface-mixin.scss'i custom property'lerle yeniden yaz: mixin yalnız border: 1px solid var(--surface-border) gibi değişkene bağlı declaration'lar üretsin, değerler :root'ta dursun. Üç sürümün (mixin, extend, custom property) ham ve gzip boyutlarını karşılaştır ve dark mode'a geçişte hangisinde kaç satır değiştirmen gerektiğini yaz.
Bir sonraki yazıda Sass'ın module sistemine geçiyoruz: @import'un neden kaldırıldığı, @use ile namespace ve private member'lar, @forward ile index dosyası, sass-migrator ile eski bir stil ağacını taşımak ve Angular component'lerinde ortak Sass kodunu CSS'i tekrarlamadan kullanmak.