Understand the principle of CSS Module scope isolation

The background where CSS Module appears

We know that with the development of Javascript, many modular specifications have appeared, such as AMD, CMD, Common JS, ESModule, etc. These modular specifications can enable our JS to achieve scope isolation. But CSS is not so lucky. There has been no modular specification until now. Since CSS matches elements globally based on selectors, if you define the same class name in two different places on the page, first Defined styles will be overwritten by later defined styles. For this reason, CSS naming conflicts have always troubled front-end personnel.

This status quo is unacceptable to front-end developers, so the CSS community has also produced various CSS modular solutions (this is not a specification), such as:

  • Naming method: artificially agreed naming rules
  • scoped: Common isolation methods in vue
  • CSS Module: Each file is an independent module
  • CSS-in-JS: This is common in react and JSX

It is currently CSS Modulethe most popular solution, and it can be used in various frameworks with CSS preprocessors.

If this article is helpful to you, ❤️Follow + Like❤️Encourage the author. The article will be published on the public account. Follow to 前端南玖get the latest article as soon as possible~

CSS Module

The popularity of CSS Module originated from the React community, and it was quickly adopted by the community. Later, due to Vue-cli’s out-of-the-box support for its integration, it was pushed to a new level.

local scope

In the w3c specification, CSS is always “global”. In traditional web development, the most troublesome thing is dealing with CSS issues. Because of its global nature, the style is clearly defined, but it does not take effect. The reason may be that it is forcibly overridden by other style definitions.

The only way to create a local scope is to give the style a unique name, CSS Modulewhich is how scope isolation is achieved.

You can use it in CSS Module :local(className)to declare a local scope CSS rule.

: local (.qd_btn) {
     border-radius : 8px ;
     color : #fff ;
}
: local (.qd_btn): nth ( 1 ) {
     color : pink;
}

: local (.qd_title) {
     font-size : 20px ;
}

CSS Module:local()The included selector will localIdentNamebe processed by rules, that is, a unique selector name will be generated for it to achieve the effect of scope isolation.

The above css will generate code like this after compilation:

Here :exportare the pseudo-classes added by CSS Module to solve export problems, which will be introduced later.

global scope

Of course, CSS Module also allows you :global(className)to declare a global scope rule.

: global (.qd_text) {
     color : chocolate;
}

No processing will be done for :global()the included selector CSS Module, because CSS rules are global by default.

Maybe many people are curious that we rarely use it in the development process :local(). For example, in vue, we only need to add it to the style tag moduleto automatically achieve the effect of scope isolation.

Yes, for the convenience of our development process, postcss-modules-local-by-defaultthe plug-in has already handled this step for us by default. As long as we enable CSS modularization, the CSS inside will be added by default during the compilation process :local().

Composing(combination)

Combination means that one selector can inherit the rules of another selector.

Inherit the current file content

: local (.qd_btn) {
     border-radius : 8px ;
     color : #fff ;
}

: local (.qd_title) {
     font-size : 20px ;
    composes: qd_btn;
}

Inherit other files

Composes can also inherit styles from external files

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

After compilation, the following code will be generated:

Import and Export

From the above compilation results, we will find that there are two pseudo-classes that we have not usually used: :import:export.

CSS Module internally ICSSsolves the problem of CSS import and export, which corresponds to the two new pseudo-classes above.

Interoperable CSS (ICSS) is a superset of standard CSS.

:import

statement :importallows importing variables from other CSS files. It does the following:

  • Get and handle dependencies
  • Resolve exports of dependencies based on imported tokens and match them tolocalAlias
  • localAliasFind and replace using dependencies somewhere in the current file (described below) exportedValue.

:export

:exportblock defines the symbols to be exported to consumers. It can be thought of as functionally equivalent to the following JS:

module . exports = {
     "exportedKey" : "exportedValue" 
}

The following syntax restrictions apply :export:

  • It must be at the top level, but can be anywhere in the file.
  • If there are more than one in a file, the keys and values ​​are combined and exported together.
  • If exportedKeya particular item is repeated, the last one (in source order) takes precedence.
  • An exportedValuecan contain any characters (including spaces) that are valid for CSS declared values.
  • exportedValueThere is no need to quote an, it is already treated as a literal string.

The following is required for output readability, but is not mandatory:

  • There should be only one :exportblock
  • It should be at the top of the file, but after any :importblocks

CSS Module principle

After roughly understanding the CSS Module syntax, we can take a look at its internal implementation and its core principle – scope isolation.

Generally speaking, it is not so troublesome for us to use it in development. For example, we can use it out of the box in the vue project. The most important plug-in is css-loader, we can start here to find out.

Here you can think about it, css-loaderwhich libraries will you mainly rely on for processing?

We need to know that CSS Modulethese new syntaxes are not actually CSS built-in syntax, so they must be compiled.

So which library do we think of first when compiling CSS?

postcss right? It’s to CSS what Babel is to javascript

You can install css-loaderit to verify:

As we expected, here we can see several postcss-moduleplug-ins starting with “.” These should be the core plug-ins that implement CSS Module.

From the above plug-in names, you should be able to tell which one implements scope isolation.

  • Postcss-modules-extract-imports: import and export functions
  • Postcss-modules-local-by-default: default local scope
  • Postcss-modules-scope: scope isolation
  • Posts-modules-values: variable function

