瞭解CSS Module作用域隔離原理

2023-03-20 12:02:48

CSS Module出現的背景

我們知道,Javascript發展到現在出現了眾多模組化規範,比如AMD、CMD、 Common JS、ESModule等,這些模組化規範能夠讓我們的JS實現作用域隔離。但CSS卻並沒有這麼幸運,發展到現在卻一直沒有模組化規範,由於CSS是 根據選擇器去全域性匹配元素的,所以如果你在頁面的兩個不同的地方定義了一個相同的類名,先定義的樣式就會被後定義的覆蓋掉。由於這個原因,CSS的命名衝突一直困擾著前端人員。

這種現狀是前端開發者不能接受的,所以CSS社群也誕生了各種各樣的CSS模組化解決方案(這並不是規範),比如:

  • 命名方法: 人為約定命名規則
  • scoped: vue中常見隔離方式
  • CSS Module: 每個檔案都是一個獨立的模組
  • CSS-in-JS: 這個常見於react、 JSX中

現在來看CSS Module是目前最為流行的一種解決方案,它能夠與CSS前處理器搭配使用在各種框架中。

如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新文章~

CSS Module

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()

Composing(組合)

組合的意思就是一個選擇器可以繼承另一個選擇器的規則。

繼承當前檔案內容

: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

語句:import允許從其他 CSS 檔案匯入變數。它執行以下操作:

  • 獲取並處理依賴項
  • 根據匯入的令牌解析依賴項的匯出,並將它們匹配到localAlias
  • 在當前檔案中的某些地方(如下所述)查詢和替換使用localAlias依賴項的exportedValue.

:export

一個:export塊定義了將要匯出給消費者的符號。可以認為它在功能上等同於以下 JS:

module.exports = {
	"exportedKey": "exportedValue"
}

語法上有以下限制:export

  • 它必須在頂層,但可以在檔案中的任何位置。
  • 如果一個檔案中有多個,則將鍵和值組合在一起並一起匯出。
  • 如果exportedKey重複某個特定項,則最後一個(按源順序)優先。
  • AnexportedValue可以包含對 CSS 宣告值有效的任何字元(包括空格)。
  • exportedValue不需要參照an ,它已被視為文字字串。

以下是輸出可讀性所需要的,但不是強制的:

  • 應該只有一個:export
  • 它應該位於檔案的頂部,但在任何:import塊之後

CSS Module原理

大概瞭解完CSS Module語法後,我們可以再來看看它的內部實現,以及它的核心原理 —— 作用域隔離。

一般來講,我們平時在開發中使用起來沒有這麼麻煩,比如我們在vue專案中能夠做到開箱即用,最主要的外掛就是css-loader,我們可以從這裡入手一探究竟。

這裡大家可以思考下,css-loader主要會依賴哪些庫來進行處理?

我們要知道,CSS Module新增的這些語法其實並不是CSS 內建語法,那麼它就一定需要進行編譯處理

那麼編譯CSS我們最先想到的是哪個庫?

postcss對吧?它對於CSS就像Babel對於javascript

可以安裝css-loader來驗證一下:

跟我們預期的一致,這裡我們能看到幾個以postcss-module開頭的外掛,這些應該就是實現CSS Module的核心外掛。

從上面這些外掛名稱應該能看出哪個才是實現作用域隔離的吧

  • Postcss-modules-extract-imports:匯入匯出功能
  • Postcss-modules-local-by-default:預設區域性作用域
  • Postcss-modules-scope:作用域隔離
  • Posts-modules-values:變數功能

編譯流程

整個流程大體上跟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一樣。

AST

PostCSS的AST主要有以下這四種:

  • rule: 選擇器開頭
#main {
    border: 1px solid black;
}
  • atrule: 以@開頭
@media screen and (min-width: 480px) {
    body {
        background-color: lightgreen;
    }
}
  • decl: 具體樣式規則
border: 1px solid black;
  • comment: 註釋
/* 註釋*/

與Babel類似,這些我們同樣可以使用工具來更清晰地瞭解CSS 的 AST:

  • Root: 繼承自 Container。AST 的根節點,代表整個 css 檔案
  • AtRule: 繼承自 Container。以 @ 開頭的語句,核心屬性為 params,例如: @import url('./default.css'),params 為url('./default.css')
  • Rule: 繼承自 Container。帶有宣告的選擇器,核心屬性為 selector,例如: .color2{},selector為.color2
  • Comment: 繼承自 Node。標準的註釋/* 註釋 */ 節點包括一些通用屬性:
  • type:節點型別
  • parent:父節點
  • source:儲存節點的資源資訊,計算 sourcemap
  • start:節點的起始位置
  • end:節點的終止位置
  • raws:儲存節點的附加符號,分號、空格、註釋等,在 stringify 過程中會拼接這些附加符號

安裝體驗

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-defaultpostcss-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裡面。

主要API

說到遍歷AST,與Babel相似Post CSS也同樣提供了很多API用於操作AST:

  • walk: 遍歷所有節點資訊
  • walkAtRules: 遍歷所有atrule 型別節點
  • walkRules: 遍歷所有rule型別節點
  • walkComments: 遍歷所有 comment 型別節點
  • walkDecls: 遍歷所有 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)
})()

原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」,如果你想進前端交流群一起學習,請點這裡

我是南玖,我們下期見!!!