前端主题切换工程化实践方案

959 阅读20分钟

总述:

今天我们来一起探讨前端主题切换的解决方案,我们将按照以下几个方面展开讨论:

  1. css变量实现主题切换
  2. 借助css预处理器实现主题切换
  3. 混合方案(css变量 + css预处理器)
  4. 借助 tailwind css 实现主题切换
  5. 站在研发流水线视角全面考虑主题切换工程化基建(高能)

css 变量的方案:

css 变量实现比较简单,我们直接在一个已经搭建好的前端工程中来逐步实现:

1. 按照梯度层级,定义出一系列css颜色变量:

这一步的是必须的,因为不管是前台的网页还是管理系统的网页,页面内部都会存在这种情况:虽然同样是蓝色,但是蓝的程度是不同的,是由梯度的:

image.png 比如掘金上的草稿箱和发布两个按钮,草稿箱按钮的边框、按钮文字颜色和发布按钮的背景颜色虽然都是蓝色,但是蓝的梯度是不一样的,而不同梯度的蓝色,色值肯定是有区别的。 因此,我们在项目中必须和ui设计师探讨和定义出颜色的色值梯度,并且由前端工程师利用预处理器的梯度变量定义出来:

// 定义公共全局变量

$yellow1: #ffff00;
$yellow2: #ffd700;
$yellow3: #ffa500;
$yellow4: #ff8c00;
$yellow5: #ff7f50;
$yellow6: #ff6347;

$green: #00ff00;
$green1: #008000;
$green2: #32cd32; // 更深一点的绿色
$green3: #228b22; // 再深一点的绿色
$green4: #006400; // 更深的绿色
$green5: #004d00; // 很深的绿色
$green6: #003300; // 最深的绿色

$red1: #ff6347;
$red2: #ff4500; // 较浅的红色
$red3: #cd5c5c; // 中等深度的红色
$red4: #8b0000; // 较深的红色
$red5: #660000; // 更深的红色
$red6: #330000; // 最深的红色

$gray1: #f0f0f0; // 较浅的灰色
$gray2: #cccccc; // 中等浅灰色
$gray3: #999999; // 中等灰色
$gray4: #666666; // 中等深灰色
$gray5: #333333; // 较深的灰色
$gray6: #1a1a1a; // 最深的灰色

$white1: #ffffff; // 纯白色
$white2: #f8f8f8; // 较浅的白色
$white3: #e6e6e6; // 中等浅白色
$white4: #cccccc; // 中等白色(与灰色重合)
$white5: #999999; // 中等深白色(与灰色重合)
$white6: #666666; // 较深的白色(与灰色重合)

2. 利用梯度变量来定义不同主题的 css 变量:

默认主题 css 变量:

// 明亮色彩的主题颜色, 利用 css变量 进行设置

:root {
    --bg-color: #{$white1};
    --text-color: #{$gray1};
    --link-color:  #{$green1};
}

暗黑主题的 css 变量:

:root.dark {
    --bg-color: #{$white3};
    --text-color: #{$gray3};
    --link-color: #{$green3};
}

如果需要支持更多主题,那么大家可以自行添加自定义主题css变量。 在项目的根目录下面导入所有主题的css变量:

import { createApp, h } from "vue"
import 'element-plus/dist/index.css'
import App from "./App.vue"
import './style.css'
import router from './router'
import elementInstall from "./plugins/elementInstall"
// 导入亮色主题配置
import '@/assets/style/theme.scss'
// 导入暗色主题配置
import '@/assets/style/theme-dark.scss'
// import { add } from '@/utils/utils'

// console.log('add', add(1, 2))

createApp(App).use(elementInstall).use(router).mount("#app")

3. 在业务代码中利用 css 变量来设置颜色:

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

这样设置之后,默认的颜色实际上就已经加上去了:

image.png

4. 添加切换主题的功能:

思路实际上就特别简单了,就是在点击主题切换的主题之后切换根元素的类名:

const handleChangeTheme = () => {
  document.documentElement.classList.toggle('dark')
}

至此基于css变量实现主题切换的核心方案就已经梳理完毕了。

