自定义PostCSS插件实现主题切换

665 阅读4分钟

对于主题切换这一话题,社区上介绍的方案往往通过CSS 变量(CSS 自定义属性)来实现,但其自动化程度以及可维护性都较差。

PostCSS 可以接收一个CSS 文件,并提供了插件机制,提供给开发者分析、修改CSS的规则,具体实现方式也是基于 AST 技术,利用这一特点,我们可以实现一套更为自动化的主题切换功能。

假设我们这样编写CSS(定义一个CSS方法cc,下文会解释其作用):

a {
   colorcc(G01);
}

假设:

  • 借助PostCSS的能力,根据不同的主题,最终输出不同的CSS样式,比如日间模式a标签的色值为#eee,而夜间模式色值为#111
  • 这里我们可以通过在HTML根节点加上属性选择器data-theme='dark'来动态改变当前页面主题是否为夜间主题。

所以需要做到的是,我们最终需要生成一份如下的CSS样式:

a {
   color#eee;
}
​
html[data-theme="dark"] a {
   color#111;
}

实现步骤大致如下:

  • 首先编写一个名为 postcss-theme-colorsPostCSS 插件,实现上述编译过程。
  • 维护一个色值,结合上例(这里以 JSON格式为例)就是:
{
 C01: '#eee',
 C02: '#111'
}

postcss-theme-colors 需要:

  • 识别cc()方法;
  • 读取色值;
  • 通过色值,对cc()方法求值,得到两种颜色,分别对应 dark 和 light 模式;
  • 原地编译 CSS 中的颜色为日间模式色值;
  • 同时将dark模式色值写到HTML 节点上。(通过PostCSS Nested或者PostCSS Nesting插件完成)

先简要介绍一下PostCSS的原理:

PostCSS自身只包括了CSS分析器CSS节点树APIsource map生成器CSS节点拼接器,而基于PostCSS的插件都是使用了CSS节点树API来实现的。

我们都知道CSS的组成如下:

element {
prop1 : rule1 rule2 ...;
prop2 : rule1 rule2 ...;
prop2 : rule1 rule2 ...;
...
}

也就是一条一条的样式规则组成,每一条样式规则包含一个或多个属性跟值。所以PostCSS的执行过程大致如下:

  1. Parser 利用CSS分析器读取CSS字符内容,得到一个完整的节点树
  2. Plugin 对上面拿到的节点树利用CSS节点树API进行一系列的转换操作
  3. Plugin 利用CSS节点拼接器将上面转换之后的节点树重新组成CSS字符
  4. Stringifier 在上面转换期间可利用source map生成器表明转换前后字符的对应关系

PostCss插件要做的就是拿到节点树上的CSS属性声明,通过转换拼接为新的CSS字符串;这里我们需要的功能的编写方式如下,可以参考 PostCSS 8 插件

module.exports = (opts = {}) => {
  return {
    postcssPlugin'postcss-dark-theme-class'// 插件名
    Once (root, { result }) { // root为根节点树,Once方法会在该节点下的所有子元素被处理之前调用
       root.walkDecls(decl=>{...}) // 遍历CSS声明
    }
  }
}
module.exports.postcss = true // 声明导出为postcss插件

post-theme-color实现如下:

const postcss = require('postcss')
​
const defaults = {
 function'cc'// 自定义CSS方法名
 groups: {}, // 存储色值分组
 colors: {}, // 存储所有色值
 useCustomPropertiesfalse,// 是否使用自定义属性
 darkThemeSelector'html[data-theme="dark"]'// 夜间模式选择器
 nestingPluginnull // 添加选择器的插件
}
​
/**
* 计算最终色值
* @param options
* @param theme
* @param group
* @param defaultValue
* @returns {string|*}
*/
const resolveColor = (options, theme, group, defaultValue) => {
 const [lightColor, darkColor] = options.groups[group] || []
 const color = theme === 'dark' ? darkColor : lightColor
 if (!color) {
   return defaultValue
}
 if (options.useCustomProperties) {
   return color.startsWith('--') ? `var(${color})` : `var(--${color})`
}
 return options.colors[color] || defaultValue
}
​
// 导出插件
module.exports = options => {
 options = Object.assign({}, defaults, options)
 // 获取色值函数(默认为 cc())
 const reGroup = new RegExp(`\b${options.function}\(([^)]+)\)`'g')
 return {
   postcssPlugin'postcss-theme-colors'// 定义插件名
   Once(root, { result }) {
     // 判断 PostCSS 工作流程中,是否使用了某些 plugins
     const hasPlugin = name =>
       name.replace(/^postcss-/'') === options.nestingPlugin ||
       result.processor.plugins.some(p => p.postcssPlugin === name)
     // 获取最终 CSS 值
     const getValue = (value, theme) => {
       return value.replace(reGroup, (match, group) => {
         return resolveColor(options, theme, group, match)
      })
    }
​
     // 遍历 CSS 声明
     root.walkDecls(decl => {
       const value = decl.value
       // 如果不含有色值函数调用,则提前退出
       if (!value || !reGroup.test(value)) {
         return
      }
       const lightValue = getValue(value, 'light')
       const darkValue = getValue(value, 'dark')
       const darkDecl = decl.clone({ value: darkValue })
       let darkRule
       // 使用插件,生成 dark 样式
       if (hasPlugin('postcss-nesting')) {
         darkRule = postcss.atRule({
           name'nest',
           params`${options.darkThemeSelector} &`,
        })
      } else if (hasPlugin('postcss-nested')) {
         darkRule = postcss.rule({
           selector`${options.darkThemeSelector} &`,
        })
      } else {
         decl.warn(result, `Plugin(postcss-nesting or postcss-nested) not found`)
      }
​
       // 添加 dark 样式到目标 HTML 节点中
       if (darkRule) {
         darkRule.append(darkDecl)
         decl.after(darkRule)
      }
       const lightDecl = decl.clone({ value: lightValue })
       decl.replaceWith(lightDecl)
    })
  }
}
}
module.exports.postcss = true

插件使用:

  • 定义CSS源文件source.css
a {
   colorcc(G01);
}
  • 定义色值:
const colors = {
 C01: '#eee',
 C02: '#111',
 C03: '#fff',
 C04: '#222',
}
  • 定义模式色值分组:
const groups = {
  G01: ['C01', 'C02'], 
  G02: ['C03', 'C04'],
}
  • 执行转换:
const css = fs.readFileSync('source.css')
postcss([
  require('./postcss-theme-colors')({ colors, groups }),
  require('postcss-nested')
]).process(css).then(res => {
  fs.writeFileSync('index.css', res.css)
})

执行完成后即可在index.css生成如下代码:

a {
    color: #eee;
}

html[data-theme="dark"] a {
    color: #111;
}

相关代码含义已在注释中详细注明。通过post-theme-colors插件,后续主题维护只需维护colorsgroups两个对象即可,可以通过JSON或者YML进行维护。

源代码请查看:github.com/smartzheng/…