了解CSS Module作用域隔离原理

开发 前端
我们知道,Javascript发展到现在出现了众多模块化规范,比如AMD、CMD、 Common JS、ESModule等,这些模块化规范能够让我们的JS实现作用域隔离。

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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

CSS Module会对:local()包含的选择器做localIdentName规则处理,也就是为其生成一个唯一的选择器名称,以达到作用域隔离的效果。

以上css经过编译后会生成这样的代码:

这里的:export是CSS Module为解决导出而新增的伪类,后面再进行介绍。

全局作用域

当然CSS Module也允许使用:global(className)来声明一个全局作用域的规则。

:global(.qd_text) {
    color: chocolate;
}
  • 1.
  • 2.
  • 3.

而对于: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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

图片


继承其它文件

Composes 还可以继承外部文件中的样式

/* a.css */
:local(.a_btn) {
    border: 1px solid salmon;
}
  • 1.
  • 2.
  • 3.
  • 4.
/** default.css **/
.qd_box {
    border: 1px solid #ccc;
    composes: a_btn from 'a.css'
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

编译后会生成如下代码:

导入导出

从上面的这些编译结果我们会发现有两个我们平时没用过的伪类::import、:export。

CSS Module 内部通过ICSS来解决CSS的导入导出问题,对应的就是上面两个新增的伪类。

Interoperable CSS (ICSS) 是标准 CSS 的超集。

:import

语句:import允许从其他 CSS 文件导入变量。它执行以下操作:

  • 获取并处理依赖项
  • 根据导入的令牌解析依赖项的导出,并将它们匹配到localAlias
  • 在当前文件中的某些地方(如下所述)查找和替换使用localAlias依赖项的exportedValue.

:export

一个:export块定义了将要导出给消费者的符号。可以认为它在功能上等同于以下 JS:

module.exports = {
 "exportedKey": "exportedValue"
}
  • 1.
  • 2.
  • 3.

语法上有以下限制: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;
}
  • 1.
  • 2.
  • 3.
  • atrule: 以@开头
@media screen and (min-width: 480px) {
    body {
        background-color: lightgreen;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • decl: 具体样式规则
border: 1px solid black;
  • 1.
  • comment: 注释
/* 注释*/
  • 1.

与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
  • 1.

这些插件的功能我们都可以自己一一去体验,我们先将这些主要的插件串联起来试一试效果,再来自行实现一个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)
})()
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

把这几个核心插件集成进来,我们会发现,我们的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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

图片


编写插件

现在我们就自己来实现一下类似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
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

核心代码

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
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.
  • 112.
  • 113.
  • 114.
  • 115.
  • 116.
  • 117.
  • 118.
  • 119.
  • 120.
  • 121.
  • 122.
  • 123.
  • 124.
  • 125.
  • 126.
  • 127.
  • 128.
  • 129.
  • 130.
  • 131.
  • 132.
  • 133.
  • 134.
  • 135.
  • 136.
  • 137.
  • 138.

使用

(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)
})()
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.


责任编辑:华轩 来源: 前端南玖
相关推荐

2023-09-27 08:33:16

作用域CSS

2011-04-29 10:22:49

CSS高性能Web开发

2021-05-25 10:15:20

JavaScript 前端作用域

2011-09-06 09:56:24

JavaScript

2021-03-09 08:35:51

JSS作用域前端

2019-03-13 08:00:00

JavaScript作用域前端

2010-09-25 16:10:09

添加DHCP作用域

2023-05-05 07:41:42

执行上下文JavaScript

2017-10-29 06:50:30

前端开发CSSWeb

2021-03-17 08:39:24

作用域作用域链JavaScript

2021-03-16 22:25:06

作用域链作用域JavaScript

2010-08-25 15:19:20

DHCP作用域

2011-04-18 09:31:35

JavaScript

2010-09-29 15:02:23

DHCP作用域

2023-11-06 09:24:14

CSS相对颜色

2010-01-07 16:16:03

VB.NET变量作用域

2021-07-05 08:43:46

Spring Beanscope作用域

2021-06-02 07:02:42

js作用域函数

2013-09-05 10:07:34

javaScript变量

2017-09-14 13:55:57

JavaScript
点赞
收藏

51CTO技术栈公众号