基于 css 预处理器实现主题切换:

除了上面的通过切换css变量的方式来实现不同主题色彩的切换,我们还可以想到另外一种很常见的解决思路:

  1. 我们使用不同的类名来定义不同的主题对应的样式:
.app.primary {
  background_color: xxx,
  color: xxx
}
.app.dark {
  background_color: xxx,
  color: xxx
}

这样,设置样式之后,实际上切换 .app 样式的主题,就可以通过切换不同的主题类名来实现了。 比如如果我们希望展示 primary 模式的样式,就可这样设置类名:

<div class="app primary"></div>

如果需要展示 dark 模式的类名,那么就切换类名:

<div class="app dark"></div>

这样,针对一个元素的主题切换,就已经没有问题了。

  1. 那如果是多个元素都要同时切换主题呢?那么就需要在切换主题的时候,同时切换很多个元素的类名。这种方式显然是行不通的,所以我们可以尝试进行以下的优化:
:root.primary .app {
  background_color: xxx,
  color: xxx
}
:root.dark .app {
  background_color: xxx,
  color: xxx
}
:root.primary .app2 {
  background_color: xxx,
  color: xxx
}
:root.dark .app2 {
  background_color: xxx,
  color: xxx
}

如果我们按照如下的方案来进行主题样式的设置之后,当我们需要进行主题切换的时候就和css变量的方式是一致的了,只需要直接切换根元素上的主题类名就可以了。

实际上这种主题切换方案的核心原理就已经实现了。之所以我们这一个主题要和 css 预处理器关联起来,其实就是借助预预处理器来优化 css 的编码体验罢了。我们可以小步快跑,逐步利用 scss 预处理器来进行优化:

  1. 我们先实现第一个目标:

任何的方案设计最重要的都是以终为始,先定义目标和最终的结果,然后我们再朝着这个目前去进行发力。所以我们先定一个目标,我们希望再业务项目中使用颜色相关的样式的时候这样设置就可以了:

**.app {
  @include background_color(app-container);
  @include font_color(main-color)
}**

我们需要设置整个页面的背景颜色的时候就只需要调用 background_color 这个函数就可以了,我们这里是需要设置app容器的背景颜色,所以我们就直接通过函数参数,告诉 scss 编译器。因此,首先我们明确一点,这里的 background_color 一定是一个类似于函数的东西,并且可以接收参数指定具体的颜色类型。基于此分析,我们可以给出以下的函数定义:

@mixin background_color($color) {
  
}
  1. 我们已经定义出了函数的框架,接下来就是填充内容了。我们这里的内容其实很简单,就是需要产生一个背景颜色的 css 指令,所以,我们可以很快为这个函数填充如下内容:
@mixin background_color($color) {
    background-color: xxx;
}

分析到这一步之后,我们接着思考:直接这样写肯定是不会出现问题的,但是仍然没有实现我们的需求呀。我们需要设置的是多个主题的颜色方案,但是我们这样写,不管是什么样的主题,都是固定的色值。所以我们接着给出如下的设想。我们如果有一个 map 结构来存储不同主题下的颜色的色值,并且 map 的key 就是主题类型,值就是当前主题下色值的定义,我们循环这个色值 map 就可以动态的设置所有主题对应的颜色了:

$themeMap:(
  primary:(
    app-container: #f3f6fd,
    main-color: #1f1c2e,
    secondary-color: #4A4A4A,
    link-color: #1f1c2e,
    link-color-hover: #c3cff4,
    link-color-active: #fff,
    link-color-active-bg: #1f1c2e,
    projects-section: #fff,
    message-box-hover: #fafcff,
    message-box-border: #e9ebf0,
    more-list-bg: #fff,
    more-list-bg-hover: #f6fbff,
    more-list-shadow: rgba(209, 209, 209, 0.4),
    button-bg: #1f1c24,
    search-area-bg: #fff,
    star: #1ff1c2,
    message-btn: #fff,
  ),
  dark:(
    app-container: #1f1d2b,
    main-color: #fff,
    secondary-color: rgba(255,255,255,.8),
    projects-section: #1f2937,
    link-color: rgba(255,255,255,.8),
    link-color-hover: rgba(195, 207, 244, 0.1),
    link-color-active-bg: rgba(195, 207, 244, 0.2),
    button-bg: #1f2937,
    search-area-bg: #1f2937,
    message-box-hover: #243244,
    message-box-border: rgba(255,255,255,.1),
    star: #ffd92c,
    light-font: rgba(255,255,255,.8),
    more-list-bg: #2f3142,
    more-list-bg-hover: rgba(195, 207, 244, 0.1),
    more-list-shadow: rgba(195, 207, 244, 0.1),
    message-btn: rgba(195, 207, 244, 0.1),
  )
);

