背景
当web应用存在着预览场景时,在进阶体验中势必存在主题配色这样的需求。
切换主题即整体修改网页中各元素的样式
成熟的ui库一般都会将用到的关键css属性抽离为单一文件,以供开发者定制颜色、元素间距等。开发者通过修改预设变量值,打包成不同的主题样式文件,在预览时使用主题相应的样式文件进行全局替换即可达到主题切换的目的。
本文以下部分均以antd
为例,其他诸如bootstrap
、material-design-lite
等实际开发工作均大同小异。
antd是由蚂蚁金服官方维护的,主要针对企业级应用场景设计的前端组件库
当然,预览区域如果是隔离的(独立页面),那本文完结。。。
但预览区域如果和配置区域耦合在一起,并使用了相同的ui库,该如何应对?所以,诞生了局部主题方案。
方案制定
css通过规则权重来确认生效的样式,后面的样式定义覆盖前面的:
.text {
color: #000;
}
// 这样做会导致预览区的样式污染配置区的样式
.text {
color: #111;
}
// 常见做法是增加一层容器来增加权重
.theme-container .text {
color: #111;
}
方案一:ui组件库增加外层class
自己定义的属性外层增加一个容器class尚不是难事,但如何为整个antd
库增加外层class?
方案二:预览区作用域隔离
当然,也可以人为地将预览区域隔离开来,使用iframe
、web components
方案都理论可行,不过解决局部样式问题的同时带来了其他的问题需要攻克:
- 配置区域和预览区域存在大量拖拽交互时,在技术上、体验上是否可行?
- 将整个ui样式库全部内置入
web components
是否可行?
本文首先使用方案一进行实施,方案二还在继续摸索中,待有突破性进展以后再做分享。
为antd库增加外层class
如何定制antd主题
官方提供了几种定制主题的方式,以下是项目中的具体实现:
// theme.less
// 由于antd内部使用了utf8编码的文字符号而不是使用\u****的Unicode码点表示
// 在cdn样式文件返回头里没有明确指明utf-8时,可能存在文字符号乱码的可能
// 编译生成过程中通过css-loader也会自动转换,而本文场景是直接编译
// 所以,为了安全起见,最好指定编码格式
@charset "utf-8";
@import "/path/to/node_modules/antd/dist/antd.less";
// 覆盖变量定义
// 所有变量详见 https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
@primary-color: #F15B41;
由于需要给样式增加外层class,postcss的插件机制更能符合定制需求,于是使用postcss来处理less。编译脚本大致如下:
#!/usr/bin/env node
const fs = require("fs")
const postcss = require("postcss")
const less = require('postcss-less-engine')
const autoprefixer = require('autoprefixer')
const clean = require('postcss-clean')
const lessContent = fs.readFileSync('/path/to/theme.less', 'utf-8')
postcss([
// 插件配置
less(),
autoprefixer(),
clean(),
]).process(lessContent, {
parser: less.parser,
src: '/path/to/theme.less',
}).then(result => {
const cssContent = result.css
fs.writeFileSync('/path/to/theme.css', cssContent)
})
这样就能得到使用新配色的主题样式文件了。
@import的使用
目前,css预处理语言中我只使用过less/scss,antd
使用的是less,本文会偶尔拿出scss来进行比较说明。
由于scss文档中明确说明了@import
可以放于选择器下,less文档中并没有提到,一开始我担心不支持该类语法。百闻不如一试,其实less也是支持的:
// theme.less
@charset "utf-8";
.theme-container {
@import "/path/to/node_modules/antd/dist/antd.less";
@primary-color: #F15B41;
}
现在所有的新主题样式在theme-container下的权重更高,预览区的样式会覆盖配置区的样式,同时不影响配置区本身的样式。
不过,通过查看生成的css(开发阶段可以先去除autoprefixer、clean两个插件)很快发现了问题。由于每个ui库都会有自己的全局样式,如果将less统一塞在theme-container下会使得全局样式无效:
// 编译出来的全局css
.theme-container html {}
.theme-container body {}
不过这种问题整体范围来说影响不大,即使无效也可能没有大问题,出了问题只要简单区分一下即可:
// theme.less
@charset "utf-8";
@import "/path/to/node_modules/antd/lib/style/index.less";
.theme-container {
@import "/path/to/node_modules/antd/lib/style/components.less";
}
@primary-color: #F15B41;
这样就结束了么?可能一些ui库已经搞定了,不过,antd的故事只是刚刚开始。
难题重重
多个&(父类选择器)的问题
&平时使用的很多,但大部分场景下不会在一条规则中使用复数个&,整个antd
用到的其实也不多,但是这些地方就出了问题:
// 原版
.ant-alert {
&&-no-icon {
padding: 8px;
}
}
// 编译后
.ant-alert.ant-alert-no-icon {
padding: 8px;
}
// 增加外层容器后
.theme-container {
.ant-alert {
&&-no-icon {
padding: 8px;
}
}
}
.theme-container .ant-alert.theme-container .ant-alert-no-icon {
padding: 8px;
}
这个编译结果是正确的,但不符合预期,造成了样式失效。那如何克服呢?
&是否可以写死父类选择器的值?
这个方案可以实现,原本antd的less文件内就有很多变量可供使用。
@alert: ant-alert;
.theme-container {
.ant-alert {
&.@{alert}-no-icon {
padding: 8px;
}
}
}
&是否可以编译为最近的父类?
我查了很久,当我看到这个issue#1075时,一个已经讨论了快5年的issue仍然是open的。我就知道这个方案悬了。
其实里面有很多设想,包括写本文时最新关联的这个issue#3053,都是解决该场景的一种设想。
而为什么有这么多奇淫技巧?因为scss是支持的。。。
我稍微花了些时间尝试了下scss在该场景下的实现,原理大致是将&对应的选择器内容作为入参,通过函数将选择器内容进行自定义修改:
// 参考地址:
// https://medium.com/@jakobud/how-to-do-sass-grandparent-selectors-b8666dcaf961
// scss还不支持&&连写,所以换个例子
.theme-container {
.ant-collapse {
& &-item-disabled {
cursor: not-allowed;
}
}
}
// 原本的编译结果
.theme-container .ant-collapse .theme-container .ant-collapse-item-disabled {
cursor: not-allowed;
}
// scss下可以这么做
@function get_last_selector($str) {
$selector: nth($str, 1);
$last: nth($selector, length($selector));
@return $last;
}
.theme-container {
.ant-collapse {
$parent: get_last_selector(&);
& #{$parent}-item-disabled {
cursor: not-allowed;
}
}
}
// 编译结果
.theme-container .ant-collapse .ant-collapse-item-disabled {
cursor: not-allowed;
}
虽然上述的每种方案都理论可行,不过到最后都无法落地,因为涉及到的改动代码量很大,有些甚至需要修改源码,会导致维护成本过高。
方案调整
新增postcss插件
postcss的编译流程大致是:
less => css => optimze(autoprefix + clean)
由于一开始在less层面能区分开antd的全局样式和组件样式,所以一直致力于在less阶段解决问题。但经过了上面的种种波折,在less层面的所有方案基本都无法实施了。
包括antd,所有的ui库都会有一个prefix头来区分样式,这其实也变相提供了在css层面区分开全局样式和组件样式的能力。
所以,只需在css => optimze阶段新增一个插件来为每条组件样式增加一个父类选择器就可以实现局部主题了!
即,最终流程会变为:
less => css => prefix => optimze
// theme.less
// 不需要再使用@import而带来多&问题了
@charset "utf-8";
@import "/path/to/node_modules/antd/dist/antd.less";
@primary-color: #F15B41;
// 插件代码
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
const process = node => {
node.walkRules(rule => {
rule.selectors = rule.selectors.map(selector => {
if (selector.startsWith('.ant')) {
return `${PREFIX} ${selector}`
}
return selector
})
})
}
return process
})
// 编译脚本内加入这个插件
postcss([
less(),
autoprefixer(),
clean(),
]).then(() => {
// ...
})
局部主题到最后几行代码就搞定了,antd的故事终于结束了。哦不,等等,还有一个小问题。。。
改造部分组件
预览区域的部分组件没有受到局部主题样式影响
antd
提供的部分组件,如Modal、Message等的实现,都是在body下插入dom,所以诸如这类样式:
.theme-container .ant-modal { ... }
在应用内都是无法生效的。解决这类问题有两种方式:
修改组件的挂载位置,让局部主题样式生效
class Preview extends PureComponent {
getContainer = () => {
const container = document.createElement('div')
document.querySelector('.theme-container').appendChild(container)
return container
}
render() {
return (
<div id="preview-area">
<Modal visible getContainer={this.getContainer}>
content
</Modal>
</div>
);
}
}
修改组件的类名,让配置区域和预览区域的类名区分开来
class Preview extends PureComponent {
render() {
return (
<div id="preview-area">
<Modal visible prefixCls="custom-modal">
content
</Modal>
</div>
);
}
}
// 插件也需要相应的修改一下
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
const process = node => {
node.walkRules(rule => {
rule.selectors = rule.selectors.map(selector => {
if (selector.startsWith('.ant')) {
if (selector.startsWith('ant-modal', 1)) {
return selector.replace(/ant-(modal)/g, 'custom-$1')
}
return `${PREFIX} ${selector}`
}
return selector
})
})
}
return process
})
后记
局部主题样式是一个非常业务化的场景,需求面较小,比较难提出一些非常通用的知识点。但在方案的制定过程中,需要了解不少less的特性、postcss的编译流程以及如何编写插件,这些可能在其他实战中得到更广泛的应用,故成此文,将整个方案的演进过程详尽记录下来,希望能启迪有类似需求的场景并方便自己之后回溯。
从长远来说,web components
可能是这类场景的最佳解决方案,目前的方案还比较偏黑科技,所以这个方案并不会停止演进,让实现变得更合理、更规范。