Appearance
IE 下切换主题配色解决方案
所谓“切换主题配色”,可以是指:
不同页面自动使用不同配色
用户切换主题配色
两种需求的底层道理是一样的,前者更复杂一些,本文先以前者为例说明,最后补充后者的实现方案。
尽量追求最佳方案
一些大型项目由于业务板块多,会考虑给每个板块的设定不同的主题配色,这就涉及到多套主题配色的解决方案。
事实上,如果是只针对现代浏览器,那么解决方案相当简单,使用CSS Variables
即可,但是 IE 浏览器不支持CSS Variables
,所以还要再考虑其他解决方案。
或许你会说:“调整配色,生成多套主题 CSS 就可以解决这个问题,而且一般 UI 库都支持”。没错,这可以解决,但不是最佳方案,比如 Element UI 的 index.min.css 是 234KB,再生成一套就合计 468KB,两套 CSS 的区别仅仅是一些颜色的差异,差异代码只有 30KB 这样的规模,相同代码有 200KB 左右,严重冗余。另外,如果尝试使用 Element UI 官方的主题生成器,就更离谱了,它生成的压缩后 CSS 文件足有 500KB。所以,我们应该考虑更佳的解决方案。
怎样才算最佳解决方案?
步骤要尽量简单,效果要尽量优异,插件要尽量少装,代码要尽量少改。
开发环境下修改 SCSS 变量之后,页面能即时热更新。
页面跳转时主题也随之切换,至少生产环境必须做到。
差量切换 CSS,差量最小化,节省带宽,加快渲染。
CSS 独立打包,便于缓存和复用。
最佳解决方案
本章以 Element UI 组件库为例详细介绍最佳解决方案。假设我正在开发一个教育行业的项目,它包含“老师”和“学生”两大业务板块,现在我们希望“教师”模块、“学生”模块、其他页面都有各自的主题配色。最佳解决方案是:
开发环境:根据 Element UI 官方文档,引入官方的 SCSS 文件并调色,直到调试出满意的多套主题配色方案。
生产环境:手写 webpack 插件,由 SCSS 生成各自的 CSS 文件,提取各个 CSS 中的相同代码和差异代码,写入
@/assets/styles/
目录。其中差异代码加命名空间,比如“教师”模块的差异 CSS 代码的每条规则要在选择器最前面加.theme-teacher
类名。最终,各业务板块的根路由组件、非业务板块各页面各自加载各自的 CSS 文件即可。
筹备方案
1. 准备主题 SCSS 文件
在@/assets/styles/
目录创建 4 个文件:element-ui-components.scss
、theme-default.scss
、theme-teacher.scss
、theme-student.scss
。
element-ui-components.scss
它的作用是指定加载哪些组件的样式。它会被所有的theme-*.scss
引用。
- 整体引入样式的话
格式类似这样,可以一字不改。
字库 CDN 可以替换成别的 CDN 或使用本地字库:../../../node_modules/element-ui/packages/theme-chalk/src/fonts
。
../../../
相对路径是必须的,因为不仅项目脚手架要读取 SCSS 文件,之后手写的 webpack 插件也需要读取这个文件。
scss
$--font-path: 'https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.9/theme-chalk/fonts';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/index.scss';
- 按需引入样式的话
格式类似这样,根据实际需求引入即可,内容可以从/node_modules/element-ui/packages/theme-chalk/src/index.scss
择取:
scss
$--font-path: 'https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.9/theme-chalk/fonts';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/pagination.scss';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/dialog.scss';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/autocomplete.scss';
// ... more
theme-default.scss
- 使用 Element UI 原装配色的话:
scss
@import './element-ui-components.scss';
- 自定义配色的话,类似这样,只需调整色值即可,其他不要改。
scss
$--color-primary: #a100a0;
$--color-success: #c1f2a1;
$--color-danger: #a10026;
$--color-warning: #a17900;
$--color-info: #6dcbd7;
// ... 其他变量
@import './element-ui-components.scss';
theme-teacher.scss
类似这样,只需调整色值即可,其他不要改。
scss
$--color-primary: #a100a0;
$--color-success: #c1f2a1;
$--color-danger: #a10026;
$--color-warning: #a17900;
$--color-info: #6dcbd7;
// ... 其他变量
@import './element-ui-components.scss';
theme-student.scss
类似于theme-teacher.scss
,只是变量色值不同,范例从略。
如果用命名空间包裹 theme-*.scss
文件是不是好主意?
也就是给 SCSS 代码外面再包裹一个总类名,比如“教师模块”的:
scss
.theme-teacher {
$--color-primary: #a100a0;
$--color-success: #c1f2a1;
$--color-danger: #a10026;
$--color-warning: #a17900;
$--color-info: #6dcbd7;
// ... 其他变量
@import './element-ui-components.scss';
}
对比一下优缺点:
使用命名空间 | 不用命名空间 | |
---|---|---|
优点 | 开发环境下切换类名即可随意切换主题 | 没有优点,但是可以不用担心 Dart Sass 编译缺陷 |
缺点 | Dart Sass 缺陷导致复杂 SCSS 编译出错 | 开发环境下热更新机制导致主题 CSS 覆盖 |
如何克服上述的缺点 | 解决方案其实也有,但会将整体方案复杂化,不值得,所以复杂 SCSS 不要用命名空间 | 虽然有覆盖现象,但是程序员开发时只专注于一个页面,所以不碍事 |
解释一下:
- 开发环境下切换主题的纠结
在开发环境下,Vue CLI 脚手架为迁就热更新机制,会将 CSS 写成行内代码,切换主题时会无脑追加新主题样式,最后追加的样式是全局生效的,如果没有使用命名空间包裹 SCSS,最后追加的主题样式会覆盖其他主题样式,导致全站都成了一个主题,只能刷新一下页面才能解决问题,这就造成了不好的开发体验。
如果使用命名空间包裹 SCSS,虽然可以解决上述问题,但是也会引来新的问题,见下文。
- Dart Sass 缺陷导致的纠结
以目前 Dart Sass 的编译机制,如果 UI 库的 SCSS 代码中包含 @font-face 和复杂的 mixins,然后给 SCSS 外层包裹命名空间,那么 Dart Sass 编译出的 CSS 会有错误,因为 Dart Sass 不能正确处理在 @font-face 和复杂的 mixins 外面包裹命名空间。
- 总结
如果你用的 UI 库的 SCSS 内容较为复杂,使用了 @font-face 和复杂的 mixins,那么不应考虑给 SCSS 外层包裹.theme-*
。虽然开发时的体验不好,但是凑合着用也问题不大。
如果你用的 UI 库的 SCSS 内容非常简单,没有用 @font-face 和复杂的 mixins,那么可以尝试给 SCSS 外层包裹.theme-*
,这样的好处是开发环境下就可以依靠切换.theme-*
来切换主题,开发体验很好。
- 如何判定某 UI 库是否适合被命名空间包裹?
原本我想写个工具来验证编译后的 CSS,后来觉得意义不大,因为极少有人愿意用这个工具,我自己都懒得用,事实上,打开编译好的 CSS,格式化,然后从上往下用眼睛扫一遍就能看出来。
- Element UI 的 SCSS 属于哪一种情况?
因为本文是以 Element UI 为例,Element UI 的 SCSS 包含复杂情况,所以不能包裹.theme-*
。
- 生产环境下会有命名空间吗?
当然!一定有!因为本方案就是要生产环境下利用切换命名空间来切换主题配色。即便开发环境没有使用命名空间,下文的 webpack 插件也会给生产环境的 CSS 加上命名空间,全自动操作,无需开发者操心。
2. 安装开发依赖包 css-tree
css-tree 是生成 CSS 的抽象语法树的最佳工具,我们用它来分析各个主题 CSS 文件。
bash
yarn add -D css-tree
3. 创建 webpack 插件 ExtractCssDiffWebpackPlugin.js
在项目根目录创建ExtractCssDiffWebpackPlugin.js
,它的原理是将几套 SCSS 文件转换为 CSS 文件,然后对比差异,提取相同代码和差异代码,分别存入不同 CSS 文件。
注意,这个插件兼容了上文提到的使用与不使用命名空间的两种情况,根据传参的scssIsWrappedByThemeName
为true
还是false
区分。
具体原理见注释:
js
const fs = require('fs');
const sass = require('sass');
const { minify } = require('csso');
const csstree = require('css-tree');
class ExtractCssDiffWebpackPlugin {
constructor(options) {
// 业务板块名称
this.themeNames = options.themeNames;
// 开发环境的 SCSS 文件是否被 '.theme-*' 包裹?
this.scssIsWrappedByThemeName = options.scssIsWrappedByThemeName;
}
apply() {
// 各 SCSS 转换为抽象语法树再转为普通对象然后取children属性
let plainObjectChildrens = {};
// 储存相同代码
let same = [];
// 储存差异代码
let diffs = {};
// 给各变量注入内容
// 其中 .replace 的作用是为解决 Element UI 的图标乱码现象,
// 做法是将 unicode 码前面多加一个 \ ,后续会有代码删除多加的 \
this.themeNames.forEach((themeName) => {
const plainObject = csstree.toPlainObject(
csstree.parse(
minify(
sass
.compile(`./src/assets/styles/theme-${themeName}.scss`, {
quietDeps: true,
style: 'expanded',
})
.css.replace(/content: "\\([a-z0-9]{4})";/g, 'content: "\\\\$1";')
).css
)
);
plainObjectChildrens[themeName] = plainObject.children;
diffs[themeName] = [];
});
// 提取相同代码和差异代码
for (let i = 0; i < plainObjectChildrens.default.length; i++) {
// 如果 this.scssIsWrappedByThemeName 为 true,
// 那么规则的差别除了 CSS 属性值的差别之外还有业务板块名称的差别,
// 所以先要使用 .replace 消除业务板块名称的差别,避免被这个假的差别干扰,
// 之后如果还有差别就一定是 CSS 属性值的差别,这才是真正的差别。
// 如果 this.scssIsWrappedByThemeName 为 false,
// 则 .replace 会无匹配,所以也不碍事。
// 另外,这里判定相同代码和差异代码的算法使用了简陋但准确的算法,
// 即:排除假的差别之后,只要 JSON 字串相同,就认为是相同的规则。
const newSet = new Set();
for (let themeName in diffs) {
newSet.add(
JSON.stringify(plainObjectChildrens[themeName][i]).replace(
RegExp('theme-' + themeName, 'g'),
''
)
);
}
if (newSet.size === 1) {
if (
this.scssIsWrappedByThemeName &&
plainObjectChildrens.default[i].type === 'Rule'
) {
plainObjectChildrens.default[i].prelude.children.forEach((v) => {
v.children.shift();
v.children.shift();
});
}
same.push(plainObjectChildrens.default[i]);
} else {
for (let themeName in diffs) {
if (
!this.scssIsWrappedByThemeName &&
plainObjectChildrens[themeName][i].type === 'Rule'
) {
plainObjectChildrens[themeName][i].prelude.children.forEach((v) => {
v.children.unshift(
{
type: 'ClassSelector',
loc: null,
name: 'theme-' + themeName,
},
{
type: 'Combinator',
loc: null,
name: ' ',
}
);
});
}
diffs[themeName].push(plainObjectChildrens[themeName][i]);
}
}
}
// 将相同代码和差异代码写入文件
fs.writeFileSync(
'./src/assets/styles/element-ui-common.css',
minify(
csstree
.generate(csstree.fromPlainObject({ type: 'StyleSheet', children: same }))
// 原因见上文注释
.replace(/\\\\/g, '\\')
).css
);
for (let themeName in diffs) {
fs.writeFileSync(
`./src/assets/styles/theme-${themeName}.css`,
minify(
csstree
.generate(
csstree.fromPlainObject({ type: 'StyleSheet', children: diffs[themeName] })
)
// 原因见上文注释
.replace(/\\\\/g, '\\')
).css
);
}
}
}
module.exports = ExtractCssDiffWebpackPlugin;
4. 在 vue.config.js 调用插件
需要给插件传递参数,参数格式如范例所示,其中themeNames
第一个元素必须是default
。
js
const ExtractCssDiffWebpackPlugin = require('./ExtractCssDiffWebpackPlugin');
module.exports = defineConfig({
chainWebpack(config) {
config.when(process.env.NODE_ENV === 'production', (config) => {
// themeNames 的第一个元素必须为 'default'
config.plugin('ExtractCssDiff').use(
new ExtractCssDiffWebpackPlugin({
themeNames: ['default', 'teacher', 'student'],
scssIsWrappedByThemeName: false,
})
);
});
},
});
方案准备工作到此已经完成。
应用方案
现在我们追求最佳实践,开发环境使用上面准备的 SCSS 文件,生产环境必须实现按需引入相同代码和不同代码,所以我们要根据环境判断引入的内容。
1. 在路由表中使用beforeEnter
钩子
- 非业务的页面,比如首页、登录页、404 页等,引入
theme-default
相关 SCSS 和 CSS:
js
{
path: '/',
name: 'home',
component: HomeView,
beforeEnter: (to, from, next) => {
if (process.env.NODE_ENV === 'development') {
import('@/assets/styles/theme-default.scss').then(() => {
next();
});
} else {
Promise.all([
import('@/assets/styles/theme-default.css'),
import('@/assets/styles/element-ui-common.css')
]).then(() => {
next();
});
}
},
},
- 给业务板块设 2 个父路由组件,分别引入
theme-teacher
或theme-student
相关 SCSS 和 CSS,以“教师”板块的父路由组件为例:
js
{
path: '/teacher',
name: 'teacher',
component: () => import(/* webpackChunkName: "teacher" */ '../views/Teacher.vue'),
beforeEnter: (to, from, next) => {
if (process.env.NODE_ENV === 'development') {
import('@/assets/styles/theme-teacher.scss').then(() => {
next();
});
} else {
Promise.all([
import('@/assets/styles/theme-teacher.css'),
import('@/assets/styles/element-ui-common.css')
]).then(() => {
next();
});
}
},
children: [
// ... 教师板块下所有路由
]
},
2. App.vue 监听$route.path
根据$route.path
的特征判定当前页面应该使用哪个主题,然后给<body></body>
元素设置对应class
:
js
export default {
watch: {
'$route.path': {
handler(newVal) {
let bodyClassName = '';
if (/^\/teacher/.test(newVal)) {
bodyClassName = 'theme-teacher';
} else if (/^\/student/.test(newVal)) {
bodyClassName = 'theme-student';
} else {
bodyClassName = 'theme-default';
}
document.body.className = bodyClassName;
},
immediate: true,
},
},
};
使用效果
开发环境下
首页和各个业务板块使用各自的 SCSS 文件,修改变量会即时生效。
如果使用了命名空间包裹 SCSS(前提是适合使用命名空间),则各个业务板块主题配色会正常切换。
如果没有使用命名空间,则只有最后加载的主题配色在全局生效,不过由于开发时程序员只会专注于一个页面,所以这个问题不严重。
生产环境下
实现了 CSS 文件按需加载:访问业务板块、非业务板块都按需加载对应的
theme-*.css
文件。实现了最小量加载:举个例子,假设全量引入样式,且修改了
$--color-primary
、$--color-success
、$--color-danger
、$--color-warning
、$--color-info
这 5 个变量,且必须兼容 IE10 和 IE11,那么:相同文件的大小为 183KB,差异文件有 3 个,大小都是 33KB,可见,有 183KB 的文件被多次复用,这是很大的优化成果。
用户切换主题的解决方案
用户只需点击主题选择器,点击任一色卡即可全局切换主题,这个需求怎么实现呢?
筹备方案阶段
完全同上文方案,有无命名空间均可。
应用方案阶段
与上文完全不同。
- 在
App.vue
使用:
js
if (process.env.NODE_ENV === 'development') {
require('@/assets/styles/theme-default.scss');
} else {
require('@/assets/styles/element-ui-common.css');
require('@/assets/styles/theme-default.css');
}
如果将default
改成teacher
等,就可以调试其他主题的配色,但最终应改回default
。
- 在主题选择器组件使用:
js
onChangeTheme(theme) {
import('@/assets/styles/theme-' + theme + '.css').then(() => {
document.body.className = 'theme-' + theme;
});
}
这段代码没有考虑到开发环境,因为上文说过,如果开发环境没有使用命名空间,那么由于热更新机制的限制,切换后会停留在最后一个主题上,无法切换回去,所以考虑开发环境可能是徒劳的。而且其实这段代码非常简单,只需在生产环境测试通过就够了。
其他问题
如果我想用babel-plugin-import
实现 Element UI 的按需引入,怎么办?
Element UI 官方手册要求在babel.config.js
里加入:
js
"plugins": [
[
"component",
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]
]
在本方案中,你应该删除styleLibraryName
这行,改为"style": false
,因为我们不需要babel-plugin-import
帮我们引入样式。其他一律按官方文档执行即可。
按需引入的话,本方案的 element-ui-components.scss 组件列表整理起来很麻烦,怎么办?
这个列表和 main.js 里的按需加载的列表是大致一一对应的,如果你觉得这个列表很麻烦,那么你就不适合搞按需加载,如果你坚持搞按需加载,就不应该觉得这个列表麻烦,事实上你可以初期先全量加载,开发接近完工再考虑优化。
ExtractCssDiffWebpackPlugin 插件是否可以用在其他 UI 库上?
不修改插件程序的情况下,只要满足这 4 个要求,就可以应用在 Element UI 之外的其他 UI 库上:
该插件只假设 UI 库使用了 Sass 或 SCSS。
该插件假设你在
@/src/assets/styles/
目录存放了若干个theme-*.scss
文件,名称与传参的themeNames
一致。该插件假设 SCSS 编译出的几套 CSS 除了命名空间和属性值之外,没有任何区别。
传参的
themeNames
的第一个元素必须是default
。