整潔架構之 CSS
在歷數技術進步的代價時,弗洛伊德遵循的路線使人感到壓抑。他同意塔姆斯的評論:我們的發明只不過是手段的改進,目的卻未見改善。——尼爾波斯曼《技術壟斷》
雖然開發工具早已經從 preprocessor 進化到了 styled component 甚至是 functional css,但在我看來新的工具並沒有讓我們的樣式代碼寫的更好,只是更快——也可能會讓代碼壞的更快。工具的繁榮並沒有讓那些導致代碼難以維護的根本問題煙消雲散,而是更易讓我們對其視而不見。這篇文章旨在回答一個問題:爲什麼樣式代碼難以寫對,它的陷阱究竟在哪裏?
如果一本正經的聊架構,套路多半是按照某些重要的特徵依次展開講解。但這些所謂的重要特徵其實在編程領域中是放之四海而皆準的,例如 “擴展性”、“可複用”、“可維護性” 等等,按這種思路聊,空談大於應用。所以我們不如通過解決某個具體的樣式問題,來審視樣式代碼應該如何編寫和組織
下圖是一個非常簡單的 popup 組件,我們會以它的樣式開發過程串起整篇的內容。
我們首先以一種簡單粗暴的方式來實現它,直覺上看,實現這個 popup 只需要三個元素即可:div 是最外面的容器,h1 用於包裹 "Success" 文案,button 用來實現按鈕
<div>
<div>Success</div>
<button>OK</button>
</div>
我不會完整的寫出它的完整樣式,只大概列出其中一些關鍵屬性
.popup {
display: flex;
justify-content: space-around;
padding: 20px;
width: 200px;
height: 200px;
div {
margin: 10px;
font-size: 24px;}
button {
background: orange;
font-size: 16px;
margin: 10px;}}
第一版實現即完成了。目前看來並沒有什麼不妥。
問題不在於實現而是在於維護。接下來我就以一些常見的實際需求變更來看看上面的代碼存在怎樣的問題。
對 DOM 元素的依賴
假設現在需要在 “Success” 下方新增一個元素用於展示成功的具體信息
想當然的我們需要新增一個 div 標籤。但如果這樣的話上面樣式中的 .popup div 樣式就會同時對這兩個 div 產生同樣的效果,這並不是我們希望的,很明顯這兩個元素的樣式是不同的。OK,如果你堅持使用標籤作爲選擇器的話,你可以使用僞類選擇器 nth-child 來區分樣式:
.popup {
div:nth-child(1) {
margin: 10px;
font-size: 24px;}
div:nth-child(2) {
margin: 5px;
font-size: 16px;}
但如果某一天你認爲 "Success" 應該使用 h1 而非 div 封裝更爲恰當的話,那麼修改的成本則是:
-
將 div 改爲 h1,
-
將 div:nth-child(1) 樣式改爲 h1 所屬,
-
將 div:nth-child(2) 還原爲 div 樣式
但如果你一開始就能給 button 和 div 一個確切的 class 名稱,那麼當你修改 DOM 元素時也僅僅需要修改 DOM 元素,而無需修改樣式文件了
上面舉得這個例子是水平拓展的情況,也就是說我在某一元素的同一級新增一個元素。縱向拓展也會出現同樣的問題,你可以完全想象的出類似於這樣的選擇器:
.popup div > div > h1 > span {}.popup {
div {
div {
span {}
}}}
無論是上面代碼中的哪一種情況,樣式是否生效都極度依賴於 DOM 結構。在一連串的 DOM 標籤的層級關係中,哪怕只有一個元素出現了問題(可能是元素標籤類型發生了修改,還有可能是在它之上新增了一個元素)都會導致樣式大面積失效。同時這樣的做法也會讓你複用樣式難上加難,如果你希望複用 .popup div > div > h1 > 的樣式,你不得不將 DOM 結構也拷貝到想要複用的地方。
所以這裏我們至少能得出一個結論:CSS 不應該過分的依賴 HTML 結構
而之所以加上 “過分” 二字,是因爲樣式完全無法脫離結構獨立存在,例如 .popup .title .icon 這樣的的依賴關係背後就暗示了 HTML 結構的大致輪廓。
所以我們可以繼續將上面的原則稍作更正:CSS 應該擁有對 HTML 的最小知識。理想情況下一個 .button 樣式無論應用在任何元素上看上去都應該像同一個立體的可點擊按鈕。
父元素依賴
上一節中我們開發完畢的組件通常會在頁面上被多處引用,但總存在個別場景需要你對組件稍作修改才得以適配。假設有一個需求是希望把這個 popup 應用在他們的移動端網站上,但爲了適配動設備,某些元素的有關尺寸例如長寬內外邊距等都要縮小,你會怎麼實現?
我見過的 90% 的解決方案都是以添加父元素的依賴進行實現,也就是判斷該組件是否在某個特定的 class 下,如果是的話則修改樣式:
body.mobile {
.popup {
padding: 10px;
width: 100px;
height: 100px;}}
但如果此時你需要給平板設備添加一個新的樣式,我猜你可能會再添加一個 body.tablet {.popup {} } 代碼。又如果移動端網站有兩處需要使用 popup ,那麼你的代碼很最終會變成這樣:
body.mobile {
.sidebar {
.popup}
.content {
.popup}}
這樣的代碼依然是難以複用的。如果某位開發者看到了移動端網站 popup 打開的樣式很喜歡,然後想移植到另一處,那麼單純引入 popup 組件是不夠的,他還需要找到真正的生效的代碼,將樣式和 DOM 層級都複製粘貼過去。
在一個組件自身已經擁有樣式的情況下,過分的依賴父組件間接的調整樣式,是一種 case by case 的編碼行爲,本質上這架空了 popup 自帶樣式。假設 popup 自帶 box-shadow 的樣式屬性,但在有的用例裏,box-shadow 可能會被加重,而在有的用例裏,box-shadow 又可能會消失,那麼它自帶的 box-shadow 根部本就沒有意義了,因爲它永遠不會生效。
架空違背了 “最小驚訝原則”,給後續的維護者帶來了 “驚喜”。如果此時 popup 的設計稿發生了修改,陰影需要減少,則修改它自身的樣式是不會生效的,或者說無法在每一處生效。而至於還有哪些地方無法生效,爲什麼它們無法生效,維護者並不知道,他同樣需要 case by case 的去查看代碼。這麼做無疑增加了修改代碼的成本.
解決這個問題並不像解決 DOM 依賴問題那麼簡單,需要我們多管齊下。
樣式角色的分離
想提高代碼的可維護性,分離關注點永遠是屢試不爽的手段。縱觀現有的各類組織樣式的方法論,比如 SMASS 或者是 ITCSS,對樣式進行適當的角色劃分是它們的核心思想之一。
我們以一個完整的 popup 樣式爲例:
.popup {
width: 100px;
height: 30px;
background: blue;
color: white;
border: 1px solid gary;
display: flex;
justify-content: center;}
在這一組樣式中,我們看到
-
有與佈局相關的 width, height
-
與視覺樣式相關的 background, color
-
自身的佈局樣式 flex
-
其他樣式比如 border
根據這些特點和常見的規範,可以考慮從下面幾個維度對樣式進行分離:
- 佈局(Layout)和尺寸(size): 一個組件在不同的父組件下擁有不同的尺寸是再正常不過的事情。與其定義一個被架空隨時會被覆蓋的尺寸,不如將佈局的工作交由專職的組件處理。反過來說,該組件自生並不擁有尺寸,例如它可以選擇總是以 100% 的寬和高充滿包裹它的容器。
從表面上看,這種行爲只是將樣式(尺寸)從一個組件轉移到另一個組件(容器)上,但卻從根本上解決了我們上面提到的父元素依賴的困惱。任何想使用 popup 的其他組件,不用再設法關心 popup 組件的尺寸是如何實現的,它只需要關自己。
進一步從深層次上說,它消滅了依賴。你可能沒有注意到,flex 佈局的樣式配置遵循的就是這種模式:當你想讓你孩子元素按照某種規則佈局的話,你只需要修改父元素和 flex 佈局樣式屬性即可,完全不用再在孩子元素的樣式上做出修改。
我個人認爲另一個反模式的例子是 text-overflow: ellipsis 屬性,單一的該樣式屬性是不足以自動省略容器內的文字,容器還需要滿足 1) 寬度必須是 px 像素爲單位 2) 元素必須擁有 overflow:hidden 和 white-space:nowrap 兩組樣式。也就是說當你想實現 A 功能時,必須依賴 B 和 C 功能的實現。
而至於佈局功能元素是與父元素爲同一元素,還是獨立元素,我傾向於後者,畢竟幾個 markup 代碼並不會給我們添加多少負擔,但清晰的職責劃分卻能給我們將來的維護帶來不少便利。
在這個前提下任何給 popup 添加的佈局樣式實際上都意味這你新增了隱性依賴,因爲你實際上是在暗示:它在這個父容器下的這個 margin 值看上去剛好。
- 修飾類(Modifier): SOLID 原則中的 open-closed 告訴我們要對修改關閉,對拓展開發,這對樣式代碼也同樣成立。
通常我們不會只需要單一樣式的按鈕,可能還需要帶有紅底白字的錯誤樣式的按鈕,還需要黃底白字的警告樣式按鈕。這種用例常見的解決方案不是新建 N 個不同的按鈕樣式,比如 primary-button, error-button(這樣務必會出現很多公共的 button 代碼),而是在一個 button 樣式的基礎上,通過提供樣式的 “修飾” 類來達到最終的目的。例如基礎款的按鈕 class 名稱爲 button, 如果你想讓它變得帶有警告樣式的話,只需要同時使用 error 的 class 名稱即可。
<div class></div>
從本質上說這也是一種關注點的分離,只不過從這個角度上看它關心的是 “變” 與“不變”。我們將 “變量” 統統轉移到 “修飾” 類中。
但這種方案在實現時會遇到不少問題,首先是修飾類的設計,例如當我在定義例如 error, primary, warning 的修飾類時,究竟哪些樣式屬性是我可以覆蓋的哪些是不可以,這必須有事前約定。否則某人在寫 error 樣式時,可能會無腦的覆蓋原 button 上的樣式直到看上去滿意爲止。它依賴於抽象能力,但糟糕的抽象比不抽象還要難以維護。
- 模塊化:藉着組件模塊化這股東風,樣式模塊化似乎是水到渠成的事情。但如果眼光放長遠一些,模塊化並不僅限於將樣式趕到某個角落封裝起來集中管理。從上面的例子也不難看到,借用樣式中父元素依賴的特性可以輕鬆打破這種封裝。
組件並非是封裝樣式的唯一單位,在一個網站中,還可能存在諸如 base、reset 這種全局或者說切面性質的樣式屬性。我理想的模塊化樣式應該能夠輕鬆達到以下的目的:
-
控制樣式影響的方向性:例如全局樣式能夠影響組件,但組件不能夠影響全局;
-
樣式模塊間的隔離和污染:雖然 A 組件是 B 組件的子元素,但 B 組件的樣式不會影響 A 的樣式。
詮釋這兩點最好的例子是在進行響應式開發時,業內通用的對字體大小適配的解決方案。例如下面這個組件的 html 結構:
<div>
<div>
parent
<div>
hello
</div>
</div>
</div>
在樣式中我們會設定:
-
ancestor 組件字體相對於根元素 html 變化,所以使用 rem 單位;
-
parent 和 child 的字體單位需要相對於該組件(也就是 ancestor)的基準字體進行變化,所以使用 em 單位。
.ancestor {
font-size: 1rem;}.parent {
font-size: 1.5em;}.child {
font-size: 2em;}
這樣當我們需要根據設備調整字體大小時,只需要調整根元素 html 字體大小,那麼頁面上其他元素就會自我調節了。而如果我們只想調整局部樣式時,我們只需要調整 .ancestor 的字體大小即可,不會影響到其他元素。
你閱讀到這裏不難看出來,樣式難寫對的問題在於它太容易影響別的組件,也太容易受別的組件所影響了。絕大部分人遇到的問題是:
-
我以爲我修改的是 A 組件的樣式,但無形中卻影響到了 B 組件;
-
組件 A 同時受好幾組樣式的影響,無論單獨修改誰都無法達到最終的效果。
解決這個問題的辦法早就有了,那就是樣式的隔離。比如在 Angular 中,它是靠給元素添加隨機屬性並且給樣式附帶上屬性選擇器來實現的,例如你同時創建了 page-title 組件和 section-title 組件,它們都擁有 h1 元素的樣式,但是在編譯之後你看到的 css 分別是:
h1[_ngcontent-kkb-c18] {
background: yellow;}h1[_ngcontent-kkb-c19] {
background: blue;}
這樣所有的 h1 元素樣式都不會被互相影響。
實現裏的問題
Pre-Processer
無論你主觀上多麼想避免以上的所有問題,給樣式一個好的整潔架構。在實現的過程中,我們依然會不小心掉入工具的陷阱中。
再一次回到我們上面提到的 popup 樣式:
.popup {
width: 100px;
height: 30px;
background: blue;
color: white;}
假如你發現 {background: blue; color: white;} 作爲常見樣式出現頻繁,希望對它進行復用,在使用 Sass 編程的前提下很明顯此時你有兩個選擇:@mixin 或者 @extend。
如果採用 mixin,代碼如下:
@mixin common {
background: blue;
color: white;}.popup {
@include common; }而如果採用 extend:.common {
background: blue;
color: white;}.popup {
@extend .common; }
第一個問題是,無論你選擇哪種模式,你都很難說開發者是有意在依賴抽象還是在依賴實現。我們可以把 @mixin common 和 .common 解讀爲對一種抽象的封裝,但很有可能後續的消費者只是想複用 background 和 color 而已。一旦如此,common 模塊就變得難以修改,因爲對任意一個屬性的修改都會影響到未知的若干個模塊。
在 SASS 中雖然我們可以給類名添加參數,把它當作參數相互傳遞,但它與我們實際編程中的變量和函數並不相同:JavaScript 中的函數我們往往只關心它的輸入與輸出,只是定義函數並不會對程序的結果造成影響。而當你在定義樣式類的那個時刻就已經可能對頁面產生了影響,並且其中的每一條屬性都會產生影響。
如果你聽說過 “組合優於繼承”,我相信會對這一點有更深刻的體驗。你可以回想繼承體系中存在的副作用,例如繼承打破了對超類的封裝,子類不能減少超類的接口等等,在 SASS 的這類複用關係中都能找到相似的影子。extend 相比 mixin 更危險的地方在於,它破壞了我們一如既往組織模塊的方式。
例如目前已有一個 page 頁面,其中擁有一組 page-title 的樣式:
.page {
.page-title {
.icon {
width: 10px;
}
.label {
width: 100px;
}} }
現在 card-title 想通過 extend 來複用它:
.card-title {
@extend .page-title;}
那麼編譯之後的結果看上去會非常奇怪:
.page .page-title .icon, .page .card-title .icon {
width: 10px;}.page .page-title .label, .page .card-title .label {
width: 100px;}
哪怕你沒有聽說過 BEM,你的編程經驗也應該會告訴你 page 和 card 的樣式應屬於不同的模塊。但事實上編譯後的結果更像是優先考慮複用,從橫切面強行把二者耦合在一起。
而如果你嘗試將公共的 title 樣式抽象爲 mixin,再在 page-title 和 card-title 中進行復用:
@mixin title {
.icon {
width: 10px;
}
.label {
width: 100px;
}}.page {
.page-title {
@include title
}}.card-title {
@include title}
編譯的結果如下:
.page .page-title .icon {
width: 10px;}.page .page-title .label {
width: 100px;}.card-title .icon {
width: 10px;}.card-title .label {
width: 100px;}
很明顯 page 和 card 的樣式更涇渭分明。
An Necessary Evil
如果你問我我是否會遵守上面自己寫的每一條原則,我的答案是否定的。在實際開發中我傾向用便捷性換取可維護性。
在編程領域裏面唯一不變的就是變化本身,無論在敲鍵盤之前你面向對象設計的多麼準確,對組件拆分的多麼恰當,任何業務上的變化都有可能讓你所有的設計推倒重來。所以爲了保證代碼能夠精確反饋業務知識的合理性,我們需要時常對代碼設計重新設計。
你可以想象整個過程需要重新審視架構,從頭閱讀理解代碼,修改完畢後驗證。執行這一系列步驟需要不小的成本,還不包括其中的試錯,以及因爲重構而浪費的添加新功能的機會。更重要的是成本擺在那裏,但收益卻並不明顯。
如果你的樣式代碼是基於 design system 之上的,那麼你的改動成本會更高。因爲你更不可能以個人的視角隨心所欲的改動代碼了,而是要自上而下的用整個產品的設計語言來衡量修改的合理性。
另一個更實際的問題是,代碼從來不是依靠個人來維護。當這一套理論在團隊內並沒有達成共識,或者是大家只在理論層面瞭解過而實操時並不在意時,少數人的精心付出終究會化爲泡影。代碼在理想狀態下應該最大成度上摒棄 “人” 這個因素成爲流水線上工業化的產品。所以當我發現某個框架只有要求人們閱讀完數十頁最佳實踐有關的文檔才能寫出符合官方標準的好代碼時,那麼現實工作中好代碼出現的概率基本爲 0——在規範輸出代碼上,一則有效的 eslint 規則比十頁文檔都要強。而在本篇中敘述的各種原則屬於後者。
然而 css 代碼被寫的亂七八糟又會怎樣呢?產品壞了是肯定的,但相比其他 bug 有意思的事情是:
-
相比腳本而言發現樣式問題的概率高,所見即所得;
-
帶來的破壞相比腳本功能小,問題下產品依然可用;
-
修復問題成本低,甚至不需要完整閱讀源碼即可有針對性的快速修復。
基於上面的三點,同時考慮到當下技術棧繁雜學習成本高,腳本開發工作量大,交付壓力重,樣式架構的正確性想當然是被犧牲掉的那一個。
最後重申我不鼓勵這樣的行爲,這只是屈服於現實壓力下其中的一種可能性而已。如果你所在的項目資源充足,以及大家有決心把事情做對,那也未嘗不可。
Functional CSS
在我看來還有一類實踐是遊離於以上體系之外的,比如 tailwind 和 tachyons 。之所以將它們稱之爲 “函數式” 樣式,是因爲在這些框架不提供組件化、語義化的樣式,比如 .card, .btn,而提供的是“工具類(utility class)”,比如 .overflow-auto,.box-content,它們 類似於函數式編程中沒有副作用的純函數。當你需要給你元素添加樣式時,只需要給這個元素添加對應的 class 名稱即可:
<div></div>
之所以說這種實踐遊離於以上體系之外,是因爲它打破了我上面所說的前提:樣式和 DOM 結構之間存在依賴關係。在這種編程模式下,因爲不再存在 “級聯” 關係,所以每個元素的樣式都是獨立的,互不影響。
如此看來這種模式簡直就是天堂,本文裏提及的所有問題都可以避免了:父元素依賴、角色耦合、預處理器裏糾結的複用。
但仔細想想,這種方式是不是很 inline style 類似?用 inline style 也能解決我們所說的上述所有問題。我們是不是又回到了起點?
除了上面的問題外,我不再給出進一步推薦或者反對意見的原因在於,一方面這種實踐存在很大的爭議。另一方面我缺乏使用這類框架的經驗。這裏經驗的判斷標準不是 “是否用過”,而是“是否長期投入到多人協作的大型項目中”——“長期”、“多人”、“大型” 這幾個關鍵詞很重要。因爲我們在做技術選型的時候,更多要考慮和現有項目的契合度、團隊的適應成本,以及評估長遠來看它能給我們帶來巨大的好處是否能抵消替換它的成本。這些經驗是我缺乏的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/kmYTUSoFQaphxJ3tSRs_7A