有了这个色组定义 map,我们很就尝试这样改写混入函数:

@mixin background_color($color) {
  // 循环色组定义
  @each $themeKey, $themeValue in $themeMap {
     background-color: map-get($themeValue, $color)
  }
}

这样写的确又进一步推进了,但是仍然有很严重的问题。虽然我们按照不同的主题定义了不同的色值,但是最终永远只能是最后一组色值被作用上去了,仍然不可能做到动态切换。所以我们必须提出最后的要求,也就是再循环定义的不同主题的色值的时候,需要为每一个主题产生主题 class,每一个主题class负责设置当前class的主题颜色。并且,这个class选择器必须挂载到html根元素下面去,我们可以通过切换根元素的class来动态设置对应的主题色值。

  1. 提出了目标,利用 scss 的能力,解决方案就呼之欲出了,我给出完整的代码例子,大家可以参考:
$themeMap:(
  primary:(
    app-container: #f3f6fd,
    main-color: #1f1c2e,
    secondary-color: #4A4A4A,
    link-color: #1f1c2e,
    link-color-hover: #c3cff4,
    link-color-active: #fff,
    link-color-active-bg: #1f1c2e,
    projects-section: #fff,
    message-box-hover: #fafcff,
    message-box-border: #e9ebf0,
    more-list-bg: #fff,
    more-list-bg-hover: #f6fbff,
    more-list-shadow: rgba(209, 209, 209, 0.4),
    button-bg: #1f1c24,
    search-area-bg: #fff,
    star: #1ff1c2,
    message-btn: #fff,
  ),
  dark:(
    app-container: #1f1d2b,
    main-color: #fff,
    secondary-color: rgba(255,255,255,.8),
    projects-section: #1f2937,
    link-color: rgba(255,255,255,.8),
    link-color-hover: rgba(195, 207, 244, 0.1),
    link-color-active-bg: rgba(195, 207, 244, 0.2),
    button-bg: #1f2937,
    search-area-bg: #1f2937,
    message-box-hover: #243244,
    message-box-border: rgba(255,255,255,.1),
    star: #ffd92c,
    light-font: rgba(255,255,255,.8),
    more-list-bg: #2f3142,
    more-list-bg-hover: rgba(195, 207, 244, 0.1),
    more-list-shadow: rgba(195, 207, 244, 0.1),
    message-btn: rgba(195, 207, 244, 0.1),
  )
);

// 在样式表的根目录中添加默认值
$currentTheme: null;

@mixin themeMixin {
  @each $themeKey, $themeValue in $themeMap {
    html.#{$themeKey} & {

      //由于外部要使用循环变量出来的内容,所以声明一个全局变量,方便外部调用
      $currentTheme: $themeValue !global;
      //可以使用scss中的@content,这个就很类似于vue中的插槽
      @content;
      //使用完之后,最好将全局变量清空
      $currentTheme: null !global;
    }
  }
}

//封装一个函数,方便调用取值
@function getVar($key) {
  @return map-get($currentTheme, $key);
}

//直接获取背景颜色的值
@mixin background_color($color) {
  @include themeMixin {
    background-color: getVar($color) !important;
  }
}

//直接获取字体颜色的值
@mixin font_color($color) {
  @include themeMixin {
    color: getVar($color) !important;
  }
}

//直接获取边框颜色的值
@mixin border_color($color) {
  @include themeMixin {
    border-color: getVar($color) !important;
  }
}

