前言
预览地址: Ant Design Theme
const App = () => {
return (
<ThemeProvider
theme={{
name: 'dark',
variables: { 'primary-color': '#00ff00' },
}}
>
<Button type="primary">Primary Button</Button>
</ThemeProvider>
);
};
开箱即用 antd-theme 欢迎 Star ❤
现有方案缺陷
-
CSS 变量
- 无法支持复杂表达式
mix(var(--primary-color), #fff, 20%)
- 浏览器兼容性有限
- 无法支持复杂表达式
-
多套 CSS 主题
- 主题样式固定,无法实时切换主题色
- CSS 代码分离不好处理
-
less 动态切换
- 需要引入less runtime 体积较大
- 切换主题时会对整个样式表进行 parse -> eval -> genCSS 处理,导致切换速度很慢
想要实现的目标
- 提供接口支持实时修改 @primary-color, @border-radius-base 之类的参数
- 浏览器页面刷新后默认就是切换后的样式,没有多余的动画、明显的加载过程
- 打包体积小,无需打包多个样式文件
- 代码分离友好,不同页面的样式可以分别加载
实现过程
编译时把不同皮肤需要修改的less变量、表达式留空, 在运行时填充。
比如
.btn {
background: @primary-color;
&:active {
background: mix(@primary-color, white, 10%);
}
}
会编译成
/* less-loader!./style.less */
.btn {
background: "[theme:primaryColor,default:#1890ff]";
}
.btn:active {
background: "[theme:e8efafb1,default:#40a9ff]";
}
这种样式是不能直接加载到页面中的的, 所以会进一步处理成
/* themed-style-loader!./style.css */
var loadStyle = require('./load-themed-style').loadStyle;
var css = `
.btn {
background: "[theme:primaryColor,default:#1890ff]";
}
.btn:active {
background: "[theme:e8efafb1,default:#40a9ff]";
}
`;
loadStyle(css);
同时也会编译出不同皮肤的替换变量,并注入到特定的文件 themes.js
/* themes.js */
module.exports = {
default: {
primaryColor: '#1890ff',
e8efafb1: '#40a9ff'
},
dark: {
primaryColor: '#1890ee',
e8efafb1: '#40a9ee'
},
compact: {
primaryColor: '#1890dd',
e8efafb1: '#40a9dd'
}
}
皮肤加载器大概长这样
/* load-themed-style.js */
var themes = require('./themes.js')
var styles = [];
var loadStyle = function(css) {
styles.push(css);
applyStyles();
}
var loadTheme = function(name) {
applyStyles(themes[name]);
}
var applyStyles = function(variables) {
var css = styles.join('').replace(
/"\[theme:([\w]+),default:(\S+)\]"/,
function(_, themeSlot, defaultValue){
return varialbes && varialbes[themeSlot] || defaultValue;
}
);
// 生成好的css插入到页面中
}
module.exports = {
loadStyle,
loadTheme
}
最后调用 loadTheme('xx')
就可以切换到对应的皮肤
变量实时修改
插件如果分析到某个表达式依赖了需要实时修改的变量,就会把该表达式对应的 AST 注入到 themes.js
里面
/* themes.js */
// @primary-color
var expr1 = {
type: 'Variable',
name: '@primary-color'
};
// mix(@primary-color, white, 10%)
var expr2 = {
type: 'Call',
name: 'mix',
args: [
{
type: 'Variable',
name: '@primary-color'
},
{
type: 'Color',
rgb: [255, 255, 255],
alpha: 255,
},
{
type: 'Dimension',
value: 10,
unit: '%'
}
]
};
module.exports = {
default: {
background: 'white',
primaryColor: { expr: expr1, default: '#1890ff' },
e8efafb1: { expr: expr2, default: '#40a9ff' }
},
dark: {
background: 'black',
primaryColor: { expr: expr1, default: '#1890ff' },
e8efafb1: { expr: expr2, default: '#1890ff' }
},
...
}
loadTheme
会根据传入的实时变量和皮肤里面的 AST 计算出填充值,填充留空并应用修改
var loadTheme = function(name, runtimeVariables) {
// 根据传入实时变量计算出本次的皮肤变量
var themeVariables = compute(
themes[name],
runtimeVariables
);
// 应用样式
applyStyles(themeVariables);
}
现在调用 loadTheme('xx', { 'primary-color': '#xxxxxx' })
就可以实时的修改页面主色调
遇到的问题
- colorPalette 函数
ant-design
内部使用了 ~`colorPalette('@{background}', 7)`
内联 Javascript 块, 导致无法跟踪表达式的变量依赖, 所以在 less 解析之前对样式代码进行预处理,全部替换成 colorPalette(@background, 7)
并提供对应的 colorPalette 函数实现。
- Mixins 展开 & CSS Guards 转换
换肤方案是基于CSS属性替换, 在所有皮肤下同一个组件生成出来的样式需要行数一致且表达式hash一致。
// Mixin
.button-color(@color) {
color: @color;
}
.btn-primary {
&:active {
// CSS Guard 1
& when (@theme = dark) {
.button-color(@primary-7);
}
// CSS Guard 2
& when not (@theme = dark) {
.button-color(~`colorPalette('@{btn-primary-bg}', 7)`);
}
}
}
上面的按钮样式在默认皮肤下会生成
.btn-primary:active {
color: "[theme:primary7]";
}
暗黑模式下生成的却是
/* sha1(colorPalette(@btn-primary-bg, 7))= 9ebde6df87d1def7be1e8e5c80144b793cb1e2c2 */
.btn-primary:active {
color: "[theme:9ebde6df]";
}
这样就会出现运行时变量填充错误,因此需要修改样式解析后的AST。先对 Mixin 调用进行展开, 然后对 CSS Guards 进行转换,在 AST 执行之前上面的按钮样式会被转换成:
.btn-primary {
&:active {
color: if(@theme = darak, @primary-7);
color: if(not @theme = dark, colorPalette(@btn-primary-bg, 7));
}
}
转换后的代码就可以愉快的用上文的方式进行处理了, 这里产生的多个color定义会在运行时拿到皮肤变量后进行删除处理。
限制
-
递归 Mixin Call 的循环变量不能作为皮肤变量, 比如
ant-design
栅格系统相关代码中的@grid-columns
.loop-grid-columns(@index, @class) when (@index > 0) { // ... .@{ant-prefix}-col@{class}-order-@{index} { order: @index; } .loop-grid-columns((@index - 1), @class); } .loop-grid-columns(@grid-columns, @class);
-
postcss-position 不兼容
postcss-position 会直接对 css 中的 position 属性值进行
value.match(/^static|absolute|fixed|relative.../).toString()
,而
'"[theme:position,default:relative]"'.match(...) === null
因此会出现在编译过程中的报错,具体的 postcss-position 代码在 这里
What's Next
-
支持 CSS Variable Backend 配置
开启 CSS Variable Backend 后样式文件会编译成
/* less-loader!./style.less */ .btn { background: var(--primaryColor, #1890ff); } .btn:active { background: var(--e8efafb1, #40a9ff); }
applyStyle
的内部实现调整为style.setProperty(--primaryColor, '#xxxxxx')
-
requestIdleCallback
采用
React Fiber
类似的方案对样式的渲染过程进行异步处理,避免过多样式同步渲染导致的页面卡顿