Skip to content

inChat 聊天室主题换肤实现

目前聊天室的基本功能也已经完成了,为了让聊天室体验感更加丰富,在调研后我决定为这个聊天室实现 利用 CSS 变量级联特性穿透组件层级样式

一、CSS初始化与定义

sequenceDiagram
    participant HTML
    participant TailwindConfig
    participant ThemeCSS
    participant CompiledCSS
    
    HTML->>TailwindConfig: 使用 bg-theme 类
    TailwindConfig->>CompiledCSS: 生成 .bg-theme { background-color: var(--bg-color) }
    CompiledCSS->>ThemeCSS: 运行时读取 --bg-color 的值
    ThemeCSS->>HTML: 应用当前主题对应的颜色值

1.1 theme.css 样式定义

  • 通过 :root 定义默认主题变量
  • 使用 [data-theme="xxx"] 属性选择器定义不同主题的变量覆盖
  • 编译时会被 PostCSS/Tailwind 等工具处理为原生 CSS 这里的定义每个主题的详细颜色,也是为了之后在 index.css 中让样式依据动态值来做变化。

1.1.1 theme.css 代码如下

css
/* 默认主题使用 :root 伪类定义 */
:root {
  /* 默认亮色主题 */
  --primary-color: #6366f1; 
  --primary-light: #818cf8;
  --primary-dark: #4f46e5;
  --text-color: #1f2937;
  --bg-color: #f9fafb;
  --card-bg: #ffffff;
  --border-color: rgba(99, 102, 241, 0.1);
  --shadow-color: rgba(0, 0, 0, 0.1);
}

/* 暗色主题 覆盖默认主题 */
html[data-theme="dark"] {
  --primary-color: #818cf8;
  --primary-light: #a5b4fc;
  --primary-dark: #6366f1;
  --text-color: #f9fafb;
  --bg-color: #111827;
  --card-bg: #1f2937;
  --border-color: rgba(129, 140, 248, 0.2);
  --shadow-color: rgba(0, 0, 0, 0.5);
}

/* 粉色主题 */
[data-theme="pink"] {
  --primary-color: #ec4899;
  --primary-light: #f472b6;
  --primary-dark: #db2777;
  --text-color: #1f2937;
  --bg-color: #fdf2f8;
  --card-bg: #ffffff;
  --border-color: rgba(236, 72, 153, 0.1);
  --shadow-color: rgba(236, 72, 153, 0.1);
}

/* 蓝色主题 */
[data-theme="blue"] {
  --primary-color: #3b82f6;
  --primary-light: #60a5fa;
  --primary-dark: #2563eb;
  --text-color: #1f2937;
  --bg-color: #eff6ff;
  --card-bg: #ffffff;
  --border-color: rgba(59, 130, 246, 0.1);
  --shadow-color: rgba(59, 130, 246, 0.1);
}

在我们定义好了原始的CSS变量后,就要建立Tailwind语义化名称与CSS变量的映射关系--Tailwind.config.js

1.2 Tailwind.config.js 关系定义

1.2.1 编译过程

1.配置解析阶段

当 Tailwind 读取到 tailwind.config.js时,就会检查颜色配置项,如:

js
// 检测到颜色配置项
colors: {
  primary: 'var(--primary-color)',
  'primary-dark': 'var(--primary-dark)'
}

生成内存映射表:

js
{
  "primary": { "DEFAULT": "var(--primary-color)" },
  "primary-dark": { "DEFAULT": "var(--primary-dark)" }
}
2.类名生成阶段

在类名生成阶段中根据配置自动生成对应的工具类:

css
/* 自动生成的类 */
.bg-primary { background-color: var(--primary-color); }
.text-primary { color: var(--primary-color); }
.border-primary { border-color: var(--primary-color); }
3. Tree Shaking

只会保留项目中实际使用到的类名,即未使用的类不会出现在最终的CSS中

1.2.2 变量引用机制

当我们在 Tailwind.config.js中定义:

js
// tailwind.config.js
colors: {
  primary: 'var(--primary-color)'
}

实际会转换为:

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

