current position:Home>Handwritten CSS modules to understand its principle

Handwritten CSS modules to understand its principle

2022-04-29 06:46:34zxg_ God said to have light

We know , in-browser JS There was no concept of modules before , All through different global variables ( Namespace ) To isolate , Then came AMD、CMD、CommonJS、ESM Other norms .

These modules are used to standardize the organization of JS After the code is compiled and packaged , There will still be module level scope isolation at runtime ( Implemented by function scope ).

Components can be placed in different modules , To implement different components JS Scope isolation for .

But components except JS also CSS ah ,CSS But there has been no specification for module isolation .

How to give css Plus the function of the module ?

Some students will say CSS Not having @import Do you ?

That's just putting different CSS The documents are combined , It won't make a difference CSS The isolation .

CSS There are two main types of isolation schemes , One is runtime, which is distinguished by naming , One is automatic conversion at compile time CSS, Add the unique identification of the upper module .

The most typical run-time solution is BEM, It's through .block__element--modifier This naming convention is used to realize style isolation , Different components have different blockName, Just write according to this specification CSS, Is to ensure that the style does not conflict .

But this scheme is not mandatory after all , There are still hidden dangers of style conflict .

There are two schemes at compile time , One is scoped, One is css modules.

scoped yes vue-loader Supported solutions , It adds... To the element by compiling data-xxx Properties of , And then to css Selector plus [data-xxx] The property selector of css The style of isolation .

such as :

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

Will be compiled into :

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

By giving css Add a globally unique attribute selector to limit css Only in this scope , That is to say scoped It means .

css-modules yes css-loader Supported solutions , stay vue、react You can use , It is implemented by compiling and modifying the selector name to be globally unique css The style of isolation .

such as :

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

Will be compiled into :

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

and scoped The difference is css-modules Change the selector name , And because the name is compiled , So the component is through style.xx To write the selector name .

Both schemes are implemented through compilation , But developers still feel different about using it :

scoped The scheme is added data-xxx Attribute selector , because data-xx It is automatically generated and added at compile time , Developers don't feel .

css-modules Our plan is to modify class、id Wait for the name of the selector , That component will pass styles.xx Reference these compiled names in a way , Developers can feel it . But it's also good , With the editor, you can achieve intelligent prompt .

Besides , except css Its own runtime 、 Compile time scheme , You can also use JS To organize css, utilize JS To achieve css Isolation , This is a css-in-js The plan .

Such as this :

import styled from 'styled-components';

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

function Guang {
    return (
        <div> <Wrapper> How to write internal documents </Wrapper> </div>
    );
}
 Copy code 

In these programs ,css-modules The compile time scheme of is the most used ,vue、react Both can be used. .

So how did it come true ?

open css-loader Of package.json, You will find dependence postcss(css The compiler of , Similar compilation js Of babel):

These four postcss-modules The plug-in at the beginning is to realize css-modules Core code .

In these four plug-ins , Scope isolation is achieved by postcss-modules-scope, Other plug-ins are not the most important , such as postcss-modules-values Just to realize the function of variables .

So , As long as we can achieve postcss-modules-scope plug-in unit , You can understand css-modules The realization principle of .

Let's go check it out postcss-modules-scope Of README, It is found that it implements such a transformation :

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

Translate it into

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

use :local Such a pseudo element selector wraps css Can compile selector names , And put the mapping relationship of names before and after compilation into :export Under this selector .

Another complicated case :

.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;
    }
}
 Copy code 

Will be compiled into :

.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;
}
 Copy code 

You can see that :local The package will be compiled , No :local The wrapped will be used as a global style .

composes-with and composes It's the same thing , It's all a combination of styles , You can see that after compilation, the compose Multiple selectors of are merged together . That is, the one to many mapping relationship .

Realized :local Conversion of selector names , Realized compose Style combinations for , Finally, the mapping relationship will be put into :export In this style .

