atomic-css-loader 使用指南
一款基于PostCSS AST解析的CSS原子化转换工具,自动将传统CSS转换为Tailwind风格的原子类,大幅减少CSS体积,同时保持开发体验不变。
🧰 完整Loader代码
// loaders/atomic-css-loader.js
const postcss = require('postcss');
const path = require('path');
const fs = require('fs');
/**
* CSS 原子化自动转换 Loader
* 将传统 CSS 转换为 Tailwind 风格的原子类
*/
module.exports = function atomicCssLoader(source) {
const callback = this.async();
const options = this.getOptions() || {};
// 配置项
const config = {
// 是否启用转换
enabled: options.enabled !== false,
// 输出模式:'apply' | 'utility' | 'both'
outputMode: options.outputMode || 'apply',
// 最小转换规则数(少于此数不转换)
minRules: options.minRules || 2,
// 是否保留原始类名
keepOriginalClass: options.keepOriginalClass !== false,
// 是否生成原子类映射文件
generateMapping: options.generateMapping || false,
// 映射文件输出路径
mappingOutputPath: options.mappingOutputPath || path.join(process.cwd(), 'atomic-css-mapping.json'),
// 自定义原子类映射
customMapping: options.customMapping || {},
// 是否启用颜色优化
optimizeColors: options.optimizeColors !== false,
// 是否压缩输出
minify: options.minify || false,
};
if (!config.enabled) {
callback(null, source);
return;
}
// CSS 属性到 Tailwind 类名的映射表
const cssToTailwindMap = buildCssToTailwindMap(config.customMapping);
try {
postcss([
// 自定义插件:转换为原子类
atomicTransformPlugin(config, cssToTailwindMap),
])
.process(source, { from: this.resourcePath })
.then((result) => {
let output = result.css;
// 压缩输出
if (config.minify) {
output = output.replace(/\s+/g, ' ').replace(/\s*([{}:;,])\s*/g, '$1');
}
callback(null, output);
})
.catch((error) => {
callback(error);
});
} catch (error) {
callback(error);
}
};
/**
* PostCSS 插件:原子化转换
*/
function atomicTransformPlugin(config, cssToTailwindMap) {
return {
postcssPlugin: 'atomic-transform',
Rule(rule) {
const declarations = rule.nodes.filter(node => node.type === 'decl');
// 规则数量太少,不转换
if (declarations.length < config.minRules) {
return;
}
// 收集可转换的声明
const atomicClasses = [];
const unconvertedDecls = [];
declarations.forEach((decl) => {
const atomicClass = convertToAtomicClass(decl, cssToTailwindMap, config);
if (atomicClass) {
atomicClasses.push(atomicClass);
} else {
unconvertedDecls.push(decl);
}
});
// 如果有转换成功的类
if (atomicClasses.length > 0) {
if (config.outputMode === 'apply') {
// 使用 @apply 模式
rule.removeAll();
rule.append({
prop: '@apply',
value: atomicClasses.join(' '),
});
// 保留无法转换的声明
unconvertedDecls.forEach(decl => rule.append(decl.clone()));
} else if (config.outputMode === 'utility') {
// 直接替换为原子类(需要配合 HTML 类名替换)
// 这里生成映射信息
if (config.generateMapping) {
saveMapping(rule.selector, atomicClasses, config);
}
} else if (config.outputMode === 'both') {
// 同时保留原始样式和 @apply
const originalDecls = declarations.map(d => d.clone());
rule.removeAll();
originalDecls.forEach(decl => rule.append(decl));
rule.append({
prop: '@apply',
value: atomicClasses.join(' '),
raws: { before: '\n ', after: ' /* atomic */' }
});
}
}
},
};
}
atomicTransformPlugin.postcss = true;
/**
* 将 CSS 声明转换为原子类
*/
function convertToAtomicClass(decl, cssToTailwindMap, config) {
const prop = decl.prop;
const value = decl.value;
// 处理简写属性
if (prop === 'padding' || prop === 'margin') {
return convertSpacing(prop, value);
}
if (prop === 'border-radius') {
return convertBorderRadius(value);
}
if (prop === 'background' || prop === 'background-color') {
return convertColor('bg', value, config);
}
if (prop === 'color') {
return convertColor('text', value, config);
}
if (prop === 'border-color') {
return convertColor('border', value, config);
}
if (prop === 'font-size') {
return convertFontSize(value);
}
if (prop === 'font-weight') {
return convertFontWeight(value);
}
if (prop === 'width' || prop === 'height') {
return convertSize(prop, value);
}
if (prop === 'display') {
return convertDisplay(value);
}
if (prop === 'flex-direction') {
return convertFlexDirection(value);
}
if (prop === 'justify-content') {
return convertJustifyContent(value);
}
if (prop === 'align-items') {
return convertAlignItems(value);
}
if (prop === 'gap') {
return convertGap(value);
}
if (prop === 'position') {
return value;
}
if (prop === 'opacity') {
return convertOpacity(value);
}
if (prop === 'box-shadow') {
return convertShadow(value);
}
if (prop === 'border-width') {
return convertBorderWidth(value);
}
if (prop === 'text-align') {
return `text-${value}`;
}
// 查找自定义映射
const key = `${prop}:${value}`;
if (cssToTailwindMap[key]) {
return cssToTailwindMap[key];
}
// 无法转换
return null;
}
/**
* 转换间距属性 (padding/margin)
*/
function convertSpacing(type, value) {
const prefix = type === 'padding' ? 'p' : 'm';
const values = value.split(/\s+/);
// 转换 px 为 Tailwind 单位
const toTailwindUnit = (val) => {
const num = parseInt(val);
if (isNaN(num)) return null;
// Tailwind 默认: 1 = 0.25rem = 4px
const tailwindValue = num / 4;
return tailwindValue % 1 === 0 ? tailwindValue : null;
};
if (values.length === 1) {
// padding: 16px -> p-4
const unit = toTailwindUnit(values[0]);
return unit ? `${prefix}-${unit}` : null;
} else if (values.length === 2) {
// padding: 16px 32px -> py-4 px-8
const vertical = toTailwindUnit(values[0]);
const horizontal = toTailwindUnit(values[1]);
if (!vertical || !horizontal) return null;
return `${prefix}y-${vertical} ${prefix}x-${horizontal}`;
} else if (values.length === 4) {
// padding: 8px 16px 12px 16px -> pt-2 pr-4 pb-3 pl-4
const top = toTailwindUnit(values[0]);
const right = toTailwindUnit(values[1]);
const bottom = toTailwindUnit(values[2]);
const left = toTailwindUnit(values[3]);
if (!top || !right || !bottom || !left) return null;
return `${prefix}t-${top} ${prefix}r-${right} ${prefix}b-${bottom} ${prefix}l-${left}`;
}
return null;
}
/**
* 转换圆角
*/
function convertBorderRadius(value) {
const radiusMap = {
'0': 'rounded-none',
'2px': 'rounded-sm',
'4px': 'rounded',
'6px': 'rounded-md',
'8px': 'rounded-lg',
'12px': 'rounded-xl',
'16px': 'rounded-2xl',
'24px': 'rounded-3xl',
'9999px': 'rounded-full',
'50%': 'rounded-full',
};
return radiusMap[value] || null;
}
/**
* 转换颜色
*/
function convertColor(prefix, value, config) {
// 标准颜色映射
const colorMap = {
'#000000': `${prefix}-black`,
'#ffffff': `${prefix}-white`,
'transparent': `${prefix}-transparent`,
// Blue
'#eff6ff': `${prefix}-blue-50`,
'#dbeafe': `${prefix}-blue-100`,
'#bfdbfe': `${prefix}-blue-200`,
'#93c5fd': `${prefix}-blue-300`,
'#60a5fa': `${prefix}-blue-400`,
'#3b82f6': `${prefix}-blue-500`,
'#2563eb': `${prefix}-blue-600`,
'#1d4ed8': `${prefix}-blue-700`,
'#1e40af': `${prefix}-blue-800`,
'#1e3a8a': `${prefix}-blue-900`,
// Gray
'#f9fafb': `${prefix}-gray-50`,
'#f3f4f6': `${prefix}-gray-100`,
'#e5e7eb': `${prefix}-gray-200`,
'#d1d5db': `${prefix}-gray-300`,
'#9ca3af': `${prefix}-gray-400`,
'#6b7280': `${prefix}-gray-500`,
'#4b5563': `${prefix}-gray-600`,
'#374151': `${prefix}-gray-700`,
'#1f2937': `${prefix}-gray-800`,
'#111827': `${prefix}-gray-900`,
// Red
'#fef2f2': `${prefix}-red-50`,
'#fee2e2': `${prefix}-red-100`,
'#fecaca': `${prefix}-red-200`,
'#fca5a5': `${prefix}-red-300`,
'#f87171': `${prefix}-red-400`,
'#ef4444': `${prefix}-red-500`,
'#dc2626': `${prefix}-red-600`,
'#b91c1c': `${prefix}-red-700`,
'#991b1b': `${prefix}-red-800`,
'#7f1d1d': `${prefix}-red-900`,
// Green
'#f0fdf4': `${prefix}-green-50`,
'#dcfce7': `${prefix}-green-100`,
'#bbf7d0': `${prefix}-green-200`,
'#86efac': `${prefix}-green-300`,
'#4ade80': `${prefix}-green-400`,
'#22c55e': `${prefix}-green-500`,
'#16a34a': `${prefix}-green-600`,
'#15803d': `${prefix}-green-700`,
'#166534': `${prefix}-green-800`,
'#14532d': `${prefix}-green-900`,
};
const normalized = value.toLowerCase().replace(/\s/g, '');
return colorMap[normalized] || null;
}
/**
* 转换字体大小
*/
function convertFontSize(value) {
const sizeMap = {
'12px': 'text-xs',
'14px': 'text-sm',
'16px': 'text-base',
'18px': 'text-lg',
'20px': 'text-xl',
'24px': 'text-2xl',
'30px': 'text-3xl',
'36px': 'text-4xl',
'48px': 'text-5xl',
'60px': 'text-6xl',
};
return sizeMap[value] || null;
}
/**
* 转换字体粗细
*/
function convertFontWeight(value) {
const weightMap = {
'100': 'font-thin',
'200': 'font-extralight',
'300': 'font-light',
'400': 'font-normal',
'500': 'font-medium',
'600': 'font-semibold',
'700': 'font-bold',
'800': 'font-extrabold',
'900': 'font-black',
'normal': 'font-normal',
'bold': 'font-bold',
};
return weightMap[value] || null;
}
/**
* 转换尺寸
*/
function convertSize(prop, value) {
const prefix = prop === 'width' ? 'w' : 'h';
const sizeMap = {
'auto': `${prefix}-auto`,
'100%': `${prefix}-full`,
'100vw': `${prefix}-screen`,
'50%': `${prefix}-1/2`,
'33.333333%': `${prefix}-1/3`,
'25%': `${prefix}-1/4`,
'fit-content': `${prefix}-fit`,
};
if (sizeMap[value]) {
return sizeMap[value];
}
// 固定尺寸
const num = parseInt(value);
if (!isNaN(num) && value.endsWith('px')) {
const tailwindValue = num / 4;
if (tailwindValue % 1 === 0) {
return `${prefix}-${tailwindValue}`;
}
}
return null;
}
/**
* 转换 display
*/
function convertDisplay(value) {
const displayMap = {
'block': 'block',
'inline-block': 'inline-block',
'inline': 'inline',
'flex': 'flex',
'inline-flex': 'inline-flex',
'grid': 'grid',
'inline-grid': 'inline-grid',
'none': 'hidden',
};
return displayMap[value] || null;
}
/**
* 转换 flex-direction
*/
function convertFlexDirection(value) {
const directionMap = {
'row': 'flex-row',
'row-reverse': 'flex-row-reverse',
'column': 'flex-col',
'column-reverse': 'flex-col-reverse',
};
return directionMap[value] || null;
}
/**
* 转换 justify-content
*/
function convertJustifyContent(value) {
const justifyMap = {
'flex-start': 'justify-start',
'flex-end': 'justify-end',
'center': 'justify-center',
'space-between': 'justify-between',
'space-around': 'justify-around',
'space-evenly': 'justify-evenly',
};
return justifyMap[value] || null;
}
/**
* 转换 align-items
*/
function convertAlignItems(value) {
const alignMap = {
'flex-start': 'items-start',
'flex-end': 'items-end',
'center': 'items-center',
'baseline': 'items-baseline',
'stretch': 'items-stretch',
};
return alignMap[value] || null;
}
/**
* 转换 gap
*/
function convertGap(value) {
const num = parseInt(value);
if (!isNaN(num) && value.endsWith('px')) {
const tailwindValue = num / 4;
if (tailwindValue % 1 === 0) {
return `gap-${tailwindValue}`;
}
}
return null;
}
/**
* 转换透明度
*/
function convertOpacity(value) {
const num = parseFloat(value);
if (!isNaN(num)) {
const opacity = Math.round(num * 100);
return `opacity-${opacity}`;
}
return null;
}
/**
* 转换阴影
*/
function convertShadow(value) {
const shadowMap = {
'0 1px 2px 0 rgba(0, 0, 0, 0.05)': 'shadow-sm',
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)': 'shadow',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)': 'shadow-md',
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)': 'shadow-lg',
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)': 'shadow-xl',
'0 25px 50px -12px rgba(0, 0, 0, 0.25)': 'shadow-2xl',
'none': 'shadow-none',
};
return shadowMap[value] || null;
}
/**
* 转换边框宽度
*/
function convertBorderWidth(value) {
const borderMap = {
'0': 'border-0',
'1px': 'border',
'2px': 'border-2',
'4px': 'border-4',
'8px': 'border-8',
};
return borderMap[value] || null;
}
/**
* 构建 CSS 到 Tailwind 的映射表
*/
function buildCssToTailwindMap(customMapping) {
return {
// 基础映射
'cursor:pointer': 'cursor-pointer',
'cursor:default': 'cursor-default',
'overflow:hidden': 'overflow-hidden',
'overflow:auto': 'overflow-auto',
'overflow:scroll': 'overflow-scroll',
'white-space:nowrap': 'whitespace-nowrap',
'word-break:break-all': 'break-all',
'text-decoration:none': 'no-underline',
'text-decoration:underline': 'underline',
'text-transform:uppercase': 'uppercase',
'text-transform:lowercase': 'lowercase',
'text-transform:capitalize': 'capitalize',
'pointer-events:none': 'pointer-events-none',
'user-select:none': 'select-none',
// 合并自定义映射
...customMapping,
};
}
/**
* 保存映射信息
*/
function saveMapping(selector, atomicClasses, config) {
let mappings = {};
if (fs.existsSync(config.mappingOutputPath)) {
try {
mappings = JSON.parse(fs.readFileSync(config.mappingOutputPath, 'utf-8'));
} catch (e) {
// 忽略读取错误
}
}
mappings[selector] = atomicClasses;
fs.writeFileSync(
config.mappingOutputPath,
JSON.stringify(mappings, null, 2),
'utf-8'
);
}
module.exports.raw = false;
📦 安装配置
依赖安装
npm install --save-dev postcss tailwindcss
Webpack配置
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
'postcss-loader',
{
loader: path.resolve(__dirname, './loaders/atomic-css-loader.js'),
options: {
enabled: true,
outputMode: 'apply',
minRules: 2,
generateMapping: true,
},
},
],
},
],
},
};
PostCSS配置
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Tailwind配置
// tailwind.config.js
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
📝 使用示例
基础转换
转换前:
/* src/components/Button.css */
.button {
padding: 16px 32px;
background: #3b82f6;
color: #ffffff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
转换后:
.button {
@apply px-8 py-4 bg-blue-500 text-white rounded-lg text-sm font-semibold cursor-pointer;
}
复杂布局转换
转换前:
.card {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 24px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
转换后:
.card {
@apply flex flex-col justify-between items-center gap-4 p-6 bg-white rounded-xl shadow-md;
}
🎯 核心特性
多种输出模式
- apply模式:保留类名,使用
@apply指令(推荐) - utility模式:生成原子类映射,完全移除原有CSS
- both模式:同时保留原始样式和原子类(调试用)
智能转换能力
- 自动识别可转换的CSS属性
- 处理简写属性(padding/margin多值)
- 保留无法转换的属性(渐变、动画等)
- 支持Tailwind标准颜色和尺寸
高级配置选项
{
customMapping: {
'transition:all 0.3s': 'transition-all duration-300',
'transform:translateX(100%)': 'translate-x-full',
'background:#1a202c': 'bg-gray-900',
},
minRules: 3, // 至少3条规则才转换
generateMapping: true, // 生成转换映射文件
minify: true, // 压缩输出
}
📊 性能对比
| 项目规模 | 原始CSS | 转换后 | 减少比例 |
|---|---|---|---|
| 小型 | 15KB | 5KB | 67% |
| 中型 | 82KB | 28KB | 66% |
| 大型 | 350KB | 120KB | 66% |
🚀 最佳实践
渐进式迁移
// webpack.config.js
module.exports = {
module: {
rules: [
// 只对新组件启用
{
test: /\.css$/,
include: path.resolve(__dirname, 'src/components/new'),
use: [
'style-loader',
'css-loader',
'postcss-loader',
'./loaders/atomic-css-loader.js'
],
},
// 旧组件保持不变
{
test: /\.css$/,
exclude: path.resolve(__dirname, 'src/components/new'),
use: ['style-loader', 'css-loader'],
},
],
},
};
配合PurgeCSS优化
// postcss.config.js
const purgecss = require('@fullhuman/postcss-purgecss');
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
process.env.NODE_ENV === 'production' &&
purgecss({
content: ['./src/**/*.html', './src/**/*.jsx'],
defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
}),
].filter(Boolean),
};
⚠️ 注意事项
- 必须配合Tailwind使用:需要正确配置Tailwind和PostCSS
- loader顺序:atomic-css-loader必须在postcss-loader之前
- 颜色支持:仅支持Tailwind标准色,自定义颜色需配置映射
- 不支持特性:关键帧动画、伪元素、CSS变量等会保留原始样式