Compilation process

The whole process is generally similar to Babel compiling javascript: parse ——> transform ——> stringier

Unlike Babel, PostCSS itself only includes a css parser, css node tree API, source map generator and css node tree splicer.

The component unit of CSS is a style rule (rule), and each style rule contains the definition of one or more attributes & values. Therefore, the execution process of PostCSS is that the css analyzer first reads the css character content and obtains a complete node tree. Next, a series of conversion operations are performed on the node tree (plug-ins based on the node tree API). Finally, the css The node tree splicer reassembles the converted node tree into css characters. During this period, a source map can be generated to indicate the character correspondence before and after conversion.

CSS also needs to generate AST during compilation, which is the same as Babel processing JS.

AST

There are mainly four types of AST in PostCSS:

  • rule: start of selector
#main {
     border : 1px solid black;
}
  • atrule: @starts with
@media screen and (min- width : 480px) {
    body {
        background- color : lightgreen;
    }
}
  • decl: specific style rules
border : 1px solid black;
  • comment: comment
/* Comments*/

Similar to Babel, we can also use these tools to understand the AST of CSS more clearly:

  • Root: Inherited from Container. The root node of AST, representing the entire css file
  • AtRule: Inherited from Container. For statements starting with @, the core attributes are params, for example: @import url('./default.css'), params isurl('./default.css')
  • Rule: Inherited from Container. With a declared selector, the core attribute is selector, for example: .color2{}, selector is.color2
  • Comment: Inherited from Node. The standard annotation/ annotation /node includes some common properties:
  • type: node type
  • parent: parent node
  • source: stores the resource information of the node and calculates the sourcemap
  • start: the starting position of the node
  • end: the end position of the node
  • raws: stores additional symbols of nodes, such as semicolons, spaces, comments, etc. These additional symbols will be spliced ​​during the stringify process.

Installation experience

npm i postcss postcss-modules-extract-imports postcss-modules-local-by-default postcss-modules-scope postcss-selector-parser

We can experience the functions of these plug-ins one by one. We first connect these main plug-ins in series to try the effect, and then implement a Postcss-modules-scopeplug-in by ourselves.

( async () => {
     const css = await  getCode ( './css/default.css' )
     const pipeline = postcss ([
         postcssModulesLocalByDefault (),
         postcssModulesExtractImports (), 
         postcssModulesScope ()
    ])

    const res = pipeline. process (css)

    console . log ( '[output]' , res. css )
})()

Integrating these core plug-ins, we will find that the styles in our css :localcan generate unique hash names without writing them, and we can also import styles from other files. This mainly relies on postcss-modules-local-by-defaulttwo postcss-modules-extract-importsplug-ins.

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

Write a plug-in

Now let’s implement a similar postcss-modules-scopeplug-in ourselves. The principle is actually very simple, which is to traverse the AST, generate a unique name for the selector, and maintain it with the name of the selector exports.

Main API

Speaking of traversing AST, similar to Babel, Post CSS also provides many APIs for operating AST:

  • walk: traverse all node information
  • walkAtRules: traverse all atruletype nodes
  • walkRules: traverse all ruletypes of nodes
  • walkComments: traverse all commenttype nodes
  • walkDecls: traverse all decltypes of nodes

(More content can be viewed on the postcss documentation)

With these APIs, it is very convenient for us to process AST

plug-in format

Writing a PostCSS plug-in is similar to Babel. We only need to process the AST according to its specifications. We don’t need to care about its compilation and target code generation.

const  plugin = ( options = {} ) => {
   return {
     postcssPlugin : 'plugin name' ,
     Once (root) {
       // Each file will be called once, similar to Babel's visitor
    }
  }
}

plugin. postcss = true 
module . exports = plugin
core code
const selectorParser = require ( "postcss-selector-parser" );
 // Randomly generate a selector name 
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 = {};
             // Export scopedName 
            function  exportScopedName ( name ) {
                 // css The mapping between a name and its corresponding scoped domain name 
                const scopedName = createScopedName (name);
                 exports [name] = exports [name] || [];
                 if ( exports [name]. indexOf (scopedName) < 0 ) {
                     exports [name] . push (scopedName);
                }
                return scopedName;
            }
            // Local node, that is, the node that requires scope isolation: 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
                            ),
                        });
                    }
                }
            }
            // Traverse node 
            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 ;
                    }
                    // Selector 
                    case  "id" :
                     case  "class" :
                         exports [node. value ] = [node. value ];
                         break ;
                     // Pseudo element 
                    case  "pseudo" :
                         if (node. value === ":local" ) {
                             const selector = localizeNode (node. first , node. spaces );

                            node.replaceWith ( selector);

                            return ;
                        } else  if (node. value === ":global" ) {

                        }
                }
                return node;
            }
            // Traverse all rule type nodes 
            root. walkRules ( ( rule ) => {
                 const parsedSelector = selectorParser (). astSync (rule);
                rule. selector = traverseNode (parsedSelector. clone ()). toString ();
                 // Traverse all decl type nodes to process 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 ( );
                });

            });

            // Process @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
use
( 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 )
})()