such css-loader call postcss-modules-scope After compiling the scope , No, you can start from :export Have you got the mapping relationship ?

Then you can use this mapping relationship to generate js modular , You can use... In the component styles.xxx Introduce the corresponding css 了 .

This is it. css-modules Implementation principle of .

that css-modules How to realize it ?

Let's analyze the idea first :

Analysis of implementation ideas

What we have to do is in two ways , One is transformation :local The name of the package , Become globally unique , The second is to collect this mapping relationship , Put it in :export In the style .

postcss Complete the from css To AST Of parse, and AST To object code and soucemap Of generate. We just need to complete in the plug-in AST The conversion is OK .

The name of the conversion selector is traversal AST, find :local Package selector , Transform and collect into an object . And deal with composes-with, That is, the one to many mapping relationship .

After the conversion is complete , The mapping relationship is there , Then generate :export Styles adding to AST Just go up .

The train of thought is clear , Let's write the code :

Code implementation

First build a postcss The basic structure of the plug-in :

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

plugin.postcss = true;

module.exports = plugin;
 Copy code 

postcss The form of a plug-in is a function that returns an object , Function to receive the of the plug-in options, The returned object contains AST Processing logic , You can specify what to AST What to do with .

there Once Represents right AST The root node is processed , The first parameter is AST, The second parameter is some auxiliary methods , For example, you can create AST.

postcss Of AST There are three main types :

  • atrule: With @ Opening rule , such as :
@media screen and (min-width: 480px) {
    body {
        background-color: lightgreen;
    }
}
 Copy code 
  • rule: The rule at the beginning of the selector , such as :
ul li {
 padding: 5px;
}
 Copy code 
  • decl: Specific patterns , such as :
padding: 5px;
 Copy code 

This can be done by  astexplorer.net  To visually view

( There are many specific code implementation details , Let's clear our thinking , Don't go into it )

The implementation of the conversion selector name is like this :


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

   root.walkRules((rule) => {   
       rule.selector =  Convert selector name ();

       rule.walkDecls(/composes|compose-with/i, (decl) => {
           //  Handle  compose
       }
   });
   
   root.walkAtRules(/keyframes$/i, (atRule) => {
       //  Convert selector name 
   });
   
}
 Copy code 

Go through all of them first rule, The name of the conversion selector , And put the mapping relationship of selector names before and after conversion into exports in . And deal with compose.

Then traverse atrule, Do the same thing .

The specific implementation of selector conversion requires selector Do it again parse, use postcss-selector-parser, Then traverse the selector AST Implement transformation :

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

root.walkRules((rule) => {
    // parse  Selector for  AST
    const parsedSelector = selectorParser().astSync(rule);

    //  Traverse selector  AST  And transform 
    rule.selector = traverseNode(parsedSelector.clone()).toString();
});
 Copy code 

such as .guang Selectors AST That's true :

Selectors AST The root of is Root, its first The attribute is Selector node , And then again first The attribute is ClassName 了 .

According to this structure , You need to treat different AST Do different things :

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;
}
 Copy code 

If it is root perhaps selector, Then continue recursive processing , If it is id、class, The description is a global style , Then collect exports in .

If it's a pseudo element selector (pseudo), And is :local Wrapped , Then you have to do the conversion , call localizeNode Implement the conversion of selector name , Then replace the original selector .

localizeNode We should also do different treatment according to different types :

  • selector The node continues to traverse the child nodes .
  • id、class The node changes the name , Then generate a new selector .
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;
    }
}
 Copy code 

Here we call exportScopedName To change the selector name , Then, new className and id node .

exportScopedName In addition to changing the selector name , Also collect the mapping relationship of selector names before and after modification to exports in :

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

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

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

    return scopedName;
}
 Copy code 

The specific name generation logic I wrote is relatively simple , Just add a random string :

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

such , We have completed the conversion and collection of selector names .

And then deal with compose:

compose The logic of is also relatively simple , Originally exports It's a one-to-one relationship , such as :

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

