前端多主题实现及切换方案

随着操作系统支持暗色模式之后,多主题逐渐流行起来,恰好最近在产品内实现了兼容多套主题的方案,于是记录一下

<!--more-->

什么是主题?简单来说,就是应用的UI风格,比如最常见的亮色Light Mode和黑夜Dark Mode主题,为了用户的阅读体验,如果背景白色、文字就要深色;反之如果背景是深色、文字就要浅色。

从代码实现上来看,每一套主题实际上就是对应了一套CSS样式表,因此多主题的切换,实际上就是多套CSS的切换。

由于CSS属于DSL,处理逻辑条件的能力比较弱,因为本文主要讨论的就是存在多套主题时,CSS样式的实现、切换和维护。

1. 多主题实现

最简单的主题切换就是通过父类选择器来限制样式的作用范围,比如

.light-theme {
    .content {
        background: #fff;
        color: #000;
    }
    // ...其他需要展示不同主题样式的类
}
.dark-theme {
    .content {
        background: #000;
        color: #fff;
    }
    // ...其他需要展示不同主题样式的类
}
.content {
    // 与主题无关的其他样式
    padding: 10px;
}

1.1. SCSS变量

所有需要根据当前主题展示不同UI的组件,都需要编写多套主题UI代码,可以借助scss,来维护相同的变量如类型、颜色等信息

$theme-light: '.theme-light';
$theme-dark: '.theme-dark';

$light-bg-color: #fff;
$light-text-color: #000;

$dark-bg-color: #000;
$dark-text-color: #fff;
// 对应主题
#{$theme-light} {
    .content {
        background: $light-bg-color;
        color: $light-text-color;
    }
     // ...其他需要展示不同主题样式的类,可以复用$light-*相关的变量
}

#{$theme-dark} {
    .content {
        background: $dark-bg-color;
        color: $dark-text-colo;
    }
}

这样,即使修改了主题类名和主题变量,只需要在原始定义的地方修改,无需再深入到每个组件文件中去改动了。

1.2. CSS变量

尽管SCSS变量已经节省了大量后续迭代的维护工作,但由于每套主题都需要编写对应的样式规则,因此上面的写法存在很多重复的地方,比如

.xxx {
    background: xxx;
    color: xxx;
}

要解决样式规则重复的问题,可以使用CSS变量。具体的CSS变量规则及使用,可以参考CSS 变量教程 - 阮一峰

SCSS变量与CSS变量最大的区别就在于:CSS变量是浏览器默认支持的,可以近似认为增强了CSS的逻辑表达能力。

因此,上面的代码可以直接改成

.light-theme {
    --bg-color: #fff;
    --text-color: #000;
}
.dark-theme {
    --bg-color: #000;
    --text-color: #fff;
}

.content {
    background: var(--bg-color);
    color: var(--text-color);
    padding: 10px;
}

这样的话,父类就只负责维护CSS变量,不再承担限制样式类范围的功能,组件只需要在需要切换主题的地方使用CSS变量替代就可以,无需再进行父类的嵌套。

实际业务中,在同一个主题下,可能需要定义数十个CSS与主题相关的CSS变量。CSS变量很适合存在颜色、通用尺寸等主题基础信息,但可能由于某些特殊的业务场景需求,在不同主题下,要求布局也发生变化。

如果用css变量来控制布局,根节点需要定义非常多信息,十分繁琐。因此通过父类选择器来限定不同主题下样式的作用范围,还是有用武之地的。

#{$theme-light} {
    .container {
        display: flex; 
        width: 100px; // 这些特定尺寸信息就不太适合放在css变量中
    }
}

#{$theme-dark} {
    .container {
        width: 200px;
        display: flex;
        flex-direction: row-reverse;
    }
}

如果在不同主题下两个组件在布局、数据源等地方差异非常大,这个时候就不太适合用CSS来处理了,更合理的方案可能是动态组件:按主题拆分成不同的组件,在对应主题下切换成对应的组件。

1.3. 原子类

tailwindwindicss等框架提供了配置theme主题的功能,大概原理就是在配置文件中,预先定义一批与主题相关的变量,然后在编译css的时候,用主题变量替换对应的占位符,与SCSS变量比较类似。

theme配置项可配置主要包括颜色集、字体、边框、断点等。

下面演示了windicss中使用主题变量的方式

// windi.config.js
import { defineConfig } from 'windicss/helpers'
import colors from 'windicss/colors'
import plugin from 'windicss/plugin'

export default defineConfig({
  darkMode: 'class', // or 'media'
  theme: {
    extend: {
      colors: {
        blue: colors.sky,
        red: colors.rose,
        pink: colors.fuchsia,
      }
    },
  },
}

然后就可以通过theme指令来读取对应的配置变量

.btn-blue {
  background-color: theme("colors.blue.500");
}

最后会被编译成

.btn-blue {
  background-color: #3b82f6;
}

于是,我们可以配置多套主题色,然后编译出多套CSS代码,多套CSS代码在主题切换时要更麻烦一点,因此这个方案一般不做考虑。

但借助原子类的思想,我们可以把与主题相关的样式抽象为多个原子类,如bg-primarytext-primary

.bg-primary {
    background: var(--bg-color);
}
.text-primary {
    color: var(--text-color);
}

这样就不用一遍遍地在各个样式类中重复var(--bg-color)等CSS变量了

2. 多主题切换

主题切换比较简单,这里只大概介绍一下。

2.1. 媒介查询自动切换主题

如果只需要跟随系统实现亮色和暗色模式的主题自动切换,可以使用@media(prefers-color-scheme)媒介查询来实现

@media (prefers-color-scheme: light) {
  :root {
    --bg-color: #fff;
    --text-color: #000;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #000;
    --text-color: #fff;
  }
}

定义好对应主题的CSS变量就行了,剩下的交给操作系统。

这个方案的缺点在于不灵活,无法支持用户主动切换主题,比如要在操作系统亮色模式下使用产品的暗色模式之类的场景。

2.2. JavaScript切换主题

通过JavaScript动态切换根节点的主题类名,是一种更普遍的做法

document.body.classList.toggle('light-theme')
document.body.classList.toggle('dark-theme')

然后在不同的主题类下面定义对应的CSS变量

.light-theme {
    --bg-color: #fff;
    --text-color: #000;
}
.dark-theme {
    --bg-color: #000;
    --text-color: #fff;
}

基于这个思路,甚至可以实现让用户自定义对应主题的功能。

如果为不同的主题准备了不同的样式表,那使用link来切换主题也许是个不错的方案

link标签有一个rel的属性字段,可以指定值为alternate,参考

下面演示了大致的主题切换方案,假设有dark.csslight.css这两个主题对应的样式表

首先指定rel="alternate stylesheet",这样浏览器就只会加载文件,但不会渲染样式表

<link href="light.css" rel="alternate stylesheet" type="text/css" title="红色">
<link href="dark.css" rel="alternate stylesheet" type="text/css" title="绿色">

然后在切换事件触发时,修改对应节点的disbaled属性

// 切换成light这个样式表
document.querySelector('link[href="light.css"]').disabled = false;
document.querySelector('link[href="dark.css"]').disabled = true;

由于多主题多套样式表目前看起来并不是很主流的方案,这里就不再展开了。

3. 小结

本文主要整理了多主题的实现思路和切换方案,欢迎交流。