css 换肤方案

介绍
其实现在社区主流的 css 换肤方案一般分为两种:css in js 和 css 变量,但 css in js 某种程度也需要配置 css 变量。
在讨论 css 主题切换功能设计之前,我们不得不提一下目前很多组件库的困局,就是样式很难拓展,因为只要不是 headless 组件库(只包含 js 和 html 逻辑,不包含样式的组件库),都难免跟 css 有耦合之处,尤其是
在你们的项目对于样式修改粒度要求很细的层次时,只要你做过大项目,那个痛苦程度是很深的。这也是为什么国外现在非常流行 headless 组件库的原因。大家对定制化组件库样式的需求非常强烈。
那么我们来看看之前 ant design 4 版本之前, material ui 等主流组件库之前的样式为什么难以定制化?
ant design 的困局
在 ant design 5之前,你知道为什么用以下方式导入 ant 组件吗?并且这样的导入方式为什么连 css 也一并导入进来了?
1import { message } from 'antd';是因为 ant 为了实现 css 的按需加载(就是我使用了 message 组件,我只加载 message 组件的 css 样式,不加载别的组件的 css),使用 babel-plugin-import,使用方法如下
官方文档这样描述:
1npm install babel-plugin-import -D然后在 .babelrc 配置文件中,举个例子可以添加以下配置代码:
1{
2 "plugins": [
3 ["import", {
4 "libraryName": "antd",
5 "libraryDirectory": "libs",
6 "style": "css"
7 }]
8 ]
9}原理是什么呢?
其实是 babel 在编译你的代码的时候,会自动帮你引入对应组件的 css,也就是帮你自动插入 css
可是成也 babel-plugin-import,败也 babel-plugin-import,这样确实很方便,对于新手用户而言。但问题又很明显,很多人用 ant-deisgn 都不知道为什么像我们上面那样导入组件,
怎么 css 也导入进来了,这样的话你根本没办法对 ant 做样式上的定制化改造。
所以很多项目,包括大厂很多项目,甚至我在用飞书的时候,飞书最开始也存在的问题,就是 css 覆盖,大家普遍的思路都是用新的,同名的 css 去覆盖 ant 的样式。
问题也很明显,css 维护起来很糟心,尤其是大型项目,因为每个模块可能都是不同的项目组在维护,很可能一个组覆盖的 css 影响了另一个组。
正确的解决办法是什么呢?(因为很多国内组件库都是这样的写法,例如字节的 arco design, 腾讯的 t-design 等等,所以介绍如何正确改造也比较重要)。
首先,不要引入 babel-plugin-import,然后单独封装每个组件的 css,举个例子,我们有一个 ant4 版本的 Button 组件,我们这样改造:
1// Button组件
2import { Button } from 'antd' ;
3// 这里引入的是 css,当然你的项目用 sass 或者 less,改成相应的就行了
4import './style/index.css'一般你只有用 ant 的 less(当然其它的库也有对应 css 的预处理器),改造成本才比较小。
1/** 这里复制粘贴 ant button 的css 然后改其中的 css 样式*/当然,如果你也可以用
1import './style/index.css'改为
1import './style/index.less'但还需要把 less 里的公共变量也需要一起改了,这个就更复杂一些。
所以你可以看到,ant design 在更改样式这块很不容易,这也是 ant5 为什么放弃 less,转向了 css-in-js,因为 css-in-js 相当于把 css 全部交给 js 去处理,这样我们在 js 里修改变量相当于修改了 css。
css-in-js 真的好用吗?
css-in-js 相当于给你开放一些css变量让你动态设置,我们看下 ant design 5 的文档是怎么使用的。
通过在 ConfigProvider 中传入 theme 变量,可以配置主题。在升级 v5 后,将默认使用 v5 的主题,以下是将配置主题示例:
1import { Button, ConfigProvider } from 'antd';
2
3const App: React.FC = () => (
4
5<ConfigProvider
6theme={{
7 token: {
8 colorPrimary: 'red',
9 },
10}}
11>
12<Button>按钮</Button>
13</ConfigProvider>
14);
15
16export default App;这将会得到以 #00b96b 为主色的主题的 Button。
也就是我们其实也不是随心所欲的去修改 css,也是在人家开放接口的范围内修改。如果你想修改的 css 样式不在其提供的修改样式的接口里,你还是不能定制化自己的样式。
所以 css-in-js 并没有想象中那么完美,任何事情没有好坏只有适不适合,如果的业务定制性没有那么高,其实 css-in-js 也能接受(如果定制性很弱,其实 ant4 的 css 方案也挺好的),但如果定制性非常强,一定是选择 headless 组件库是更好的。
如果我们用 less 或者 sass,其实也能做到类似 css-in-js 的功能,怎么做呢,休息喝口水,咋们接着看。
css 变量换肤方案
之前做过一个名为 @mx-design 的组件库,我觉得这样的 css 主题色设计方案还挺好用的。
我们拿 button组件为例,最终打包的组件库,我会生成一个 index.css,这个 css 中包含了一些 css 变量,例如
1.mx-base-button {
2 display: inline-flex;
3 position: relative;
4 align-items: center;
5 justify-content: center;
6 outline: none;
7 padding: var(--btn-padding);
8 height: var(--btn-height);
9 appearance: none;
10 user-select: none;
11 cursor: pointer;
12 white-space: nowrap;
13 transition: all 0.2s var(--transition-timing-function-standard);
14 box-sizing: border-box;
15 line-height: 1.5715;
16 border-radius: var(--btn-radius);
17}然后,对外暴露一个修改 css 变量的方法,例如:
1import { isObject } from './is';
2/**
3* 更换css变量的方法
4*/
5export function setCssVariables(variables: Record<string, any>, root = document.body) {
6 if (variables && isObject(variables)) {
7 Object.keys(variables).forEach((themKey) => {
8 root.style.setProperty(themKey, variables[themKey]);
9 });
10 }
11}假设你有两份文件,分别是 light.ts 和 dark.ts,两份文件里的变量名都是一样的,只是值不一样,我们可以这样使用:
1import { setCssVariables } from './css';
2import light from './light';
3import dark from './dark';
4
5// 切换到亮色主题
6setCssVariables(light);
7
8// 切换到暗色主题
9setCssVariables(dark);light.ts 如下(dark.ts 跟 light.ts 的变量名一样,从而达到换肤的效果,同时也支持更多的皮肤):
1export const lightTheme = {
2 // base color
3 '--brand-color-1': '#f2f3ff',
4 '--brand-color-2': '#d9e1ff',
5 '--brand-color-3': '#b5c7ff',
6 '--brand-color-4': '#8eabff',
7 '--brand-color-5': '#618dff',
8 '--brand-color-6': '#366ef4',
9 // ... other variable
10 // brand color status
11 '--brand-color': 'var(--brand-color-7)',
12 '--brand-color-hover': 'var(--brand-color-6)',
13 '--brand-color-focus': 'var(--brand-color-2)',
14 '--brand-color-active': 'var(--brand-color-8)',
15 '--brand-color-disabled': 'var(--brand-color-3)',
16 // error color
17 // warning color
18 // success color
19 // ...
20 ’other‘: 'var(--other-color)',
21}那么全局样式改变我们已经完成了,但一个好的 css 换肤方案一定要兼顾全局和局部的样式改变,也就是我只想改一个 Button 的样式,而不要一改变量牵扯到所有的 Button 样式不在其提供的修改样式的接口里,你还是不能定制化自己的样式。
我们的实现的目标为:
1、局部样式改变
比如所有的 button 默认都是蓝色,但是某个 button 我想是黄色,允许单独给 button 传递主题色。
2、全局样式改变
比如我想对所有的 button 的主色都设为绿色,提供一个全局更换颜色配置的入口。
全局的之前讲过了,我们来讲讲局部样式更改。
局部更换主题色
最开始我是想向 css-in-js 那样,传递参数,然后用 style 来设置样式,但是毫无疑问不能这样做,这样会影响 css 优先级,有些同学更改了 class 可能因为没有 style 优先级高而导致样式不生效,这就造成奇怪的体验。
当然这也是普通 css 不如 css-in-js 的地方,可以像传入 js 变量一样更改 css。但是我们也有办法。之前说了,这样独立样式的 button 毕竟不是常见的需求,因为一般大家的 ui 都有一套设计规范,我们在全局更换主题色即可。
这样单独修改的需求可以将从打包好的css样式中提取出对应组件的css,把样式单独更改后,和 js 一起导出。
也可以采取覆盖局部样式的方式。(不推荐)
现在想来,局部更换主题色似乎不太好做,还好,原生 css 变量支持 css 变量的作用域,什么意思呢?在 mdn 中,这种作用域被称之为继承性。以下转自 mdn 对继承性的解释和案例:
自定义属性会继承。这意味着如果在一个给定的元素上,没有为这个自定义属性设置值,在其父元素上的值会被使用。看这一段 HTML:
1<div class="one">
2<div class="two">
3 <div class="three"></div>
4 <div class="four"></div>
5</div>
6</div>配套的 CSS:
1.two {
2--test: 10px;
3}
4.three {
5--test: 2em;
6}在这个情况下, var(--test) 的结果分别是:
对于元素 class="two" :10px
对于元素 class="three" :2em
对于元素 class="four" :10px (继承自父属性)
啥意思呢,就是我的 button 组件,我可以默认用全局变量的样式,比如我设置在 body 上,然后 button 组件包裹一个 div, div 上也有同名的一个变量,那么 button 组件会优先使用 div 上的变量。
基于以上原理我可以在 button 组件里,直接在 style 中用来设置 css 变量,代码如下
1const localBtnTheme = {
2 "--btn-color": "red",
3 "--btn-width": 12
4};
5
6<div style={{ style, ...localBtnTheme }} >其中 style 是正常外面传给组件的 style, localBtnTheme 是指 css 变量
如果你有其他组件库主题切换的方案,欢迎在评论区,或者我的 github 上、微信组件库讨论群一起讨论哦