剔除冗余 CSS 代码——PurgeCSS 原理

843 阅读7分钟

本文大纲:

  • 剔除冗余 CSS 代码的业界方案对比
  • 介绍 PurgeCSS 核心原理
  • 检测工具

前言

随着业务不断的迭代,我们的页面中出现了越来越多冗余的代码。这些冗余的代码,既占用 CDN 带宽,也会影响页面的性能。对于 JS 代码,可以利用 Tree-Shaking 进行摇树优化,剔除掉不必要的代码。对于 CSS 代码,是否也可以进行 “Tree-Shaking” 呢?答案是肯定的。这篇文章会介绍主流的 CSS 冗余代码优化方案,并重点介绍 PurgeCSS 的原理。

方案对比

UnCSS

UnCSS 的工作方式如下:

  • 由 JSDOM 加载 HTML 文件并执行 JavaScript 代码。
  • PostCSS 解析所有样式表。 通过 document.querySelector 筛选出 HTML 文件中未找到的选择器。
  • 将其余的样式规则转换回 CSS 代码。

UnCSS 可以通过 JSDOM 模拟 HTML 和 JS 的执行,来删除 CSS 中没有用到的选择器。由于它可以运行时去执行,所以相比于其他静态检测方案,它的准确性会比较好。但它的问题是,需要将模板文件提前转换成 HTML 才能执行,并且对于 SSR 的页面,它也无能为力。而且,这个库最近几年都没有人在维护了。

PurifyCSS

PurifyCSS 是一个比较老的精简 CSS 的方案了。它是基于静态代码检测,查看文件中的所有单词,并将它们与 CSS 中的选择器进行比较。由于每个单词都被视为选择器,这可能会错误的找到许多选择器,导致影响它的准确性。它的优势是,支持各种模板文件,不局限于 HTML。

PurgeCSS 原理

这里重点介绍一下当前比较主流的方案:PurgeCSS。用过 Tailwind CSS 的同学可能会知道,可以在 tailwind.config.js 中配置 purge 选项,实现按需打包用到的 CSS,它其实就是基于 PurgeCSS 去实现的。

PurgeCSS 也是一个基于静态代码检测的方案。通过分析你的 HTML(也支持 React JSX、Vue、Jade等其他模板) 和 CSS 文件,首先它将 CSS 文件中使用的选择器与内容文件中的选择器进行匹配,然后它会从 CSS 中删除未使用的选择器,从而生成更小的 CSS 文件。简单的例子如下:

await new PurgeCSS().purge({
  content: [
    {
      raw: '<html><body><div class="app"></div></body></html>',
      extension: 'html'
    },
    '**/*.js',
    '**/*.html',
    '**/*.vue'
  ],
  css: [
    {
      raw: 'body { margin: 0 }'
    },
    'css/app.css'
  ]
})

调用时,需要告诉 PurgeCSS DOM 节点所在的文件和 CSS 文件的路径。

PurgeCSS 主要做的事情就是两个:

  1. 提取出模板文件中可能的样式选择器;
  2. 分析 CSS 的规则,剔除掉没有被引用的规则;

笔者的项目都用 React 开发,所以这里主要介绍下如何提取出 React JSX 中的样式选择器。核心源码如下:

function purgeFromJsx(options?: acorn.Options) {
  return (content: string): string[] => {
    // Will be filled during walk
    const state: NodeState = { selectors: [] };

    // Parse and walk any JSXElement
    walk.recursive<NodeState>(
      acorn.Parser.extend(
        jsx() as (BaseParser: typeof acorn.Parser) => typeof acorn.Parser,
      ).parse(content, options || { ecmaVersion: "latest" }),
      state,
      {
        JSXOpeningElement(acornNode, state, callback) {
          const node = acornNode as JSXOpeningElement;
          const nameState: NodeState = {};

          callback(node.name, nameState);
          if (nameState.text) {
            state.selectors?.push(nameState.text);
          }

          for (let i = 0; i < node.attributes.length; ++i) {
            callback(node.attributes[i], state);
          }
        },
        JSXAttribute(acornNode, state, callback) {
          const node = acornNode as JSXAttribute;

          if (!node.value) {
            return;
          }

          const nameState: NodeState = {};
          callback(node.name, nameState);

          // node.name is id or className
          switch (nameState.text) {
            case "id":
            case "className":
              {
                // Get text in node.value
                const valueState: NodeState = {};
                callback(node.value, valueState);

                // node.value is not empty
                if (valueState.text) {
                  state.selectors?.push(...valueState.text.split(" "));
                }
              }
              break;
            default:
              break;
          }
        },
        JSXIdentifier(acornNode, state) {
          const node = acornNode as JSXIdentifier;
          state.text = node.name;
        },
        JSXNamespacedName(acornNode, state) {
          const node = acornNode as JSXNamespacedName;
          state.text = node.namespace.name + ":" + node.name.name;
        },
        // Only handle Literal for now, not JSXExpressionContainer | JSXElement
        Literal(acornNode, state) {
          const node = acornNode as Literal;
          if (typeof node.value === "string") {
            state.text = node.value;
          }
        },
      },
      { ...walk.base },
    );

    return state.selectors || [];
  };
}

