我們知道,Javascript發展到現在出現了眾多模組化規範,比如AMD、CMD、 Common JS、ESModule等,這些模組化規範能夠讓我們的JS實現作用域隔離。但CSS卻並沒有這麼幸運,發展到現在卻一直沒有模組化規範,由於CSS是 根據選擇器去全域性匹配元素的,所以如果你在頁面的兩個不同的地方定義了一個相同的類名,先定義的樣式就會被後定義的覆蓋掉。由於這個原因,CSS的命名衝突一直困擾著前端人員。
這種現狀是前端開發者不能接受的,所以CSS社群也誕生了各種各樣的CSS模組化解決方案(這並不是規範),比如:
現在來看CSS Module
是目前最為流行的一種解決方案,它能夠與CSS前處理器搭配使用在各種框架中。
如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖
第一時間獲取最新文章~
CSS Module的流行源於React社群,它獲得了社群的迅速採用,後面由於Vue-cli對其整合後開箱即用的支援,將其推到了一個新高度。
在w3c 規範中,CSS 始終是「全域性生效的」。在傳統的 web 開發中,最為頭痛的莫過於處理 CSS 問題。因為全域性性,明明定義了樣式,但就是不生效,原因可能是被其他樣式定義所強制覆蓋。
產生區域性作用域的唯一方法就是為樣式取一個獨一無二的名字,CSS Module
也就是用這個方法來實現作用域隔離的。
在CSS Module中可以使用:local(className)
來宣告一個區域性作用域的CSS規則。
:local(.qd_btn) {
border-radius: 8px;
color: #fff;
}
:local(.qd_btn):nth(1) {
color: pink;
}
:local(.qd_title) {
font-size: 20px;
}
CSS Module
會對:local()
包含的選擇器做localIdentName
規則處理,也就是為其生成一個唯一的選擇器名稱,以達到作用域隔離的效果。
以上css經過編譯後會生成這樣的程式碼:
這裡的:export
是CSS Module為解決匯出而新增的偽類,後面再進行介紹
當然CSS Module也允許使用:global(className)
來宣告一個全域性作用域的規則。
:global(.qd_text) {
color: chocolate;
}
而對於:global()
包含的選擇器CSS Module
則不會做任何處理,因為CSS規則預設就是全域性的。
或許很多了會好奇我們在開發過程好像很少使用到:local()
,比如在vue中,我們只要在style標籤上加上module
就能自動達到作用域隔離的效果。
是的,為了我們開發過程方便,postcss-modules-local-by-default
外掛已經預設幫我們處理了這一步,只要我們開啟了CSS模組化,裡面的CSS在編譯過程會預設加上:local()
。
組合的意思就是一個選擇器可以繼承另一個選擇器的規則。
:local(.qd_btn) {
border-radius: 8px;
color: #fff;
}
:local(.qd_title) {
font-size: 20px;
composes: qd_btn;
}
Composes 還可以繼承外部檔案中的樣式
/* a.css */
:local(.a_btn) {
border: 1px solid salmon;
}
/** default.css **/
.qd_box {
border: 1px solid #ccc;
composes: a_btn from 'a.css'
}
編譯後會生成如下程式碼:
從上面的這些編譯結果我們會發現有兩個我們平時沒用過的偽類::import
、:export
。
CSS Module 內部通過ICSS
來解決CSS的匯入匯出問題,對應的就是上面兩個新增的偽類。
Interoperable CSS (ICSS) 是標準 CSS 的超集。
語句:import
允許從其他 CSS 檔案匯入變數。它執行以下操作:
localAlias
localAlias
依賴項的exportedValue
.一個:export
塊定義了將要匯出給消費者的符號。可以認為它在功能上等同於以下 JS:
module.exports = {
"exportedKey": "exportedValue"
}
語法上有以下限制:export
:
exportedKey
重複某個特定項,則最後一個(按源順序)優先。exportedValue
可以包含對 CSS 宣告值有效的任何字元(包括空格)。exportedValue
不需要參照an ,它已被視為文字字串。以下是輸出可讀性所需要的,但不是強制的:
:export
塊:import
塊之後大概瞭解完CSS Module語法後,我們可以再來看看它的內部實現,以及它的核心原理 —— 作用域隔離。
一般來講,我們平時在開發中使用起來沒有這麼麻煩,比如我們在vue專案中能夠做到開箱即用,最主要的外掛就是css-loader
,我們可以從這裡入手一探究竟。
這裡大家可以思考下,css-loader
主要會依賴哪些庫來進行處理?
我們要知道,CSS Module
新增的這些語法其實並不是CSS 內建語法,那麼它就一定需要進行編譯處理
那麼編譯CSS我們最先想到的是哪個庫?
postcss對吧?它對於CSS就像Babel對於javascript
可以安裝css-loader
來驗證一下:
跟我們預期的一致,這裡我們能看到幾個以postcss-module
開頭的外掛,這些應該就是實現CSS Module的核心外掛。
從上面這些外掛名稱應該能看出哪個才是實現作用域隔離的吧
整個流程大體上跟Babel編譯javascript類似:parse ——> transform ——> stringier
與Babel不同的是,PostCSS自身只包括css分析器,css節點樹API,source map生成器以及css節點樹拼接器。
css的組成單元是一條一條的樣式規則(rule),每一條樣式規則又包含一個或多個屬性&值的定義。所以,PostCSS的執行過程是,先css分析器讀取css字元內容,得到一個完整的節點樹,接下來,對該節點樹進行一系列轉換操作(基於節點樹API的外掛),最後,由css節點樹拼接器將轉換後的節點樹重新組成css字元。期間可生成source map表明轉換前後的字元對應關係。
CSS在編譯期間也是需要生成AST得,這點與Babel處理JS一樣。
PostCSS的AST主要有以下這四種:
#main {
border: 1px solid black;
}
@
開頭@media screen and (min-width: 480px) {
body {
background-color: lightgreen;
}
}
border: 1px solid black;
/* 註釋*/
與Babel類似,這些我們同樣可以使用工具來更清晰地瞭解CSS 的 AST:
@import url('./default.css')
,params 為url('./default.css')
.color2{}
,selector為.color2
npm i postcss postcss-modules-extract-imports postcss-modules-local-by-default postcss-modules-scope postcss-selector-parser
這些外掛的功能我們都可以自己一一去體驗,我們先將這些主要的外掛串聯起來試一試效果,再來自行實現一個Postcss-modules-scope
外掛
(async () => {
const css = await getCode('./css/default.css')
const pipeline = postcss([
postcssModulesLocalByDefault(),
postcssModulesExtractImports(),
postcssModulesScope()
])
const res = pipeline.process(css)
console.log('【output】', res.css)
})()
把這幾個核心外掛整合進來,我們會發現,我們的css中的樣式不用再寫:local
也能生成唯一hash名稱了,並且也能夠匯入其它檔案的樣式了。這主要是依靠postcss-modules-local-by-default
、postcss-modules-extract-imports
兩個外掛。
/* default.css */
.qd_box {
border: 1px solid #ccc;
composes: a_btn from 'a.css'
}
.qd_header {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
composes: qd_box;
}
.qd_box {
background: coral;
}
現在我們就自己來實現一下類似postcss-modules-scope
的外掛吧,其實原理很簡單,就是遍歷AST,為選擇器生成一個唯一的名字,並將其與選擇器的名稱維護在exports
裡面。
說到遍歷AST,與Babel相似Post CSS也同樣提供了很多API用於操作AST:
atrule
型別節點rule
型別節點comment
型別節點decl
型別節點(更多內容可在postcss檔案上檢視)
有了這些API我們處理AST就非常方便了
編寫PostCSS外掛與Babel類似,我們只需要按照它的規範進行處理AST就行,至於它的編譯以及目的碼生成我們都不需要關心。
const plugin = (options = {}) => {
return {
postcssPlugin: 'plugin name',
Once(root) {
// 每個檔案都會呼叫一次,類似Babel的visitor
}
}
}
plugin.postcss = true
module.exports = plugin
const selectorParser = require("postcss-selector-parser");
// 隨機生成一個選擇器名稱
const createScopedName = (name) => {
const randomStr = Math.random().toString(16).slice(2);
return `_${randomStr}__${name}`;
}
const plugin = (options = {}) => {
return {
postcssPlugin: 'css-module-plugin',
Once(root, helpers) {
const exports = {};
// 匯出 scopedName
function exportScopedName(name) {
// css名稱與其對應的作用域名城的對映
const scopedName = createScopedName(name);
exports[name] = exports[name] || [];
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
// 本地節點,也就是需要作用域隔離的節點:local()
function localizeNode(node) {
switch (node.type) {
case "selector":
node.nodes = node.map(localizeNode);
return node;
case "class":
return selectorParser.className({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
case "id": {
return selectorParser.id({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
}
}
}
// 遍歷節點
function traverseNode(node) {
// console.log('【node】', node)
if(options.module) {
const selector = localizeNode(node.first, node.spaces);
node.replaceWith(selector);
return node
}
switch (node.type) {
case "root":
case "selector": {
node.each(traverseNode);
break;
}
// 選擇器
case "id":
case "class":
exports[node.value] = [node.value];
break;
// 偽元素
case "pseudo":
if (node.value === ":local") {
const selector = localizeNode(node.first, node.spaces);
node.replaceWith(selector);
return;
}else if(node.value === ":global") {
}
}
return node;
}
// 遍歷所有rule型別節點
root.walkRules((rule) => {
const parsedSelector = selectorParser().astSync(rule);
rule.selector = traverseNode(parsedSelector.clone()).toString();
// 遍歷所有decl型別節點 處理 composes
rule.walkDecls(/composes|compose-with/i, (decl) => {
const localNames = parsedSelector.nodes.map((node) => {
return node.nodes[0].first.first.value;
})
const classes = decl.value.split(/\s+/);
classes.forEach((className) => {
const global = /^global\(([^)]+)\)$/.exec(className);
// console.log(exports, className, '-----')
if (global) {
localNames.forEach((exportedName) => {
exports[exportedName].push(global[1]);
});
} else if (Object.prototype.hasOwnProperty.call(exports, className)) {
localNames.forEach((exportedName) => {
exports[className].forEach((item) => {
exports[exportedName].push(item);
});
});
} else {
console.log('error')
}
});
decl.remove();
});
});
// 處理 @keyframes
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^:local\((.*)\)$/.exec(atRule.params);
if (localMatch) {
atRule.params = exportScopedName(localMatch[1]);
}
});
// 生成 :export rule
const exportedNames = Object.keys(exports);
if (exportedNames.length > 0) {
const exportRule = helpers.rule({ selector: ":export" });
exportedNames.forEach((exportedName) =>
exportRule.append({
prop: exportedName,
value: exports[exportedName].join(" "),
raws: { before: "\n " },
})
);
root.append(exportRule);
}
},
}
}
plugin.postcss = true
module.exports = plugin
(async () => {
const css = await getCode('./css/index.css')
const pipeline = postcss([
postcssModulesLocalByDefault(),
postcssModulesExtractImports(),
require('./plugins/css-module-plugin')()
])
const res = pipeline.process(css)
console.log('【output】', res.css)
})()
我是南玖,我們下期見!!!
-------------------------------------------
如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新的文章~
掃描下方二維條碼關注公眾號,回覆進群,拉你進前端學習交流群(WX),這裡有一群志同道合的前端小夥伴,交流技術、生活、內推、面經、摸魚,這裡都有哈,快來加入我們吧~ 回覆資料,獲取前端大量精選前端電子書及學習視訊~