通过上面的例子,我们应该可以很清晰的感受到,任何的编程语言或者工具库都只是一个工具而已,任何解决方案的核心都是方向的明确和思路的理顺。

混合方案

所谓的混合方案就是上面提到的两种方案结合起来实现项目的主题切换。这种方案最典型的场景就是实现组件库主题切换的时候。我们以element-plus 中的 el-button 组件为例子,我们来看一下它的 css 设置:

image.png

我们可以分为两部分进行分析:

  1. 首先,组件库实现普通模式和 dark 模式的主题切换是通过--el-color-white 这种变量来实现的,这一点和我们文档的css变量实现主题切换的实现方案是完全一致的。
  2. 每一个组件本身又被分成了众多的主题,比如 el-button 这个组件

image.png

每一个主题类型对应的样式、交互都是不一样的,所以组件库中都会为每一个主题设置对应的类名以及样式来进行实现。

了解了组件库主题方案的设计方案之后,我们可以进一步思考一下,为什么一般组件库都需要这样进行设计?其实可以从很多的方面来进行思考,下面我们给出以下角度:

1,从组件库用户角度出发思考的话,通用组件库在某些业务项目中使用的时候可能需要让组件支默认主题以及其他主题切换的功能。但是也存在很多业务项目并不需要支持主题切换。那么在需要支持主题切换的场景中,用户是可以分别导入默认主题以及其他主题的样式的;在不需要支持主题切换的业务项目中,我们就只需要导入light主题的样式就可以了,这样在业务项目打包的时候就可以避免无脑的打包所有主题的css样式,一定程度提升打包相率和浏传输效率。基于以上要求。针对这种需求,分别创建不同主题的样式文件,将每一个主题的css样式变量分别维护进去,打包的时候分别输出到不同的样式文件,无疑是最好的选择。

  1. 至于说每一个具体的组件为什么需要针对不同的业务主题,分别设置不同主题的样式,其实原因很简单。首先业务组件的业务主题css不存在按需打包和导入的需求,在打包的时候统一打包进去,用户在使用的时候统一进行导入。其次,设置组件具体的主题样式的时候当然也是可以使用css变量的:
.el-button.primery {
  --bg-color: xxx
}
.el-button.success {
  --bg-color: xxx
}

.el-button-succsss {
  background-color: var(--bg-color)
}

需要进行button按钮,实际上也只是需要根据不同的主题,切换button组件根元素上的样式变量就可以了。所以很多时候需要达到同一种效果,可以选用的方案是多种多样的。但是这种方案在实现上有一个很明显的瑕疵,就是同一类主题覅的css代码我得写两次,设置颜色变量的时候编写一次,编写主题具体的样式的时候又得编写一次,两个选择器之间通过css变量来进行通信。这样不如直接把样式全部写在一个css主题类下面来的方便。但是对用户来说,两种方案基本上是差不多的。

使用 tailwind css 方便的进行主题切换:

这种方案其实就是直接使用 tailwind css 框架提供的内置的主题切换的能力来进行实现,流程比较固定我这里就不一一赘述了。我在这里要和大家一起深度思考的是它实现主题切换的本质:

image.png

通过最终 css 样式的层叠效果不难看出 tailwind css 底层实际上就是先根据用户的配置来提前分别构建好了不同的主题的css声明,用户切换主题无非就是让对应css主题的css声明生效罢了,和我们前面拆解的通过 scss 实现主题切换其实在最终效果上并没有本质区别,只是这种方案,用户编码阶段比较友好。

站在研发流水线视角全面考虑主题切换工程化基建(高能)

以上所有的实现方案站在前端工程师的开发角度是各有优劣,都是比较合理的实现方案。但是如果我们的视角更加全面,站在一个比较大型的公司,ui设计团队以及前端研发团队的产研合作上,还是欠缺了很多能力的。 我们来设想一下一个比较工业化的流水线:

image.png

