淺談邏輯選擇器 -- 父選擇器它來了!

chokcoco發表於2022-05-18

在 CSS 選擇器家族中,新增這樣一類比較新的選擇器 -- 邏輯選擇器,目前共有 4 名成員:

  • :is
  • :where
  • :not
  • :has

本文將帶領大家瞭解、深入它們。做到學以致用,寫出更現代化的選擇器。


:is 偽類選擇器

:is() CSS偽類函式將選擇器列表作為引數,並選擇該列表中任意一個選擇器可以選擇的元素。

在之前,對於多個不同父容器的同個子元素的一些共性樣式設定,可能會出現如下 CSS 程式碼:

header p:hover,
main p:hover,
footer p:hover {
  color: red;
  cursor: pointer;
}

而如今有了 :is() 偽類,上述程式碼可以改寫成:

:is(header, main, footer) p:hover {
  color: red;
  cursor: pointer;
}

它並沒有實現某種選擇器的新功能,更像是一種語法糖,類似於 JavaScript ES6 中的 Class() 語法,只是對原有功能的重新封裝設計,實現了更容易的表達一個操作的語法,簡化了某些複雜程式碼的寫法。

語法糖(syntactic sugar)是指程式語言中可以更容易的表達一個操作的語法,它可以使程式設計師更加容易去使用這門語言,操作可以變得更加清晰、方便,或者更加符合程式設計師的程式設計習慣。用比較通俗易懂的方式去理解就是,在之前的某個語法的基礎上改變了一種寫法,實現的功能相同,但是寫法不同了,主要是為了讓開發人員在使用過程中更方便易懂。

一圖勝前言(引用至 New CSS functional pseudo-class selectors :is() and :where()):

支援多層層疊連用

再來看看這種情況,原本的 CSS 程式碼如下:

<div><i>div i</i></div>
<p><i>p i</i></p>
<div><span>div span</span></div>
<p><span>p span</span></p>
<h1><span>h1 span</span></h1>
<h1><i>h1 i</i></h1>

如果要將上述 HTML 中,<div><p> 下的 <span><i> 的 color 設定為 red,正常的 CSS 可能是這樣:

div span,
div i,
p span,
p i {
    color: red;
}

有了 :is() 後,程式碼可以簡化為:

:is(div, p) :is(span, i) {
    color: red;
}

結果如下:

這裡,也支援 :is() 的層疊連用。通過 :is(div, p) :is(span, i) 的排列組合,可以組合出上述 4 行的選擇器,達到同樣的效果。

當然,這個例子比較簡單,看不出 :is() 的威力。下面這個例子就比較明顯,這麼一大段 CSS 選擇器程式碼:

ol ol ul,     ol ul ul,     ol menu ul,     ol dir ul,
ol ol menu,   ol ul menu,   ol menu menu,   ol dir menu,
ol ol dir,    ol ul dir,    ol menu dir,    ol dir dir,
ul ol ul,     ul ul ul,     ul menu ul,     ul dir ul,
ul ol menu,   ul ul menu,   ul menu menu,   ul dir menu,
ul ol dir,    ul ul dir,    ul menu dir,    ul dir dir,
menu ol ul,   menu ul ul,   menu menu ul,   menu dir ul,
menu ol menu, menu ul menu, menu menu menu, menu dir menu,
menu ol dir,  menu ul dir,  menu menu dir,  menu dir dir,
dir ol ul,    dir ul ul,    dir menu ul,    dir dir ul,
dir ol menu,  dir ul menu,  dir menu menu,  dir dir menu,
dir ol dir,   dir ul dir,   dir menu dir,   dir dir dir {
  list-style-type: square;
}

可以利用 :is() 優化為:

:is(ol, ul, menu, dir) :is(ol, ul, menu, dir) :is(ul, menu, dir) {
  list-style-type: square;
}

不支援偽元素

有個特例,不能用 :is() 來選取 ::before::after 兩個偽元素。譬如:

注意,僅僅是不支援偽元素,偽類,譬如 :focus:hover 是支援的。
div p::before,
div p::after {
    content: "";
    //...
}

不能寫成:

div p:is(::before, ::after) {
    content: "";
    //...
}

:is 選擇器的優先順序

看這樣一種有意思的情況:

<div>
    <p class="test-class" id="test-id">where & is test</p>
</div>
<div>
    <p class="test-class">where & is test</p>
</div>

我們給帶有 .test-class 的元素,設定一個預設的顏色:

div .test-class {
    color: red;
}

如果,這個時候,我們引入 :is() 進行匹配:

div :is(p) {
    color: blue;
}