借助于 acorn 将 JSX 模板字符串解析成 AST,使用 acorn-walk 递归遍历 AST,提取出节点的 elementName、id、className,将它们放进 selectors 数组里。

这里 PurgeCSS 可以在默认提取器的基础上,支持用户自定义提取器,以获得更为准确的结果。用法如下:

import purgeFromHTML from 'purge-from-html'

await new PurgeCSS().purge({
  content: ['index.html', '**/*.js', '**/*.html', '**/*.vue'],
  css: ['css/app.css'],
  extractors: [
    {
      extractor: purgeFromHTML,
      extensions: ['html']
    },
    {
      extractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
      extensions: ['vue', 'js']
    }
  ]
})

所谓的提词器 extractor,其实就是一个函数,你可以自己写一个正则,来提取出你想要的选择器。

第二步,解析 CSS 文件。多数项目中都用了 Less 等 PostCSS 方案,这种项目可以用 PostCSS 去解析 Less 文件,原理还是将 Less 文件解析成 AST,遍历 AST,判断每个选择器是否在 selectors 数组中,如果没有,就代表没有使用,就可以删掉该选择器。核心源码如下:


  const root = postcss.parse(cssContent, {
    from: isFromFile ? option : undefined,
  });

  root.walk((node) => {
      if (node.type === "rule") {
        return this.evaluateRule(node, selectors);
      }
      //...这里忽略了一些非关键代码
  });

  /**
   * 解析css选择器并判断是否应该移除
   * @param node - node of postcss AST
   * @param selectors - selectors used in content files
   */
  private evaluateRule(
    node: postcss.Node,
    selectors: ExtractorResultSets,
  ): void {
    // ...这里忽略了一些非关键代码

    const selectorsRemovedFromRule: string[] = [];

    // 两次遍历解析出来的选择器列表
    // 第一次遍历,移除无用选择器,但不包括:where and :is
    // 第二次遍历,移除空的伪类:where and :is选择器
    node.selector = selectorParser((selectorsParsed) => {
      selectorsParsed.walk((selector) => {
        if (selector.type !== "selector") {
          return;
        }

        const keepSelector = this.shouldKeepSelector(selector, selectors);

        if (!keepSelector) {
          if (this.options.rejected) {
            this.selectorsRemoved.add(selector.toString());
          }
          if (this.options.rejectedCss) {
            selectorsRemovedFromRule.push(selector.toString());
          }
          selector.remove();
        }
      });

      // removes selectors containing empty :where and :is
      selectorsParsed.walk((selector) => {
        if (selector.type !== "selector") {
          return;
        }

        if (selector.toString() && /(:where)|(:is)/.test(selector.toString())) {
          selector.walk((node) => {
            if (node.type !== "pseudo") return;
            if (node.value !== ":where" && node.value !== ":is") return;
            if (node.nodes.length === 0) {
              selector.remove();
            }
          });
        }
      });
    }).processSync(node.selector);

    // declarations
    if (node.selector && typeof node.nodes !== "undefined") {
      for (const childNode of node.nodes) {
        if (childNode.type !== "decl") continue;
        this.collectDeclarationsData(childNode);
      }
    }

    // 移除空rule
    const parent = node.parent;
    if (!node.selector) {
      node.remove();
    }
    if (isRuleEmpty(parent)) parent?.remove();

    // 收集被删除的节点
    if (this.options.rejectedCss) {
      if (selectorsRemovedFromRule.length > 0) {
        const clone = node.clone();
        const parentClone = parent?.clone().removeAll().append(clone);
        clone.selectors = selectorsRemovedFromRule;
        const nodeToPreserve = parentClone ? parentClone : clone;
        this.removedNodes.push(nodeToPreserve);
      }
    }
  }

同时,PurgeCSS 支持指定 CSS 选择器白名单,允许用户指定哪些选择器可以保留在最终的 CSS 中。这个特性在使用了外部样式库的项目中比较重要。

局限性

场景一:动态生成DOM或者className