这条业务线最核心的目标是:

  1. 研发流程中不同的角色负责关注和处理本角色的职责:
    1. ui设计师关注ui设计稿颜色色值的定义,通过语义化的变量定义好颜色的色值。
    2. 前端开发着只需要关注业务开发,比如a业务区域需要使用 a 颜色,那么就直接在业务代码中设置a颜色就可以了,至于a颜色具体在哪个主题下的色值,前端开发者不需要关心。
    3. 工程平台以及前端研发脚手架负责打通ui设计师与前端开发者之间的联系。工程平台负责让ui设计师设计指定业务线的主题色值,并且存储主题色值;前端研发脚手架负责获取ui设计师设计的色值配置并且基于配置进行预编译,产生最终的css代码,让界面上正常渲染不同主题对应的颜色。
  2. 研发流程完全实现自动化,基本上不需要人工去进行干预,一旦色值平台被更新了,那么在进行项目编译的时候会自动读取色值配置,并且编译产出最新的css代码,前端项目构建运行起来之后,前端界面就可以看到最新的主题颜色。

我们首先进行架构设计,要达到上述的效果,我们至少需要以下几个系统或者平台:

  1. 提供主题色值配置的工业平台,需要提供前端设置主题色值的界面以及负责crud的后端系统。
  2. 需要一个色值处理工具:查询色值配置、编译css代码、产出最终的css代码的。
  3. 需要一个流程工具:可以在前端开发的时候串联起:预编译 css 代码 --> 产出不同主题的css代码 --> 启动前端项目的构建与编译。

第一个色值管理平台的前后端设计我们在这里就不过多赘述了,我们假设现在ui设计师已经通过这个平台设计好了当前项目的主题颜色配置:

{
  primary: {
    'bg-color': '#fff',
    'text-color': '#000',
    'link-color': '#333'
  },
  dark: {
    'bg-color': '#000',
    'text-color': '#fff',
    'link-color': '#999'
  },
  gray: {
    'bg-color': '#ccc',
    'text-color': '#999',
    'link-color': '#666'
  }
}

实际上最终产出的配置结果就是一个json对象。我们现在已经读取到了这个对象,那么基于这个json对象,我们的基于色值配置自动编译产出css代码的工具的设计方案可以怎样设计呢:

方案1:我们在开发规范中约定,在设置css颜色相关的指令的时候,不直接写死颜色的色值,而是写成一个函数调用:

.container {
  color: themeColors(text-color)
}

然后在项目的构建流程中,针对 css 类型的资源,加入一个自定义 postcss 插件,来负责将 themeColors 函数调用编译转换为主题代码:

:root.primery .container {
  color: #000;
}
:root.dark .container of the p {
  color: #fff;
}

实际上就是达到和方案2类似的效果。 这个方案自然是可行的,而且从实现上来说,只需要开发一个自定义的postcss插件就可以了,可以直接串联到webpack这些构建工具的工作流程中。但是,这个方案在最终效果上有两个很明显的为问题:1. 污染了业务代码,业务代码中所有的颜色配置全部被换成了一个自定义函数调用。2. 因为这个插件是直接作为webpack这些构建工具的构建环节中的一环串联进去的,所以每一次构建,不管 themeColors(text-color)函数参数对应的具体色值是否被改变了,都会重新去去进行编译,并且产出主题颜色代码。实际上,主题颜色一旦确定之后,变化的频率一般是不会很高的,所以没有必要每次都重复进行编译构建。我们需要对这个方案进行一下优化。

方案2:这个方案其实是基于方案1的一个变种和优化。主要就是:

  1. 在业务前端项目中配置一个颜色变量的模板文件,然后在设置css变量的时候设置 themeColors 函数:
:root {
    --bg-color: theme(bg-color);
    --text-color: theme(text-color);
    --link-color: theme(link-color);
}

这样做了之后,业务开发者在设置具体的css色值的时候就不会受到太大的侵入了:

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