这里的 var(--primary-color) 是原生 CSS 变量语法,浏览器在渲染时会动态解析该值

1.2.3 Tailwind.config.js 代码

js
// 配置 Tailwind CSS 的内容路径
module.exports = {
  content: [
    // 指定页面组件的路径
    './src/pages/**/*.tsx', // 这里包含所有页面组件的路径
    // 指定通用组件的路径
    './src/components/**/*.tsx', // 这里包含所有通用组件的路径
    // 指定布局组件的路径
    './src/layouts/**/*.tsx', // 这里包含所有布局组件的路径
  ],
  theme: {
    extend: {
      colors: {
        // 将 Tailwind类映射到CSS变量
        primary: 'var(--primary-color)', // 主色使用 CSS 变量
        'primary-light': 'var(--primary-light)', // 主色的浅色变体
        'primary-dark': 'var(--primary-dark)', // 主色的深色变体
        'theme-text': 'var(--text-color)', // 主题文本颜色
        'theme-bg': 'var(--bg-color)', // 主题背景颜色
        'theme-card': 'var(--card-bg)', // 主题卡片背景颜色
        'theme-border': 'var(--border-color)', // 主题边框颜色
      },
      // backgroundColor 下的简明 theme 会生成 bg-theme 类
      backgroundColor: {
        theme: 'var(--bg-color)', // 主题背景颜色
        card: 'var(--card-bg)', // 卡片背景颜色
      },
      textColor: {
        theme: 'var(--text-color)', // 主题文本颜色
      },
      borderColor: {
        theme: 'var(--border-color)', // 主题边框颜色
      },
      boxShadow: {
        theme: '0 4px 6px var(--shadow-color)', // 主题阴影效果
      },
    },
  },
  plugins: [], // 插件配置,当前没有使用任何插件
}
// var 的作用:
// 这里的 var(--primary-color) 是 CSS 变量引用
// 表示这些颜色值由外部 CSS 文件(themes.css)动态定义,而非硬编码
// Tailwind 会将这些变量转换为实际的工具类。
// var 是 配置声明,仅在构建阶段被 Tailwind 使用。

1.3 index.css动态实现

当我们定义好了关系映射,主题具体颜色,现在我们就要将他们运用起来,即在index.css中运用动态变量。

1.3.1 index.css 代码

css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 将变量注入 Tailwind 上下文,编译时会给予变量值的 utility classes
   如: bg-card --> background-color: var(--card-bg);
*/
@import './styles/themes.css';

/* 使用主题变量的全局样式, 注入Tailwind基础样式 */
body {
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s ease, color 0.3s ease;
}

/* 卡片样式 */
.card {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  box-shadow: 0 4px 6px var(--shadow-color);
}

/* 按钮样式 */
.btn-primary {
  background-color: var(--primary-color);
  color: white;
}

.btn-primary:hover {
  background-color: var(--primary-dark);
}

/* 输入框样式 */
.input {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  color: var(--text-color);
}

/* 主题过渡效果 */
* {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}

