工程化思维处理方案:如何实现应用主题切换功能?
前几讲内容,我们主要围绕 JavaScript 和项目相关工程化方案展开。实际上,在前端基础建设中,样式方案的处理也必不可少。这一讲,就让我们设计一个工程化主题切换功能,并梳理现代前端样式的解决方案。
设计一个主题切换工程架构
随着 iOS 13 引入 Dark Mode(深色模式),各大应用和网站也都开始支持深色模式。相比传统的页面配色方案,深色模式具有较好的降噪性,也能让用户的眼睛看内容更舒适。
那么对于前端来说,如何高效地支持深色模式呢?这里的高效就是指工程化、自动化方案,不需要开发中 hard coding。
在介绍具体方案前,我们先来了解一个必备概念和工程化神器——PostCSS。
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 编写的,非常有利于开发者扩展。基于前几节介绍的 Babel 思想,对比 JavaScript 的编译器,我们不难猜出 PostCSS 的工作原理:PostCSS 接收一个 CSS 文件,并提供了插件机制,提供给开发者分析、修改 CSS 的规则,具体实现方式也是基于 AST 技术。
而我们这一讲介绍的工程化主题切换架构,也离不开 PostCSS 的基础能力。我们马上来设计一个相关结构能力。
架构思路总结
主题切换——社区上介绍的方案往往通过 CSS 变量(CSS 自定义属性)来实现,关于 CSS 变量的介绍,相对基础,这里我们只贴出知识链接:CSS 自定义属性。
这无疑是一个很好的思路,但是作为架构来说,使用 CSS 自定义属性——只是其中一个环节。站在更高、更中台化的视觉思考,我们还需要设计:
如何维护不同主题色值;
谁来维护不同主题色值;
研发和设计之间,如何保持不同主题色值的同步沟通;
如何最小化前端工程师的开发量,不需要 hard coding 两份颜色数值;
如何做到一键切换时的性能最优;
如何配合 JavaScript 状态管理,同步主题切换的信号。
基于以上考虑,以一个超链接样式为例,我们希望做到在开发时,编写:
a {
color: cc(GBK05A);
}
这样的代码,就能一劳永逸——直接支持两套(light/dark)主题模式。也就是说,在应用编译时,上述代码预期被编译为下面这样的代码:
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 插件,实现上述编译过程。
维护一个色值,结合上例(这里以 YML 格式为例)就是:
GBK05A: [BK05, BK06]
BK05: '#808080'
BK06: '#999999'
postcss-theme-colors 需要:
识别
cc()
方法;读取色值;
通过色值,对
cc()
方法求值,得到两种颜色,分别对应 dark 和 light 模式;原地编译 CSS 中的颜色为 light 模式色值;
同时 dark 模式色值写到 HTML 节点上。
这里需要补充的是,为了将 dark 模式色值按照html[data-theme='dark']
方式写到 HTML 节点上,我们使用了另外两个 PostCSS 插件完成:
整体架构设计,总结为下图:
PostCSS 架构转换设计
主题色切换架构实现
有了架构,这部分我们就来实现架构环节中的重点环节。首先,我们需要了解 PostCSS 插件体系。
PostCSS 插件体系
PostCSS 具有天生的插件化体系,开发者一般很容易上手插件开发:
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
(源码链接定义在postcss.plugin中 )工厂方法返回一个插件实体,形如:
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
上述内容,在 PostCSS 插件设计中,我们看到了清晰的 AST 设计痕迹,经过之前小节的学习,我们应该对于 AST 不再陌生。根据插件代码骨架,我们加入具体实现逻辑,代码如下:
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)
})
}
})
上面代码我加入了相关注释,整体逻辑并不难理解。理解了这部分源码,使用方式也就呼之欲出了:
const colors = {
C01: '#eee',
C02: '#111',
}
const groups = {
G01: ['C01', 'C02'],
}
postcss([
require('postcss-theme-colors')({colors, groups}),
]).process(css)
通过上述操作,我们实现了 postcss-theme-colors 插件,整体架构也就完成了大半。接下来,我们将继续完善,最终打造出一个更符合基础建设要求的方案。
架构平台化——色组 & 色值平台设计
上面的使用示例中,我们采用了 hard coding 的方式,如下代码:
const colors = {
C01: '#eee',
C02: '#111',
}
const groups = {
G01: ['C01', 'C02'],
}
声明了colors
和groups
两个常量,并传递给 postcss-theme-colors 插件。其中groups
变量声明了色组的概念,比如 group1 命名为 G01,它对应了 C01(日间色),C02(夜间色)两个色值。这样的好处显而易见。
我们将 postcss-theme-colors 插件和色值声明解藕, postcss-theme-colors 插件并不关系颜色,而是接受
colors
和groups
变量。色值和色组解耦:
colors
维护具体色值;groups
维护具体色组。
这样一来,如前文一个超链接样式声明:
a {
color: cc(GBK05A);
}
如上代码,我们在业务开发中,直接声明了“使用 GBK05A 这个色组”。业务开发者不需要关心这个色组在 light/dark 模式下分别对应哪些色值。而设计团队可以专门维护色组和色值,最终只提供给开发者色组即可。
在此基础上,我们完全可以抽象出一个色组 & 色值平台,方便设计团队更新内容。这个平台可以以 JSON 或者 YML 任何形式存储色值色组对应关系,方便各个团队协作。
在前文提到的主题切换设计架构图的基础上,我们扩充其为平台化的解决方案:
总结
这一讲我们没有聚焦具体 CSS 样式的用法,而是从更高的角度,梳理了现代化前端基础建设当中的样式相关工程方案。并从“主题切换”这一话题,联动了 PostCSS、Webpack,甚至前端状态管理流程。
这里,我想给大家留几个思考问题,postcss-custom-properties 有什么作用,它又是如何实现的?基于 CSS 变量新特性,我们可以如何优化本讲中的 postcss-theme-colors 插件?(答案已经在源代码中了。)
# 精选评论
# **宇
楼下两位对 开发 编译 部署 runtime的理解还不够熟悉吧,不要把runtime做的事情和编译混为一谈。用户选择颜色,再根据算法推导出配色然后更新自己的主题这种需求,完全是runtime层面的,然后让后端存一下就可以了。如果去更新配色平台的group,再次打包有什么意义呢,你源码里已经hard code了,难不成用户操作还要修改源码,用户操作该影响的永远是内存或者数据库。这种场景搞不清的我自创了一个原则:只和自己相关就runtime,和所有人相关才可以编译
# **林
老师好,我发现darkRule.append(darkDecl),">decl.after(darkRule)这两个语句一起调用,会出现Maximum call stack size exceeded,可否贴一下最后生成的样式是怎样的?
# 讲师回复
需要有终止判断条件,你可以写 demo
# **宇
用户选择颜色发生在运行时,静态编译解决不了,运行时就用状态去维护就好了
# **0058
算法推导的情况 我觉得可以利用node平台,根据选择的色值,更新groups,再次打包,替换原本的style样式
# **伦
这种方案应该是事先已经确认好主题色的值,所以才能在打包的时候处理,如果像那种由用户直接在颜色面板选择,再利用算法推导出整体配色的需求,就不太适合了。
# 潘
不错
# **林
😁
# **用户8230
get