现在我们经常可以看到一些网站会有类似暗黑模式/白天模式的主题切换功能,效果也是十分炫酷,在平时的开发场景中也有越来越多这样的需求,这里大致罗列一些常见的主题切换方案并分析其优劣,大家可根据需求综合分析得出一套适用的方案。

方案1:link标签动态引入

其做法就是提前准备好几套CSS主题样式文件,在需要的时候,创建link标签动态加载到head标签中,或者是动态改变link标签的href属性。

表现效果如下:

网络请求如下:

优点:

  • 实现了按需加载,提高了首屏加载时的性能

缺点:

  • 动态加载样式文件,如果文件过大网络情况不佳的情况下可能会有加载延迟,导致样式切换不流畅
  • 如果主题样式表内定义不当,会有优先级问题
  • 各个主题样式是写死的,后续针对某一主题样式表修改或者新增主题也很麻烦

方案2:提前引入所有主题样式,做类名切换

这种方案与第一种比较类似,为了解决反复加载样式文件问题提前将样式全部引入,在需要切换主题的时候将指定的根元素类名更换,相当于直接做了样式覆盖,在该类名下的各个样式就统一地更换了。其基本方法如下:

 1
 2body.day .box {
 3  color: #f90;
 4  background: #fff;
 5}
 6
 7body.dark .box {
 8  color: #eee;
 9  background: #333;
10}
11
12.box {
13  width: 100px;
14  height: 100px;
15  border: 1px solid #000;
16}
 1<div class="box">
 2  <p>hello</p>
 3</div>
 4<p>
 5  选择样式:
 6  <button onclick="change('day')">day</button>
 7  <button onclick="change('dark')">dark</button>
 8</p>
 1function change(theme) {
 2  document.body.className = theme;
 3}

表现效果如下:

优点:

  • 不用重新加载样式文件,在样式切换时不会有卡顿

缺点:

  • 首屏加载时会牺牲一些时间加载样式资源
  • 如果主题样式表内定义不当,也会有优先级问题
  • 各个主题样式是写死的,后续针对某一主题样式表修改或者新增主题也很麻烦

方案小结

通过以上两个方案,我们可以看到对于样式的加载问题上的考量就类似于在纠结是做SPA单页应用还是MPA多页应用项目一样。两种其实都误伤大雅,但是最重要的是要保证在后续的持续开发迭代中怎样会更方便。因此我们还可以基于以上存在的问题和方案做进一步的增强。

在做主题切换技术调研时,看到了网友的一条建议:

因此下面的几个方案主要是针对变量来做样式切换

方案3:CSS变量+类名切换

灵感参考:Vue3官网
Vue3官网有一个暗黑模式切换按钮,点击之后就会平滑地过渡,虽然Vue3中也有一个v-bind特性可以实现动态样式绑定,但经过观察以后Vue官网并没有采取这个方案,针对Vue3v-bind特性在接下来的方案中会细说。
大体思路跟方案2相似,依然是提前将样式文件载入,切换时将指定的根元素类名更换。不过这里相对灵活的是,默认在根作用域下定义好CSS变量,只需要在不同的主题下更改CSS变量对应的取值即可。
顺带提一下,在Vue3官网还使用了color-scheme: dark;将系统的滚动条设置为了黑色模式,使样式更加统一。

 1html.dark {
 2  color-scheme: dark;
 3}

实现方案如下:

 1
 2:root {
 3  --theme-color: #333;
 4  --theme-background: #eee;
 5}
 6
 7.dark{
 8  --theme-color: #eee;
 9  --theme-background: #333;
10}
11
12.pink{
13  --theme-color: #fff;
14  --theme-background: pink;
15}
16
17.box {
18  transition: all .2s;
19  width: 100px;
20  height: 100px;
21  border: 1px solid #000;
22  
23  color: var(--theme-color);
24  background: var(--theme-background);
25}

表现效果如下:

