本文大纲:
- 剔除冗余 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 主要做的事情就是两个:
- 提取出模板文件中可能的样式选择器;
- 分析 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 的一些局限性,将其直接用于生产环境去剔除代码存在一定的风险。不过,我们可以利用它,写一个检测工具,在开发阶段做冗余代码检测,辅助开发者去优化代码。检测工具主要做的事情如下:
- 提取页面的样式选择器。如果是 React 项目,PurgeCSS 已经内置了 purgecss-from-tsx,用于提取 tsx 中的样式选择器。目前 PurgeCSS 内置支持 HTML/JSX/TSX/Pug/Vue 这几种模板,如果页面使用的其他模板,比如 ejs 等,就需要你自己实现提词器了。
- 提取出页面引入的 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);
}
}
});
- 检测结果分析。将新生成的代码和原始代码做对比,可以算出来 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,可以实现一个检测工具,辅助开发者优化代码。