手写 css-modules 来深刻理解它的原理

2022年05月15日 阅读数:3
这篇文章主要向大家介绍手写 css-modules 来深刻理解它的原理,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

咱们知道,浏览器里的 JS 以前没有模块的概念,都是经过不一样的全局变量(命名空间)来隔离,后来出现了 AMD、CMD、CommonJS、ESM 等规范。javascript

经过这些模块规范组织的 JS 代码通过编译打包以后,运行时依然会有模块级别的做用域隔离(经过函数做用域来实现的)。css

组件就能够放在不一样的模块中,来实现不一样组件的 JS 的做用域隔离。html

可是组件除了 JS 还有 CSS 呀,CSS 却一直没有模块隔离的规范。vue

如何给 css 加上模块的功能呢?java

有的同窗会说 CSS 不是有 @import 吗?node

那个只是把不一样的 CSS 文件合并到一块儿,并不会作不一样 CSS 的隔离。react

CSS 的隔离主要有两类方案,一类是运行时的经过命名区分,一类是编译时的自动转换 CSS,添加上模块惟一标识。git

运行时的方案最典型的就是 BEM,它是经过 .block__element--modifier 这种命名规范来实现的样式隔离,不一样的组件有不一样的 blockName,只要按照这个规范来写 CSS,是能保证样式不冲突的。github

可是这种方案毕竟不是强制的,仍是有样式冲突的隐患。json

编译时的方案有两种,一种是 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 主要有三种:

  • atrule:以 @ 开头的规则,好比:
@media screen and (min-width: 480px) {
    body {
        background-color: lightgreen;
    }
}
复制代码
  • rule:选择器开头的规则,好比:
ul li {
 padding: 5px;
}
复制代码
  • decl:具体的样式,好比:
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也要根据不一样的类型作不一样处理:

  • selector 节点就继续遍历子节点。
  • id、class 节点就作对名字作转换,而后生成新的选择器.
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,对每个样式作下判断:

  • 若是 compose 的是 global 样式,那就给每个 exports[选择器原来的名字] 添加上当前 composes 的 global 选择器的映射

  • 若是 compose 的是 local 的样式,那就从 exports 中找出它编译以后的名字,添加到当前的映射数组里。

  • 若是 compose 的选择器没找到,就报错

最后还要用 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 的核心功能。

插件代码在这里贴一份:

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 实现模块隔离主要有运行时和编译时两类方案:

  • 运行时经过命名空间来区分,好比 BEM 规范。
  • 编译时自动转换选择器名字,添加上惟一标识,好比 scoped 和 css-modules

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 插件:

  • 遍历全部选择器,对 :local 伪元素包裹的选择器作转化,而且收集到 exports 中。
  • 对 composes 的选择器作一对多的映射,也收集到 exports 中。
  • 根据 exports 收集到的映射关系生成 :exports 样式

这就是 css-modules 的做用域隔离的实现原理。

文中代码部分细节比较多,能够把代码下载下来跑一下,相信若是你能本身实现 css-modules 的核心编译功能,那必定是完全理解了 css-modules 了。

最后

若是你以为这篇文章对你有点用的话,麻烦请给咱们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

免费获取源码地址:http://ym.baisouvip.cn/html/wzym/36.html

PHP学习手册:https://doc.crmeb.com

技术交流论坛:https://q.crmeb.com