优点:

  • 不用重新加载样式文件,在样式切换时不会有卡顿
  • 在需要切换主题的地方利用var()绑定变量即可,不存在优先级问题
  • 新增或修改主题方便灵活,仅需新增或修改CSS变量即可,在var()绑定样式变量的地方就会自动更换

缺点:

  • IE兼容性(忽略不计)
  • 首屏加载时会牺牲一些时间加载样式资源

方案4:Vue3新特性(v-bind)

虽然这种方式存在局限性只能在Vue开发中使用,但是为Vue项目开发者做动态样式更改提供了又一个不错的方案。

简单用法

 1<script setup>
 2  
 3  const theme = {
 4    color: 'red'
 5  }
 6</script>
 7
 8<template>
 9<p>hello</p>
10</template>
11
12<style scoped>
13  p {
14    color: v-bind('theme.color');
15  }
16</style>

Vue3中在style样式通过v-bind()绑定变量的原理其实就是给元素绑定CSS变量,在绑定的数据更新时调用CSSStyleDeclaration.setProperty更新CSS变量值。

实现思考

前面方案3基于CSS变量绑定样式是在:root上定义变量,然后在各个地方都可以获取到根元素上定义的变量。现在的方案我们需要考虑的问题是,如果是基于JS层面如何在各个组件上优雅地使用统一的样式变量?
我们可以利用Vuex或Pinia对全局样式变量做统一管理,如果不想使用类似的插件也可以自行封装一个hook,大致如下:

 1
 2export default {
 3  fontSize: '16px',
 4  fontColor: '#eee',
 5  background: '#333',
 6};
 1
 2export default {
 3  fontSize: '20px',
 4  fontColor: '#f90',
 5  background: '#eee',
 6};
 1import { shallowRef } from 'vue';
 2
 3import theme_day from './theme_day';
 4import theme_dark from './theme_dark';
 5
 6
 7const theme = shallowRef({});
 8
 9export function useTheme() {
10  
11  const localTheme = localStorage.getItem('theme');
12  theme.value = localTheme ? JSON.parse(localTheme) : theme_day;
13  
14  const setDayTheme = () => {
15    theme.value = theme_day;
16  };
17  
18  const setDarkTheme = () => {
19    theme.value = theme_dark;
20  };
21  
22  return {
23    theme,
24    setDayTheme,
25    setDarkTheme,
26  };
27}

使用自己封装的主题hook

 1<script setup lang="ts">
 2import { useTheme } from './useTheme.ts';
 3import MyButton from './components/MyButton.vue';
 4  
 5const { theme } = useTheme();
 6</script>
 7
 8<template>
 9  <div class="box">
10    <span>Hello</span>
11  </div>
12  <my-button />
13</template>
14
15<style lang="scss">
16.box {
17  width: 100px;
18  height: 100px;
19  background: v-bind('theme.background');
20  color: v-bind('theme.fontColor');
21  font-size: v-bind('theme.fontSize');
22}
23</style>
 1<script setup lang="ts">
 2import { useTheme } from '../useTheme.ts';
 3  
 4const { theme, setDarkTheme, setDayTheme } = useTheme();
 5  
 6const change1 = () => {
 7  setDarkTheme();
 8};
 9  
10const change2 = () => {
11  setDayTheme();
12};
13</script>
14
15<template>
16  <button class="my-btn" @click="change1">dark</button>
17  <button class="my-btn" @click="change2">day</button>
18</template>
19
20<style scoped lang="scss">
21.my-btn {
22  color: v-bind('theme.fontColor');
23  background: v-bind('theme.background');
24}
25</style>

表现效果如下:

其实从这里可以看到,跟Vue的响应式原理一样,只要数据发生改变,Vue就会把绑定了变量的地方通通更新。

优点:

  • 不用重新加载样式文件,在样式切换时不会有卡顿
  • 在需要切换主题的地方利用v-bind绑定变量即可,不存在优先级问题
  • 新增或修改主题方便灵活,仅需新增或修改JS变量即可,在v-bind()绑定样式变量的地方就会自动更换