compose Is to turn it into one to many :

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

That's it :

therefore compose If you encounter a mapping with the same name, put it into an array :

rule.walkDecls(/composes|compose-with/i, (decl) => {
    //  Because of the  AST  yes  Root-Selector-Xx  Structure , So do the down conversion 
    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();
});
 Copy code 

use wakDecls To traverse all composes and composes-with The style of , Do... On its value exports The merger of .

First ,parsedSelector.nodes It was before us parse Out of the selector AST, Because it is Root、Selector、ClassName( or Id etc. ) The three-tier structure of , So first map . This is the original name of the selector .

Then on compose The value of split, Make a judgment on each style :

  • If compose Yes. global style , Then give it to everyone exports[ The original name of the selector ] Add current composes Of global Selector mapping

  • If compose Yes. local The style of , Then from exports Find its compiled name in , Add to the current mapping array .

  • If compose Your selector was not found , Just report a mistake

Last but not least decl.remove hold composes Style delete , The generated code does not need this style .

such , We have completed the conversion of the selector and compose, And collecting .

Use the above case to test this logic :

You can see Selector conversion and compose The maps of are collected normally .

Let's move on to keyframes Part of , This is similar to the top , If it is :local Package selector , Just call the above method for conversion :

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

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

After the conversion is complete , Next, do the second step , Take the collected exports Generate AST To add to css The original AST On .

This part is called helpers.rule establish rule node , Traverse exports, call append Method to add a style .

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

Last use root.append Put this rule Of AST Add to root .

That's it css-modules Selector conversion and compose also export All functions of collection and generation .

So let's test that out :

test

The above code implementation details are still quite many , But the general idea should be clear .

Let's test it to see if it works properly :

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

 Copy code 

call postcss, The incoming plug-ins are organized and compiled pipeline, And then call process Method , Incoming processing css, Print generated css:

After testing ,global The style is not converted ,:local The style is converted by selector , The mapping relationship of the transformation is put into :export In the style , also compose It also does realize one to many mapping .

such , We have achieved css-modules Core functions .

The complete code of the plug-in has been uploaded to github: github.com/QuarkGluonP…, Also post a copy here :

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

            //  Handle  :local  Selectors 
            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();
                });

            });

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

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

            //  Generate  :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;
 Copy code 

summary

CSS There are two schemes to realize module isolation: runtime and compile time :

  • The runtime distinguishes by namespace , such as BEM standard .
  • Automatically convert selector names at compile time , Add a unique identifier , such as scoped and css-modules

scoped By adding... To the element data-xxx attribute , And then in css Add [data-xx] Property selector to implement , It is transparent to developers . yes vue-loader Realized , Mainly used in vue in .

css-modules It is realized by compiling and modifying the selector name to be globally unique , Developers need to use styles.xx To reference the compiled name , Opaque to developers , But it also has the advantage of cooperating with the editor to realize intelligent prompt . yes css-loader Realized ,vue、react All available .

Of course , In fact, there is a third kind of Scheme , It is through JS To manage css, That is to say css-in-js.

css-modules Our scheme is the most used , We looked at its implementation principle :

css-loader It's through postcss Plug in css-modules Of , The core of it is postcss-modules-scope plug-in unit .

We wrote one ourselves postcss-modules-scope plug-in unit :

  • Traverse all selectors , Yes :local The selector wrapped by pseudo elements is transformed , And collect exports in .
  • Yes composes Do a one to many mapping with the selector , Also collected exports in .
  • according to exports The collected mapping relationship generates :exports style

This is it. css-modules Implementation principle of scope isolation .

There are many details in the code part of the article , You can download the code and run it , Believe that if you can achieve it yourself css-modules The core compilation function of , That must be a thorough understanding css-modules 了 .

copyright notice
author[zxg_ God said to have light],Please bring the original link to reprint, thank you.
https://en.qdmana.com/2022/04/202204290646307535.html

Random recommended