React 暗黑模式 + 一键换肤

785 阅读10分钟

老板:我们持续收到用户反馈,希望在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 方案对项目重构。

  1. 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',
}
  1. 在 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体积变大,另外并没有在现有项目中跑通,存在依赖包之间版本不兼容问题,但是从设计思路上,这个是最好的实践。

官方文档:zougt/some-loader-utils

方案六: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值、背景图等

  1. 建立 UI 标准化的色值体系

将项目中的所有色值检索出来,去重后交给 UI,协助 UI 梳理出项目中的所有色值,并对色值进行合并,提供出亮色和暗黑的映射关系。

  1. 配置暗黑主题色值变量

当 body 的 classname 是 lx-theme-dark 生效暗黑主题色值变量

// dark-theme.scss
:root .lx-theme-dark {
    --brand-7: #445fe5;
    --brand-6: #4c6aff;
}
  1. 配置亮色主题色值变量

当 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;
}
  1. 使用变量
div {
    background: var(--brand-6, #4c6aff);
}
p {
    background: var(--brand-7, #445fe5);
}
  1. 如何解决 css Variables 兼容性问题

目前通用有效的方法都是使用 CSS Polyfill 库,比如 css-vars-ponyfill、postcss-custom-properties 或者 postcss-css-variables 等。

3.png

CSS Polyfill 库star最近更新Github
css-vars-ponyfill14002022.10.13github.com/jhildenbidd…
postcss-custom-properties7072023.7.25github.com/csstools/po…
postcss-css-variables5192023.04.12github.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;

image.png

  1. 背景图片的特殊处理

需要在 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;
}

选择跟随系统,如何获取当前系统主题

  1. prefers-color-scheme

prefers-color-scheme 是 CSS 媒体特性用于检测用户是否有将系统的主题色设置为亮色或者暗色。使用 prefers-color-scheme 是目前 W3C 官方推荐的检测系统主题的方法,prefers-color-scheme 具有以下优点:

  1. 标准化:prefers-color-scheme 是 W3C 规范中定义的媒体查询之一,可以在各种浏览器和操作系统中使用,并且未来也不太可能被废弃或更改。
  2. 灵活性:prefers-color-scheme 可以检测三种主题模式:亮色、暗色和无主题设置。对于支持暗色模式的操作系统,用户可以选择在日间或夜间使用不同的主题模式,而 prefers-color-scheme 可以灵活地检测到这些设置。
  3. 易于使用:使用 prefers-color-scheme 可以很容易地实现主题模式的检测和切换。只需要使用一个简单的媒体查询即可,而且代码也比较简洁易懂。

因此,使用 prefers-color-scheme 是一种简单、标准化、灵活的检测系统主题的方法,可以满足大部分前端开发的需求。

  1. Window.matchMedia()

Window.matchMedia() 方法返回一个新的 MediaQueryList 对象,表示指定的媒体查询 (en-US)字符串解析后的结果。返回的 MediaQueryList 可被用于判定 Document 是否匹配媒体查询,或者监控一个 document 来判定它匹配了或者停止匹配了此媒体查询。

当用户在设置【外观】选择【跟随系统】的时候,会触发系统主题的监听,当系统主题变更时,触发切换主题的操作。

image.png

// 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 的暗黑主题样式

  1. 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 则生效暗黑主题样式,否则生效亮色主题样式。

  1. 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;
}
  1. ant-dark.css 引入项目
<link rel="stylesheet" href={SUB_PATH + '/ant-dark.css'} type="text/css" />

总结

方案不算复杂,主要坑点在于很多调研的方案落地到自己的项目中,总是会遇到各种问题,一般都是版本问题,项目中的 webpack 的版本,或者其他一些依赖版本过低,导致很多调研的方案跑不通,这是一个折磨并且费时的过程。

另外,对于一个已经开发多年的老项目,实现主题的切换,真的是个体力活,好多三级四级的页面,有时候很难梳理到,提测初期可能会遇到井喷一样的 UI 问题反馈过来,要有心理准备,这也是为什么我们前面设计了 extheme 和 ant-allow-dark css类的原因,有些特殊的功能模块,特殊的页面,不是很常用的,完全可以先隔离,暂时不生效暗黑模式,留着以后慢慢优化。