手寫 css-modules 來深入理解它的原理

我們知道,瀏覽器裏的 JS 之前沒有模塊的概念,都是通過不同的全局變量(命名空間)來隔離,後來出現了 AMD、CMD、CommonJS、ESM 等規範。

通過這些模塊規範組織的 JS 代碼經過編譯打包之後,運行時依然會有模塊級別的作用域隔離(通過函數作用域來實現的)。

組件就可以放在不同的模塊中,來實現不同組件的 JS 的作用域隔離。

但是組件除了 JS 還有 CSS 呀,CSS 卻一直沒有模塊隔離的規範。

如何給 css 加上模塊的功能呢?

有的同學會說 CSS 不是有 @import 嗎?

那個只是把不同的 CSS 文件合併到一起,並不會做不同 CSS 的隔離。

CSS 的隔離主要有兩類方案,一類是運行時的通過命名區分,一類是編譯時的自動轉換 CSS,添加上模塊唯一標識。

運行時的方案最典型的就是 BEM,它是通過 .block__element--modifier 這種命名規範來實現的樣式隔離,不同的組件有不同的 blockName,只要按照這個規範來寫 CSS,是能保證樣式不衝突的。

但是這種方案畢竟不是強制的,還是有樣式衝突的隱患。

編譯時的方案有兩種,一種是 scoped,一種是 css modules。

scoped 是 vue-loader 支持的方案,它是通過編譯的方式在元素上添加了 data-xxx 的屬性,然後給 css 選擇器加上 [data-xxx] 的屬性選擇器的方式實現 css 的樣式隔離。

比如:

<style scoped> 
.guang { 
    color: red; 
} 
</style>  
<template>  
    <div class="guang">hi</div>  
</template>

會被編譯成:

<style> 
.guang[data-v-f3f3eg9] 
{ 
    color: red; 
} 
</style> 
<template> 
    <div class="guang" data-v-f3f3eg9>hi</div> 
</template>

通過給 css 添加一個全局唯一的屬性選擇器來限制 css 只能在這個範圍生效,也就是 scoped 的意思。

css-modules 是 css-loader 支持的方案,在 vue、react 中都可以用,它是通過編譯的方式修改選擇器名字爲全局唯一的方式來實現 css 的樣式隔離。

比如:

<style module> 
.guang {
    color: red; 
} 
</style>  
<template>
    <p :class="$style.guang">hi</p>  
</template>

會被編譯成:

<style module>
._1yZGjg0pYkMbaHPr4wT6P__1 { 
    color: red; 
} 
</style> 
<template> 
    <p class="_1yZGjg0pYkMbaHPr4wT6P__1">hi</p> 
</template>

和 scoped 方案的區別是 css-modules 修改的是選擇器名字,而且因爲名字是編譯生成的,所以組件裏是通過 style.xx 的方式來寫選擇器名。

兩種方案都是通過編譯實現的,但是開發者的使用感受還是不太一樣的:

scoped 的方案是添加的 data-xxx 屬性選擇器,因爲 data-xx 是編譯時自動生成和添加的,開發者感受不到。

css-modules 的方案是修改 class、id 等選擇器的名字,那組件裏就要通過 styles.xx 的方式引用這些編譯後的名字,開發者是能感受到的。但是也有好處,配合編輯器可以做到智能提示。

此外,除了 css 本身的運行時、編譯時方案,還可以通過 JS 來組織 css,利用 JS 的作用域來實現 css 隔離,這種是 css-in-js 的方案。

比如這樣:

import styled from 'styled-components';

const Wrapper = styled.div`
    font-size: 50px;
    color: red;
`;

function Guang {
    return (
        <div>
            <Wrapper>內部文件寫法</Wrapper>
        </div>
    );
}

這些方案中,css-modules 的編譯時方案是用的最多的,vue、react 都可以用。

那它是怎麼實現的呢?

打開 css-loader 的 package.json,你會發現依賴了 postcss(css 的編譯工具,類似編譯 js 的 babel):

其中這四個 postcss-modules 開頭的插件就是實現 css-modules 的核心代碼。

這四個插件裏,實現作用域隔離的是 postcss-modules-scope,其他的插件不是最重要的,比如 postcss-modules-values 只是實現變量功能的。

