CSS補丁的痛楚 — 菲利普·沃爾頓
在這篇文章中,我指出通過Houdini APIs(包括一些其他東西)能夠用一種現在還不能做到的方式進行CSS功能補全。
當這篇文章被大家所普遍接受時,我在我的收件箱和推特上一次又一次地提到了一個突然出現的問題。這一問題的根本主旨是:
為什么CSS補丁這么難?我用過很多CSS補丁,它們都能夠很好地滿足我。
然后我意識到大家應當也會遇到這一麻煩。如果你從來沒有自己去編寫一個CSS補丁,你或許可能從來沒經歷過這種痛楚。
所以我想能夠說明這一問題最好的辦法就是向你展示一下CSS打補丁到底有多困難,這也能表明為什么我對于Houdini感到那么興奮。
那么,最好的方式就是我們動手寫一個補丁。
提示:這篇文章是我在 dotCSS on December 2, 2016 上演講的文字版本。這篇文章會更加詳細,但是如果你愿意也可以觀看視頻(可在原文中查看,大概23分鐘)。
關鍵字 random
我們將要實現(或者模擬)的補丁功能是CSS中的新關鍵字 random ,給出一個0到1間的值(和Javascript中 Math.random 的返回值相同)。
下面演示一下 random 如何使用:
.foo {
color: hsl(calc(random * 360), 50%, 50%);
opacity: random;
width: calc(random * 100%);
}
如你所見, random 返回一個無單位的數值,能夠用 calc() 轉變為任何值。既然它能被轉變為任何值,它就能用于任何屬性(如 color , opacity , width 等)。
接下來,我們將一起來看下我在演講中所用的 示例 。下面是示例的界面:
頁面如果使用了 random 關鍵字的效果 示例
這個頁面包括引導語“Hello World”,在內容區域的頂部有四個Bootstrap .progress-bar 元素。
除了 bootstrap.css ,這個例子還使用以下規則包含了其他的CSS文件:
.progress-bar {
width: calc(random * 100%);
}
不過上面給出的例子中,進度條使用了固定的(寫死的)寬度值。而我們的想法是實現random補丁,這樣每次刷新頁面時,進度條都會有不同的、隨機的寬度。
補丁如何生效
在Javascript中,寫補丁相對來說較為容易,因為Javascript是一門動態語言,允許在運行時編輯內部對象。
例如,你可以這樣實現 Math.random 的補丁:
if (typeof Math.random != 'function') {
Math.random = function() {
// Implement polyfill here...
};
}
相反的,CSS并不是動態的。它不可能(至少還不能)在運行時進行編輯,從而告訴瀏覽器實現一個原生不支持的功能。
這就意味著給一個瀏覽器 不能理解 的CSS功能打補丁,就是你必須動態地編輯CSS來偽裝成瀏覽器能夠理解該功能的一種行為。
換句話說,你必須將下面的代碼進行轉換:
.foo {
width: calc(random * 100%);
}
像下面這樣,在運行時隨機生成:
.foo {
width: calc(0.35746 * 100%);
}
轉換CSS
因此,我們必須將現有的CSS進行修改,添加新的樣式規則,這樣能模擬出和想要打補丁功能的表現行為。
CSS對象模型(CSSOM)便是你能夠這么做的最常規的地方。可以通過 documnet.styleSheets 訪問。相關代碼如下面所示:
for (const stylesheet of document.styleSheets) {
// 展開嵌套的規則(如@media塊)到一個單獨的數組
const rules = [...stylesheet.rules].reduce((prev, next) => {
return prev.concat(next.cssRules ? [...next.cssRules] : [next]);
}, []);
// 遍歷每個展開的規則,并且替換random為一個隨機數字
for (const rule of rules) {
for (const property of Object.keys(rule.style)) {
const value = rule.style[property];
if (value.includes('random')) {
rule.style[property] = value.replace('random', Math.random());
}
}
}
}
提示:如果是一個真實的補丁,你不能只是簡單地查找并替換 random 關鍵字,因為有其他情況會存在random這一字符串(如,在URL中、 content 屬性的值等)。最終的示例的實際代碼中使用了一個更強健的替換機制,但是為了便于講解,我在這里使用了簡化的版本。
如果你打開 示例2 ,將上述代碼復制到console并運行,它確實做了如你所想的工作,但是運行結束后你并不會看到任何隨機長度的進度條。
這是因為CSSOM中不包含存在 random 關鍵字的規則。
你應該知道為什么了,當瀏覽器遇到一個無法識別的CSS規則時,它會直接忽略。大多數情況下,這是一件好事。因為這樣版本較低的瀏覽器也能加載CSS,頁面不會完全無法查看。不幸的是,這也意味著你必須訪問未改變的原始CSS,并且必須自己去獲取它。
手動獲取頁面樣式
CSS規則可以通過 <style> 元素或者 <link rel="stylesheet"> 元素添加,所以想要獲取未改變的原始CSS,你可以執行 querySelectorAll() ,手動獲取 <style> 元素的 innerHTML 或者使用 fetch() 獲取 <link ref="stylesheet"> 中的資源文件。
下面的代碼定義了一個獲取頁面樣式的通用方法 getPageStyles 。返回一個Promise,包含全部的頁面樣式CSS文本。
const getPageStyles = () => {
// Query the document for any element that could have styles.
var styleElements =
[...document.querySelectorAll('style, link[rel="stylesheet"]')];
// Fetch all styles and ensure the results are in document order.
// Resolve with a single string of CSS text.
return Promise.all(styleElements.map((el) => {
if (el.href) {
return fetch(el.href).then((response) => response.text());
} else {
return el.innerHTML;
}
})).then((stylesArray) => stylesArray.join('\n'));
}
如果你打開 示例3 ,將上述代碼拷貝到瀏覽器的控制臺,這樣會定義 getPageStyles() 函數,執行下面的代碼就能夠獲得完成的CSS文本。
getPageStyles().then((cssText) => {
console.log(cssText);
});
解析獲取到的樣式
有了原始的CSS文本后,接下來需要解析它。
你可能認為瀏覽器已經有能夠解析CSS的函數可供使用。不幸的是,并不是這樣。并且,即使瀏覽器提供了這樣一個函數,如 parseCSS() ,也仍然改變不了瀏覽器不能理解 random 關鍵字的事實,所以 parseCSS() 函數有可能仍然沒用(期待將來的解析規范能夠允許未知的不符合現存語法的關鍵值)。
現在有一些很好的,開源的CSS解析器,為了實現本示例,我們將選用 PostCSS (因為它能夠在瀏覽器端加載插件系統,這一點我們要在后面利用到)。
如果你針對下面的CSS文本執行 postcss.parse() :
.progress-bar {
width: calc(random * 100%);
}
你將獲得類似下面的內容:
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".progress-bar",
"nodes": [
{
"type": "decl",
"prop": "width",
"value": "calc(random * 100%)"
}
]
}
]
}
這個json對象被稱作 抽象語法樹 (AST),你也可以將其理解為我們自己版本的CSSOM。
現在我們已經有能夠獲取完整CSS文本的通用函數,并且有一個函數來解析它,那么目前為止,我們的補丁將看起來像下面這樣:
import postcss from 'postcss';
import getPageStyles from './get-page-styles';
getPageStyles()
.then((css) => postcss.parse(css))
.then((ast) => console.log(ast));
如果你打開 示例4 并查看控制臺,你將看到一個包含了整個頁面樣式的完整PostCSS AST對象。
實現補丁
到現在為止,我們寫了很多代碼,但奇怪的是,沒有一行對我們想要實現的補丁功能起到效果。這些都只是我們為了最終效果所必需做的前期準備工作,也就是我們必須手動地完成一系列瀏覽器本該替我們做的事情。
想要最終實現補丁邏輯,我還要進行以下工作。
- 修改CSS AST,將出現的random關鍵字替換為一個隨機數。
- 將修改后的CSS AST字符串化成CSS。
- 用修改后的樣式替換現有的頁面樣式。
修改CSS抽象語法樹
PostCSS帶來了一個很好的插件系統,它提供了修改CSS抽象語法樹的輔助函數。我們可以使用那些函數來將出現的 random 關鍵字的地方替換成一個隨機數。
const randomKeywordPlugin = postcss.plugin('random-keyword', () => {
return (css) => {
css.walkRules((rule) => {
rule.walkDecls((decl, i) => {
if (decl.value.includes('random')) {
decl.value = decl.value.replace('random', Math.random());
}
});
});
};
});
將CSS AST字符串化成CSS
另一個關于PostCSS插件的好處是它提供了將CSS抽象語法樹字符串化成CSS的內部邏輯。我們需要做的就是創建一個PostCSS實例,傳入你想使用的插件(或插件組),然后執行 process() ,將返回一個已經resolved的promise對象,包含一個字符串CSS對象作為參數。
postcss([randomKeywordPlugin]).process(css).then((result) => {
console.log(result.css);
});
替換頁面樣式
想要完成頁面樣式的替換,我們可以編寫一個類似于 getPageStyles() 函數的通用函數。這個方法能夠找到所有的 <style> 元素和 <link ref="stylesheet> 元素,并移除它們。然后創建一個新的 <style> 標簽,將傳入函數的CSS值設為 <style> 的內容.
const replacePageStyles = (css) => {
// Get a reference to all existing style elements.
const existingStyles =
[...document.querySelectorAll('style, link[rel="stylesheet"]')];
// Create a new <style> tag with all the polyfilled styles.
const polyfillStyles = document.createElement('style');
polyfillStyles.innerHTML = css;
document.head.appendChild(polyfillStyles);
// Remove the old styles once the new styles have been added.
existingStyles.forEach((el) => el.parentElement.removeChild(el));
};
整合所有代碼
整合了用于修改CSS抽象語法樹的PostCSS插件和我們編寫的兩個用來獲取和替換頁面樣式的通用函數,我們的插件代碼看起來像下面這樣子:
import postcss from 'postcss';
import getPageStyles from './get-page-styles';
import randomKeywordPlugin from './random-keyword-plugin';
import replacePageStyles from './replace-page-styles';
getPageStyles()
.then((css) => postcss([randomKeywordPlugin]).process(css))
.then((result) => replacePageStyles(result.css));
如果你打開 示例5 ,你將看到具體的效果。多刷新幾次頁面,并注意觀察我們的完全隨機數。額...好像并不是想象中的樣子,是嗎?
到底哪里錯了呢?
從技術的角度來說,這個插件是有效的,它對選擇器匹配的每個元素應用了相同的隨機值。
仔細想想我們所做的事情就能知道,我們完成的只是重寫了單一規則的單一屬性。
確實是這樣子,但最簡單的CSS插件需要的不止是重寫單一的屬性值,而是需要對每一個匹配的DOM元素有著很詳細的了解(大小,內容,順序等)。這也是為什么這個問題的預處理和服務端解決方法絕對不能單獨存在的原因。
那么我們想一下這個重要的問題: 我們該如何修改插件,才能針對不同的元素進行修改?
標記獨立的匹配元素
以我的個人經驗來說,有三種方法能夠標記獨立的DOM元素,但是它們之中沒有一個是很棒的。
方法一:內聯樣式
到目前為止,內聯樣式這一方法是我所見過的其他插件作者解決標記獨立的元素問題的常用方式。這一方法是使用CSS規則選擇器查找所有頁面的匹配元素,然后直接對它們應用內聯樣式。
我們可以向下面這樣修改我們的PostCSS插件:
// ...
rule.walkDecls((decl, i) => {
if (decl.value.includes('random')) {
const elements = document.querySelectorAll(rule.selector);
for (const element of elements) {
element.style[decl.prop] =
decl.value.replace('random', Math.random());
}
}
});
// ...
具體的效果如 示例6 所示。
乍一看,好像能夠很好的工作。但不幸的是,這很容易打破。想象下,如果我們在現有的 .progress-bar 規則后面又加了另一條規則。
.progress-bar {
width: calc(random * 100%);
}
#some-container .progress-bar {
width: auto;
}
上述的代碼表明除了作為 #some-container 后代元素的進度條的寬度不是隨機值,其他所有進度條元素都有一個隨機的寬度。
顯然地,這并不能正常工作,因為我們應對所有元素應用了內聯樣式,而內聯樣式的優先級要比通過 #some-containner .progress-bar 定義的樣式優先級高。
這就意味著我們的插件打破了CSS的一些根本設定(所以就我自己而言,我認為這個方法是不能接受的)。
方法二:使用內聯樣式,但是嘗試對方法一中的陷阱進行處理。
第二種方法接受方法一中許多常規的CSS用法可能會失敗,所以它試著解決它們。特別地,在方法二中我們將實現更新如下:
- 檢查剩下的CSS匹配規則,然后只有當某條規則是最后一條匹配規則時,才將其中的隨機關鍵字替換成隨機數,然后應用到內聯樣式中。
- 等一下,這還不夠,我們必須知道規則的特殊性,所以必須手動解析每一個選擇器并計算特殊性。這樣我們才能夠根據特殊性從低到高對匹配的規則進行排序,然后只應用特殊性最高的選擇器對應的規則聲明。
- 此外,還有 @media 媒體檢測中的規則,我們同樣需要手動核對其中匹配的規則。
- 提到 @ 規則,同樣不能忘記 @support
- 最后我們要考慮到屬性的繼承,對于此,針對每一個匹配的元素我們需要遍歷DOM樹,查找它所有的祖先,得到完整的計算屬性集合。
- 還有一件事:我們還要處理 !important ,這一標志針對每個單一的屬性而不是每個規則。因此,我們必須有一個單獨的映射表來算出哪些聲明會最終生效。
是的,如果你不能理解我剛剛說的內容,我其實只是描述了一下層疊,這是我們應該依賴于瀏覽器為我們做的事情。
盡管使用Javascript確切地可能重新實現層疊,但它將需要很多工作,而我寧愿看一看方法3是什么。
方法三: 為匹配的獨立元素重寫CSS并保持層疊順序
第三個方法,我認為是這些壞的方法中最好的一個。它重寫CSS,將原本的一個CSS選擇器的規則轉變為匹配多個元素的多個選擇器,每一個都匹配一個單獨的元素,并且不改變最終的匹配元素集合。
最后一句話可能并不好理解,讓我通過一個例子來解釋吧。考慮下下面的CSS文件,引用于一個包含三個段落元素的頁面。
* {
box-sizing: border-box;
}
p { /* Will match 3 paragraphs on the page. */
opacity: random;
}
.foo {
opacity: initial;
}
如果我們給DOM中的每個段落元素添加一個唯一的data屬性,我們可以向下面這樣改寫CSS,為每個段落指定它們所特有的獨立的規則:
* {
box-sizing: border-box;
}
p[data-pid="1"] {
opacity: .23421;
}
p[data-pid="2"] {
opacity: .82305;
}
p[data-pid="3"] {
opacity: .31178;
}
.foo {
opacity: initial;
}
當然,如果你仔細想想,你會發現這仍然不能很好的工作,因為這改變了這些選擇器的特殊性,這很有可能導致不希望出現的影響。 然而 ,我們可以通過一些巧妙的技巧,讓每個其他的選擇器提高相同的特殊性,從而保證正確的層疊順序。
*?:not(.z) {
box-sizing: border-box;
}
p[data-pid="1"] {
opacity: .23421;
}
p[data-pid="2"] {
opacity: .82305;
}
p[data-pid="3"] {
opacity: .31178;
}
.foo:not(.z) {
opacity: initial;
}
上面的改變使用了 :not() 偽類選擇器方法,傳入一個DOM中不存在的類名(這里我使用了 .z ,意味著如果你在DOM中使用了 .z 類,你就要選擇一個其他的名字)。那么,既然 :not() 因為給定類名不存在而總是生效,那么這就能夠用來提高選擇器的特殊性并且不會改變它所匹配的元素。
示例7 展示了這一策略實現的結果,你可以通過 示例的源碼 來查看 random-keyword 插件的一系列改變。
方法三種最好的部分就是仍讓讓瀏覽器來控制層疊,這一點瀏覽器很擅長處理。這就意味著你可以隨意地使用媒體查詢, !important 規則,自定義屬性, @support 規則以及任何CSS功能,它都能很好地工作。
缺陷
看起來通過方法三我解決了這一CSS插件的所有問題,但事實并非如此。仍然還有很多遺留的問題,一些是能夠通過更多的工作解決的問題,一些是不能解決并且難以避免的問題。
未解決的問題
首先,我故意忽略了一些 style> 和 <link ref="stylesheet"> 標簽之外的,但存在頁面之內的CSS樣式位置。
- 內聯樣式
- 隱藏DOM
我們能夠修改我們的插件來解決這些問題,但是這種方式將需要更多的工作,我想在我的博客上進行討論。
我們也沒有考慮DOM樹發生改變的可能性。畢竟,我們是基于DOM內容重寫CSS,所以我們必須在DOM發生改變的同時重寫CSS。
不可避免的問題
除了我上面所提到的這些問題(雖然很難,但是能解決的問題),還有一些問題難以避免。
- 它們需要大量的額外代碼。
- 對跨域的樣式表不起作用。
- 當需要改變(如DOM改變,滾動或變換尺寸等)時它將表現的很糟糕。
我們的 random 關鍵字插件只是一個簡單的例子,但是我確信你能夠輕易想象 position:sticky 這樣一個插件會有多么糟糕的表現,因為每次用戶滾動式,都需要重新執行一遍我描述的所有邏輯。
可能的改進方法
由于時間限制,有一個解決方法我在演講中并沒有提到。這一方法有可能減輕前兩種問題,那就是在服務端編譯過程中進行解析和獲取CSS.
那么,你將不需要從 style 中加載文件,而是加載一個包含抽象語法樹的Javascript文件,那么第一步要做的事情就是字符串化抽象語法樹,然后給頁面添加樣式。你還可以添加一個 <noscript> 標簽,在用戶禁止Javascript的時候,指向原始的CSS文件。
例如,你可以替換下面的代碼:
<link ref="stylesheet" href="styles.css">
替換成:
<script src="styles.css.js"></script>
<noscript><link ref="stylesheet" href="styles.css"></noscript>
正如我所提到的,這么做解決了在Javascript中引入整個CSS解析器的問題,并且也允許你提前進行CSS的解析,但這并不能解決所有性能問題。
不過你如何嘗試,你仍然需要在CSS發生改變的時候重寫CSS。
理解性能的影響
為了讓大家理解為什么CSS補丁的性能如此差,大家必須理解瀏覽器的渲染過程——尤其是作為一個開發者所能夠接觸到的各個步驟。
Javascript訪問CSS渲染流
如你所見,整個流程中只有DOM這一個真實的節點,我們的補丁主要就是在這一節點中進行,查詢匹配相應CSS選擇器的元素,并且通過 <style> 標簽更新CSS文本。
但是由于Javascript訪問瀏覽器渲染流程的現狀,這一方式是我們的補丁必須采取的方式。
補丁在瀏覽器渲染流中的實體節點
可以看出,Javascript在DOM構建之后不能干涉原始的渲染流程,這就意味著我們的補丁造成的任何改變都需要整個渲染過程重新開始。
這就意味著CSS補丁的性能保持在60fps是不可能,因為所有的改變導致隨之而來的一系列渲染和構造。
總結
這就是我通過本文想向大家傳達的觀點:“對CSS進行打補丁是非常困難”。因為我們作為開發者需要做的所有工作都受到了現如今web的樣式和布局的限制。
下面是在我們的補丁中需要手動去實現的瀏覽器已經做了的事情,但是我們作為開發者卻不能夠使用:
-
獲取CSS
-
解析CSS
-
創建CSS文件模式(CSSOM)
-
控制樣式的層疊
-
使樣式失效
-
再使樣式生效
這就是我為什么對 Houdini 感到那么興奮的原因. 沒有Houdini APIs,開發者被迫去求助于Hack方法,并且犧牲了性能和可用性。
這意味著CSS補丁必然會出現下列情況:
-
太大
-
太慢
-
太多錯誤
不過我們并不會三種情況都出現,我們需要做出選擇。
沒有低級的樣式原語,革新的速度和瀏覽器實施的速度一樣慢。
開發者們抱怨Javascript社區更新太慢。但是你從來沒有聽過有人抱怨CSS。一部分原因就是我在文章中所提到的這些限制。
來自:http://www.zcfy.cc/article/the-dark-side-of-polyfilling-css-mdash-philip-walton-2540.html