动态主题方案?一文解决你的所有问题

659 阅读10分钟

掘友们大家好呀,我是pepedd864。

在看到本文前,你做过的前端主题样式需求有哪些:暗色模式?多颜色主题?根据主题颜色自动延申出沉浸色?修改系统背景图片识别颜色并添加沉浸色?这篇文章将带你了解各种样式切换方案并最终解决我们开头的问题。

在此之前,我写过一篇多主题样式的解决方案ant-design-vue 4.x实现动态主题和暗色模式 - 掘金 (juejin.cn)

本文相当于从基础到优化,根本解决样式问题。

文章目录思维导图

image-20240701232735407

1. 基本的样式方案

这里是一些基本的样式切换方案

1.1 动态引入样式文件

这个方案是经典的方法,对各种主题的定制也是比较方便的,定制程度是最大化的。

因为它是每一个文件对应一种整体的样式,你在其中可以尽可能修改你网页的细节,不单单是颜色之类的,你还可以设置不同的元素大小,各种特效等等。就像Markdown编辑器的主题方案

1.1.1 两种引入方式

它可以用两种方法,分别是

  • 动态更改linkhref引入
<link rel="stylesheet" href="style.css">
  • 使用@import指令引入
@import url('light.css')

1.1.2 使用媒体查询

同时他们还可以这样

<link rel="stylesheet" media="screen and (prefers-color-scheme: light)" href="./light.css">
<link rel="stylesheet" media="screen and (prefers-color-scheme: dark)" href="./dark.css">

和这样

<style>
    @import url('dark.css') screen and (prefers-color-scheme: dark);
    @import url('light.css') screen and (prefers-color-scheme: light);
</style>

1.1.3 通过JS操作

通过JS操作link标签的href属性,也可以动态改变引入的样式

例如这样

<link rel="stylesheet" data-type="css-link" href="./light.css">
<script>
  const link = document.querySelector('[data-type="css-link"]');
  const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
  if (darkQuery.matches) {
    link.href = './dark.css';
  }
  darkQuery.addEventListener('change', e => {
    if (e.matches) {
      link.href = './dark.css';
    } else {
      link.href = './light.css';
    }
  });
</script>

1.2 使用类名或者自定义属性切换

上面的方案中,如果网站对于定制化程度并不是很高

便可以通过类名切换主题

例如上面的,可以改成

/* light样式主题 */
html.light .box {
    color: #000;
    background: #fff;
}
/* dark样式主题 */
html.dark .box {
    color: #eee;
    background: #333;
}

.box {
    width: 100px;
    height: 100px;
}


或者使用自定义属性data-theme

/* light样式主题 */
html[data-theme='light'] .box {
    color: #000;
    background: #fff;
}
/* dark样式主题 */
html[data-theme='dark'] .box {
    color: #eee;
    background: #333;
}

.box {
    width: 100px;
    height: 100px;
}

这样可以加快切换速度,但是内容量上来了之后,其实网站的加载会很慢的,而且控制也不好控制,缺点基本都是link方式的缺点。

1.3 CSS变量

1.3.1 静态切换

和上面差不多,只不过操作的是CSS变量,这种方案中,CSS变量可以方便修改,**动态计算(使用calc或者各种CSS内置函数)**等等

例如让GPT生成有哪些CSS内置函数

calc(): 用于计算长度值,例如 width: calc(100% - 50px)。  
attr(): 返回元素属性的值,例如 content: attr(data-content)。  
url(): 用于加载外部资源,例如 background-image: url('image.jpg')。  
rgb(), rgba(), hsl(), hsla(): 用于定义颜色值。  
linear-gradient(), radial-gradient(): 用于创建渐变背景。  
var(): 用于引用CSS自定义属性(变量)的值,例如 color: var(--main-color)。  
min(), max(), clamp(): 用于响应式设计,例如 font-size: clamp(1rem, 2vw, 1.5rem)。  
repeat(): 用于定义网格布局的重复轨道列表,例如 grid-template-columns: repeat(3, 1fr)。  
fit-content(): 用于定义一个元素的尺寸,例如 width: fit-content(20%)。  
counter-reset, counter-increment, counter(), counters(): 用于创建或控制计数器。  
:root {
    --color: #000;
    --background: #fff;
    --ele-color: #eee;
    --ele-background: #333;
    --ele-radius: 5px;
    /*为'box'这个元素单独设置一个变量*/
    --box-radius-ratio: 1.2
}

