1 多套 CSS 样式动态切换
原理:实现多套 CSS 样式(比如 ligth.css
和 dark.css
),根据用户切换操作,通过动态修改 link 标签的 href 来加载不同的模式的样式,主要解决了多个模式被编译到一个文件中导致单个文件过大的问题。
优缺点:
实现示例:
// 动态切换 link 样式表的源
function setTheme(theme: 'light' | 'dark' = 'light') {
const linkId = '#theme-link';
let link = document.querySelector(linkId);
const href = `/theme/${theme}.css`;
if (link) {
link.href = href;
} else {
const head = document.querySelector('head');
link = document.creatElement('link');
link.id = linkId;
link.rel = 'stylesheet';
link.href = href;
head.appendChild(link);
}
}
2 CSS 变量
原理:通过 document.documentElement.setAttribute 动态修改 html 上的 CSS 变量(颜色,字体,宽高等),使得页面上的其他部分可以根据 CSS 变量应用最新的 CSS 变量对应的样式。
优缺点:优点是简单,缺点是存在兼容性(IE不支持),可通过 css-vars-ponyfill 对 CSS 变量进行兼容处理,只需要导入该 ponyfill 即可。
实现示例:
/* 方式 1. 在 theme.css 中定义好 css 变量, 然后在 themeUtil.ts 中编写处理模式切换的工具函数 */
// theme.css
:root {
--bg: #fff;
--color: rgb(51, 50, 50);
--img-bg: #ffffff;
--border-color: #d6d6d6;
}
:root {
--bg: rgb(49, 51, 51);;
--color: #ffffff;
--border-color: #ffffff;
}
// themeUtil.ts
export default setTheme = (isLight = true) => {
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
}
// index.css
.header {
...省略
color: var(--color);
border-bottom: 1px solid var(--border-color);
background-color: var(--bg);
}
/* 方式 2. 考虑 CSS 变量兼容性处理,即利用 css-var-ponyfill 来处理在 theme.ts 中定义好的 css 变量集合 */
// theme.ts
// 字体变量
const baseSize = {
"--font-size-large-x": "22px",
"--font-size-large": "18px",
"--font-size-medium": "14px",
"--font-size-medium-x": "16px",
"--font-size-small-s": "10px",
"--font-size-small": "12px"
};
// 浅色
export const lightTheme = {
"--fill-1": "#fff",
"--text": "#3c3c3c",
"--text-1": "#757575",
"--text-2": "#222",
...baseSize
};
// 深色
export const darkTheme = {
"--fill-1": "#222",
"--text": "#fff",
"--text-1": "rgba(255, 255, 255, 0.3)",
"--text-2": "#ffcd32", ...baseSize
};
// themeUtil.ts
import { lightTheme, darkTheme } from './theme';
import cssVars from 'css-vars-ponyfill';
export const setTheme = (isLight = true) => {
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
cssVars({
watch: true, // 添加、删除、修改 <link> 或 <style> 元素的 disable 属性或 href 属性时,或更改 <style> 元素的 textContent,ponyfill 将自行调用(利用 MutationObserver 来监听 <link> 和 <style> 的变化)
variables: isLight ? lightTheme : darkTheme, // variables 自定义属性名/值对的集合
onlyLegacy: false // false 默认值,将 css 变量编译为浏览器识别的 css 样式;true 当浏览器不支持 css 变量不支持 css 变量的时候将 css 变量编译为识别的 css
});
}
// index.css
.header {
...省略
color: var(--color);
border-bottom: 1px solid var(--border-color);
background-color: var(--bg);
}
3 CSS 样式覆盖
在保留不变的样式,抽离变化的样式;给不同的皮肤/模式定义一个对应的 class;根据不同皮肤/模式切换成对应 class 来设置不同的样式。缺点明显:多种皮肤/模式样式都要引入,导致代码量增大;样式不易管理,查找复杂;开发效率低,拓展性差。不太推荐使用。
4 已有项目快速支持
对于已有项目,要支持换肤,若采用颜色变量的方式,需要手动将项目中所有颜色值手动替换为对应颜色变量,工作量巨大,有必要实现自动化替换。
因此,可以实现一个 Stylelint 插件,分析并识别样式文件中的颜色字面量,并给出提示,针对有对应的颜色变量的,支持自动替换。
比如,替换前:
..edmi-write-btn {
color: white;
background: #eee;
border: 1px solid rgb(0, 0, 0);
}
替换后:
.edmi-write-btn {
color: var(--color-white);
background: var(--color-tertiary-light-hover);
border: 1px solid var(--color-black);
}
原理:首先,使用 stylelint 解析识别 css/scss/stylus/less/Sass 等样式文件中的颜色字面量(包括 颜色关键字、Hex、rgb, rgba, hsl, hsla,hwb,gray 等函数)。 然后,使用 chorma-js 的 chroma.distance 计算识别出的颜色字面量和颜色变量对应的颜色值是否相同或相近来判断识别出的颜色字面量是否可以替换为某个颜色变量,并支持自动替换。
可以有以下两种形式进行使用:
1. 通过 StyleLint CLI 命令行工具一键查找和替换所有颜色变量;
/** 安装 stylelint 插件 */
npm i -D stylelint @edmi/stylelint-color-autofix
/** 项目根目录 .stylelintrc.json 配置 @edmi/stylelint-color-autofix 与 color-no-literal 规则(颜色不能是字面量)*/
{
"plugins": ["@edmi/stylelint-color-autofix"],
"rules": {
"edmi/color-no-literal": [true, { "fix": false }]
}
}
/** 检查 */
$ npx stylelint "**/*.scss"
/** 替换 */
$ npx stylelint "**/*.scss" --fix
2. 通过 Stylelint VSCode 编辑器插件,代码编写过程中提示颜色字面量需要用颜色变量来替代,可设置保存时自动替换;
如何使用:
// .stylelintrc
{
"plugins": ["@edmi/stylelint-color-autofix"],
"rules": {
"edmi/color-no-literal": [true, { "fix": false }]
}
}
// package.json
{
"scripts": {
"lint": "stylelint --fix \"**/*.{less,css,scss}\""
}
}
实现的 Stylelint 插件如下:
const stylelint = require('stylelint');
const chroma = require('chroma-js');
const valueParser = require('postcss-value-parser');
const { report, ruleMessages, validateOptions } = stylelint.utils;
const name = 'edmi/color-no-literal'; // 规则名称
// 规则提示消息
const messages = ruleMessages(ruleName, {
expected: (unfixed, fixed) => `Expected '${unfixed}' to be '${fixed}'`
});
// 元数据
const meta = {
url: 'https://github.com/R2h1/stylelint-color-no-literal/README.md'
}
const cssColorKeywords = [
'white',
'black',
'red',
....
];
// 判断是否为颜色字面量
const isColorLiteral = (value) => {
if (colorKeyWords.includes(value)) {
return true;
}
if (/^#[0-9a-fA-F]{3|6})$/.test(value)) {
return true;
}
if (/^rgba?|hsla?|rgb|hsl?)$/.text(value)) {
return true;
}
return false;
}
// 规则处理函数
function rule(primary, secondary, context) {
return (root, result) => {
const validOptions = validateOptions(result, ruleName,
{
actual: primary, // 验证主要选项
},
{
actual: secondary // 验证辅助选项
}
);
if (!validOptions) { // 选项无效
return;
}
const isAutoFixing = Boolean(context.fix) && !secondary.fix;
root.walkDecls((decl) => {
// decl 即 css 声明(例如 background-color: rgb(0, 0, 0))
// 对值进行解析
valueParser(decl.value).walk((node) => {
const { value, type } = node;
if (!isColorLiteral(value)) return;
/* 利用 chroma.js 找到相同或者相近的颜色,即 targetVal, */
if (isAutoFixing) { // 自动修复模式下
const newValue = decl.value.replace(decl.value, targetVal);
if (decl.raws.value) {
decl.raws.value.raw = newValue;
} else {
decl.value = newValue;
}
} else {
report({
name,
message: message.expected(`${decl.value}`, `${targetVal}`),
result,
node: decl, // 指定报告的节点
word: decl.value, // 哪个词导致了错误?这将正确定位错误
});
}
});
});
};
}
rule.ruleName = name;
rule.messages = messages;
rule.meta = meta;
module.exports = stylelint.createPlugin(name, rule);
5 总结
对于上面提到的实现方案,均不涉及持久化,可将对应的模式保存到本地缓存(localStorage)里,然后每次渲染读取缓存的模式值进行渲染,并且切换的时候修改缓存中的模式即可。