老板:我们持续收到用户反馈,希望在UI上支持暗黑主题,用户表示现在的白底看着比较刺眼。这么基础的功能,一个星期搞定够了吗?
我:.......
需求背景/挑战
- 项目是一个迭代了三年的大型跨端项目(React + Electron + antd + gatsby),涉及多个复杂功能模块。
- UI 没有严格的规范化标准,甚至没有标准的色值体系,可以说几乎是野蛮生长的迭代了三年。
- css代码规范混乱,某个元素的样式被全局默认样式,行内样式,外部样式共同作用下,利用优先级关系来实现最后的展示效果。并且部分样式结合了很多业务逻辑,不同的场景下设置了不同的样式,需要梳理业务逻辑,为暗黑主题的开发带来了很多额外的工作量。
- antd 版本过低 & antd 高度定制,项目现在使用的是 antd 4.16.13 版本,4.17.0 版本官方才实验性的提出了动态主题的方案,v5 版本官方正式提供动态主题的解决方案,加上我们 antd 使用过程中,在样式设置以及很多交互逻辑方面进行了高度的定制,导致版本升级基本不可行。另外样式高度定制,对我们希望全局解决组件库的暗黑主题效果带来很多的不确定因素。
方案调研
方案一:DarkMode.js
DarkMode.js 是众多实现 Dark Mode 切换效果的JavaScript库之一。这个库使用CSS混合模式,可以将Dark Mode模式添加到任意网站上。其操作非常的简单,只需要将代码复制到相应的项目中,就可以在Web页面上有一个Dark Mode切换的小控件。DarkMode.js虽然实现Dark Mode切换成本较低,但效果未必能满足项目定制样式需求。
npm i darkmode-js -D
// App.js
import Darkmode from 'darkmode-js'; new Darkmode().showWidget();
方案二:React-styled-components(CSS-in-JS)
这个方案主要是基于 styled-components 库的构建一个允许用户在亮模式和暗模式之间切换的切换器,但是需要采用 css in js 方案对项目重构。
- src/目录下创建 global.js 和 theme.js 两个文件。在 global.js 文件中创建主题需要的基本样式,然后在 theme.js 中创建亮色和暗色系主题需要的变量。
// src/global.js
import { createGlobalStyle } from 'styled-components'
export const GlobalStyles = createGlobalStyle`
body {
height: 100px;
width: 100px;
background: ${({theme}) => theme.body};
color: ${({ theme }) => theme.text };
}
a {
color: ${({ theme }) => theme.text};
}
`;
// src/theme.js
export const lightTheme = {
body: '#e2e2e2',
text: '#363537',
}
export const darkTheme = {
body: '#363537',
text: '#fafafa',
}
- 在 App.js 导入了 lightTheme、darkTheme 和 ThemeProvider,并把 lightTheme 传递给ThemeProvider 的 theme。另外把全局样式 GlobalStyles 引入进来,并且集中放置在同一个地方。
// src/App.js
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { lightTheme, darkTheme } from './theme';
import { GlobalStyles } from './global';
function App() {
return (
<ThemeProvider theme={lightTheme}>
<>
<GlobalStyles />
<button> Toggle Theme</button>
<h1>It's a light theme!</h1>
</>
</ThemeProvider>
);
}
export default App;
方案三:sass 变量 + mixin
优点:可以利用项目现有的sass变量的能力。
缺点:打包后的css文件变大,替换色值有一定的开发量,后期团队使用有一定的学习成本。
// 所有主题样式
$bg-color: (
// 亮色
light: #fff,
// 暗色
dark: #091a28
);
$title-color: (
light: #363636,
dark: #ffffff
);
$subtitle-color: (
light: #4a4a4a,
dark: cyan
);
@mixin themify($key, $valueMap) {
// theme list
$themes: light, dark;
@each $theme in $themes {
[data-theme=#{$theme}] & {
#{$key}: map-get($valueMap, $theme);
}
}
}
.app {
@include themify('background-color', $bg-color);
}
.title {
@include themify('color', $title-color);
}
.subtitle {
@include themify('color', $subtitle-color);
}
方案四:Less + 类名
引入的 .less 文件,需要在浏览器中编译,这对浏览器性能有影响,不建议在生产环境使用。另外,现在项目中用的是 scss,改为 less 有一定的改动成本。
<link rel="stylesheet/less" type="text/css" href="/index.less" />
<script>
window.less = {
async: false,
env: "production" //production development
};
</script>
<script src="https://cdn.bootcss.com/less.js/2.7.3/less.min.js"></script
// theme.light.less
@theme: #363636;
// theme mixin把变量传入
.theme(
@theme,
);
// theme.dark.less
@theme: #ffffff;
// theme mixin把变量传入
.theme(
@theme,
);
// theme_light、theme_dark 是用来切换主题的类名,类名不同应用不同的主题变量
.theme_light {
// 引入定义的theme函数和变量
@import './theme.light.less';
}
.theme_dark {
@import './theme.dark.less';
}
// 可扩展其他主题 ...
方案五:zougt/some-loader-utils + sass
优点:可以很好的利用起现有的scss色值变量的能力,切换也比较平滑。
缺点:打包后css体积变大,另外并没有在现有项目中跑通,存在依赖包之间版本不兼容问题,但是从设计思路上,这个是最好的实践。
方案六:css3 Variables
css3 Variables 优点就是简单,主流的实现方案,缺点就是有一些兼容性问题。
:root {
--text-color: #444;
--background-color: #f4f4f4;
}
:root .theme-dark {
--text-color: rgba(255,255,255,.8);
--background-color: #121212;
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
body.theme-dark {
background-color: var(--background-color);
color: var(--text-color);
}
最终的实现方案
使用 css3 Variables 构建不同主题的色值、css值、背景图等
- 建立 UI 标准化的色值体系
将项目中的所有色值检索出来,去重后交给 UI,协助 UI 梳理出项目中的所有色值,并对色值进行合并,提供出亮色和暗黑的映射关系。
- 配置暗黑主题色值变量
当 body 的 classname 是 lx-theme-dark 生效暗黑主题色值变量
// dark-theme.scss
:root .lx-theme-dark {
--brand-7: #445fe5;
--brand-6: #4c6aff;
}
- 配置亮色主题色值变量
当 body 的 classname 不是 lx-theme-dark 则生效亮色主题色值变量。或者如果上层元素有设置 extheme classname 表示当前单元在暗黑主题下依然保持亮色样式,同样使用亮色主题色值变量。
为什么 暗黑主题下要有 extheme 让一部分内容不生效暗黑主题呢?因为总有一些功能模块是暗黑主题不能处理的,我们需要人为的为一部分模块做暗黑主题的隔离。如果当前 dom 设置了 extheme,那么无论是否在暗黑主题下,该 dom 下所有元素都展示亮色的效果。
// default-theme.scss
:root,
:root .extheme {
--brand-7: #445fe5 !important;
--brand-6: #4c6aff !important;
}
- 使用变量
div {
background: var(--brand-6, #4c6aff);
}
p {
background: var(--brand-7, #445fe5);
}
- 如何解决 css Variables 兼容性问题
目前通用有效的方法都是使用 CSS Polyfill 库,比如 css-vars-ponyfill、postcss-custom-properties 或者 postcss-css-variables 等。
| CSS Polyfill 库 | star | 最近更新 | Github |
|---|---|---|---|
| css-vars-ponyfill | 1400 | 2022.10.13 | github.com/jhildenbidd… |
| postcss-custom-properties | 707 | 2023.7.25 | github.com/csstools/po… |
| postcss-css-variables | 519 | 2023.04.12 | github.com/MadLittleMo… |
根据业务需要以及库自身质量综合考虑最后使用 postcss-custom-properties 来解决 css Variables 的兼容性问题,postcss-custom-properties 会利用 css Variables 的默认值生成兜底代码,插入到使用 css var() 代码的前面。在 css var() 不生效的时候,插入到前面的兜底代码生效。postcss-custom-properties 使用方式以及效果如下:
// "postcss-custom-properties": "10.0.0",
// gatsby-config.js
const exportsContent = {
plugins: [
{
resolve: `gatsby-plugin-sass`,
options: {
postCssPlugins: [require('postcss-custom-properties')()],
},
},
],
};
module.exports = exportsContent;
- 背景图片的特殊处理
需要在 default-theme.scss 中配置亮色下的背景图,dark-theme.scss 中配置暗黑下的背景图,在 variables.scss 中配置背景图片的scss变量,然后业务代码中使用 variables.scss 中的背景图变量。
注意:css Variables 有兼容问题,低版本浏览器可能会不识别,虽然使用了 postcss-custom-properties 来解决兼容性问题,但是背景图片 postcss-custom-properties 并没有生成兼容性代码,所以需要自己写兜底的代码,如下示例:
// default-theme.scss
:root,
:root .extheme {
--logo-qiye: url(../images/icons/login/qiye_mail_logo.png) !important;
}
// dark-theme.scss
:root .lx-theme-dark {
--logo-qiye: url(../images/icons/login/qiye_mail_logo_dark.png);
}
// variables.scss
$logo-qiye: var(--logo-qiye);
// 业务scss使用
div {
background: url('../web/src/images/icons/login/qiye_mail_logo.png') center center no-repeat;
//上面的一行是解决 css Variables 兼容性的兜底代码,防止低版本浏览器不支持css Variables 下面一行中使用 $logo-qiye 会失效,这种情况下上面一行的代码生效,起到兜底的作用。
background: $logo-qiye center center no-repeat;
}
选择跟随系统,如何获取当前系统主题
prefers-color-scheme 是 CSS 媒体特性用于检测用户是否有将系统的主题色设置为亮色或者暗色。使用 prefers-color-scheme 是目前 W3C 官方推荐的检测系统主题的方法,prefers-color-scheme 具有以下优点:
- 标准化:prefers-color-scheme 是 W3C 规范中定义的媒体查询之一,可以在各种浏览器和操作系统中使用,并且未来也不太可能被废弃或更改。
- 灵活性:prefers-color-scheme 可以检测三种主题模式:亮色、暗色和无主题设置。对于支持暗色模式的操作系统,用户可以选择在日间或夜间使用不同的主题模式,而 prefers-color-scheme 可以灵活地检测到这些设置。
- 易于使用:使用 prefers-color-scheme 可以很容易地实现主题模式的检测和切换。只需要使用一个简单的媒体查询即可,而且代码也比较简洁易懂。
因此,使用 prefers-color-scheme 是一种简单、标准化、灵活的检测系统主题的方法,可以满足大部分前端开发的需求。
Window.matchMedia() 方法返回一个新的 MediaQueryList 对象,表示指定的媒体查询 (en-US)字符串解析后的结果。返回的 MediaQueryList 可被用于判定 Document 是否匹配媒体查询,或者监控一个 document 来判定它匹配了或者停止匹配了此媒体查询。
当用户在设置【外观】选择【跟随系统】的时候,会触发系统主题的监听,当系统主题变更时,触发切换主题的操作。
// gatsby-browser.js
const changeColorMode = () => {
let className = '';
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
className = 'lx-theme-dark';
}
const doc = document.querySelector('body');
if (doc) {
doc.classList.remove('lx-theme-dark');
className && doc.classList.add(className);
}
};
if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') {
// 浏览器支持 prefers-color-scheme 获取系统主题模式
const matchMediaHandler = window.matchMedia('(prefers-color-scheme: dark)');
// 监听系统主题
matchMediaHandler?.addEventListener && matchMediaHandler.addEventListener('change', changeColorMode);
}
antd 的暗黑主题样式
- antd 官方动态主题现状
antd 官方在 4.17.0 实验性的支持了动态主题,在 v5 的版本正式支持动态主题,但是办公现在的 antd 版本是 4.16.13,并且上面也提到了,目前也不具备升级版本的条件。不过好在 antd 在 4.0.0 提供了一些官方主题,其中就包括暗黑主题。所以可以对 antd 暗黑主题的样式文件 antd/dist/antd.dark.css 的样式选择器进行改造,当 body 的 classname 是 lx-theme-dark,当前业务场景中 antd 组件上层元素设置 classname 为 ant-allow-dark 则生效暗黑主题样式,否则生效亮色主题样式。
- ant-allow-dark
为什么要 antd 组件上层元素额外设置 classname 为 ant-allow-dark 才生效组件的暗黑样式,主要是出于可控的考虑,开发过程中发现项目中对 antd 的样式做了很多定制,这些定制不仅仅只是颜色,还有边距、高度等,这些定制在暗黑和亮色下都是要不受影响的。
如果一个大模块都需要使用 antd 的暗黑主题样式,只需要在模块最外层的元素的 clasname 设置 ant-allow-dark 就可以。
// lessc --js --modify-var='ant-prefix=.lx-theme-dark .ant-allow-dark .ant' node_modules/antd/dist/antd.dark.less ./src/styles/ant-dark.css
//lessc --js --modify-var='ant-prefix=ant' node_modules/antd/dist/antd.less ./src/styles/ant-default.css
// ant-dark.css
// 部分代码
.lx-theme-dark .ant-allow-dark .ant-fade-appear,
.lx-theme-dark .ant-allow-dark .ant-fade-enter {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
}
.lx-theme-dark .ant-allow-dark .ant-fade-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
}
.lx-theme-dark .ant-allow-dark .ant-fade-appear.ant-fade-appear-active,
.lx-theme-dark .ant-allow-dark .ant-fade-enter.ant-fade-enter-active {
animation-name: antFadeIn;
animation-play-state: running;
}
- ant-dark.css 引入项目
<link rel="stylesheet" href={SUB_PATH + '/ant-dark.css'} type="text/css" />
总结
方案不算复杂,主要坑点在于很多调研的方案落地到自己的项目中,总是会遇到各种问题,一般都是版本问题,项目中的 webpack 的版本,或者其他一些依赖版本过低,导致很多调研的方案跑不通,这是一个折磨并且费时的过程。
另外,对于一个已经开发多年的老项目,实现主题的切换,真的是个体力活,好多三级四级的页面,有时候很难梳理到,提测初期可能会遇到井喷一样的 UI 问题反馈过来,要有心理准备,这也是为什么我们前面设计了 extheme 和 ant-allow-dark css类的原因,有些特殊的功能模块,特殊的页面,不是很常用的,完全可以先隔离,暂时不生效暗黑模式,留着以后慢慢优化。