body {
    color: var(--color);
    background: var(--background);
}

html[data-theme='dark']:root {
    --color: #eee;
    --background: #000;
    --ele-color: #333;
    --ele-background: #eee;
}

html[data-theme='light']:root {
    --color: #000;
    --background: #fff;
    --ele-color: #eee;
    --ele-background: #333;
}


div {
    width: 100px;
    height: 100px;
    color: var(--ele-color);
    background: var(--ele-background);
    border-radius: var(--ele-radius);
}

.box {
    border-radius: calc(var(--ele-radius) * var(--box-radius-ratio));
}

使用原生CSS变量是一种效率相对较高的方式,并且,它可以控制整体样式,因为如果在类名中直接定义属性值,会导致编码时难以统一颜色,大小圆角等等,使用CSS变量就可以将其定义为一种一种的变量,便于管理和控制

1.3.2 使用JS代码动态操作CSS变量

使用CSS变量的另一大好处就是可以使用JS API的CSSStyleDeclaration.setProperty方法。比如我在:root中再添加一个--primary-color变量,那么我可以定义bluegreenred等等几组样式,但是如果我需要能够使用取色盘获取颜色呢,这就需要使用JS去操作CSS变量。

例如soybean的主题控制:

gif

:root {
    ...
    --primary-color: blue;
    ...
}

使用setProperty方法

dom.style.setProperty(prop, val)

2. 使用预处理器和CSS框架方案

这里主要是如Sass预处理器和TailwindCSS、UnoCSS一类原子化CSS框架的方案

2.1 使用SASS(SCSS)预处理器