根据 PurgeCSS 的原理,很容易可以看出它的局限性。由于它基于静态代理检测,因此在一些动态生成 DOM 或者 className 的场景就不适用了。举个例子:

const wrapClassName = calClassName();
return (
    <div className={`wrap ${wrapClassName}`}></div>
)

像这种动态生成的类名,PurgeCSS 会检测不出来。好在 PurgeCSS 支持白名单机制,通过将动态类名加到白名单里,可以避免掉误删除的情况。

const purgecss = new Purgecss({
    content: [], // content
    css: [], // css
    safelist: {
      standard: [/red$/],
      deep: [/blue$/],
      greedy: [/yellow$/]
    }
})

如果你的 DOM 节点是动态计算出来的,PurgeCSS 也无能为力,不过这种情况在实际的业务代码不太常见。

<div className="set_meal__explain">
    <span dangerouslySetInnerHTML={{ __html: wrapEm(subDesc) }}></span>
</div>

场景二:其他页面或者外部组件,依赖当前页面的样式

这种情况,PurgeCSS 也会出现误判。建议开发时注意,不要将公共样式放在业务目录下

检测工具

PurgeCSS 有对应的 Webpack 插件(purgecss-webpack-plugin),可以在编译时帮我们自动剔除掉冗余代码。但鉴于 PurgeCSS 的一些局限性,将其直接用于生产环境去剔除代码存在一定的风险。不过,我们可以利用它,写一个检测工具,在开发阶段做冗余代码检测,辅助开发者去优化代码。检测工具主要做的事情如下:

  1. 提取页面的样式选择器。如果是 React 项目,PurgeCSS 已经内置了 purgecss-from-tsx,用于提取 tsx 中的样式选择器。目前 PurgeCSS 内置支持 HTML/JSX/TSX/Pug/Vue 这几种模板,如果页面使用的其他模板,比如 ejs 等,就需要你自己实现提词器了。
  2. 提取出页面引入的 CSS 文件路径。通常来说,引入 CSS 的方式有这么几种:
  • link 标签外链引入。
<link rel="stylesheet" href="xxx.css">

这种情况,CSS 代码并不在本地代码库中。我们可以使用 cheerio 获取到页面的 link 标签的 href 值,然后用 node.js 去请求外链 url,将结果存到本地。再将本地 CSS 文件路径传递给 PurgeCSS。

  • tsx/jsx 中 import 本地 CSS。
import 'style/xxx/app.css';

这种情况,代码放在本地,但我们需要解析出其所在的文件路径。可以借助 babel parser 来实现。

  const cssFiles = [];
  // 读取 TSX 文件
  const tsxContent = fs.readFileSync(jsxFile, 'utf8');
  
  // 解析 TSX
  const ast = parser.parse(tsxContent, {
    sourceType: 'module',
    plugins: [
      'jsx',  // 支持 JSX
      'typescript'  // 支持 TypeScript
    ]
  });
  
  // 遍历 AST
  traverse(ast, {
    ImportDeclaration(path) {
      if (path.node.source.value.endsWith('.css')) {
        cssFiles.push(path.node.source.value);
      }
    }
  });
  1. 检测结果分析。将新生成的代码和原始代码做对比,可以算出来 CSS 的冗余体积。给 PurgeCSS 指定 rejected 参数为 true,可以浏览被删除内容的列表,看看是否有明显的错误。

配置示例

purgecss.config.js配置

module.exports = {
  content: ['./index.html', './**/*.tsx'],
  // css文件路径
  css: ['./*.css', './**/*.css'],
  // 写入精简后的css文件到此目录
  output: './',
  fontFace: true,
  safelist: {
    // 用于保留部分选择器
    // 如果页面包含某些动态class名,需要将其加入到safelist里,以防止误删除样式
    // 比如这种<section className={`set_meal col_${count > 3 ? 3 : count}`}>
    // 将/^col_/加入到safelist deep里,col_1、col_2这种className及其子元素样式就会得到保留
    deep: [/^mui_loading/, /^swiper/, /^col_/],
    // 保留全局reset样式
    standard: ['html', 'body', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'img', 'input', 'ul', 'ol', 'li', 'button', 'select', 'textarea', 'table', 'td', 'th', 'page_client10', 'page_downright', 'page_downright--hide']
  }
}

purgecss.config.js详细文档参考(www.purgecss.cn/CLI

总结

这篇文章主要带大家了解了处理 CSS 冗余代码的几种方案,并重点介绍 PurgeCSS 的原理和局限性,结合 PurgeCSS,可以实现一个检测工具,辅助开发者优化代码。