CSS TreeShaking 原理揭秘: 手写一个 PurgeCss

6,656 阅读5分钟

TreeShking 是通过静态分析的方式找出源码中不会被使用的代码进行删除,达到减小编译打包产物的代码体积的目的。

JS 我们会用 Webpack、Terser 进行 TreeShking,而 CSS 会用 PurgeCss。

PurgeCss 会分析 html 或其他代码中 css 选择器的使用情况,进而删除没有被使用的 css。

是否对 PurgeCss 怎么找到无用的 css 的原理比较好奇呢?今天我们就来手写个简易版 PurgeCss 来探究下吧。

思路分析

PurgeCss 要指定 css 应用到哪些 html,它会分析 html 中的 css 选择器,根据分析结果来删除没有用到的 css:

const { PurgeCSS } = require('purgecss')
const purgeCSSResult = await new PurgeCSS().purge({
  content: ['**/*.html'],
  css: ['**/*.css']
})

我们要做的事情就可以分为两部分:

  • 提取 html 中的可能的 css 选择器,包括 id、class、tag 等
  • 分析 css 中的 rule,根据选择器是否被 html 使用,删掉没被用到的部分

从 html 中提取信息的部分,叫做 html 提取器(extractor)。

我们可以基于 posthtml 来实现 html 的提取器,它可以做 html 的 parse、分析、转换等,api 和 postcss 类似。

css 的部分使用 postcss,通过 ast 可以分析出每一条 rule。

遍历 css 的 rule,对每个 rule 的选择器都判断下是否在从 html 中提取到选择器中,如果没有,就代表没有被使用,就删掉该选择器。

如果一个 rule 的所有的选择器都删掉了,那么就把这个 rule 删掉。

这就是 purgecss 的实现思路。我们来写下代码。

代码实现

我们来写一个 postcss 插件来做这件事情,postcss 插件就是基于 AST 做 css 的分析和转换的。

const purgePlugin = (options) => {
  
    return {
        postcssPlugin: 'postcss-purge',
        Rule (rule) {}
    }
}

module.exports = purgePlugin;

postcss 插件的形式是一个函数,接收插件的配置参数,返回一个对象。对象里声明 Rule、AtRule、Decl 等的 listener,也就是对不同 AST 的处理函数。

这个 postcss 插件的名字叫做 purge,可以被这样调用:

const postcss = require('postcss');
const purge = require('./src/index');
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync('./example/index.css');

postcss([purge({
    html: path.resolve('./example/index.html'),
})]).process(css).then(result => {
    console.log(result.css);
});

通过参数传入 html 的路径,插件里可以通过 option.html 拿到。

接下来我们来实现下这个插件。

前面分析过,实现过程整体分为两步:

  • 通过 posthtml 提取 html 中的 id、class、tag
  • 遍历 css 的 ast,删掉没被 html 使用的部分

我们封装一个 htmlExtractor 来做提取的事情:

const purgePlugin = (options) => {
    const extractInfo = {
        id: [],
        class: [],
        tag: []
    };

    htmlExtractor(options && options.html, extractInfo);

    return {
        postcssPlugin: 'postcss-purge',
        Rule (rule) {}
    }
}

module.exports = purgePlugin;

htmlExtractor 的具体实现就是读取 html 的内容,对 html 做 parse 生成 AST,遍历 AST,记录 id、class、tag:

function htmlExtractor(html, extractInfo) {
    const content = fs.readFileSync(html, 'utf-8');

    const extractPlugin = options => tree => {      
        return tree.walk(node => {
            extractInfo.tag.push(node.tag);
            if (node.attrs) {
              extractInfo.id.push(node.attrs.id)
              extractInfo.class.push(node.attrs.class)
            }
            return node
        });
    }

    posthtml([extractPlugin()]).process(content);

    // 过滤掉空值
    extractInfo.id = extractInfo.id.filter(Boolean);
    extractInfo.class = extractInfo.class.filter(Boolean);
    extractInfo.tag = extractInfo.tag.filter(Boolean);
}

posthtml 的插件形式和 postcss 类似,我们在 posthtml 插件里遍历 AST 并记录了一些信息。

最后,过滤掉 id、class、tag 中的空值,就完成了提取。

我们先不着急做下一步,先来测试下现在的功能。

我们准备这样一个 html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div class="aaa"></div>

    <div id="ccc"></div>

    <span></span>
</body>
</html>

测试下提取的信息:

可以看到,id、class、tag 都正确的从 html 中提取了出来。

接下来,我们继续做下一步:从 css 的 AST 中删掉没被使用的部分。

我们声明了 Rule 的 listener,可以拿到 rule 的 AST。要分析的是 selector 部分,需要先根据 “,” 做拆分,然后对每一个选择器做处理。

Rule (rule) {                        
     const newSelector = rule.selector.split(',').map(item => {
        // 对每个选择器做转换
    }).filter(Boolean).join(',');

    if(newSelector === '') {
        rule.remove();
    } else {
        rule.selector = newSelector;
    }
}

选择器可以用 postcss-selector-parser 来做 parse、分析和转换。

处理以后的选择器如果都被删掉了,就说明这个 rule 的样式就没用了,就删掉这个 rule。否则可能只是删掉了部分选择器,该样式还会被用到。

const newSelector = rule.selector.split(',').map(item => {
    const transformed = selectorParser(transformSelector).processSync(item);
    return transformed !== item ? '' : item;
}).filter(Boolean).join(',');

if(newSelector === '') {
    rule.remove();
} else {
    rule.selector = newSelector;
}

接下来实现对选择器的分析和转换,也就是 transformSelector 函数。

这部分的逻辑就是对每个选择器判断下是否在从 html 提取到的选择器中,如果不在,就删掉。

const transformSelector = selectors => {
    selectors.walk(selector => {
        selector.nodes && selector.nodes.forEach(selectorNode => {
            let shouldRemove = false;
            switch(selectorNode.type) {
                case 'tag':
                    if (extractInfo.tag.indexOf(selectorNode.value) == -1) {
                        shouldRemove = true;
                    }
                    break;
                case 'class':
                    if (extractInfo.class.indexOf(selectorNode.value) == -1) {
                        shouldRemove = true;
                    }
                    break;
                case 'id':
                    if (extractInfo.id.indexOf(selectorNode.value) == -1) {
                        shouldRemove = true;
                    }
                    break;
            }

            if(shouldRemove) {
                selectorNode.remove();
            }
        });
    });
};

我们完成了 html 中选择器信息的提取,和 css 根据 html 提取的信息做无用 rule 的删除,插件的功能就已经完成了。

我们来测试下效果:

css:

.aaa, ee , ff{
    color: red;
    font-size: 12px;
}
.bbb {
    color: red;
    font-size: 12px;
}

#ccc {
    color: red;
    font-size: 12px;
}

#ddd {
    color: red;
    font-size: 12px;
}

p {
    color: red;
    font-size: 12px;
}
span {
    color: red;
    font-size: 12px;
}

html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div class="aaa"></div>

    <div id="ccc"></div>

    <span></span>
</body>
</html>

按理说, p、#ddd、.bbb 的选择器和样式,ee、ff 的选择器都会被删除。

我们使用下该插件:

const postcss = require('postcss');
const purge = require('./src/index');
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync('./example/index.css');

postcss([purge({
    html: path.resolve('./example/index.html'),
})]).process(css).then(result => {
    console.log(result.css);
});

经测试,功能是对的:

这就是 PurgeCss 的实现原理。我们完成了 css 的 three shaking!

代码上传到了 github:github.com/QuarkGluonP…

当然,我们只是简易版实现,有的地方做的不完善:

  • 只实现了 html 提取器,而 PurgeCss 还有 jsx、pug、tsx 等提取器(不过思路都是一样的)
  • 只处理了单文件,没有处理多文件(再加个循环就行)
  • 只处理了 id、class、tag 选择器,没处理属性选择器(属性选择器的处理稍微复杂一些)

虽然没有做到很完善,但是 PurgeCss 的实现思路已经通了,不是么~

总结

JS 的 TreeShking 使用 Webpack、Terser,而 CSS 的 TreeShking 使用 PurgeCss。

我们实现了一个简易版的 PurgeCss 来理清了它的实现原理:

通过 html 提取器提取 html 中的选择器信息,然后对 CSS 的 AST 做过滤,根据 Rule 的 selector 是否被使用到来删掉没用到的 rule,达到 TreeShking 的目的。

实现这个工具的过程中,我们学习了 postcss 和 posthtml 插件的写法,这两者形式上很类似,只不过一个针对 css 做分析和转换,一个针对 html。

Postcss 可以分析和转换 CSS,比如这里的删除无用 css 就是一个很好的应用。你还见过别的 postcss 的很棒的应用场景么,不妨一起来讨论下吧~