缺点:

  • IE兼容性(忽略不计)
  • 首屏加载时会牺牲一些时间加载样式资源
  • 这种方式只要是在组件上绑定了动态样式的地方都会有对应的编译成哈希化的CSS变量,而不像方案3统一地就在:root上设置(不确定在达到一定量级以后的性能),也可能正是如此,Vue官方也并未采用此方式做全站的主题切换

方案5:SCSS + mixin + 类名切换

主要是运用SCSS的混合+CSS类名切换,其原理主要是将使用到mixin混合的地方编译为固定的CSS以后,再通过类名切换去做样式的覆盖,实现方案如下:
定义SCSS变量

 1
 2$font_samll:12Px;
 3$font_medium_s:14Px;
 4$font_medium:16Px;
 5$font_large:18Px;
 6
 7
 8$background-color-theme: #d43c33;//背景主题颜色默认(网易红)
 9$background-color-theme1: #42b983;//背景主题颜色1(QQ绿)
10$background-color-theme2: #333;//背景主题颜色2(夜间模式)
11
12 
13$background-color-sub-theme: #f5f5f5;//背景主题颜色默认(网易红)
14$background-color-sub-theme1: #f5f5f5;//背景主题颜色1(QQ绿)
15$background-color-sub-theme2: #444;//背景主题颜色2(夜间模式)
16
17
18$font-color-theme : #666;//字体主题颜色默认(网易)
19$font-color-theme1 : #666;//字体主题颜色1(QQ)
20$font-color-theme2 : #ddd;//字体主题颜色2(夜间模式)
21
22
23$font-active-color-theme : #d43c33;//字体主题颜色默认(网易红)
24$font-active-color-theme1 : #42b983;//字体主题颜色1(QQ绿)
25$font-active-color-theme2 : #ffcc33;//字体主题颜色2(夜间模式)
26
27
28$border-color-theme : #d43c33;//边框主题颜色默认(网易)
29$border-color-theme1 : #42b983;//边框主题颜色1(QQ)
30$border-color-theme2 : #ffcc33;//边框主题颜色2(夜间模式)
31
32
33$icon-color-theme : #ffffff;//边框主题颜色默认(网易)
34$icon-color-theme1 : #ffffff;//边框主题颜色1(QQ)
35$icon-color-theme2 : #ffcc2f;//边框主题颜色2(夜间模式)
36$icon-theme : #d43c33;//边框主题颜色默认(网易)
37$icon-theme1 : #42b983;//边框主题颜色1(QQ)
38$icon-theme2 : #ffcc2f;//边框主题颜色2(夜间模式)

定义混合mixin

 1@import "./variable.scss";
 2
 3@mixin bg_color(){
 4  background: $background-color-theme;
 5  [data-theme=theme1] & {
 6    background: $background-color-theme1;
 7  }
 8  [data-theme=theme2] & {
 9    background: $background-color-theme2;
10  }
11}
12@mixin bg_sub_color(){
13  background: $background-color-sub-theme;
14  [data-theme=theme1] & {
15    background: $background-color-sub-theme1;
16  }
17  [data-theme=theme2] & {
18    background: $background-color-sub-theme2;
19  }
20}
21
22@mixin font_color(){
23  color: $font-color-theme;
24  [data-theme=theme1] & {
25    color: $font-color-theme1;
26  }
27  [data-theme=theme2] & {
28    color: $font-color-theme2;
29  }
30}
31@mixin font_active_color(){
32  color: $font-active-color-theme;
33  [data-theme=theme1] & {
34    color: $font-active-color-theme1;
35  }
36  [data-theme=theme2] & {
37    color: $font-active-color-theme2;
38  }
39}
40
41@mixin icon_color(){
42    color: $icon-color-theme;
43    [data-theme=theme1] & {
44        color: $icon-color-theme1;
45    }
46    [data-theme=theme2] & {
47        color: $icon-color-theme2;
48    }
49}
50
51@mixin border_color(){
52  border-color: $border-color-theme;
53  [data-theme=theme1] & {
54    border-color: $border-color-theme1;
55  }
56  [data-theme=theme2] & {
57    border-color: $border-color-theme2;
58  }
59}
 1<template>
 2  <div class="header" @click="changeTheme">
 3    <div class="header-left">
 4      <slot name="left">左边</slot>
 5    </div>
 6    <slot name="center" class="">中间</slot>
 7    <div class="header-right">
 8      <slot name="right">右边</slot>
 9    </div>