此時,由於 div :is(p) 可以看成 div p,優先順序是沒有 div .test-class 高的,因此,被選中的文字的顏色是不會發生變化的。

但是,如果,我們在 :is() 選擇器中,加上一個 #test-id,情況就不一樣了。

div :is(p, #text-id) {
    color: blue;
}

按照理解,如果把上述選擇器拆分,上述程式碼可以拆分成:

div p {
    color: blue;
}
div #text-id {
    color: blue;
}

那麼,我們有理由猜想,帶有 #text-id<p> 元素由於有了更高優先順序的選擇器,顏色將會變成 blue,而另外一個 div p 由於優先順序不夠高的問題,導致第一段文字依舊是 green

但是,這裡,神奇的是,兩段文字都變成了 blue

CodePen Demo -- the specificity of CSS :is selector

這是由於,:is() 的優先順序是由它的選擇器列表中優先順序最高的選擇器決定的。我們不能把它們割裂開來看。

對於 div :is(p, #text-id)is:() 內部有一個 id 選擇器,因此,被該條規則匹配中的元素,全部都會應用 div #id 這一級別的選擇器優先順序。這裡非常重要,再強調一下,對於 :is() 選擇器的優先順序,我們不能把它們割裂開來看,它們是一個整體,優先順序取決於選擇器列表中優先順序最高的選擇器

:is 的別名 :matches() 與 :any()

:is() 是最新的規範命名,在之前,有過有同樣功能的選擇,分別是:

:is(div, p) span {}
// 等同於
:-webkit-any(div, p) span {}
:-moz-any(div, p) span {}
:matches(div, p) span {}

當然,下面 3 個都已經廢棄,不建議再繼續使用。而到今天(2022-04-27):is() 的相容性已經非常不錯了,不需要相容 IE 系列的話可以考慮開始用起來(配合 autoprefixer),看看 CanIUse

:where 偽類選擇器

瞭解了 :is 後,我們可以再來看看 :where,它們兩個有著非常強的關聯性。:where 同樣是將選擇器列表作為其引數,並選擇可以由該列表中的選擇器之一選擇的任何元素。

還是這個例子:

:where(header, main, footer) p:hover {
  color: red;
  cursor: pointer;
}

上述的程式碼使用了 :where,可以近似的看為:

header p:hover,
main p:hover,
footer p:hover {
  color: red;
  cursor: pointer;
}

這就有意思了,這不是和上面說的 :is 一樣了麼?

那麼它們的區別在什麼地方呢?

:is:where 的區別

首先,從語法上,:is:where 是一模一樣的。它們的核心區別點在於 優先順序

來看這樣一個例子:

<div>
    <p>where & is test</p>
</div>

CSS 程式碼如下:

:is(div) p {
    color: red;
}
:where(div) p {
    color: green;
}

正常按我們的理解而言,:is(div) p:where(div) p 都可以轉化為 div p,由於 :where(div) p 後定義,所以文字的顏色,應該是 green 綠色,但是,實際的顏色表現為 color: red 紅色:

這是因為,:where():is() 的不同之處在於,:where() 的優先順序總是為 0 ,但是 :is() 的優先順序是由它的選擇器列表中優先順序最高的選擇器決定的。

上述的例子還不是特別明顯,我們再稍微改造下:

<div id="container">
    <p>where & is test</p>
</div>

我們給 div 新增上一個 id 屬性,改造上述 CSS 程式碼:

:is(div) p {
    color: red;
}
:where(#container) p {
    color: green;
}

即便如此,由於 :where(#container) 的優先順序為 0,因此文字的顏色,依舊為紅色 red。:where() 的優先順序總是為 0 這一點在使用的過程中需要牢記。

組合、巢狀

CSS 選擇器的一個非常大的特點就在於組合巢狀。:is:where 也不例外,因此,它們也可以互相組合巢狀使用,下述的 CSS 選擇器都是合理的:

/* 組合*/
:is(h1,h2) :where(.test-a, .test-b) {
  text-transform: uppercase;
}
/* 巢狀*/
.title:where(h1, h2, :is(.header, .footer)) {
  font-weight: bold;
}

這裡簡單總結下,:is:where 都是非常好的分組邏輯選擇器,唯一的區別在於:where() 的優先順序總是為 0,而:is() 的優先順序是由它的選擇器列表中優先順序最高的選擇器決定的。

:not 偽類選擇器

下面我們介紹一下非常有用的 :not 偽類選擇器。

:not 偽類選擇器用來匹配不符合一組選擇器的元素。由於它的作用是防止特定的元素被選中,它也被稱為反選偽類(negation pseudo-class)。

舉個例子,HTML 結構如下:

<div class="a">div.a</div>
<div class="b">div.b</div>
<div class="c">div.c</div>
<div class="d">div.d</div>
div:not(.b) {
    color: red;
}

div:not(.b) 它可以選擇除了 class 為 .b 元素之外的所有 div 元素:

MDN 的錯誤例子?一個有意思的現象

有趣的是,在 MDN 介紹 :not 的頁面,有這樣一個例子:

/* Selects any element that is NOT a paragraph */
:not(p) {
  color: blue;
}

意思是,:not(p) 可以選擇任何不是 <p> 標籤的元素。然而,上面的 CSS 選擇器,在如下的 HTML 結構,實測的結果不太對勁。

<p>p</p>
<div>div</div>
<span>span</span>
<h1>h1</h1>

結果如下:

意思是,:not(p) 仍然可以選中 <p> 元素。我嘗試了多個瀏覽器,得到的效果都是一致的。

CodePen Demo -- :not pesudo demo

這是為什麼呢?這是由於 :not(p) 同樣能夠選中 <body>,那麼 <body> 的 color 即變成了 blue,由於 color 是一個可繼承屬性,<p> 標籤繼承了 <body> 的 color 屬性,導致看到的 <p> 也是藍色。

我們把它改成一個不可繼承的屬性,試試看:

/* Selects any element that is NOT a paragraph */
:not(p) {
  border: 1px solid;
}

OK,這次 <p> 沒有邊框體現,沒有問題!實際使用的時候,需要注意這一層繼承的問題!

:not 的優先順序問題

下面是一些使用 :not 需要注意的問題。

:not:is:where 這幾個偽類不像其它偽類,它不會增加選擇器的優先順序。它的優先順序即為它引數選擇器的優先順序。

並且,在 CSS Selectors Level 3:not() 內只支援單個選擇器,而從 CSS Selectors Level 4 開始,:not() 內部支援多個選擇器,像是這樣:

/* CSS Selectors Level 3,:not 內部如果有多個值需要分開 */
p:not(:first-of-type):not(.special) {
}
/* CSS Selectors Level 4 支援使用逗號分隔*/
p:not(:first-of-type, .special) {
}

:is() 類似,:not() 選擇器本身不會影響選擇器的優先順序,它的優先順序是由它的選擇器列表中優先順序最高的選擇器決定的。

:not(*) 問題

使用 :not(*) 將匹配任何非元素的元素,因此這個規則將永遠不會被應用。

相當於一段沒有任何意義的程式碼。

:not() 不能巢狀 :not()

禁止套娃。:not 偽類不允許巢狀,這意味著 :not(:not(...)) 是無效的。

:not() 實戰解析

那麼,:not() 有什麼特別有意思的應用場景呢?我這裡列舉一個。

W3 CSS selectors-4 規範 中,新增了一個非常有意思的 :focus-visible 偽類。

:focus-visible 這個選擇器可以有效地根據使用者的輸入方式(滑鼠 vs 鍵盤)展示不同形式的焦點。

有了這個偽類,就可以做到,當使用者使用滑鼠操作可聚焦元素時,不展示 :focus 樣式或者讓其表現較弱,而當使用者使用鍵盤操作焦點時,利用 :focus-visible,讓可獲焦元素獲得一個較強的表現樣式。

看個簡單的 Demo:

<button>Test 1</button>
button:active {
  background: #eee;
}
button:focus {
  outline: 2px solid red;
}

使用滑鼠點選:

可以看到,使用滑鼠點選的時候,觸發了元素的 :active 偽類,也觸發了 :focus偽類,不太美觀。但是如果設定了 outline: none 又會使鍵盤使用者的體驗非常糟糕。因為當鍵盤使用者使用 Tab 嘗試切換焦點的時候,會因為 outline: none 而無所適從。

因此,可以使用 :focus-visible 偽類改造一下:

button:active {
  background: #eee;
}
button:focus {
  outline: 2px solid red;
}
button:focus:not(:focus-visible) {
  outline: none;
}

看看效果,分別是在滑鼠點選 Button 和使用鍵盤控制焦點點選 Button:

CodePen Demo -- :focus-visible example

可以看到,使用滑鼠點選,不會觸發 :foucs,只有當鍵盤操作聚焦元素,使用 Tab 切換焦點時,outline: 2px solid red 這段程式碼才會生效。

這樣,我們就既保證了正常使用者的點選體驗,也保證了無法使用滑鼠的使用者的焦點管理體驗,在可訪問性方面下了功夫。

值得注意的是,這裡為什麼使用了 button:focus:not(:focus-visible) 這麼繞的寫法而不是直接這樣寫呢:

button:focus {
  outline: unset;
}
button:focus-visible {
  outline: 2px solid red;
}

解釋一下,button:focus:not(:focus-visible) 的意思是,button 元素觸發 focus 狀態,並且不是通過 focus-visible 觸發,理解過來就是在支援 :focus-visible 的瀏覽器,通過滑鼠啟用 :focus 的 button 元素,這種情況下,不需要設定 outline

為的是相容不支援 :focus-visible 的瀏覽器,當 :focus-visible 不相容時,還是需要有 :focus 偽類的存在。

因此,這裡藉助 :not() 偽類,巧妙的實現了一個實用效果的方案降級。

這裡有點繞,需要好好理解理解。

:not 相容性

經歷了 CSS Selectors Level 3 & CSS Selectors Level 4 兩個版本,到今天(2020-05-04),除去 IE 系列,:not 的相容性已經非常之好了:

:has 偽類選擇器

OK。最後到所有邏輯選擇器裡面最重磅的 :has 出場了。它之所以重要是因為它的誕生,填補了在之前 CSS 選擇器中,沒有核心意義上真正的父選擇器的空缺。

:has 偽類接受一個選擇器組作為引數,該引數相對於該元素的 :scope 至少匹配一個元素。

實際看個例子:

<div>
    <p>div -- p</p>
</div>
<div>
    <p class="g-test-has">div -- p.has</p>
</div>
<div>
    <p>div -- p</p>
</div>
div:has(.g-test-has) {
    border: 1px solid #000;
} 

我們通過 div:has(.g-test-has) 選擇器,意思是,選擇 div 下存在 class 為 .g-test-has 的 div 元素。

注意,這裡選擇的不是 :has() 內包裹的選擇器選中的元素,而是使用 :has() 偽類的宿主元素。

效果如下:

可以看到,由於第二個 div 下存在 class 為 .g-test-has 的元素,因此第二個 div 被加上了 border。

:has() 父選擇器 -- 巢狀結構的父元素選擇

我們再通過幾個 DEMO 加深下印象。:has() 內還可以寫的更為複雜一點。

<div>
    <span>div span</span>
</div>

<div>
    <ul>
        <li>
            <h2><span>div ul li h2 span</span></h2>
        </li>
    </ul>
</div>

<div>
    <h2><span>div h2 span</span></h2>
</div>
div:has(>h2>span) {
    margin-left: 24px;
    border: 1px solid #000;
}

這裡,要求準確選擇 div 下直接子元素是 h2,且 h2 下直接子元素有 span 的 div 元素。注意,選擇的最上層使用 :has() 的父元素 div。結果如下:

這裡體現的是巢狀結構,精確尋找對應的父元素

:has() 父選擇器 -- 同級結構的兄元素選擇

還有一種情況,在之前也比較難處理,同級結構的兄元素選擇。

看這個 DEMO:

<div class="has-test">div + p</div>
<p>p</p>

<div class="has-test">div + h1</div>
<h1>h1</h1>

<div class="has-test">div + h2</div>
<h2>h2</h2>

<div class="has-test">div + ul</div>
<ul>ul</ul>

我們想找到兄弟層級關係中,後面接了 <h2> 元素的 .has-test 元素,可以這樣寫:

.has-test:has(+ h2) {
    margin-left: 24px;
    border: 1px solid #000;
}

效果如下:

這裡體現的是兄弟結構,精確尋找對應的前置兄元素

這樣,一直以來,CSS 沒有實現的父選擇器,藉由 :has() 開始,也能夠做到了。這個選擇器,能夠極大程度的提升開發體驗,解決之前需要比較多 JavaScript 程式碼才能夠完成的事。

上述 DEMO 彙總,你可以戳這裡 CodePen Demo -- :has Demo

:has() 相容性,給時間一點時間

比較可惜的是,:has() 在最近的 Selectors Level 4 規範中被確定,目前的相容性還比較慘淡,截止至 2022-05-04,Safari 和 最新版的 Chrome(V101,可通過開啟 Experimental Web Platform features 體驗)

Chrome 下開啟該特性需要,1. 瀏覽器 URL 框輸入 chrome://flags,2. 開啟 #enable-experimental-web-platform-features

耐心等待,給給時間一點時間,這麼好的選擇器馬上就能大規模應用了。

最後

本文到此結束,希望對你有幫助 :)

想 Get 到最有意思的 CSS 資訊,千萬不要錯過我的公眾號 -- iCSS前端趣聞 ?

更多精彩 CSS 技術文章彙總在我的 Github -- iCSS ,持續更新,歡迎點個 star 訂閱收藏。

如果還有什麼疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

相關文章