如何定制化网站主题色

如何切换主题色

首先抛个问题,我们根据什么来辨别同类型的网站?

我觉得是网站的“主题色”,试想下把淘宝的橘红色换成京东的红色,是不是就和后者一样了。

可以说:主题色作为网站辨识度最高的设计元素之一。

实际工作场景中,一个 web 项目可能改个主题色及页面内容,就可以给不同业务方使用。对于如今的前端开发来说,即使有着成熟的 UI 组件框架,比如 Ant DesignElement UI 等,
但为了变出不同主题色系的网站风格,我们往往会创建新的主题样式来覆盖原有的 UI ,工作量仍然巨大,稍有疏忽就会有遗漏。

那有没有省时省力的方式来定制化 UI 组件色系呢?

肯定有。下面就举例 Ant Design Pro (一款基于 VueAnt Design 的中后台前端框架),看下它是基于什么实现的?

Ant Design Pro 到底有什么魔法?

我们通过 npm run serve 启动项目,进入主页后,在页面右侧的设置按钮来切换主题色,能看到页面颜色也跟着发生了改变:

试着找些明面上能看到的东西:

打开调试工具,重新刷新页面,看到浏览器发送了一个 /css/theme-colors-xxx.css 的请求,响应内容为一大段样式代码:

审查页面有关主题颜色的元素,发现利用了 样式覆盖 的特性让新颜色起了效果:

这些新的样式代码被定义在一个 style 标签内:

如此,将覆盖原先 UI 组件的颜色样式,从而将达到主题色切换的目的。不知所以然的你,肯定就会有如下问题:

  • css 样式代码根据什么规则生成的?
  • 请求 /css/theme-colors-xxx.css 哪里发起的?
  • 主题色的切换,页面怎么快速做样式更新的?

这些都围绕着 webpack-theme-color-replacer 展开,下面就进入代码来一探究竟。

主题样式代码生成规则?

翻阅 Ant Design Prowebpack 配置,发现有个和主题颜色配置相关的 plugins 逻辑:

1
2
3
4
5
// vue.config.js

const createThemeColorReplacerPlugin = require("./config/plugin.config");
//...
vueConfig.configureWebpack.plugins.push(createThemeColorReplacerPlugin());

createThemeColorReplacerPlugin 返回的就是 webpack-theme-color-replacer 插件对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
const ThemeColorReplacer = require("webpack-theme-color-replacer");

//...
const themePluginOption = {
fileName: "css/theme-colors-[contenthash:8].css",
matchColors: getAntdSerials("#1890ff"), // 主色系列
changeSelector(selector) {},
};

const createThemeColorReplacerPlugin = () =>
new ThemeColorReplacer(themePluginOption);

module.exports = createThemeColorReplacerPlugin;

注意到:themePluginOption 中的 fileName 和主题样式请求地址一样(和问题二有关);另外 matchColors 默认通过 getAntdSerials#1890ff 转化为一组蓝色系的主题色:

1
2
3
4
5
6
[
"#1890ff", "#2f9bff", "#46a6ff", "#5db1ff", "#74bcff", "#8cc8ff", "#a3d3ff", "#badeff", "#d1e9ff",
"#e6f7ff", "#bae7ff", "#91d5ff", "#69c0ff", "#40a9ff",
"#1890ff", "#096dd9", "#0050b3", "#003a8c", "#002766",
"24,144,255"
]

webpack plugins 运行时,则调用 ThemeColorReplacer.apply 方法,并触发 Handler.handler 方法:

1
2
3
4
5
6
7
8
9
class ThemeColorReplacer {
constructor(options) {
this.handler = new Handler(options);
}
apply(compiler) {
//...
this.handler.handle(compilation);
}
}

Handler 中,会先初始化一个文件提取器 AssetsExtractor ,通过内部 extractAssets 方法来提取主题样式代码到 output,最后调用 addToEntryJs 方法,将提取结果加到每个入口文件里:

1
2
3
4
5
6
7
8
9
10
11
class Handler {
constructor(options) {
//...
this.assetsExtractor = new AssetsExtractor(this.options);
}
handle(compilation) {
var output = this.assetsExtractor.extractAssets(compilation.assets);
//...
this.addToEntryJs(outputName, compilation, output);
}
}