所以說,我們只要能實現 postcss-modules-scope 插件,就能搞懂 css-modules 的實現原理了。

我們去看下 postcss-modules-scope 的 README,發現它實現了這樣的轉換:

:local(.continueButton) {
  color: green;
}

編譯成

:export {
  continueButton: __buttons_continueButton_djd347adcxz9;
}
.__buttons_continueButton_djd347adcxz9 {
  color: green;
}

用 :local 這樣的僞元素選擇器包裹的 css 會做選擇器名字的編譯,並且把編譯前後的名字的映射關係放到 :export 這個選擇器下。

再來個複雜點的案例:

.guang {
    color: blue;
}
:local(.dong){
    color: green;
}
:local(.dongdong){
    color: green;
}

:local(.dongdongdong){
    composes-with: dong;
    composes: dongdong;
    color: red;
}

@keyframes :local(guangguang) {
    from {
        width: 0;
    }
    to {
        width: 100px;
    }
}

會被編譯成:

.guang {
    color: blue;
}
._input_css_amSA5i__dong{
    color: green;
}
._input_css_amSA5i__dongdong{
    color: green;
}

._input_css_amSA5i__dongdongdong{
    color: red;
}

@keyframes _input_css_amSA5i__guangguang {
    from {
        width: 0;
    }
    to {
        width: 100px;
    }
}

:export {
  dong: _input_css_amSA5i__dong;
  dongdong: _input_css_amSA5i__dongdong;
  dongdongdong: _input_css_amSA5i__dongdongdong _input_css_amSA5i__dong _input_css_amSA5i__dongdong;
  guangguang: _input_css_amSA5i__guangguang;
}

可以看到以 :local 包裹的纔會被編譯,不是 :local 包裹的會作爲全局樣式。

composes-with 和 composes 的作用相同,都是做樣式的組合,可以看到編譯之後會把 compose 的多個選擇器合併到一起。也就是一對多的映射關係。

實現了 :local 的選擇器名字的轉換,實現了 compose 的樣式組合,最後會把映射關係都放到 :export 這個樣式下。

這樣 css-loader 調用 postcss-modules-scope 完成了作用域的編譯之後,不就能從 :export 拿到映射關係了麼?

然後就可以用這個映射關係生成 js 模塊,組件裏就可以用 styles.xxx 的方式引入對應的 css 了。

這就是 css-modules 的實現原理。

那 css-modules 具體是怎麼實現呢?

我們先來分析下思路:

實現思路分析

我們要做的事情就是兩方面,一個是轉換 :local 包裹的選擇器名字,變成全局唯一的,二是把這個映射關係收集起來,放到 :export 樣式裏。

postcss 完成了從 css 到 AST 的 parse,和 AST 到目標代碼和 soucemap 的 generate。我們在插件裏只需要完成 AST 的轉換就可以了。

轉換選擇器的名字就是遍歷 AST,找到 :local 包裹的選擇器,轉換並且收集到一個對象裏。並且要處理下   composes-with,也就是一對多的映射關係。

轉換完成之後,映射關係也就有了,然後生成 :export 樣式添加到 AST 上就可以了。

思路理清了,我們來寫下代碼吧:

代碼實現

首先搭一個 postcss 插件的基本結構:

const plugin = (options = {}) ={
    return {
        postcssPlugin: "my-postcss-modules-scope",
        Once(root, helpers) {
        }
    }
}

plugin.postcss = true;

module.exports = plugin;

postcss 插件的形式是一個函數返回一個對象,函數接收插件的 options,返回的的對象裏包含了 AST 的處理邏輯,可以指定對什麼 AST 做什麼處理。

這裏的 Once 代表對 AST 根節點做處理,第一個參數是 AST,第二個參數是一些輔助方法,比如可以創建 AST。

postcss 的 AST 主要有三種:

@media screen and (min-width: 480px) {
    body {
        background-color: lightgreen;
    }
}
ul li {
 padding: 5px;
}
padding: 5px;

這些可以通過 astexplorer.net 來可視化的查看

轉換選擇器名字的實現大概這樣的:

