Sass Module Sistemi: @use, @forward, Namespace, Private Member ve @import'tan Migration
@use her dosyayı namespace'li ve bir kez yükler, private member'larla public API'yi çizer; @forward ile index dosyası, !default ve with() ile kütüphane konfigürasyonu gelir. @import'un global namespace yüzünden marka rengini nasıl ezdiğini ve CSS'i nasıl iki kez ürettiğini gösteriyor, ağacı sass-migrator ile taşıyor ve Angular'da includePaths ile CSS üretmeyen bir abstracts kuruyoruz; CSS üreten abstracts her component'e kopyalanıyor.
Sürüm notu: Bu yazıdaki örnekler Dart Sass 1.108.2, Sass Migrator 2.7.0 ve Angular 22.2 (TypeScript 6.0; Angular CLI'ın içindeki Sass 1.104.1) ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yıllar önce yazılmış bir stil ağacı var. Marka rengi kahverengi; aradaki bir dosya, repoya kopyalanmış eski bir datepicker'ın partial'ı:
// _variables.scss
$brand: #b45309;
$space: 16px;
$radius: 8px;
// vendor/_datepicker.scss
$brand: #2563eb;
$radius: 2px;
.datepicker {
border-radius: $radius;
border: 1px solid $brand;
}
// _buttons.scss
.button {
padding: $space * 0.5 $space;
background-color: $brand;
color: #ffffff;
}
// _book-card.scss ve _cart.scss ikisi de buttons'ı import ediyor
@import "buttons";
// main.scss
@import "variables";
@import "mixins";
@import "vendor/datepicker";
@import "book-card";
@import "cart";
Derlenmiş CSS'te .button hangi renkte? Ve kaç kez geçiyor? İki sorunun cevabı da @import'un neden deprecated olduğunu tek başına anlatıyor.
Önceki yazılarda tek dosyalık örneklerle çalıştık. Gerçek bir projede stil kodu onlarca dosyaya bölünüyor ve bu dosyaların birbirini nasıl gördüğü, Sass'ın en çok değişen tarafı. Bu yazıda 2019'da gelen module sistemine (@use ve @forward) bakıyor, eski ağacı sass-migrator ile taşıyor ve Bookstore'un Angular istemcisinde (bookstore-web) ortak Sass kodunu component'lerde CSS tekrarlamadan kullanıyoruz.
@import'un Sorunları
npx sass 04/legacy/main.scss
DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
...
DEPRECATION WARNING [global-builtin]: Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
DEPRECATION WARNING [color-functions]: darken() is deprecated. Suggestions:
...
DEPRECATION WARNING [slash-div]: Using / for division is deprecated and will be removed in Dart Sass 2.0.0.
...
WARNING: 2 repetitive deprecation warnings omitted.
.datepicker {
border-radius: 2px;
border: 1px solid #2563eb;
}
.button {
padding: 8px 16px;
background-color: #2563eb;
color: #ffffff;
}
.book-card {
padding: 16px;
border: 1px solid rgb(7.2829131653%, 29.8599439776%, 79.3837535014%);
border-radius: 2px;
}
.book-card__price {
color: #b91c1c;
}
.button {
padding: 8px 16px;
background-color: #2563eb;
color: #ffffff;
}
.cart {
padding: 16px;
border: 1px solid rgb(7.2829131653%, 29.8599439776%, 79.3837535014%);
border-radius: 2px;
width: 33.3333333333%;
}
Buton mavi ve iki kez yazılmış. Üç sorun bir arada:
- Tek bir global namespace var.
@importdosyanın içeriğini olduğu yere yapıştırıyor; datepicker'ın$brand'ı Bookstore'unkini sessizce eziyor ve ondan sonra derlenen her kural (buton, kart kenarlığı, köşe yuvarlaklığı) mavi ve 2px oluyor. - Aynı dosya her import'ta yeniden derleniyor.
_buttons.scss'i hem kart hem sepet import ettiği için.buttonçıktıda iki kez var. Dosya sayısı arttıkça bu hem CSS'i hem derleme süresini büyütüyor. - Sıra bağımlılığı.
_mixins.scss$brand'ı kullanıyor ama import etmiyor; çalışmasımain.scss'tevariables'ın ondan önce import edilmesine bağlı. Bir dosyanın neye ihtiyaç duyduğunu dosyanın kendisine bakarak anlayamıyorsun.
Uyarıların ikisi ayrıca dikkat istiyor: @import ve global built-in function'lar (darken(), map-get(), percentage()) Dart Sass 3.0.0'da kaldırılacak; slash-div ise 2.0.0'da. Kart kenarlığındaki yüzdeli rgb() değeri bir hata değil: Sass 1.79.0'dan beri renk fonksiyonlarının sonucunu tam sayıya yuvarlamıyor ve 1.101.4'ten beri tam sayı olmayan kanalları yüzde olarak yazıyor; geçerli CSS.
@use: Namespace, as * ve Private Member
Aynı ağacı module sistemiyle yeniden yazdım. Her dosya ihtiyaç duyduğu dosyayı kendisi @use ile yüklüyor ve onun üyelerine bir namespace ile erişiyor. Ortak tasarım değerleri ve mixin'ler abstracts klasöründe:
// abstracts/_tokens.scss
$brand: #b45309;
$space: 16px;
$radius: 8px;
// abstracts/_mixins.scss
@use "sass:color";
@use "tokens";
// private: - ile başlıyor, bu dosyanın dışından görünmez
$-border-shade: -10%;
@function -border-color($color) {
@return color.scale($color, $lightness: $-border-shade);
}
@mixin card-surface {
padding: tokens.$space;
border: 1px solid -border-color(tokens.$brand);
border-radius: tokens.$radius;
}
// yalnız layout'larda hata ayıklamak için, public API'nin parçası değil
@mixin debug-outline {
outline: 1px dashed magenta;
}
@use "tokens" dosyayı yüklüyor ve namespace'i dosya adından alıyor: tokens.$space. Namespace'i @use "tokens" as t ile değiştirebilir, as * ile tamamen kaldırabilirsin. Built-in module'ler de aynı şekilde yükleniyor: sass:color, sass:map, sass:math. Değişkenler, mixin'ler ve function'lar namespace'li; CSS kuralları ise namespace'siz, çünkü onlar doğrudan çıktıya gidiyor.
Adı - ya da _ ile başlayan üyeler private. Bunlar dosyanın içinde serbestçe kullanılıyor ama dışarıdan erişim derleme hatası:
@use "../modern/abstracts/mixins";
.debug {
border-color: mixins.$-border-shade;
}
Error: Private members can't be accessed from outside their modules.
╷
4 │ border-color: mixins.$-border-shade;
│ ^^^^^^^^^^^^^^^^^^^^^
╵
errors/private.scss 4:19 root stylesheet
Bu, ekipler arası paylaşılan bir stil kütüphanesinde neyin public API olduğunu açıkça çizmeyi sağlıyor. -border-color()'ı yarın değiştirdiğinde onu kullanan başka bir dosya olmadığından eminsin.
Bir module'ün CSS'i, kaç dosya onu @use ederse etsin, derlemede bir kez üretiliyor. Bir kural daha var: @use satırları dosyanın en başında, diğer kurallardan önce yazılmalı (yalnız @forward ve değişken tanımları önüne geçebilir).
@forward ile Index Dosyası: show ve hide
Her component'in tokens ve mixins'i ayrı ayrı yüklemesi yerine, abstracts klasörüne onları birlikte dışarı açan bir index dosyası ekledim:
// abstracts/_index.scss
@forward "tokens";
@forward "mixins" hide debug-outline;
@forward, yüklediği module'ün public üyelerini kendi üyeleriymiş gibi dışarı açıyor ama dosyanın içinde kullanılabilir yapmıyor (onu da kullanmak istiyorsan ayrıca @use gerekir). _index.scss adı özel: @use "abstracts" klasörü yüklediğinde Sass bu dosyayı arıyor. hide belirtilen üyeleri gizliyor, show ise yalnız belirtilenleri açıyor. debug-outline artık index üzerinden görünmüyor:
Error: Undefined mixin.
╷
4 │ @include abstracts.debug-outline;
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
╵
errors/hidden.scss 4:5 root stylesheet
Component dosyaları artık tek satırla başlıyor. _buttons.scss namespace'li, _book-card.scss as * ile namespace'siz yüklüyor:
// components/_buttons.scss
@use "../abstracts";
.button {
padding: abstracts.$space * 0.5 abstracts.$space;
background-color: abstracts.$brand;
color: #ffffff;
}
// components/_book-card.scss
@use "sass:map";
@use "../abstracts" as *;
@use "buttons";
$price-colors: (sale: #b91c1c, regular: #1c1917);
.book-card {
@include card-surface;
&__price {
color: map.get($price-colors, sale);
}
}
as * yazımı kısaltıyor ama namespace'in sağladığı "bu değer nereden geliyor?" bilgisini kaybettiriyor ve iki module aynı adda bir üye açarsa o üyeyi kullandığın satırda Sass This mixin is available from multiple global modules. hatası veriyor. Benim kuralım: projenin kendi abstracts'ı için as * kabul, üçüncü parti kütüphaneler için her zaman namespace.
with() ile Kütüphane Konfigürasyonu
Datepicker sorununu çözmenin doğru yolu onu bir module'e çevirip dışarıdan ayarlanabilir yapmak. !default, "bu değişkene daha önce bir değer verilmediyse bunu kullan" demek:
// vendor/_datepicker.scss
// dışarıdan @use ... with () ile ayarlanabilir
$brand: #2563eb !default;
$radius: 2px !default;
.datepicker {
border-radius: $radius;
border: 1px solid $brand;
}
// main.scss
@use "abstracts";
@use "vendor/datepicker" with (
$brand: abstracts.$brand,
$radius: abstracts.$radius
);
@use "components/book-card";
@use "components/cart";
npx sass 04/modern/main.scss
.datepicker {
border-radius: 8px;
border: 1px solid #b45309;
}
.button {
padding: 8px 16px;
background-color: #b45309;
color: #ffffff;
}
.book-card {
padding: 16px;
border: 1px solid rgb(63.5294117647%, 29.2941176471%, 3.1764705882%);
border-radius: 8px;
}
.book-card__price {
color: #b91c1c;
}
.cart {
padding: 16px;
border: 1px solid rgb(63.5294117647%, 29.2941176471%, 3.1764705882%);
border-radius: 8px;
width: 33.3333333333%;
}
Buton kahverengi, .button bir kez var ve datepicker Bookstore'un marka rengini ve köşe yuvarlaklığını kullanıyor. Hiçbir uyarı yok. Datepicker'ın $brand'ı artık yalnız kendi module'ünde yaşıyor; Bookstore'un $brand'ını ezemez. Konfigürasyon yalnız !default ile tanımlanmış değişkenler için çalışıyor; tasarım değerlerini dışarıdan değiştirmeye kalkınca:
@use "../modern/abstracts/tokens" with ($brand: #1d4ed8);
Error: This variable was not declared with !default in the @used module.
╷
1 │ @use "../modern/abstracts/tokens" with ($brand: #1d4ed8);
│ ^^^^^^^^^^^^^^^
╵
errors/not-default.scss 1:41 root stylesheet
Bir module derleme boyunca tek bir konfigürasyonla yükleniyor; bu yüzden with()'i giriş dosyasında ve module ilk kez yüklenirken vermek gerekiyor. Angular Material'ın @use '@angular/material' as mat ile yüklenip mat.theme() gibi mixin'lerle ayarlanması da bu sistemin üzerine kurulu; bir sonraki yazıda tema için kullanacağız.
Global Function'lardan Module'lere: sass-migrator
Elle taşımak yerine Sass ekibinin migrator'ını eski ağacın bir kopyasında çalıştırdım. --migrate-deps, giriş dosyasının yüklediği bütün dosyaları da taşıyor:
npm install --save-dev --save-exact sass-migrator@2.7.0
npx sass-migrator module --migrate-deps --verbose 04/migrated/main.scss
Migrating 04/migrated/_mixins.scss
Migrating 04/migrated/vendor/_datepicker.scss
Migrating 04/migrated/_buttons.scss
Migrating 04/migrated/_book-card.scss
Migrating 04/migrated/_cart.scss
Migrating 04/migrated/main.scss
Sonuçtan üç dosya:
// _mixins.scss
@use "sass:color";
@use "variables";
@mixin card-surface {
padding: variables.$space;
border: 1px solid color.adjust(variables.$brand, $lightness: -10%);
border-radius: variables.$radius;
}
// _cart.scss
@use "sass:math";
@use "buttons";
@use "mixins";
.cart {
@include mixins.card-surface;
width: math.percentage(1 / 3);
}
// vendor/_datepicker.scss
@use "../variables";
// yıllar önce repoya kopyalanmış üçüncü parti partial
variables.$brand: #2563eb;
variables.$radius: 2px;
.datepicker {
border-radius: variables.$radius;
border: 1px solid variables.$brand;
}
Migrator dört şey yaptı: her dosyaya ihtiyaç duyduğu @use'ları ekledi (artık sıra bağımlılığı yok), üyelere namespace koydu, darken()'ı birebir karşılığı olan color.adjust(..., $lightness: -10%)'a, map-get() ve percentage()'ı map.get() ve math.percentage()'a çevirdi. Derlenmiş çıktı eskisiyle aynı, tek fark .button'ın artık bir kez yazılması.
Datepicker'a dikkat. Migrator davranışı korumak zorunda; eski kodda datepicker global $brand'ı eziyordu, yenisinde de variables.$brand: #2563eb; ile variables module'ünün değişkenine atama yapıyor. Hata hâlâ orada ama artık görünür: bir dosyanın başka bir module'ün değişkenine yazdığı açıkça okunuyor. Bu tür satırları migrator'dan sonra elle düzeltmek gerekiyor; yukarıdaki !default ve with() sürümü o düzeltmenin hâli. 1 / 3 da olduğu gibi kaldı ve hâlâ slash-div uyarısı veriyor; onu sass-migrator division düzeltiyor, serinin 07. yazısında kullanacağız.
Migrator'ın darken() için seçtiği color.adjust() birebir aynı sonucu veriyor ama Sass'ın dokümanı çoğu durumda color.scale()'i öneriyor: adjust lightness'tan sabit 10 puan çıkarıyor (zaten koyu bir renk siyaha kadar gidebiliyor), scale ise kalan mesafenin yüzde 10'u kadar koyulaştırıyor; çok koyu ya da çok açık renklerde daha öngörülebilir sonuç veriyor. Yeni yazdığım -border-color()'da bu yüzden scale var.
Angular'da Module Sistemi: includePaths ve CSS Üretmeyen abstracts
bookstore-web'de önceki yazıda @use "../styles/breakpoints" as bp; yazmıştık. Göreli yollar component klasörü değiştikçe bozuluyor. Angular'da bunu angular.json'daki stylePreprocessorOptions.includePaths çözüyor; burada verilen klasörler Sass'ın load path'lerine ekleniyor:
"options": {
"styles": ["src/styles.scss"],
"stylePreprocessorOptions": {
"includePaths": ["src/styles"]
}
}
Stil ağacı artık şöyle: src/styles/abstracts/ altında _tokens.scss (tasarım değerleri), _breakpoints.scss (map ve from() mixin'i) ve bunları dışarı açan _index.scss; src/styles/_utilities.scss ise spacing class'larını üretiyor.
// src/styles/abstracts/_index.scss
// abstracts CSS üretmez: yalnız değişken, function ve mixin
@forward "tokens";
@forward "breakpoints";
Global styles.scss ve her component aynı satırla başlıyor:
@use "abstracts" as *;
includePaths olmadan aynı build şu hatayla duruyor:
✘ [ERROR] Error: Can't find stylesheet to import.
╷
1 │ @use "abstracts" as *;
│ ^^^^^^^^^^^^^^^^^^^^^
╵
Taşımadan önce ve sonra ng build'in ürettiği global stil dosyasının adı aynı kaldı (styles-73WT35JD.css, 793 byte); Angular dosya adına içeriğin hash'ini koyduğu için bu, global CSS'in byte byte değişmediği anlamına geliyor.
Buradaki en önemli kural _index.scss'teki yorum satırı: abstracts CSS üretmemeli. Angular her component'in stil dosyasını ayrı ayrı derliyor; Sass'ın "bir module bir kez" kuralı tek bir derleme için geçerli. Abstracts'a CSS kuralı üreten bir dosya girerse, onu @use eden her component o CSS'in kendi kopyasını alıyor. Denedim: _index.scss'e @forward "../utilities"; ekleyip build aldım. Bookstore'da abstracts'ı kullanan iki component var (App ve BookCard):
main-ZA2LCQVS.js | main | 127.21 kB | 38.81 kB
main-NKAADIWN.js | main | 129.35 kB | 39.00 kB
25 utility kuralı iki component'e kopyalandı ve JavaScript bundle'ı 2,1 kB büyüdü. Daha kötüsü, bu kopyalar işe de yaramıyor: emulated encapsulation onları .m-0[_ngcontent-%COMP%] gibi component'e özel selector'lere çeviriyor, yani her kopya yalnız kendi component'inin template'inde çalışıyor. Yüz component'lik bir projede aynı hata yüz kopya demek. Utility'ler ve diğer global CSS yalnız styles.scss'ten yüklenmeli.
Üretimde Dikkat
Abstracts'ın CSS üretmediğini otomatik kontrol et. En basit yol npx sass --load-path=src/styles src/styles/abstracts/_index.scss çıktısının boş olduğunu CI'da doğrulamak; tek satırlık bir kontrol, yukarıdaki kopyalama hatasını büyümeden yakalıyor.
Migration'ı küçük adımlarla yap. sass-migrator module'ü önce bir branch'te tüm ağaçta çalıştır, derlenmiş CSS'i eskisiyle diff ile karşılaştır, sonra with() ve private member düzeltmelerini elle yap. Bookstore'da fark yalnız tekrar eden kuralların kaybolmasıydı; senin projende sıra bağımlılığına dayanan bir override varsa diff onu gösterecek.
@import uyarılarını susturmak yerine say. Dart Sass 2.0'da @import hâlâ çalışacak ve deprecation olarak kalacak; kaldırılması Dart Sass 3.0'da. Bu acele etmemek için değil, planlı taşımak için bir süre. Kütüphanelerin @import uyarılarıyla nasıl başa çıkılacağı serinin 07. yazısında.
Sık Düşülen Tuzaklar
Her şeyi as * ile yüklemek. Üç kütüphane ve kendi abstracts'ın as * ile yüklendiğinde iki tanesi aynı adda bir mixin açarsa build kırılıyor; daha kötüsü, kod okuyan biri @include theme'un nereden geldiğini bilmiyor.
@forward'ı @use sanmak. _index.scss'te @forward "tokens" yazıp aynı dosyada $brand'ı kullanmaya çalışmak "Undefined variable" hatası veriyor; forward edilen module'ü dosyanın içinde kullanmak için ayrıca @use gerekiyor.
Migrator'ın çıktısını gözden geçirmeden merge etmek. Migrator davranışı koruyor, kötü davranışı da. Datepicker örneğindeki gibi başka bir module'ün değişkenine atama yapan satırlar (variables.$brand: …) migrator sonrası aranması gereken ilk şey.
Angular'da @use ile global stil dosyasını component'e yüklemek. @use "styles" gibi bir satır, bütün global CSS'i o component'in içine kopyalıyor; bütçe uyarısı (anyComponentStyle) genelde bunun ilk belirtisi.
Küçük Bir Deneme
★☆☆ 04/modern/abstracts/_index.scss'te hide yerine show kullan ve yalnız card-surface'i dışarı aç. components/_buttons.scss'in neden hata verdiğini açıkla ve düzelt.
★★☆ 04/legacy'nin yeni bir kopyasında önce sass-migrator module, sonra sass-migrator division çalıştır. Eski ve yeni derleme çıktısını diff ile karşılaştır; fark eden her satırın sebebini yaz.
★★★ bookstore-web'de abstracts'ın CSS üretmediğini kontrol eden bir npm script'i (check:abstracts) yaz: sass'ın JavaScript API'siyle _index.scss'i derlesin ve çıktı boş değilse sıfırdan farklı bir exit code ile çıksın. Sonra _index.scss'e bilerek bir CSS kuralı ekleyip script'in yakaladığını göster.
Bir sonraki yazıda Sass'ı theming için kullanıyoruz: token map'inden :root ve .dark altında custom property üreten bir mixin, runtime'da tema değiştirmek, light-dark() ve Bookstore'un marka renkleriyle bir Angular Material M3 (Material Design 3; Google'ın tasarım sisteminin üçüncü sürümü) teması.