和我们前面聊的基于css变量实现主题切换的写法完全一致了。这样就解决了方案一带来的侵入业务代码的问题了。

  1. --bg-color: theme(bg-color) 这样设置css变量浏览器肯定是无法正常进行样式解析的。所以还是需要进行编译。但是不是每一次都要进行编译呢?我们希望前端业务开发者可以进行灵活的控制,所以我们给出如下两种的设想:

    1. 开发者可以通过命令行参数来控制是否需要编译,比如命令行中带上 --buildthemeColor, 那么就会进行主题样式的编译流程。如果不带上这个参数,那么就不跳过主题样式的编译流程。
    2. 在项目编译流程中嵌入自动化处理流程,自动检测设计师是否更新了主题颜色配置。如果更新了,那么就重新编译并且输出主题样式代码,如果没有更新,那么就不再触发编译。

我们在这里简单演示以下方案一的实现思路。但不论是采取哪一种策略,我们首先都是需要提供一个自定义的构建命令的,比如我们在研发脚手架中提供一个如下的 command:

const enrollThemeBuildCommand = (cli: yargs.Argv) => {
  cli.command({
      command: 'themeBuild',
      describe: "编译产出主题颜色代码 + 编译启动整个项目",
      builder(yargs) {
        // 注册 command 参数
        yargs.option('buildthemeColor', {
          alias: 'btc',
          default: '',
          describe: '是否进行主题颜色代码的编译',
          type:'boolean',
        })
        return yargs;
      },
      async handler(argv) {
        const btc = argv.btc as string
        // 将应用名设置到环境变量中
        process.env.btc = btc
        build()
      },
  })
}

注册 command:

export default function (cli: yargs.Argv) {
  // 注册 create 命令
  enrollCreateCommand(cli)
  // 注册 publish 命令
  enrollPublishCommand(cli)
  // 注册 download 命令
  enrollDownloadCommand(cli)
  enrollBuildCommand(cli)
  // 注册工程化主题编译的command
  enrollThemeBuildCommand(cli)
  return cli
}

创建出子包并且将其链接到 @frontend-dev-cli/cli 中来:

image.png

image.png

image.png

进行了command的配置之后,我们的主题切换工程化编译的 command 就可以正常处理任务了:

image.png

当我们关闭主题编译的开关的时候,那么就不会触发编译主题颜色代码的逻辑,而是直接进入到项目编译的逻辑。

image.png 当我们打开开关之后,就会先进行主题代码的编译然后再串行项目的编译操作。 当然以上是流程性的伪代码,只是把大致的意思说明白了,具体的细节我们后面可以慢慢完善。 添加好了 command之后,我们接下来就是需要在 command 中加入主题色值的编译相关处理逻辑就ok了,这段代码会涉及到比较多的 postcss 的抽象语法树操作的逻辑,感兴趣的话大家可以仔细看一下:

import postcss from 'postcss';

interface ThemeColors {
  [key: string]: string;
}

interface ThemeContext {
  [key: string]: ThemeColors;
}

interface PluginOptions {
  themeColorCtx: ThemeContext;
}

const themeColorCtx: ThemeContext = {
  primary: {
    'bg-color': '#fff',
    'text-color': '#000',
    'link-color': '#333'
  },
  dark: {
    'bg-color': '#000',
    'text-color': '#fff',
    'link-color': '#999'
  },
  gray: {
    'bg-color': '#ccc',
    'text-color': '#999',
    'link-color': '#666'
  }
};

function themeColors(options: PluginOptions) {
  return {
    postcssPlugin: 'theme-colors',
    Once(root: postcss.Root, result) {
      // 遍历所有规则
      root.walkRules((rule) => {
        // 遍历每个规则中的所有声明
        rule.walkDecls((decl) => {
          // 检查声明值中的 theme() 调用
          const match = decl.value.match(/theme\(([^)]+)\)/);
          if (match) {
            const variableName = match[1].trim();
            const colorValue = getThemeColor(variableName, options.themeColorCtx);
            if (colorValue) {
              decl.value = colorValue;
            }
          }
        });
      });

      // 生成并插入不同主题下的 CSS 规则
      generateAndInsertThemeRules(root, options.themeColorCtx);
    }
  };
}

function getThemeColor(variableName: string, themeColorCtx: ThemeContext): string | undefined {
  const themeNames = Object.keys(themeColorCtx);
  for (const themeName of themeNames) {
    if (themeColorCtx[themeName][variableName]) {
      return themeColorCtx[themeName][variableName];
    }
  }
  return undefined;
}

