Published on

自定义loader:传统CSS转原子化工具 atomic-css-loader

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转换后减少比例
小型15KB5KB67%
中型82KB28KB66%
大型350KB120KB66%

🚀 最佳实践

渐进式迁移

// 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),
};

⚠️ 注意事项

  1. 必须配合Tailwind使用:需要正确配置Tailwind和PostCSS
  2. loader顺序:atomic-css-loader必须在postcss-loader之前
  3. 颜色支持:仅支持Tailwind标准色,自定义颜色需配置映射
  4. 不支持特性:关键帧动画、伪元素、CSS变量等会保留原始样式