級聯中的 CSS 自定義屬性
已發表: 2022-03-10上個月,我在 Twitter 上討論了“範圍”樣式(在構建過程中生成)和 CSS 原生“嵌套”樣式之間的區別。 我問為什麼,有趣的是,開發人員會避免 ID 選擇器的特殊性,同時採用 JavaScript 生成的“範圍樣式”? 基思格蘭特建議,區別在於平衡級聯*和繼承,即優先考慮接近性而不是特異性。 讓我們來看看。
級聯
CSS 級聯基於三個因素:
- 由
!important
標誌定義的重要性和样式來源(用戶 > 作者 > 瀏覽器) - 使用的選擇器的特殊性(內聯 > ID > 類 > 元素)
- 代碼本身的源順序(最新優先)
任何地方都沒有提到接近性——選擇器各部分之間的 DOM 樹關係。 下面的段落都將是紅色的,即使#inner p
描述的關係比第二段的#outer p
更密切:
<section> <p>This text is red</p> <div> <p>This text is also red!</p> </div> </section>
#inner p { color: green; } #outer p { color: red; }
兩個選擇器具有相同的特異性,它們都描述了相同的p
元素,並且都沒有標記為!important
——因此結果僅基於源順序。
BEM 和範圍樣式
像 BEM(“Block__Element-Modifier”)這樣的命名約定用於確保每個段落的“範圍”僅限於一個父級,從而完全避免級聯。 段落“元素”被賦予特定於其“塊”上下文的唯一類:
<section class="outer"> <p class="outer__p">This text is red</p> <div class="inner"> <p class="inner__p">This text is green!</p> </div> </section>
.inner__p { color: green; } .outer__p { color: red; }
這些選擇器仍然具有相同的相對重要性、特異性和源順序——但結果不同。 “Scoped”或“modular” CSS 工具自動執行該過程,基於 HTML 為我們重寫 CSS。 在下面的代碼中,每個段落的範圍僅限於其直接父級:
<section outer-scope> <p outer-scope>This text is red</p> <div outer-scope inner-scope> <p inner-scope>This text is green!</p> </div> </section>
p[inner-scope] { color: green } p[outer-scope] { color: red; }
遺產
接近不是級聯的一部分,但它是 CSS 的一部分。 這就是繼承變得重要的地方。 如果我們從選擇器中刪除p
,每個段落都會從其最近的祖先那裡繼承一種顏色:
#inner { color: green; } #outer { color: red; }
由於#inner
和#outer
描述了不同的元素,即我們的div
和section
,因此兩個顏色屬性的應用都沒有衝突。 嵌套的p
元素沒有指定顏色,因此結果由繼承(直接父級的顏色)而不是cascade確定。 接近優先, #outer
#inner
但是有一個問題:為了使用繼承,我們在我們的section
和div
中設置了所有的樣式。 我們要專門針對段落顏色。
(重新)引入自定義屬性
自定義屬性提供了一種新的瀏覽器原生解決方案; 它們像任何其他屬性一樣繼承,但不必在定義它們的地方使用。 使用純 CSS,沒有任何命名約定或構建工具,我們可以創建一個既具有目標性又具有上下文的樣式,並且接近優先於級聯:
p { color: var(--paragraph); } #inner { --paragraph: green; } #outer { --paragraph: red; }
自定義--paragraph
屬性與color
屬性一樣繼承,但現在我們可以控制該值的應用方式和位置。 --paragraph
屬性的作用類似於可以通過直接選擇(特異性規則)或上下文(鄰近規則)傳遞給p
組件的參數。
我認為這揭示了我們經常與函數、mixin 或組件相關聯的自定義屬性的潛力。
自定義“功能”和參數
函數、mixin 和組件都基於相同的思想:可重用代碼,可以使用各種輸入參數運行以獲得一致但可配置的結果。 區別在於他們對結果的處理方式。 我們將從一個條帶梯度變量開始,然後我們可以將其擴展為其他形式:
html { --stripes: linear-gradient( to right, powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
該變量是在根html
元素上定義的(也可以使用:root
,但這會增加不必要的特殊性),因此我們的 striped 變量將在文檔中的任何地方都可用。 我們可以在任何支持漸變的地方應用它:
body { background-image: var(--stripes); }
添加參數
函數像變量一樣使用,但定義了用於更改輸出的參數。 我們可以通過在其中定義一些類似參數的變量來更新我們的--stripes
變量,使其更像函數。 我將首先用var(--stripes-angle)
替換to right
,以創建一個角度改變參數:
html { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
我們可以創建其他參數,具體取決於函數的用途。 我們應該允許用戶選擇自己的條紋顏色嗎? 如果是這樣,我們的函數是接受 5 個不同的顏色參數,還是只接受 3 個像現在這樣由外而內的參數? 我們是否也想為色標創建參數? 我們添加的每個參數都以簡單性和一致性為代價提供了更多的自定義。
這種平衡沒有普遍的正確答案——一些功能需要更靈活,而另一些需要更有主見。 抽象的存在是為了在你的代碼中提供一致性和可讀性,所以退後一步,問問你的目標是什麼。 真正需要定制什麼,應該在哪裡實施一致性? 在某些情況下,擁有兩個自以為是的功能可能比一個完全可定制的功能更有幫助。
要使用上面的函數,我們需要為--stripes-angle
參數傳入一個值,並將輸出應用到 CSS 輸出屬性,例如background-image
:
/* in addition to the code above… */ html { --stripes-angle: 75deg; background-image: var(--stripes); }
繼承與通用
我出於習慣在html
元素上定義了--stripes
函數。 自定義屬性繼承,我希望我的函數在任何地方都可用,所以將它放在根元素上是有意義的。 這對於繼承像--brand-color: blue
這樣的變量很有效,所以我們也可以期望它也適用於我們的“函數”。 但是如果我們嘗試在嵌套選擇器上再次使用這個函數,它將不起作用:
div { --stripes-angle: 90deg; background-image: var(--stripes); }
新的--stripes-angle
被完全忽略。 事實證明,對於需要重新計算的函數,我們不能依賴繼承。 這是因為每個屬性值對每個元素(在我們的例子中是html
根元素)計算一次,然後計算值被繼承。 通過在文檔根目錄定義我們的函數,我們不會讓整個函數對後代可用——只有我們函數的計算結果。
如果您根據級聯--stripes-angle
參數來構建它,這是有道理的。 像任何繼承的 CSS 屬性一樣,它對後代可用,但對祖先不可用。 我們在嵌套div
上設置的值不適用於我們在html
根祖先上定義的函數。 為了創建一個可以重新計算任何元素的通用函數,我們必須在每個元素上定義它:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
通用選擇器使我們的函數在任何地方都可用,但如果我們願意,我們可以更嚴格地定義它。 重要的是它只能在明確定義的地方重新計算。 以下是一些替代方案:
/* make the function available to elements with a given selector */ .stripes { --stripes: /* etc… */; } /* make the function available to elements nested inside a given selector */ .stripes * { --stripes: /* etc… */; } /* make the function available to siblings following a given selector */ .stripes ~ * { --stripes: /* etc… */; }
這可以使用任何不依賴繼承的選擇器邏輯進行擴展。
自由參數和備用值
在我們上面的例子中, var(--stripes-angle)
沒有價值也沒有回退。 與在調用之前必須定義或實例化的 Sass 或 JS 變量不同,CSS 自定義屬性可以在不定義的情況下調用。 這將創建一個“自由”變量,類似於可以從上下文繼承的函數參數。
我們最終可以在html
或:root
(或任何其他祖先)上定義變量來設置繼承值,但首先我們需要考慮未定義值時的回退。 有幾個選項,具體取決於我們想要的行為
- 對於“必需的”參數,我們不想要回退。 照原樣,在定義
--stripes-angle
之前,該函數什麼都不做。 - 對於“可選”參數,我們可以在
var()
函數中提供一個備用值。 在變量名之後,我們添加一個逗號,後跟默認值:
var(--stripes-angle, 90deg)
每個var()
函數只能有一個回退——因此任何額外的逗號都將成為該值的一部分。 這使得使用內部逗號提供複雜的默認值成為可能:
html { /* Computed: Hevetica, Ariel, sans-serif */ font-family: var(--sans-family, Hevetica, Ariel, sans-serif); /* Computed: 0 -1px 0 white, 0 1px 0 black */ test-shadow: var(--shadow, 0 -1px 0 white, 0 1px 0 black); }
我們還可以使用嵌套變量來創建自己的級聯規則,為不同的值賦予不同的優先級:
var(--stripes-angle, var(--global-default-angle, 90deg))
- 首先,嘗試我們的顯式參數(
--stripes-angle
); - 如果可用,則回退到全局“用戶默認值”(
--user-default-angle
); - 最後,回退到我們的“出廠默認值”
(90deg
)。
通過在var()
中設置後備值而不是顯式定義自定義屬性,我們確保對參數沒有特異性或級聯限制。 所有*-angle
參數都是“自由”的,可以從任何上下文中繼承。
瀏覽器回退與變量回退
當我們使用變量時,我們需要牢記兩條後備路徑:
- 沒有變量支持的瀏覽器應該使用什麼值?
- 當特定變量丟失或無效時,支持變量的瀏覽器應該使用什麼值?
p { color: blue; color: var(--paragraph); }
雖然舊瀏覽器會忽略變量聲明屬性,並回退到blue
——現代瀏覽器會同時讀取並使用後者。 我們的var(--paragraph)
可能沒有定義,但它是有效的並且會覆蓋之前的屬性,所以支持變量的瀏覽器會回退到繼承的或初始值,就像使用unset
關鍵字一樣。
起初這似乎令人困惑,但有充分的理由。 第一個是技術性的:瀏覽器引擎在“解析時間”(首先發生)處理無效或未知的語法,但直到“計算值時間”(稍後發生)才解析變量。
- 在解析時,語法無效的聲明會被完全忽略——退回到之前的聲明。 這是舊瀏覽器將遵循的路徑。 現代瀏覽器支持變量語法,因此之前的聲明被丟棄了。
- 在計算值時,變量被編譯為無效,但為時已晚——先前的聲明已被丟棄。 根據規範,無效變量值被視為與
unset
相同:
html { color: red; /* ignored as *invalid syntax* by all browsers */ /* - old browsers: red */ /* - new browsers: red */ color: not a valid color; color: var(not a valid variable name); /* ignored as *invalid syntax* by browsers without var support */ /* valid syntax, but invalid *values* in modern browsers */ /* - old browsers: red */ /* - new browsers: unset (black) */ --invalid-value: not a valid color value; color: var(--undefined-variable); color: var(--invalid-value); }
這對作為作者的我們也有好處,因為它允許我們為支持變量的瀏覽器使用更複雜的回退,並為舊瀏覽器提供簡單的回退。 更好的是,這允許我們使用null
/ undefined
狀態來設置所需的參數。 如果我們想將一個函數變成一個 mixin 或組件,這一點就變得尤為重要。
自定義屬性“混合”
在 Sass 中,函數返回原始值,而 mixin 通常返回帶有屬性值對的實際 CSS 輸出。 當我們定義一個通用的--stripes
屬性,而不將它應用到任何視覺輸出時,結果是類似函數的。 我們也可以通過通用定義輸出來使其表現得更像一個 mixin:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
只要--stripes-angle
仍然無效或未定義,mixin 就無法編譯,並且不會應用background-image
。 如果我們在任何元素上設置一個有效的角度,該函數將計算並給我們一個背景:
div { --stripes-angle: 30deg; /* generates the background */ }
不幸的是,該參數值將繼承,因此當前定義在div
和所有後代上創建背景。 為了解決這個問題,我們必須確保--stripes-angle
值不會繼承,方法是在每個元素上將其設置為initial
值(或任何無效值)。 我們可以在同一個通用選擇器上做到這一點:
* { --stripes-angle: initial; --stripes: /* etc… */; background-image: var(--stripes); }
安全的內聯樣式
在某些情況下,我們需要從 CSS 外部動態設置參數——基於來自後端服務器或前端框架的數據。 使用自定義屬性,我們可以安全地在 HTML 中定義變量,而不必擔心通常的特殊性問題:
<div>...</div>
內聯樣式具有很高的特異性,並且很難被覆蓋——但是對於自定義屬性,我們還有另一個選擇:忽略它。 如果我們將 div 設置為background-image: none
(例如)該內聯變量將沒有影響。 更進一步,我們可以創建一個中間變量:
* { --stripes-angle: var(--stripes-angle-dynamic, initial); }
現在我們可以選擇在 HTML 中定義--stripes-angle-dynamic
,或者忽略它,直接在樣式表中設置--stripes-angle
。
預設值
對於更複雜的值,或者我們想要重用的常見模式,我們還可以提供一些預設變量供選擇:
* { --tilt-down: 6deg; --tilt-up: -6deg; }
並使用這些預設,而不是直接設置值:
<div>...</div>
這非常適合基於動態數據創建圖表和圖形,甚至可以佈置日程表。
上下文組件
我們還可以將我們的“mixin”重新構建為一個“組件”,方法是將其應用於顯式選擇器,並使參數可選。 與其依賴--stripes-angle
的存在或不存在來切換我們的輸出,我們可以依賴組件選擇器的存在或不存在。 這使我們能夠安全地設置後備值:
[data-stripes] { --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
通過將回退放在var()
函數中,我們可以讓--stripes-angle
未定義並且“自由”地從組件外部繼承一個值。 這是將組件樣式的某些方面暴露給上下文輸入的好方法。 即使是由 JS 框架生成的“範圍”樣式(或在 shadow-DOM 中的範圍,如 SVG 圖標)也可以使用這種方法來公開特定參數以供外部影響。
隔離組件
如果我們不想暴露參數進行繼承,我們可以用默認值定義變量:
[data-stripes] { --stripes-angle: to right; --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
這些組件也可以與類或任何其他有效的選擇器一起使用,但我選擇了data-
屬性來為我們想要的任何修飾符創建命名空間:
[data-stripes='vertical'] { --stripes-angle: to bottom; } [data-stripes='horizontal'] { --stripes-angle: to right; } [data-stripes='corners'] { --stripes-angle: to bottom right; }
選擇器和參數
我經常希望我可以使用 data-attributes 來設置一個變量——CSS3 attr()
規範支持的一個功能,但尚未在任何瀏覽器中實現(請參閱資源選項卡以了解每個瀏覽器上的鏈接問題)。 這將使我們能夠更緊密地將選擇器與特定參數相關聯:
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
同時,我們可以通過使用style
屬性來實現類似的效果:
<div>...</div> /* The `*=` atttribute selector will match a string anywhere in the attribute */ [style*='--stripes-angle'] { /* Only define the function where we want to call it */ --stripes: linear-gradient(…); }
當我們想要包含除了要設置的參數之外的其他屬性時,這種方法最有用。 例如,設置網格區域還可以添加填充和背景:
[style*='--grid-area'] { background-color: white; grid-area: var(--grid-area, auto / 1 / auto / -1); padding: 1em; }
結論
當我們開始將所有這些部分放在一起時,很明顯自定義屬性遠遠超出了我們熟悉的常見變量用例。 我們不僅能夠存儲值,並將它們限定在級聯範圍內——而且我們可以使用它們以新的方式操作級聯,並直接在 CSS 中創建更智能的組件。
這要求我們重新思考過去依賴的許多工具——從 SMACSS 和 BEM 等命名約定,到“範圍”樣式和 CSS-in-JS。 其中許多工具有助於解決特殊性,或用另一種語言管理動態樣式——我們現在可以直接使用自定義屬性來處理這些用例。 我們經常在 JS 中計算的動態樣式現在可以通過將原始數據傳遞給 CSS 來處理。
起初,這些變化可能被視為“增加了複雜性”——因為我們不習慣在 CSS 中看到邏輯。 而且,與所有代碼一樣,過度工程可能是一個真正的危險。 但我認為,在許多情況下,我們可以使用這種能力不增加複雜性,而是將復雜性從第三方工具和約定中移出,回到網頁設計的核心語言中,並且(更重要的是)回到瀏覽器。 如果我們的樣式需要計算,那麼計算應該存在於我們的 CSS 中。
所有這些想法都可以更進一步。 自定義屬性剛剛開始得到更廣泛的採用,而我們才剛剛開始觸及可能的表面。 我很高興看到它的發展方向,以及人們還能想出什麼。 玩得開心!
延伸閱讀
- “是時候開始使用 CSS 自定義屬性了,”Serg Hospodarets
- “CSS 自定義屬性的策略指南”,Michael Riethmuller