10  </div>
11</template>
12
13<script>
14  export default {
15    name: 'Header',
16    methods: {
17      changeTheme () {
18        document.documentElement.setAttribute('data-theme', 'theme1')
19      }
20    }
21  }
22</script>
23
24<style scoped lang="scss">
25@import "../assets/css/variable";
26@import "../assets/css/mixin";
27.header{
28  width: 100%;
29  height: 100px;
30  font-size: $font_medium;
31  @include bg_color();
32}
33</style>

表现效果如下:

可以发现,使用mixin混合在SCSS编译后同样也是将所有包含的样式全部加载:

这种方案最后得到的结果与方案2类似,只是在定义主题时由于是直接操作的SCSS变量,会更加灵活。

优点:

  • 不用重新加载样式文件,在样式切换时不会有卡顿
  • 在需要切换主题的地方利用mixin混合绑定变量即可,不存在优先级问题
  • 新增或修改主题方便灵活,仅需新增或修改SCSS变量即可,经过编译后会将所有主题全部编译出来

缺点:

  • 首屏加载时会牺牲一些时间加载样式资源

方案6:CSS变量+动态setProperty

此方案较于前几种会更加灵活,不过视情况而定,这个方案适用于由用户根据颜色面板自行设定各种颜色主题,这种是主题颜色不确定的情况,而前几种方案更适用于定义预设的几种主题。
方案参考:vue-element-plus-admin
主要实现思路如下:
只需在全局中设置好预设的全局CSS变量样式,无需单独为每一个主题类名下重新设定CSS变量值,因为主题是由用户动态决定。

 1:root {
 2  --theme-color: #333;
 3  --theme-background: #eee;
 4}

定义一个工具类方法,用于修改指定的CSS变量值,调用的是CSSStyleDeclaration.setProperty

 1export const setCssVar = (prop: string, val: any, dom = document.documentElement) => {
 2  dom.style.setProperty(prop, val)
 3}

在样式发生改变时调用此方法即可

 1setCssVar('--theme-color', color)

表现效果如下:

vue-element-plus-admin主题切换源码:

这里还用了vueuseuseCssVar不过效果和Vue3中使用v-bind绑定动态样式是差不多的,底层都是调用的CSSStyleDeclaration.setProperty这个api,这里就不多赘述vueuse中的用法。

优点:

  • 不用重新加载样式文件,在样式切换时不会有卡顿
  • 仔细琢磨可以发现其原理跟方案4利用Vue3的新特性v-bind是一致的,只不过此方案只在:root上动态更改CSS变量而Vue3中会将CSS变量绑定到任何依赖该变量的节点上。
  • 需要切换主题的地方只用在:root上动态更改CSS变量值即可,不存在优先级问题
  • 新增或修改主题方便灵活

缺点:

  • IE兼容性(忽略不计)
  • 首屏加载时会牺牲一些时间加载样式资源(相对于前几种预设好的主题,这种方式的样式定义在首屏加载基本可以忽略不计)

方案总结

说明:两种主题方案都支持并不代表一定是最佳方案,视具体情况而定。

方案/主题样式固定预设主题样式主题样式不固定
方案1:link标签动态引入√(文件过大,切换延时,不推荐)×
方案2:提前引入所有主题样式,做类名切换×
方案3:CSS变量+类名切换√(推荐)×
方案4:Vue3新特性(v-bind)√(性能不确定)√(性能不确定)
方案5:SCSS + mixin + 类名切换√(推荐,最终呈现效果与方案2类似,但定义和使用更加灵活)×
方案6:CSS变量+动态setProperty√(更推荐方案3)√(推荐)
个人笔记记录 2021 ~ 2025