0%

工程化思维处理方案:如何实现应用主题切换功能

在前端基础建设中,样式方案的处理也必不可少

设计一个主题切换工程架构

随着 iOS 13 引入 Dark Mode(深色模式),各大应用和网站也都开始支持深色模式。相比传统的页面配色方案,深色模式具有较好的降噪性,也能让用户的眼睛看内容更舒适

PostCSS 原理和相关插件能力

简单来说, PostCSS 是一款编译 CSS 的工具。

PostCSS is a tool for transforming styles with JS plugins. These plugins can lint your CSS, support variables and mixins, transpile future CSS syntax, inline images, and more.

如上介绍,postCSS具有良好的插件性,其插件也是使用 javascript 编写的,非常有利于开发者拓展。PostCSS的工作原理:PostCSS接收一个CSS文件,并提供了插件机制,提供给开发者分析、修改CSS的规则,具体实现方式也是基于AST技术

架构思路总结

主题切换——社区上介绍的方案往往通过 CSS 变量(CSS 自定义属性)来实现

这无疑是一个很好的思路,但是作为架构来说,使用 CSS 自定义属性——只是其中一个环节。站在更高、更中台化的视觉思考,我们还需要设计:

  • 如何维护不同主题色值
  • 谁来维护不同颜色值
  • 研发和设计之间,如何保持不同颜色值的同步沟通
  • 如何最小化前端工程师的开发量,不需要 hard coding 两份颜色数值
  • 如何做到一键切换时的性能最优
  • 如何配合javascript状态管理,同步主题切换的信号

基于以上考虑,以一个超链接样式为例,我们希望做到开发时,编写:

1
2
3
a {
color: cc(GBK05A);
}

这样的代码,就能一劳永逸直接支持两套主题模式。也就是说,在应用编译上,上述代码预期被编译为下面这样的代码:

1
2
3
4
5
6
7
a {
color: #646464;
}

html[data-theme='dark'] a {
color: #808080;
}

我们来看看在编译时,构建环节发生了什么:

  • cc(GBK05A) 这样的声明,被编译为#646464
  • 也就是说,cc 是一个CSS function,而 GBK05A是一组色值,分别包含了light和dark两种主题的颜色
  • 同时在HTML根节点上,添加属性选择器 data-theme='dark',并添加 a 标签color色值样式为 #808080

我们设想,用户点击“切换主题”按钮时,首先通过 Javascript 将 HTML 根节点标签添加 data-theme 为dark的属性值,这时CSS选择器html[data-theme=’dark’] a 将起作用,实现了样式的切换

结合下图理解:

描述

如何在构建时完成 CSS 的样式编译转换呢?答案指向了 PostCSS

  • 首先编写一个名为 postcss-theme-colors 的PostCSS插件
  • 维护一个色值,结合上例子就是:
1
2
3
GBK05A: [BK05, BK06]
BK05: '#808080'
BK06: '#999999'

postcss-theme-colors需要:

  1. 识别 cc() 方法
  2. 读取色值
  3. 通过色值,对 cc() 方法求值,得到两种颜色,分别对应 dark 和 light 模式
  4. 原地编译 CSS 中的颜色为 light 模式色值
  5. 同时 dark 模式色值写到 HTML 节点上

这里需要补充的是,为了将 dark 模式色值按照 html[data-theme=’dark’] 方式写到 HTML 节点上,需要使用另外两个PostCSS插件完成:

  • PostCSS Nested
  • PostCSS Nesting

整体架构设计,总结为下图:

描述

主题色架构实现

PostCSS 插件体系

PostCSS具有天生的插件化体系,开发者一般很容易上手插件开发

1
2
3
4
5
6
7
8
var postcss = require('postcss');
module.exports = postcss.plugin('pluginname', function (opts) {
opts = opts || {};
// Work with options here
return function (css, result) {
// Transform the CSS AST
};
})

上面代码是一个典型的PostCSS插件编写模版。一个PostCSS就是一个Node.js模块,开发者调用 postcss.plugin 工厂方法返回一个插件实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
return {
postcssPlugin: 'PLUGIN_NAME',
/*
Root (root, postcss) {
// Transform CSS AST here
}
*/
/*
Declaration (decl, postcss) {
// The faster way to find Declaration node
}
*/
/*
Declaration: {
color: (decl, postcss) {
// The fastest way find Declaration node if you know property name
}
}
*/
}
}

在编写 PostCSS 插件时,我们可以直接使用postcss.plugin方法完成实际开发。接下来,开始动手实现 postcss-theme-colors。

动手实现postcss-theme-colors

具体实现逻辑,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const postcss = require('postcss')
const defaults = {
function: 'cc',
groups: {},
colors: {},
useCustomProperties: false,
darkThemeSelector: 'html[data-theme="dark"]',
nestingPlugin: null,
}
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 = postcss.plugin('postcss-theme-colors', options => {
options = Object.assign({}, defaults, options)
// 获取色值函数(默认为 cc())
const reGroup = new RegExp(`\\b${options.function}\\(([^)]+)\\)`, 'g')
return (style, 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 声明
style.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)
})
}
})

理解了这部分源码,使用方式也就呼之欲出了:

1
2
3
4
5
6
7
8
9
10
const colors = {
C01: '#eee',
C02: '#111',
}
const groups = {
G01: ['C01', 'C02'],
}
postcss([
require('postcss-theme-colors')({colors, groups}),
]).process(css)