Once(root, helpers) {
   const exports = {};

   root.walkRules((rule) ={   
       rule.selector = 轉換選擇器名字();

       rule.walkDecls(/composes|compose-with/i, (decl) ={
           // 處理 compose
       }
   });
   
   root.walkAtRules(/keyframes$/i, (atRule) ={
       // 轉換選擇器名字
   });
   
}

先遍歷所有的 rule,轉換選擇器的名字,並把轉換前後選擇器名字的映射關係放到 exports 裏。還要處理下 compose。

然後遍歷 atrule,做同樣的處理。

具體實現選擇器的轉換需要對 selector 也做一次 parse,用 postcss-selector-parser,然後遍歷選擇器的 AST 實現轉換:

const selectorParser = require("postcss-selector-parser");

root.walkRules((rule) ={
    // parse 選擇器爲 AST
    const parsedSelector = selectorParser().astSync(rule);

    // 遍歷選擇器 AST 並實現轉換
    rule.selector = traverseNode(parsedSelector.clone()).toString();
});

比如 .guang 選擇器的 AST 是這樣的:

選擇器 AST 的根是 Root,它的 first 屬性是 Selector 節點,然後再 first 屬性就是 ClassName 了。

根據這樣的結構,就需要分別對不同 AST 做不同處理:

function traverseNode(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;
            }
    }
    return node;
}

如果是 root 或者 selector,那就繼續遞歸處理,如果是 id、class,說明是全局樣式,那就收集到 exports 裏。

如果是僞元素選擇器(pseudo),並且是 :local 包裹的,那就要做轉換了,調用 localizeNode 實現選擇器名字的轉換,然後替換原來的選擇器。

localizeNode 也要根據不同的類型做不同處理:

function localizeNode(node) {
    switch (node.type) {
        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
                ),
            });
        }
        case "selector":
            node.nodes = node.map(localizeNode);
            return node;
    }
}

這裏調用了 exportScopedName 來修改選擇器名字,然後分別生成了新的 className 和 id 節點。

exportScopedName 除了修改選擇器名字之外,還要把修改前後選擇器名字的映射關係收集到 exports 裏:

function exportScopedName(name) {
    const scopedName = generateScopedName(name);

    exports[name] = exports[name] || [];

    if (exports[name].indexOf(scopedName) < 0) {
        exports[name].push(scopedName);
    }

    return scopedName;
}

具體的名字生成邏輯我寫的比較簡單,就是加了一個隨機字符串:

function generateScopedName(name) {
    const randomStr = Math.random().toString(16).slice(2);
    return `_${randomStr}__${name}`;
};

這樣,我們就完成了選擇器名字的轉換和收集。

然後再處理 compose:

compose 的邏輯也比較簡單,本來 exports 是一對一的關係,比如:

{
    aaa: 'xxxx_aaa',
    bbb: 'yyyy_bbb',
    ccc: 'zzzz_ccc'
}

compose 就是把它變成了一對多:

{
    aaa: ['xxx_aaa''yyy_bbb'],
    bbbb: 'yyyy_bbb',
    ccc: 'zzzz_ccc'
}

也就是這樣的:

所以 compose 的處理就是如果遇到同名的映射就放到一個數組裏:

rule.walkDecls(/composes|compose-with/i, (decl) ={
    // 因爲選擇器的 AST 是 Root-Selector-Xx 的結構,所以要做下轉換
    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);

        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 {
            throw decl.error(
                `referenced class name "${className}" in ${decl.prop} not found`
            );
        }
    });

    decl.remove();
});

用 wakDecls 來遍歷所有 composes 和 composes-with 的樣式,對它的值做 exports 的合併。

首先,parsedSelector.nodes 是我們之前 parse 出的選擇器的 AST,因爲它是 Root、Selector、ClassName(或 Id 等)的三層結構,所以要先映射一下。這就是選擇器原本的名字。

然後對 compose 的值做下 split,對每一個樣式做下判斷:

最後還要用 decl.remove 把 composes 的樣式刪除,生成後的代碼不需要這個樣式。

這樣,我們就完成了選擇器的轉換和 compose,以及收集。

用上面的案例測試一下這段邏輯:

可以看到 選擇器的轉換和 compose 的映射都正常收集到了。

接下來繼續處理 keyframes 的部分,這個和上面差不多,如果是 :local 包裹的選擇器,就調用上面的方法做轉換即可:

