关于前端:了解CSS-Module作用域隔离原理

4次阅读

共计 7856 个字符,预计需要花费 20 分钟才能阅读完成。

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

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

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

正文完
 0