function generateAndInsertThemeRules(root: postcss.Root, themeColorCtx: ThemeContext) {
  const themeNames = Object.keys(themeColorCtx);
  // 生成默认主题的规则
  const defaultTheme = themeColorCtx['primary'];
  const defaultRule = postcss.rule({
    selector: ':root',
    nodes: [
      postcss.decl({ prop: '--bg-color', value: defaultTheme['bg-color'] }),
      postcss.decl({ prop: '--text-color', value: defaultTheme['text-color'] }),
      postcss.decl({ prop: '--link-color', value: defaultTheme['link-color'] })
    ]
  });
  root.prepend(defaultRule);

  // 生成其他主题的规则
  for (const themeName of themeNames) {
    if (themeName === 'primary') continue; // 跳过默认主题
    const theme = themeColorCtx[themeName];

    const newRule = postcss.rule({
      selector: `:root.${themeName}`,
      nodes: [
        postcss.decl({ prop: '--bg-color', value: theme['bg-color'] }),
        postcss.decl({ prop: '--text-color', value: theme['text-color'] }),
        postcss.decl({ prop: '--link-color', value: theme['link-color'] })
      ]
    });

    root.prepend(newRule);
  }
}

// 使用 postcss
// 在真正的项目中需要读取前端项目中配置的css变量模板代码
const inputCSS = `
:root {
    --bg-color: theme(bg-color);
    --text-color: theme(text-color);
    --link-color: theme(link-color);
}
`;

const options: PluginOptions = {
  themeColorCtx
};

const compile = async () => {
    const result = await postcss([themeColors(options)]).process(inputCSS, { from: undefined });

    console.log(result.css); // 输出处理后的 CSS
    // 将 result 按照各种主题维度输出为 css 文件
}

export default compile

因为我们在这里没有搭建后端服务器,所以我在插件内部就暂时先直接写死了一段主题颜色的数据源,通过往 postcss 插件内部注入这个数据源,来编译出最终的各个主题的 css 样式变量。最后将这些样式变量输出到特定的样式css配置文件中就可以了。 插件编写完毕之后,我们在command中引入这个postcss编译逻辑:

import themeColorsCompile from './themeColor'

export default async function themeColors() {
  // themeColors 开关
  const btc = process.env.btc
  if (btc) {
    // 编译主题颜色代码
    await themeColorsCompile()
  }
  // 开始正常的前端项目的构建编译流程
  console.log('执行项目 build 命令')
}

至此我们就从总体上完成了工业流水线级别的css主题切换的方案的梳理。我们一定要明确,具体的实现细节,其实都是比较简单的,比如如何编写上面的 themeColorsCompile 这个postcss 插件,这些甚至都可以借助 chatgpt 来实现。真正有价值以及有挑战性的还是整个工程化思考能力的锻炼以及方案的设计和架构能力。这也是目前 chatgpt 无法取代的能力,普通的编码和实现,其实没有太大学习价值,任何人利用chatgpt都可以搞出来。所以我们在这个地方不需要将所有的细节全部展开,而只需要在宏观上梳理整个工程化流程就可以了。

方案梳理搭配这里普通前端项目的主题切换工程化实现已经一马平川了。但是我们还可以扩展思考一下,如果这个项目中借助 tailwind css 来实现了前端工程化,那么我们这种方案怎么完善呢? 我们前面给的tailwind css例子,主题颜色的定义是直接写死的 css 色值:

image.png

但实际上,tailwind css 配置自定义颜色也是可以使用 css 变量的:

image.png

image.png

image.png

那也就是实际上我们依然可以利用以上的方案来通过工程化设置不同主题的 css 变量来达到类似的效果。 当然,如果我们不想再 tailwind css 里面去使用 css 变量,那么我们就可以按照类似的思路提供一个tailwind 模板配置文件,编写一个编译 tailwind css 的工具来提供给用户编译产生最终的 tailwind css 配置。

至此,我们就完成了多种前端主题切换的方案的梳理。