/* 动画 */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes scaleIn {
  from { transform: scale(0.95); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

.animate-fadeIn {
  animation: fadeIn 0.3s ease forwards;
}

.animate-scaleIn {
  animation: scaleIn 0.3s ease forwards;
}

/* var 的作用:
这里的 var(--primary-color) 是 CSS 变量的使用
直接引用已定义的变量值。
var 是 样式应用,在浏览器运行时生效
*/

上文我们也提到了 Tailwind.config.js 的代码会将 css 转换成类似于如下的代码,这里就与 index.css代码相吻合, 这里的动态变量就取决于theme中对应的颜色值啦。

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

1.4 引入Tailwind CSS

现在已经将基本的写好了,那么现在就是要在整个项目中生效,之前我们是将 theme.css 引入到 index.css 中, 那么为了让 index.css 中生效,我们需要将 index.css 引入到项目的 index.tsx文件中

tsx
// 导入主题进而形成编译结果,
// 编译结果就会有这些变量了,这个文件会根据主题的变化而变化
import './index.css'; // 引入 Tailwind CSS
import Login from './pages/login/login'; // 引入 Login 组件
import themeService from './services/themeService'; // 引入主题服务

// 确保主题服务在应用启动时初始化
themeService.getCurrentTheme(); // 这会触发从本地存储加载主题

二、运行时初始化

2.1 themeService.ts 主题服务

这里的 themeService 主题服务函数就是规定一些类以及相应的模式函数以及相应的主题设置 具体代码如下:

ts
// 定义可用的主题类型
export type ThemeType = 'light' | 'dark' | 'pink' | 'blue';

// 主题服务类
class ThemeService {
  private static instance: ThemeService;
  private currentTheme: ThemeType = 'light';
  private readonly STORAGE_KEY = 'app_theme';
  
  // 单例模式
  public static getInstance(): ThemeService {
    if (!ThemeService.instance) {
      ThemeService.instance = new ThemeService();
    }
    return ThemeService.instance;
  }
  
  // 私有构造函数
  private constructor() {
    this.loadThemeFromStorage();
  }
  
  // 从本地存储加载主题
  private loadThemeFromStorage(): void {
    try {
      const savedTheme = localStorage.getItem(this.STORAGE_KEY) as ThemeType;
      if (savedTheme) {
        this.setTheme(savedTheme); // 默认情况下,保存到本地存储
      } else {
        // 如果没有保存的主题,检查系统偏好
        this.checkSystemPreference();
      }
    } catch (error) {
      console.error('加载主题失败:', error);
    }
  }
  
  // 检查系统颜色偏好
  private checkSystemPreference(): void {
    // 检查系统颜色偏好,使用 matchMedia API 判断用户的颜色模式偏好
    // matchMedia 是一个用于检测媒体查询的 API,允许我们根据不同的条件(如屏幕尺寸、方向或颜色模式)来应用不同的样式。
    // matches 属性返回一个布尔值,指示当前文档是否匹配指定的媒体查询。
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
        // 通过 matchMedia API,浏览器可以检测用户的系统主题偏好设置
      this.setTheme('dark', false);
    }
  }
  
  // 设置主题
  public setTheme(theme: ThemeType, saveToStorage: boolean = true): void {
    this.currentTheme = theme;

    document.documentElement.setAttribute('data-theme', theme);
    
    // 添加对应的类名到 body,用于组件主题穿透
    document.body.className = '';
    // 双重保障: date-theme 控制全局, theme-${theme} 控制组件作用域
    document.body.classList.add(`theme-${theme}`);
    
    // 保存到本地存储
    if (saveToStorage) {
      localStorage.setItem(this.STORAGE_KEY, theme);
    }
    
    // 触发自定义事件,通知应用主题已更改
    // 创建一个自定义事件对象,并将其 detail 属性设置为当前主题
    const event = new CustomEvent('themeChanged', { detail: { theme } });
    // 触发自定义事件,通知应用主题已更改
    document.dispatchEvent(event);
  }
  
  // 获取当前主题
  public getCurrentTheme(): ThemeType {
    return this.currentTheme;
  }
  
  // 切换到下一个主题
  public toggleTheme(): void {
    const themes: ThemeType[] = ['light', 'dark', 'pink', 'blue'];
    const currentIndex = themes.indexOf(this.currentTheme);
    const nextIndex = (currentIndex + 1) % themes.length;
    this.setTheme(themes[nextIndex]);
  }
}

export default ThemeService.getInstance();

2.1.1 主题服务初始化

  • 优先读取 localStorage 的 app_theme
  • 默认存储时检测系统偏好
  • 默认使用light主题

2.1.2 根元素属性注入

ts
    // 设置根元素的 data-theme 属性为当前主题,以便在 CSS 中使用该属性进行样式调整
    // setAttribute 方法用于设置指定元素的属性及其值,这里将根元素的 data-theme 属性设置为当前主题,以便在 CSS 中使用该属性进行样式调整。
document.documentElement.setAttribute('data-theme', theme)
  • 初始化设置<html data-theme="light">
  • 触发CSS变量级联生效

2.1.3 Body 类名注入

ts
document.body.classList.add(`theme-${theme}`)
  • 示例结果:<body class="theme-light">
  • 提供组件级主题穿透能力

2.2 主题切换

我们在 themeService.ts 文件中定义好了相应的配置与方法,现在我们就要来实现一个主题的切换,这里我们需要定义一个切换的组件,以便在页面中使用。 具体的实现就是用监听主题变化事件,addEventListener进行监听,组件中切换主题的按钮绑定点击事件。

2.2.1 ThemeSwitcher 主题切换组件实现如下

tsx
import React, { useState, useEffect } from 'react';
import themeService, { ThemeType } from '../services/themeService';

const ThemeSwitcher: React.FC = () => {
  const [currentTheme, setCurrentTheme] = useState<ThemeType>(themeService.getCurrentTheme());
  
  useEffect(() => {
    // 监听主题变化事件
    // CustomEvent 是一种用于创建自定义事件的接口,可以携带额外的数据。
    // 它不是 React 自带的,而是原生 JavaScript 的一部分。
    const handleThemeChange = (e: CustomEvent) => {
      setCurrentTheme(e.detail.theme);
    };
    
    document.addEventListener('themeChanged', handleThemeChange as EventListener);
    
    return () => {
      document.removeEventListener('themeChanged', handleThemeChange as EventListener);
    };
  }, []);
  
  const themes: { type: ThemeType; name: string; icon: string }[] = [
    { type: 'light', name: '浅色', icon: '☀️' },
    { type: 'dark', name: '深色', icon: '🌙' },
    { type: 'pink', name: '粉色', icon: '🌸' },
    { type: 'blue', name: '蓝色', icon: '🌊' }
  ];
  
  return (
    <div className="theme-switcher">
      <div className="flex flex-col items-center space-y-2">
        {themes.map((theme) => (
          <button
            key={theme.type}
            onClick={() => themeService.setTheme(theme.type)}
            className={`w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200
                      ${currentTheme === theme.type 
                        ? 'bg-primary text-white shadow-theme transform scale-110' 
                        : 'bg-card text-theme hover:bg-primary/10'}`}
            title={theme.name}
          >
            {theme.icon}
          </button>
        ))}
      </div>
    </div>
  );
};

export default ThemeSwitcher;

三、组件中具体使用与机制详解

3.1 Chat页面中使用

在 Chat 组件中使用 bg-theme text-theme border-theme即可生效了。但读到这你可能会疑惑,我们之前并没有提到这些类,这些类仅仅在Chat页面中使用就生效了吗?其实,这就是Tailwind CSS编译机制的功劳了。

3.2 核心机制拆解

3.2.1 Tailwind 的 JIT(即时编译)模式

js
// tailwind.config.js
module.exports = {
  content: [
    './src/**/*.tsx' // 扫描所有组件文件
  ]
}
  • Tailwind 会扫描指定目录下的所有文件并识别代码中所有的 Tailwind 类名(含自定义类名),之后将他们按需生成实际中被使用的工具类样式

3.2.2 配置映射原理

js
// tailwind.config.js
extend: {
  backgroundColor: {
    theme: 'var(--bg-color)' // 关键映射配置
  }
}

这段代码实际上创建了一个名为theme的背景色配置项,Tailwind会自动生成对应的CSS类:

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

3.2.3 Just-In-Time 的优点与关键技术实现

关键技术实现

  1. AST转换引擎
  • 使用 PostCSS AST 分析 HTML/JSX 模版
  • 精准识别类名使用场景(包括动态拼接)
tsx
// 能正确识别的动态类名
const size = 'lg';
<div className={`text-${size}`}> // 仍会被解析
  1. 智能缓存系统
  • 文件修改哈希比对
  • 增量编译流程图
bash
文件修改 哈希比对 更新依赖图 仅重新生成变化部分 CSS
  1. 安全机制
  • 类名白名单校验(防止恶意输入)
  • 开发模式下的全量编译兜底

优点

  • 按需动态生成
  • CSS文件大小仅含实用类时为几十KB
  • 开发构建速度极快——增量生成
  • 原生支持任意值 如 top-[11px]