这里主要是和[1.2 使用类名或者自定义属性切换](##1.2 使用类名或者自定义属性切换)差不多。但是使用了Sass简化的代码书写(并没有减少代码量,甚至代码量非常大,会带来很大的性能消耗)。

比如下面这段代码

body {
  position: relative;
  transition: background-color 0.3s,
  color 0.3s;
  html[data-dark='light'][data-theme='red'] & {
    background-color: red;
    color: #000;
  }
}

data-dark='light'data-theme='red'同时成立时,背景颜色设置成红色

那如果我们需要设置多组主题和模式时,代码就会变成下面这个样子

body {
  position: relative;
  transition: background-color 0.3s,
  color 0.3s;
  html[data-dark='light'][data-theme='red'] & {
    background-color: red;
    color: #000;
  }
  html[data-dark='light'][data-theme='orange'] & {
    background-color: orange;
    color: #000;
  }
  html[data-dark='light'][data-theme='yellow'] & {
    background-color: yellow;
    color: #000;
  }
  html[data-dark='light'][data-theme='cyan'] & {
    background-color: cyan;
    color: #000;
  }
  ...
}

因此,我们需要使用sass来帮我们减少重复的代码,使用@mixin@each可以批量生成上面重复的片段

$modes: (
        light: (
                bgColor: #fff,
                infoColor: #000
        ),
        dark: (
                bgColor: #000,
                infoColor: #fff
        )
);

@mixin useTheme() {
  @each $key, $value in $modes {
    html[data-dark='#{$key}'] & {
      @content;
    }
  }
}

下面这段代码

body {
  position: relative;
  transition: background-color 0.3s,
  color 0.3s;
  @include useTheme {
  }
}

相当于

body {
  position: relative;
  transition: background-color 0.3s,
  color 0.3s;
  html[data-dark='light'] & {

  }
  html[data-dark='dark'] & {

  }
  ...
}

同样的,因为我们使用了主题色和亮暗模式的组合,所以也需要使用到两层@each循环遍历

@mixin useTheme() {
  @each $key1, $value1 in $modes {
    @each $key2, $value2 in $colors {
      html[data-dark='#{$key1}'][data-theme='#{$key2}'] & {
        @content;
      }
    }
  }
}

接下来,就是根据当前的主题和模式返回对应的颜色了,这里我们需要一个全局变量存储当前的颜色和模式

$curMode: light;
$curTheme: red;
@mixin useTheme() {
  @each $key1, $value1 in $modes {
    $curMode: $key1 !global;
    @each $key2, $value2 in $colors {
      $curTheme: $key2 !global;
      html[data-dark='#{$key1}'][data-theme='#{$key2}'] & {
        @content;
      }
    }
  }
}

并完成颜色的定义

$colors: (
        red: (
                primary: $red,
                info: $red,
        ),
        orange: (
                primary: $orange,
                info: $orange,
        ),
        yellow: (
                primary: $yellow,
                info: $yellow,
        ),
        cyan: (
                primary: $cyan,
                info: $cyan,
        ),
        green: (
                primary: $green,
                info: $green,
        ),
        blue: (
                primary: $blue,
                info: $blue,
        ),
        purple: (
                primary: $purple,
                info: $purple,
        )
);

然后,写一个根据$curMode$curTheme返回对应值的函数

@function getModeVar($key) {
  $modeMap: map-get($modes, $curMode);
  @return map-get($modeMap, $key);
}

@function getColor($key) {
  $themeMap: map-get($colors, $curTheme);
  @return map-get($themeMap, $key);
}

然后在混合里面就可以使用函数获取当前主题或模式对应的颜色值了

body {
  position: relative;
  transition: background-color 0.3s,
  color 0.3s;
  @include useTheme {
    background-color: getModeVar('bgColor');
    color: getModeVar('infoColor');
  }
}

使用theme.scss

<template>
	<div class="test">test</div>
</template>
<style lang="scss" scoped>
@import "@/styles/theme";

.test {
  @include useTheme {
    background: getColor('primary');
  }
}
</style>

如果大家理解了这种方法的话,其实会觉得这种方法写代码确实比较方便,因为Sass内置丰富的如变量、循环、分支、函数等等可以让我们写出的样式十分灵活且简洁。

比如在我的项目中,使用Sass的内置函数方便的提取出颜色的色相进行转换得到各种沉浸色。

image-20240619110755489

代码像这样

image-20240619111020005

再讲为什么他会导致代码量非常大,并且性能损耗很大。

因为使用的是双层循环生成,时间复杂度达到了O(n2)O(n^2),不过预处理器会在代码编译阶段生成对应的CSS代码,它可能会导致编译时间增加一点,但是编译工具的效率一般都非常高,不会受这个影响,除非你的循环层数非常多,代码中使用的地方非常多,那应该是你的内存先承受不住了。

像这样的代码

body {
  @include useTheme {
    background-color: getModeVar('bgColor');
    color: getModeVar('infoColor');
  }
}

他会编译成28=162*8=16个差不多的代码,一份代码能解决的事情,变成了16份,CSS文件的大小会变得很大

2.2 原子化CSS框架

这里我就讲Tailwind CSS,没用过UnoCSS

TailwindCSS只需一个dark指令便可完成跟随系统主题切换

body {
    @apply dark:bg-gray-800 bg-gray-50;
}

当然也支持手动操作,更多可查看Tailwind CSS 文档 Dark Mode - Tailwind CSS

它支持一个theme函数,它可以像这样使用

body {
    background: theme(colors.primary);
}

tailwind.config.js定义值即可

export default {
  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  theme: {
    colors: {
      'primary': '#f500ff',
    },
  },
  corePlugins: {
    preflight: false,
  },
  plugins: [],
}

但是要注意在sass中的使用,它应该是这样的

body {
  background: #{'theme(colors.primary)'};
}

更多的以后再更新 TODO

3. 前端框架方案

3.1 Vue v-bind方案

<script setup>
import {useAppStore} from "@/stores/app.js"
import variables from '@/styles/variables.module.scss'
import {ref} from "vue";

const app = useAppStore()
const backgroundColor = ref('')

function toggleColor(color) {
  console.log(color)
  backgroundColor.value = color
}
</script>

<template>
  <a-config-provider :theme="app.themeConfig">
    <a-select v-model:value="app.themeName" style="width: 240px">
      <a-select-option v-for="(color, name) in variables" :value="name"> {{ name }}:{{ color }}</a-select-option>
    </a-select>
    <a-button-group>
      <a-button @click="toggleColor(variables[app.themeName])">切换主题- {{ app.themeName }}</a-button>
    </a-button-group>
    <div class="test">test</div>
  </a-config-provider>
</template>

<style lang="scss" scoped>
@import "@/styles/theme.scss";

.test {
  background: v-bind(backgroundColor);
}
</style>

使用的就是Vue3 的 v-bind这个指令

底层也是使用CSSStyleDeclaration.setProperty实现的,这里带上了Vue scoped的样式隔离,计算了一个独一无二的哈希值 。

3.2 React CSS in JS方案

CSS in JS是一种方案,Vue也能用,不过大多数都是在React中使用的。

例如

import {useState} from 'react';
import styled from 'styled-components';

const StyledButton = styled.button`
  background-color: ${props => props.color};
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s ease;
`;

function App() {
    const [color, setColor] = useState('blue');

    const handleClick = () => {
        setColor(prevColor => prevColor === 'blue' ? 'green' : 'blue');
    };

    return (
        <StyledButton color={color} onClick={handleClick}>
            Click me
        </StyledButton>
    );
}

export default App;

它的话,几乎就是纯JS了,那么你可以定义一个全局的样式变量来控制组件的样式,例如Ant Design Vue就是使用CSS in JS方案实现的主题。

4. 实战

首先,我们需要明白一点,在真实的项目中,不可能只用CSS就能实现完整的复杂样式操作;也不会用JS去进行消耗性能的CSS操作。更多是时候是,CSS负责样式,JS负责逻辑,就像我们学习这两个语言时一样。

我们这里参考Soybean的主题实现方案,在他的官方文档中也给出了,我这里实现一个简单版本的主题控制。系统主题 | SoybeanAdmin (soybeanjs.cn)

4.1 生成颜色调色盘

第一步就是生成一系列颜色调色盘,这样我们便可以根据一组颜色去设计网页整体风格

更高级的可能还会加入二级颜色,像这样

不过我们这里实现一级颜色即可。

实现的效果像这个示例网址给出的一样。uicolors.app/create

gif

我们可以观察到,它是由一个主颜色生成了11个颜色,其中“锁”所在的位置便是主颜色,并且各个颜色块上的字体都是适配背景的暗亮色和根据背景生成的沉浸色。

同时它支持暗亮色模式,外部字体和body的背景会跟着模式切换而变化,但是字体是常规的颜色。

image-20240624105612294

分析思路

我们需要使用HSL转换颜色

什么是HSL:

  • H即色相:8bit颜色可分为256份不同的颜色,使用环状图像表示。

  • S即饱和度:代表颜色的鲜艳程度,S越低,颜色越偏向灰色,S越高,颜色的灰度会减小。

  • L即亮度:代表颜色是更偏向于白色还是黑色,L越高,颜色越亮。

  1. 首先我们根据颜色获得H(色相)、S(饱和度)、L(亮度)
  2. 设置亮度的最大值和最小值,
  3. 然后根据当前颜色的亮度将其放在11等分颜色的某个颜色格子处,
  4. 再根据这个颜色,等分(例如向左加7.2的亮度,向右减7.2的亮度),最终得到11个颜色。

屏幕截图 2024-06-24 195457

这里我们需要使用一个colord库,方便我们进行颜色的转化。

例如我们使用

const color = '#1890ff'

console.log(getHsl(color));

得到

{h: 209, s: 100, l: 55, a: 1}

我们根据生成的规则,可以写出如下代码

import {getHex, getHsl} from "./theme/utils/colord.ts";
import {colord} from "colord";

const color = '#ff0000'
// 调色盘数值数组
const colorPaletteNumbers = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
const len = colorPaletteNumbers.length
// 生成的颜色数组
let colorPalette = []
const min = 10 // 亮度最小值
const max = 90 // 亮度最大值
// 每个部分之间相隔的亮度
const part = ((max - min) / 11) // 这个用于定位
// 计算索引
const hslC = getHsl(color)
if (hslC.l < min) hslC.l = min
if (hslC.l > max) hslC.l = max
console.log(part, (len - Math.floor((hslC.l - min) / part)) - 1)
const index = (len - Math.floor((hslC.l - min) / part)) - 1
// 添加到调色盘中
colorPalette[index] = {
    num: colorPaletteNumbers[index],
    color: getHex(hslC)
}
// 根据当前值计算其他值
for (let i = 0; i < len; i++) {
    if (i === index) {
        continue
    }
    const diff = i - index
    const newL = hslC.l - (diff * part)
    const newColor = colord({...hslC, l: Math.floor(newL)}).toHex()
    console.log(i, diff, newL, colorPaletteNumbers[i], getHsl(newColor))
    colorPalette[i] = {
        num: colorPaletteNumbers[i],
        color: newColor
    }
}

document.querySelector<HTMLElement>('#app')!.innerHTML = `
<div class="container">
    ${colorPalette.map((item, idx) =>
    `<div class="box" style="background:${item.color};">
${item.num}${idx === index ? '✨' : ''}<br/>${item.color}
</div>`
).join('')
}
</div>
`

效果是这样的,还不错,基本还原了他的效果(其实它这里对颜色的色相还进行了一些调整,他这里的算法并不只是简单根据亮度进行分段,比较复杂,我这里只实现了简单的),你会发现,颜色深的地方的字体根本没法看啊,于是,我们需要根据背景生成对应的字体颜色。

image-20240625112334546

只需要将亮度大于50的颜色混合一个黑色便可得到字体颜色,同理小于50的颜色混合白色

// 计算字体颜色
const midColor = colorPalette[Math.floor(len / 2)].color
colorPalette = colorPalette.map(item => {
    const color = colord(item.color)
    const text = mixColor(color.toHsl().l < 50 ? '#ffffff' : '#000000', midColor, 0.5)
    return {
        ...item,
        text,
    }
})

document.querySelector<HTMLElement>('#app')!.innerHTML = `
<div class="container">
    ${colorPalette.map((item: any, idx: any) =>
    `<div class="box" style="background:${item.color};color:${item.text}">
${item.num}${idx === index ? '✨' : ''}<br/>${item.color}
</div>`
).join('')
}
</div>
`

image-20240625113619163

做到这里,你会发现,这有什么用呢,我要的是动态主题啊,你这是什么

而下面我就要讲到,如何使用这一关键的调色盘

4.2 使用CSS变量加类名便于使用

在上文中,我们得到了colorPalette数组,根据这个数组,我们只需要将其放在CSS变量中,并使用类名进行管理,便可以很轻松地实现动态主题。

首先是生成CSS变量,并将其放在style标签中

function addPaletteToHTML(palette) {
    function getCssVarStr(arr) {
        const cssVarArr = arr.map(item => {
            const cssVarPrimary = `--primary-${item.num}-color:${item.color};\n`
            const cssVarImmersiveText = `--immersive-text-${item.num}-color:${item.text};\n`
            return cssVarPrimary + cssVarImmersiveText
        })
        return cssVarArr.join('')
    }

    const cssVarStr = getCssVarStr(palette)

    const styleId = 'palette-colors'
    const style = document.querySelector(`#${styleId}`) || document.createElement('style');
    style.id = styleId
    // 插入生成的调色盘颜色CSS变量
    style.innerHTML = `
    html {
        ${cssVarStr}
    }
    `
    document.head.appendChild(style);
}

像这样

image-20240625120808956

然后生成类名,我们便可以直接使用类名了

function addThemeClassToHTML() {
    function getThemeClass() {
        const colorPaletteNumbers = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
        const classStr = colorPaletteNumbers.map(num => {
            return `.bg-primary-${num} {background-color:var(--primary-${num}-color);color:var(--immersive-text-${num}-color)}\n`
        })
        return classStr.join(' ')
    }

    const classStr = getThemeClass()
    console.log(classStr)
    const styleId = 'theme-class'
    const style = document.querySelector(`#${styleId}`) || document.createElement('style');
    style.id = styleId

    style.innerHTML = `${classStr}`

    document.head.appendChild(style)
}

像这样,你会觉得看起来很像原子化CSS的写法,所以之后在框架中,我们将使用 TailwindCSS 管理

const colorPaletteNumbers = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]