AssetsExtractor 内部逻辑过于复杂,代码流程如下:

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
function AssetsExtractor(options) {
this.extractor = new Extractor(options);

this.extractAssets = function (assets) {
// 1. 遍历 assets 文件对象
var srcArray = this.extractToArray(assets);
var output = dropDuplicate(srcArray).join("\n");
//...
return output;
};

this.extractToArray = function (assets) {
//...
var cssSrcs = [];
Object.keys(assets).map((fn) => {
var asset = assets[fn];
cssSrcs = cssSrcs.concat(that.extractAsset(fn, asset));
});
return cssSrcs;
};

this.extractAsset = function (fn, asset) {
//...
var cssSrcs = [];
// 2. 解析 js module 中符合 cssReg 的 css 代码
var CssCodeReg = Css_Loader_Reg_UGLY;
src.replace(CssCodeReg, (match, $1) => {
cssSrcs = cssSrcs.concat(this.extractor.extractColors($1));
});
return cssSrcs;
};
}
function Extractor(options) {
// 3. 通过指针逐个遍历符合条件的内容,重新组装层复合 css 语法的代码,并提取到 ret 数组中
this.extractColors = function (src) {
src = src.replace(Reg_Lf_Rem, "");
var ret = [];
var nameStart,
nameEnd,
cssEnd = -1;
while (true) {
//...
ret.push(selector + "{" + rules.join(";") + "}");
}
return ret;
};
}

遍历 compilation.assets 下每个资源内容,通过正则 CssCodeReg 将符合 css 代码的内容抓取出来。这些代码就是图中【绿色】中的内容:

再通过 extractColors 方法逐行解析,得到一个有关主题色的 css 数组:

最后将结果输出到 theme-colors-xxx.css 中。

这里就回答其中一个问题:

css 样式代码根据什么规则生成的?

不能遗漏的是:添加样式代码到入口文件时,相关 themePluginOption 配置将被赋值到 window.__theme_COLOR_cfg 作为入口文件的一部分代码,供客户端使用:

1
2
3
4
5
6
getEntryJs(outputName, assetSource, cssCode) {
var config = {url: outputName, colors: this.options.matchColors}
var configJs = '\n(typeof window==\'undefined\'?global:window).__theme_COLOR_cfg=' + JSON.stringify(config) + ';\n'
//...
return new ConcatSource(assetSource, configJs)
}

主题样式请求怎么发起?

我们以页面的主题设置为入口,看下 SettingDrawer 组件内部的功能。

首先该组件提供了这些配置选项:

在该项目中,会发现有 config\themePluginConfig.js 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// config\themePluginConfig.js
export default {
theme: [
{
key: "#F5222D",
fileName: "#F5222D.css",
modifyVars: {
"@primary-color": "#F5222D",
},
},
{
key: "#FA541C",
fileName: "#FA541C.css",
modifyVars: {
"@primary-color": "#FA541C",
},
},
],
};

如果你熟悉 Ant Design Pro,那么在 动态主题 中也能看到一样的配置,其通过 umi-plugin-antd-theme 进行设置。

这些配置在程序启动时,就挂载至 window.umi_plugin_ant_themeVar 下:

1
2
// main.js
window.umi_plugin_ant_themeVar = themePluginConfig.theme;

作为页面主题的调色板元数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var getThemeList = function getThemeList(i18nRender) {
var list = window.umi_plugin_ant_themeVar || [];
//...
list.forEach(function (item) {
var color = (item.modifyVars || {})["@primary-color"];

//...
lightColorList.push(
_objectSpread(
{
color: color,
},
item
)
);
});
return {
colorList: {
dark: darkColorList,
light: lightColorList,
},
themeList: themeList,
};
};

当主题颜色修改时,则会触发更新主题方法 updateTheme()

1
2
3
4
5
6
function handleChangeSetting(key, value, hideMessageLoading) {
if (key === "primaryColor") {
// 更新主色调
updateTheme(value);
}
}

updateTheme 会调用 themeColor.changeColor 方法,生成新的主题色系 newColors ,再交给 webpack-theme-color-replacer/client 处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
export var themeColor = {
//...
changeColor: function changeColor(newColor) {
var options = {
newColors: this.getAntdSerials(newColor),
// new colors array, one-to-one corresponde with `matchColors`
changeUrl: function changeUrl(cssUrl) {
return "/".concat(cssUrl); // while router is not `hash` mode, it needs absolute path
},
};
return client.changer.changeColor(options, Promise);
},
};

