2

了解CSS Module作用域隔离原理 - 前端南玖

 1 year ago
source link: https://www.cnblogs.com/songyao666/p/17235496.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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经过编译后会生成这样的代码:

css-1.png

这里的: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;
}
css-2.png

继承其它文件

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

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

编译后会生成如下代码:

css-3.png

从上面的这些编译结果我们会发现有两个我们平时没用过的伪类::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来验证一下:

css-4.png

跟我们预期的一致,这里我们能看到几个以postcss-module开头的插件,这些应该就是实现CSS Module的核心插件。

从上面这些插件名称应该能看出哪个才是实现作用域隔离的吧

  • Postcss-modules-extract-imports:导入导出功能
  • Postcss-modules-local-by-default:默认局部作用域
  • Postcss-modules-scope:作用域隔离
  • Posts-modules-values:变量功能

整个流程大体上跟Babel编译javascript类似:parse ——> transform ——> stringier

css-5.png

与Babel不同的是,PostCSS自身只包括css分析器,css节点树API,source map生成器以及css节点树拼接器。

css的组成单元是一条一条的样式规则(rule),每一条样式规则又包含一个或多个属性&值的定义。所以,PostCSS的执行过程是,先css分析器读取css字符内容,得到一个完整的节点树,接下来,对该节点树进行一系列转换操作(基于节点树API的插件),最后,由css节点树拼接器将转换后的节点树重新组成css字符。期间可生成source map表明转换前后的字符对应关系。

CSS在编译期间也是需要生成AST得,这点与Babel处理JS一样。

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:

css-6.png
  • 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;
}
css-7.png

现在我们就自己来实现一下类似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)
})()

原文首发地址点这里,欢迎大家关注公众号 「前端南玖」,如果你想进前端交流群一起学习,请点这里

我是南玖,我们下期见!!!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK