背景

  • 页面中存在大量重复冗余的css样式
  • 组件中难以维护、优化

实现 Tailwind CSS 的任意值语法

webpack 自定义插件

// scripts/webpackPlugins/atomicCss/index.js
const chalk = require('chalk')
/**
 * Atomic CSS Webpack 插件
 * 用于支持类似 Tailwind CSS 的任意值语法
 */
class AtomicCSSPlugin {
  constructor(options = {}) {
    this.options = {
      test: /\.(js|jsx|ts|tsx)$/,
      exclude: /node_modules/,
      include: /src/,
      ...options,
    }
  }

  apply(compiler) {
    const { test, exclude } = this.options

    // 获取 webpack 配置中的 module.rules
    const { rules } = compiler.options.module
    // 添加自定义 loader
    rules.push({
      test,
      exclude,
      use: [
        {
          loader: require.resolve('./loader'),
        },
      ],
    })

    // 注册插件名称
    compiler.hooks.done.tap('AtomicCSSPlugin', (stats) => {
      if (stats.hasErrors()) {
        return
      }
      console.log(`${chalk.green('[AtomicCSSPlugin]')}: 任意值语法处理完成`)
    })
  }
}

module.exports = AtomicCSSPlugin

//scripts/webpackPlugins/atomicCss/loader.js
const fs = require('fs')
const path = require('path')
const { parseContent } = require('./parser')

/**
 * 自定义 loader,用于解析和转换带有任意值语法的类名
 * @param {string} source 源代码
 * @returns {string} 转换后的代码
 */
module.exports = function (source) {
  // 解析源代码中的任意值语法
  const { cssRules, transformedContent } = parseContent(source)

  if (cssRules.length === 0) {
    return source
  }

  const cssContent = cssRules.join('\n')

  const styleId = `atomic-styles-${Math.random().toString(36).substring(2, 9)}`

  if (process.env.TARO_ENV === 'h5') {
    const styleTag = `
      const style = document.createElement('style');
      style.id = '${styleId}';
      style.textContent = \`${cssContent}\`;

      const existingStyle = document.getElementById('${styleId}');
      if (existingStyle) {
        existingStyle.parentNode.removeChild(existingStyle);
      }

      if (document.head) {
        document.head.appendChild(style);
      }
    `
    return `${styleTag}\n${transformedContent}`
  }
  // 其他环境将任意值css保存到本地css文件中
  const atomicCssPath = path.resolve(__dirname, '../../../packages/atomic/index.css')
  const atomicCssDir = path.dirname(atomicCssPath)
  if (!fs.existsSync(atomicCssDir)) {
    fs.mkdirSync(atomicCssDir, { recursive: true })
  }
  fs.writeFileSync(
    atomicCssPath,
    `${cssContent}`
  )

  return transformedContent
}

//scripts/webpackPlugins/atomicCss/parser.js
/**
 * 解析带有任意值语法的类名
 * 例如: p-[24px] -> .p-\[24px\] { padding: 24px; }
 */

// 匹配任意值语法的正则表达式
const ARBITRARY_VALUE_REGEX = /([a-zA-Z0-9_-]+)-\[(.*?)\]/g
// 属性映射表
const PROPERTY_MAP = {
  // 间距
  p: 'padding',
  pt: 'padding-top',
  pr: 'padding-right',
  pb: 'padding-bottom',
  pl: 'padding-left',
  px: ['padding-left', 'padding-right'],
  py: ['padding-top', 'padding-bottom'],

  m: 'margin',
  mt: 'margin-top',
  mr: 'margin-right',
  mb: 'margin-bottom',
  ml: 'margin-left',
  mx: ['margin-left', 'margin-right'],
  my: ['margin-top', 'margin-bottom'],

  // 尺寸
  w: 'width',
  h: 'height',
  'min-w': 'min-width',
  'min-h': 'min-height',
  'max-w': 'max-width',
  'max-h': 'max-height',
  size: ['width', 'height'],

  // 字体
  text: 'font-size',
  leading: 'line-height',
  tracking: 'letter-spacing',
  indent: 'text-indent',

  // 颜色
  bg: 'background-color',
  color: 'color',
  border: 'border-color',
  'bg-size': 'background-size',

  // 圆角
  rounded: 'border-radius',
  'rounded-t': ['border-top-left-radius', 'border-top-right-radius'],
  'rounded-r': ['border-top-right-radius', 'border-bottom-right-radius'],
  'rounded-b': ['border-bottom-left-radius', 'border-bottom-right-radius'],
  'rounded-l': ['border-top-left-radius', 'border-bottom-left-radius'],

  // 其他常用属性
  opacity: 'opacity',
  z: 'z-index',
  top: 'top',
  right: 'right',
  bottom: 'bottom',
  left: 'left',
  gap: 'gap',
}

/**
 * 生成 CSS 规则
 * @param {string} selector CSS 选择器
 * @param {string|string[]} properties CSS 属性
 * @param {string} value CSS 值
 * @returns {string} CSS 规则
 */
function generateCSSRule(selector, properties, value) {
  // 解析值,如果包含下划线则分割为数组
  const values = value.includes('_') ? value.split('_') : [value]
  // 处理属性数组的情况
  if (Array.isArray(properties)) {
    // 创建CSS声明
    let declarations
    // 如果值数组长度大于1且与属性数组长度相等,则一一对应
    if (values.length > 1 && properties.length === values.length) {
      declarations = properties.map((prop, index) => `${prop}: ${values[index]};`).join(' ')
    } else {
      // 否则所有属性使用相同的值(第一个值)
      declarations = properties.map((prop) => `${prop}: ${values[0]};`).join(' ')
    }
    return `.${selector} { ${declarations} }`
  }
  // 处理单个属性的情况
  const cssValue = values.length > 1 ? values.join(' ') : value
  return `.${selector} { ${properties}: ${cssValue}; }`
}

/**
 * 将带方括号的类名转换为下划线格式
 * 例如: p-[24px] -> p-_24px_
 * @param {string} className 原始类名
 * @returns {string} 转换后的类名
 */
function transformClassName(className) {
  return className.replace(/\[(.*?)\]/g, (match, p1) => {
    // 定义特殊字符映射表
    const specialCharsMap = {
      '#': 'hex_',
      '(': '_lp_',
      ')': '_rp_',
      ',': '_c_',
      '.': '_d_',
      '%': '_pct_',
    }
    // 替换颜色值中的 # 为 hex-
    let value = p1
    // 替换所有特殊字符
    Object.entries(specialCharsMap).forEach(([char, replacement]) => {
      if (value.includes(char)) {
        value = value.replace(new RegExp(`\\${char}`, 'g'), replacement)
      }
    })
    // 替换空格
    value = value.replace(/\s+/g, '_s_')
    return `_${value}_`
  })
}

/**
 * 解析包含任意值语法的 HTML 内容
 * @param {string} content HTML 内容
 * @returns {Object} 解析结果,包含提取的类和生成的 CSS
 */
function parseContent(content) {
  const matches = []
  const cssRules = []
  const transformedClasses = {}
  let match

  // 直接匹配所有的任意值语法
  ARBITRARY_VALUE_REGEX.lastIndex = 0
  while ((match = ARBITRARY_VALUE_REGEX.exec(content)) !== null) {
    const [fullMatch, property, value] = match
    if (PROPERTY_MAP[property]) {
      const transformedClassName = transformClassName(fullMatch)
      transformedClasses[fullMatch] = transformedClassName

      const cssRule = generateCSSRule(
        transformedClassName,
        PROPERTY_MAP[property],
        value
      )

      if (!cssRules.includes(cssRule)) {
        cssRules.push(cssRule)
      }
      matches.push({
        original: fullMatch,
        transformed: transformedClassName,
      })
    }
  }

  let transformedContent = content
  Object.entries(transformedClasses).forEach(([original, transformed]) => {
    // 使用正则表达式确保只替换类名而不是其他内容
    transformedContent = transformedContent.replaceAll(original, transformed)
  })

  return {
    matches,
    cssRules: [...new Set(cssRules)],
    transformedContent,
  }
}

module.exports = {
  parseContent,
  ARBITRARY_VALUE_REGEX,
  PROPERTY_MAP,
  transformClassName,
}

注册webpack插件

//config/index.ts
mini:{
    webpackChain(chain) {
      // 添加 AtomicCSS Webpack 插件
      chain.plugin('AtomicCSSPlugin').use(AtomicCSSPlugin, [{
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        include: /src/,
      }])
    },
}

注意点

  • 小程序中class会过滤掉一些特殊字符变成空格([ =>' ')
  • 小程序中不支持动态创建css文件所以要将动态生成css写入到产物的common.wxss
  • 注意css权重问题 原子式css权重很低容易被覆盖

页面使用

      <View className="fixed top-[0] left-[0] p-[24px] m-[16px] w-[200px] bg-[#ff0000]">
        内容
      </View>

照片