webpack-theme-color-replacer/client 中定义了如何更改主题色的逻辑,注意这里会用到前面 plugins 中定义的 __theme_COLOR_cfg 变量,如此确认了主题色系 oldColors 和样式请求地址 cssUrl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// node_modules\webpack-theme-color-replacer\client\themeColorChanger.js
module.exports = {
changeColor: function (options, promiseForIE) {
//...
return new Promise(function (resolve, reject) {
//...
if (!theme_COLOR_config) {
theme_COLOR_config = win().__theme_COLOR_cfg;
var later = retry();
//重试直到theme_COLOR_config加载
if (later) return later;
}
var oldColors = options.oldColors || theme_COLOR_config.colors || [];
var newColors = options.newColors || [];

var cssUrl = theme_COLOR_config.url || options.cssUrl;
//...
setCssText(last, cssUrl, resolve, reject);
});
},
};

setCssText 方法中,会去寻找 idcss_xxxstyle 标签,并调用 getCssString() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function setCssText(last, url, resolve, reject) {
var id = "css_" + +new Date();
elStyle = document
.querySelector(options.appendToEl || "body")
.appendChild(document.createElement("style"));

elStyle.setAttribute("id", id);

_this.getCssString(
url,
function (cssText) {
setCssTo(cssText);
_urlColors[url] = { id: id, colors: newColors };
resolve();
},
reject
);
}

getCssString() 内通过 xhr 来发动主题样式文件的请求。从而回答了第二个问题:

请求 /css/theme-colors-xxx.css 哪里发起的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
getCssString: function (url, resolve, reject) {
var css = win().__theme_COLOR_css;
if (css) {
// css已内嵌在js中
win().__theme_COLOR_css = "";
resolve(css);
return;
}

var xhr = new XMLHttpRequest();
//...
xhr.open("GET", url);
xhr.send();
},
};

调用完 getCssString 后,将得到 cssText 代码。然后通过 setCssTo 替换新老颜色,这就回答了最后个问题:

主题色的切换,页面怎么快速做样式更新的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function setCssTo(cssText) {
cssText = _this.replaceCssText(cssText, oldColors, newColors);
elStyle.innerText = cssText;
}
function replaceCssText(cssText, oldColors, newColors) {
oldColors.forEach(function (color, t) {
//#222、#222223、#22222350、222, 255,3 => #333、#333334、#33333450、211,133,53、hsl(27, 92.531%, 52.745%)
var reg = new RegExp(
color.replace(/\s/g, "").replace(/,/g, ",\\s*") +
"([\\da-f]{2})?(\\b|\\)|,|\\s)",
"ig"
);
cssText = cssText.replace(reg, newColors[t] + "$1$2"); // 255, 255,3
});
return cssText;
}

这样有个好处,有关主题颜色的样式文件只在首次加载,后续通过替换 style 标签内容,从而达到主题切换。

总结

webpack-theme-color-replacer 是个很小众的库,github 才 200 个 Star。但它的确解决了产品上某些问题。

有时候我们每天忙碌于业务代码的“搬砖”中,枯燥乏味。作为一个软件程序员,除了完成需求外,还需要更多的思考业务,来促使代码有更多的扩展性。

如果你说业务固定不变,或者离我太远,也可以发现开发中的“重复性劳动”,将 ctrl C/V 最大程度地程序化。什么页面可视化搭建,低代码平台可不光光是 KPI 产物,我觉得它们能促使开发人员有更多时间思考,去挖掘更高的价值。

最后,还是需要不断纵向学习,本篇只简单说了该插件的工作过程。但它内部有关生成调色板的逻辑,怎么解析 css 代码,怎么和 webpack 融合都没有呈现出来,更多需要你去深究,别想用到时方恨少。

【长按关注】看看↓↓↓?
Eminoda wechat
【前端雨爸】分享前端技术实践,持续输出前端技术文章
欢迎留言,评论交流,一起讨论前端问题
📢 因为是开源博客,为避免 Gitalk授权 带来的 安全风险,也可访问