document.querySelector<HTMLElement>('#app')!.innerHTML = `
<div class="container">
    ${colorPaletteNumbers.map((item: any) =>
    `<div class="box bg-primary-${item}">
${item}
</div>`
).join('')
}
</div>
`

但是,如何才能直观地看到它是动态的呢,比如这个例子

const setting = new Proxy({
    color: '#1890ff'
}, {
    set: function (target, property, value) {
        if (property === 'color') {
            target[property] = value;
            const colorPalette = generatePalette(value)
            addPaletteToHTML(colorPalette)
            return true
        }
        target[property] = value;
        return true
    }
})

`<input type="color" id="color-picker" value="#1890ff">`

const colorPicker = document.querySelector<HTMLInputElement>('#color-picker')!;
colorPicker.addEventListener('input', function () {
    setting.color = this.value;
});

gif

4.3 实现暗色模式

这里主要通过类名控制CSS变量,实现暗色模式,详细可看[1.3.1 静态切换](####1.3.1 静态切换)

function addPaletteToHTML(palette) {
    function getCssVarStr(arr) {
        const cssVarArr = arr.map(item => {
            const cssVarPrimary = `--primary-${item.num}-color:${item.color};\n`
            const cssVarImmersiveText = `--immersive-text-${item.num}-color:${item.text};\n`
            return cssVarPrimary + cssVarImmersiveText
        })
        return cssVarArr.join('')
    }

    const cssVarStr = getCssVarStr(palette)

    const innerHTML = `
    html {
        ${cssVarStr}
        --background-color: #fff;
        --text-color: #000;
    }
    html.dark {
        ${cssVarStr}
        --background-color: #1C1C1CFF;
        --text-color: #fff;
    }
    `
    updateStyleToEle('palette-colors', innerHTML)
}

const setting = new Proxy({
    color: '#1890ff',
    darkMode: true
}, {
    set: function (target, property, value) {
        target[property] = value;
        if (property === 'color') {
            const colorPalette = generatePalette(value)
            addPaletteToHTML(colorPalette)
            return true
        }

        if (property === 'darkMode') {
            document.documentElement.classList.toggle('dark', value);
        }
        return true
    }
})

`
<div>
     <label for="dark-mode-switch">暗色模式</label>
     <input type="radio" id="dark-mode-switch" name="mode">
     <label for="light-mode-switch">亮色模式</label>
     <input type="radio" id="light-mode-switch" name="mode">
 </div>
`

const modeSwitches = document.querySelectorAll<HTMLInputElement>('input[name="mode"]');

modeSwitches.forEach(switchElement => {
    switchElement.addEventListener('change', function () {
        // 当用户切换模式时,更新themeSetting.darkMode
        setting.darkMode = this.id === 'dark-mode-switch';
    });
});

在css中便可以直接使用变量

body {
    display: flex;
    justify-content: center;
    align-items: center;
    background: var(--background-color);
    color: var(--text-color);
    height: 100vh;
    transition: all 0.3s;
}

4.4 发布npm包

将代码拆分,放到对应目录下,使用npm包模板(这边是我自己做的一个)打包发布即可。

image-20240626031029780

然后在框架使用

npm install @healwrap/hp-theme

引入依赖使用即可

import { initTheme, themeSetting } from '@healwrap/hp-theme'

initTheme(themeSetting.colorConfig)

...

参考链接

代码