root.walkAtRules(/keyframes$/i, (atRule) ={
    const localMatch = /^:local\((.*)\)$/.exec(atRule.params);

    if (localMatch) {
        atRule.params = exportScopedName(localMatch[1]);
    }
});

轉換完成之後,接下來做第二步,把收集到的 exports 生成 AST 並添加到 css 原本的 AST 上。

這部分就是調用 helpers.rule 創建 rule 節點,遍歷 exports,調用 append 方法添加樣式即可。

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);
}

最後用 root.append 把這個 rule 的 AST 添加到根節點上。

這樣就完成了 css-modules 的選擇器轉換和 compose 還有 export 的收集和生成的全部功能。

我們來測試一下:

測試

上面的代碼實現細節還是比較多的,但是大概的思路應該能理清。

我們測試一下看看它的功能是否正常:

const postcss = require('postcss');
const modulesScope = require("./src/index");

const input = `
.guang {
    color: blue;
}
:local(.dong){
    color: green;
}
:local(.dongdong){
    color: green;
}

:local(.dongdongdong){
    composes-with: dong;
    composes: dongdong;
    color: red;
}

@keyframes :local(guangguang) {
    from {
        width: 0;
    }
    to {
        width: 100px;
    }
}

@media (max-width: 520px) {
    :local(.dong) {
        color: blue;
    }
}
`
const pipeline = postcss([modulesScope]);

const res = pipeline.process(input);

console.log(res.css);

調用 postcss,傳入插件組織好編譯 pipeline,然後調用 process 方法,傳入處理的 css,打印生成的 css:

經測試,global 樣式沒有做轉換,:local 樣式做了選擇器的轉換,轉換的映射關係放到了 :export 樣式裏,並且 compose 也確實實現了一對多的映射。

這樣,我們就實現了 css-modules 的核心功能。

插件完整代碼上傳到了 github: https://github.com/QuarkGluonPlasma/postcss-plugin-exercize,也在這裏貼一份:

const selectorParser = require("postcss-selector-parser");

function generateScopedName(name) {
    const randomStr = Math.random().toString(16).slice(2);
    return `_${randomStr}__${name}`;
};

const plugin = (options = {}) ={
    return {
        postcssPlugin: "my-postcss-modules-scope",
        Once(root, helpers) {
            const exports = {};

            function exportScopedName(name) {
                const scopedName = generateScopedName(name);

                exports[name] = exports[name] || [];

                if (exports[name].indexOf(scopedName) < 0) {
                    exports[name].push(scopedName);
                }

                return scopedName;
            }

            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) {
                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;
                        }
                }
                return node;
            }

            // 處理 :local 選擇器
            root.walkRules((rule) ={
                const parsedSelector = selectorParser().astSync(rule);

                rule.selector = traverseNode(parsedSelector.clone()).toString();

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

                        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 {
                            throw decl.error(
                                `referenced class name "${className}" in ${decl.prop} not found`
                            );
                        }
                    });

                    decl.remove();
                });

            });

            // 處理 :local 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;

總結

CSS 實現模塊隔離主要有運行時和編譯時兩類方案:

scoped 是通過給元素添加 data-xxx 屬性,然後在 css 中添加 [data-xx] 的屬性選擇器來實現的,對開發者來說是透明的。是 vue-loader 實現的,主要用在 vue 裏。

css-modules 則是通過編譯修改選擇器名字爲全局唯一的方式實現的,開發者需要用 styles.xx 的方式來引用編譯後的名字,對開發者來說不透明,但是也有能配合編輯器實現智能提示的好處。是 css-loader 實現的,vue、react 都可用。

當然,其實還有第三類方案,就是通過 JS 來管理 css,也就是 css-in-js。

css-modules 的方案是用的最多的,我們看了它的實現原理:

css-loader 是通過 postcss 插件來實現 css-modules 的,其中最核心的是 postcss-modules-scope 插件。

我們自己寫了一個 postcss-modules-scope 插件:

這就是 css-modules 的作用域隔離的實現原理。

文中代碼部分細節比較多,可以把代碼下載下來跑一下,相信如果你能自己實現 css-modules 的核心編譯功能,那一定是徹底理解了 css-modules 了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CX-LC014iZ4vpTko59Sf7A