一、为什么要做动态主题切换

动态主题切换说白了就是让你的应用能在不同外观风格之间灵活切换,比如浅色模式和深色模式之间的切换,或者让用户自定义品牌色。听起来是个挺基础的需求,但真做起来的时候,很多人会发现没那么简单。

为什么这个需求很重要?首先,用户有视觉偏好,有人喜欢在浅色背景上工作,有人觉得深色模式更护眼,还有人需要高对比度来适应自己的视觉障碍。其次,企业级的后台管理系统经常需要根据不同品牌方定制一套专属配色,总不能每次改代码吧。再者,深色模式在移动端已经成了标配,苹果从 iOS 13 开始就支持系统级深色模式,安卓也早就有了。如果你的应用不能跟随系统切换,用户会觉得很别扭。

更深层的原因是用户体验。好的产品是"无感"的,用户不需要为了换个主题而重新加载页面、不需要担心状态丢失。主题切换应该是丝滑的、即时的,就像手机系统切换壁纸一样自然。这也是为什么很多开发者一开始会掉进坑里——他们试图通过刷新页面来实现主题切换,结果用户体验很差。

二、Ant Design 主题系统的底层逻辑

要理解怎么实现动态主题切换,得先搞清楚 Ant Design 的主题系统是怎么运作的。

早期的 Ant Design(3.x 和 4.x 初期)用的是 less 预处理器来处理样式,所有的颜色、字号、间距等都被定义成 less 变量。比如一个按钮的圆角大小,在代码里就写的是 @border-radius-base。这些变量在构建阶段就被"编译"成了固定的 CSS 值,运行时没法动态修改。也就是说,如果你想换个主色,得重新编译整个项目,这对于在线切换主题来说是根本做不到的。

到了 Ant Design 4.x 后期,官方开始引入 CSS-in-JS 方案,用 JavaScript 在运行时动态生成 CSS 并注入到页面中。这就打开了动态主题的可能性——既然样式是运行时生成的,那运行时改配置就能改样式。

到了 Ant Design 5.x,官方全面转向 CSS 变量方案,配合 ConfigProvider 组件,实现了一套非常优雅的主题系统。简单来说,你只需要修改 ConfigProvider 的 theme 属性,整个应用就会自动更新外观。这就是我们今天要重点讲的方案。

关联技术:CSS 变量

CSS 变量(也叫自定义属性)是实现动态主题的核心武器。它允许你在 CSS 中定义"变量",然后在需要的时候引用它们。关键之处在于,CSS 变量是可以在运行时通过 JavaScript 修改的。

/* 定义全局 CSS 变量 */
:root {
  --primary-color: #1677ff;       /* 品牌主色 */
  --border-radius: 6px;            /* 全局圆角 */
  --bg-color: #ffffff;             /* 背景色 */
  --text-color: rgba(0, 0, 0, 0.88); /* 文字颜色 */
}

/* 在组件中引用变量 */
.button {
  background-color: var(--primary-color);
  border-radius: var(--border-radius);
  color: var(--text-color);
}

/* 如果修改了 :root 中的变量,所有引用它的地方都会自动更新 */

Ant Design 5.x 就是大量使用了 CSS 变量。比如按钮的背景色就是 var(--ant-color-primary),你只要改了这个变量的值,所有按钮的颜色就都变了,不需要刷新页面,也不需要重新编译。

关联技术:ConfigProvider 组件

ConfigProvider 是 Ant Design 提供的"全局配置容器",它的作用范围是它的整个子树。你可以理解为,ConfigProvider 就像一个"主题广播站",所有在它下面的 Ant Design 组件都会自动接收并应用它的配置。

import { ConfigProvider } from 'antd';

const App = () => (
  // 所有在 ConfigProvider 内部的组件都会继承这个主题配置
  <ConfigProvider theme={{ token: { colorPrimary: '#52c41a' } }}>
    {/* 这里的按钮会自动使用绿色作为主色 */}
    <Button>绿色按钮</Button>
    {/* 这里的表格也会使用绿色作为表头背景色 */}
    <Table dataSource={data} />
  </ConfigProvider>
);

理解了这两个概念,后面的方案就很好理解了。

三、方案一:利用 CSS 变量实现主题切换(Ant Design 5.x 推荐)

这是目前最推荐的方案,适用于 Ant Design 5.x 及以上版本。核心思路很简单:用 ConfigProvider 的 theme 属性来切换主题配置,由于 Ant Design 5.x 底层基于 CSS 变量,所以切换是即时的、无感的。

技术栈说明

React 18.x + Ant Design 5.x + TypeScript + Vite

3.1 定义主题配置

我们先把每种主题的配置写成一个独立的对象,方便管理:

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/theme/presets.ts
// 作用:集中定义所有可用主题的配置

import { theme } from 'antd';
import type { ThemeConfig } from 'antd';

/**
 * 定义三个主题预设
 * classic  - 经典蓝色,默认浅色主题,适合日常办公场景
 * ocean    - 海洋蓝,自定义浅色主题,适合科技类应用
 * midnight - 午夜黑,深色主题,适合夜间或护眼场景
 *
 * 每个预设包含两层配置:
 *   algorithm - 算法层,决定亮色/暗色的基础规则
 *   token     - 令牌层,定义具体的颜色、尺寸等视觉参数
 */
const themePresets: Record<string, ThemeConfig> = {
  // 经典蓝主题 - 使用默认浅色算法
  classic: {
    algorithm: theme.defaultAlgorithm, // 默认亮色算法
    token: {
      colorPrimary: '#1677ff',       // 品牌主色:Ant Design 经典蓝
      colorSuccess: '#52c41a',       // 成功状态色
      colorWarning: '#faad14',       // 警告状态色
      colorError: '#ff4d4f',         // 错误状态色
      colorInfo: '#1677ff',          // 信息提示色
      colorBgBase: '#ffffff',        // 基础背景色:白色
      colorTextBase: '#000000',      // 基础文字色:黑色
      borderRadius: 6,               // 全局圆角:6px
      fontSize: 14,                  // 基础字号
      lineHeight: 1.5715,            // 行高比例
      // 可以添加更多 token 来微调细节
      // 比如字体、动画时长等
      fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif',
      // motionDurationMid: 0.25,    // 中等动画时长
    },
  },

  // 海洋蓝主题 - 自定义浅色主题
  ocean: {
    algorithm: theme.defaultAlgorithm,
    token: {
      colorPrimary: '#0958d9',       // 深蓝色,更有科技感
      colorSuccess: '#389e0d',
      colorWarning: '#d48806',
      colorError: '#cf1322',
      colorInfo: '#0958d9',
      colorBgBase: '#f0f5ff',        // 淡蓝色背景,与主色呼应
      colorTextBase: '#1e293b',      // 深灰色文字,比纯黑更柔和
      borderRadius: 8,               // 更大的圆角,更圆润的感觉
      fontSize: 14,
      lineHeight: 1.6,
      fontFamily: 'PingFang SC, "Microsoft YaHei", sans-serif',
    },
  },

  // 午夜黑主题 - 深色主题
  midnight: {
    algorithm: theme.darkAlgorithm,  // 深色算法,会自动调整大部分颜色
    token: {
      colorPrimary: '#4096ff',       // 深色下主色稍微调亮,保证可读性
      colorSuccess: '#95de64',       // 深色下的成功色
      colorWarning: '#ffc53d',       // 深色下的警告色
      colorError: '#ff7875',         // 深色下的错误色
      colorInfo: '#4096ff',
      colorBgBase: '#141414',        // 深色背景
      colorTextBase: '#ffffffd9',    // 浅色文字(带透明度)
      borderRadius: 6,
      fontSize: 14,
      lineHeight: 1.5715,
      fontFamily: 'PingFang SC, "Microsoft YaHei", sans-serif',
    },
  },
};

// 导出预设,方便其他地方引用
export default themePresets;
export type ThemeName = keyof typeof themePresets;

3.2 封装主题管理 Hook

把主题管理的逻辑封装成自定义 Hook,这样任何组件都能方便地使用:

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/hooks/useTheme.ts
// 作用:封装主题切换、持久化、系统偏好跟随等逻辑

import { useState, useEffect, useCallback, useMemo } from 'react';
import type { ThemeConfig } from 'antd';
import themePresets, { ThemeName } from '../theme/presets';

// 用于 localStorage 的存储键名
// 使用版本号前缀,方便未来升级时做迁移
const STORAGE_KEY = 'app-theme-v2';

/**
 * 读取已保存的主题偏好
 * 优先级:localStorage > 系统偏好 > 默认主题
 */
function getSavedTheme(): ThemeName {
  // 第一步:从 localStorage 读取
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved && saved in themePresets) {
    return saved as ThemeName;
  }
  // 第二步:检查系统是否偏好深色模式
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    return 'midnight';
  }
  // 第三步:返回默认主题
  return 'classic';
}

/**
 * 保存主题偏好到 localStorage
 */
function persistTheme(themeName: ThemeName): void {
  localStorage.setItem(STORAGE_KEY, themeName);
}

/**
 * useTheme - 自定义主题管理 Hook
 *
 * 功能:
 *   1. 管理当前主题状态
 *   2. 主题切换时自动持久化
 *   3. 自动跟随系统主题变化(仅在用户未手动设置时)
 *   4. 提供当前是否为深色模式的判断
 */
function useTheme() {
  // 当前主题名称,初始值从 localStorage 或系统偏好获取
  const [themeName, setThemeName] = useState<ThemeName>(getSavedTheme);

  // 监听系统主题变化
  // 这个 effect 在组件挂载时注册监听,卸载时清理
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    // 监听系统主题变化的回调函数
    const handleSystemChange = (event: MediaQueryListEvent) => {
      // 关键逻辑:只在用户没有手动设置过主题时才跟随系统
      // 这样就不会覆盖用户的选择
      if (!localStorage.getItem(STORAGE_KEY)) {
        setThemeName(event.matches ? 'midnight' : 'classic');
      }
    };

    // 注册监听器
    mediaQuery.addEventListener('change', handleSystemChange);

    // 清理函数:组件卸载时移除监听,防止内存泄漏
    return () => {
      mediaQuery.removeEventListener('change', handleSystemChange);
    };
  }, []);

  // 切换主题的回调函数
  // 使用 useCallback 避免不必要的重新渲染
  const switchTheme = useCallback((newTheme: ThemeName) => {
    setThemeName(newTheme);
    persistTheme(newTheme);
  }, []);

  // 计算当前主题的完整配置
  // 使用 useMemo 缓存计算结果,避免每次渲染都重新创建对象
  const themeConfig: ThemeConfig = useMemo(() => {
    return themePresets[themeName] || themePresets.classic;
  }, [themeName]);

  // 判断当前是否为深色主题
  const isDark = useMemo(() => {
    return themeName === 'midnight';
  }, [themeName]);

  // 获取所有可用主题名称列表
  const availableThemes = useMemo(() => {
    return Object.keys(themePresets) as ThemeName[];
  }, []);

  return {
    themeName,           // 当前主题名称
    themeConfig,         // 当前主题配置对象
    switchTheme,         // 切换主题的函数
    isDark,              // 是否深色模式
    availableThemes,     // 所有可用主题
  };
}

export default useTheme;

3.3 实现主题切换界面

提供一个可视化的操作界面,让用户能直观地切换主题:

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/components/ThemeSwitcher.tsx
// 作用:提供可视化的主题切换操作界面

import React from 'react';
import {
  Card,
  Select,
  Switch,
  Space,
  Typography,
  Divider,
  theme,
} from 'antd';
import type { ThemeConfig } from 'antd';
import {
  MoonOutlined,
  SunOutlined,
  PaintbrushOutlined,
} from '@ant-design/icons';
import themePresets from '../theme/presets';
import type { ThemeName } from '../theme/presets';

const { Text } = Typography;

/**
 * ThemeSwitcher 组件的 Props 定义
 */
interface ThemeSwitcherProps {
  /** 当前选中的主题名称 */
  currentTheme: ThemeName;
  /** 所有可用的主题名称列表 */
  availableThemes: ThemeName[];
  /** 主题切换回调 */
  onThemeChange: (theme: ThemeName) => void;
  /** 当前是否为深色模式 */
  isDark: boolean;
}

/**
 * 主题切换面板组件
 * 提供下拉选择 + 开关按钮两种交互方式
 */
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({
  currentTheme,
  availableThemes,
  onThemeChange,
  isDark,
}) => {
  // 当前主题的配置,用于显示主题预览信息
  const currentConfig = themePresets[currentTheme];

  return (
    <Card
      title={
        <Space>
          <PaintbrushOutlined />
          <span>主题设置</span>
        </Space>
      }
      size="small"
    >
      <Space direction="vertical" style={{ width: '100%' }}>
        {/* 主题选择下拉框 */}
        <div>
          <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
            选择主题风格
          </Text>
          <Select
            value={currentTheme}
            onChange={(value: ThemeName) => onThemeChange(value)}
            style={{ width: '100%' }}
            // 使用 Ant Design 的主题算法来判断选项标签
            options={availableThemes.map((t) => {
              const config = themePresets[t];
              const isDarkTheme = config.algorithm === theme.darkAlgorithm;
              return {
                value: t,
                label: (
                  <Space>
                    {isDarkTheme ? <MoonOutlined /> : <SunOutlined />}
                    <span>
                      {t === 'classic' && '经典蓝(默认)'}
                      {t === 'ocean' && '海洋蓝(科技风)'}
                      {t === 'midnight' && '午夜黑(深色)'}
                    </span>
                  </Space>
                ),
              };
            })}
          />
        </div>

        <Divider style={{ margin: '8px 0' }} />

        {/* 深色模式快捷开关 */}
        <div>
          <Space>
            <Text type="secondary">快速切换深色模式</Text>
            <Switch
              checked={isDark}
              onChange={(checked) => {
                // 开:切换到午夜黑
                // 关:切换到经典蓝
                onThemeChange(checked ? 'midnight' : 'classic');
              }}
              checkedChildren={<MoonOutlined />}
              unCheckedChildren={<SunOutlined />}
            />
          </Space>
        </div>
      </Space>
    </Card>
  );
};

export default ThemeSwitcher;

3.4 在应用入口集成

把主题系统接入应用的根组件:

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/App.tsx
// 作用:应用根组件,集成主题系统并展示业务内容

import React from 'react';
import {
  ConfigProvider,
  Layout,
  Menu,
  Button,
  Card,
  Table,
  Input,
  Space,
  Tag,
  Typography,
  Statistic,
  Row,
  Col,
} from 'antd';
import type { MenuProps } from 'antd';
import {
  DashboardOutlined,
  SettingOutlined,
  AppstoreOutlined,
} from '@ant-design/icons';
import useTheme from './hooks/useTheme';
import ThemeSwitcher from './components/ThemeSwitcher';

const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography;

/**
 * 主应用组件
 * 集成主题切换功能,并展示各类 Ant Design 组件的预览
 */
const App: React.FC = () => {
  // 使用自定义 Hook 获取主题相关状态和方法
  const { themeName, themeConfig, switchTheme, isDark, availableThemes } =
    useTheme();

  // 侧边栏菜单配置
  const menuItems: MenuProps['items'] = [
    {
      key: 'dashboard',
      icon: <DashboardOutlined />,
      label: '仪表盘',
    },
    {
      key: 'components',
      icon: <AppstoreOutlined />,
      label: '组件预览',
    },
    {
      key: 'settings',
      icon: <SettingOutlined />,
      label: '设置',
    },
  ];

  // 示例表格数据,用于展示主题切换效果
  const tableColumns = [
    { title: '组件名称', dataIndex: 'name', key: 'name' },
    { title: '分类', dataIndex: 'category', key: 'category', render: (text: string) => <Tag color="blue">{text}</Tag> },
    { title: '版本', dataIndex: 'version', key: 'version' },
    { title: '状态', dataIndex: 'status', key: 'status', render: (text: string) => text === '已发布' ? <Tag color="green">已发布</Tag> : <Tag color="orange">开发中</Tag> },
  ];

  const tableData = [
    { key: '1', name: 'Button', category: '基础组件', version: '5.12', status: '已发布' },
    { key: '2', name: 'Table', category: '数据展示', version: '5.12', status: '已发布' },
    { key: '3', name: 'Form', category: '表单组件', version: '5.12', status: '已发布' },
    { key: '4', name: 'Chart', category: '数据展示', version: '6.0', status: '开发中' },
    { key: '5', name: 'Upload', category: '表单组件', version: '5.12', status: '已发布' },
  ];

  return (
    // ConfigProvider 包裹整个应用
    // theme 属性传入当前主题配置,切换时会自动更新所有子组件的外观
    <ConfigProvider theme={themeConfig}>
      <Layout style={{ minHeight: '100vh' }}>
        {/* 侧边栏导航 */}
        <Sider breakpoint="lg" collapsedWidth="0">
          <div style={{ padding: 20, textAlign: 'center', fontSize: 16, fontWeight: 'bold' }}>
            主题演示
          </div>
          <Menu
            mode="inline"
            defaultSelectedKeys={['dashboard']}
            items={menuItems}
            style={{ borderRight: 0 }}
          />
        </Sider>

        <Layout>
          {/* 顶部导航栏 */}
          <Header
            style={{
              padding: '0 24px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'flex-end',
              gap: 16,
            }}
          >
            <Text type="secondary">
              当前主题:<Tag color={isDark ? 'purple' : 'blue'}>{themeName}</Tag>
            </Text>
          </Header>

          {/* 主内容区 */}
          <Content style={{ padding: 24 }}>
            <Title level={2}>动态主题切换演示</Title>
            <Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>
              使用 Ant Design 5.x 的 ConfigProvider + CSS 变量实现无缝主题切换
            </Text>

            <Row gutter={24} style={{ marginBottom: 24 }}>
              {/* 主题切换面板 */}
              <Col xs={24} md={8} lg={6}>
                <ThemeSwitcher
                  currentTheme={themeName}
                  availableThemes={availableThemes}
                  onThemeChange={switchTheme}
                  isDark={isDark}
                />
              </Col>

              {/* 数据卡片展示 */}
              <Col xs={24} md={16} lg={18}>
                <Row gutter={16}>
                  <Col span={6}>
                    <Card size="small">
                      <Statistic title="组件总数" value={150} />
                    </Card>
                  </Col>
                  <Col span={6}>
                    <Card size="small">
                      <Statistic title="使用次数" value={1024} suffix="万" />
                    </Card>
                  </Col>
                  <Col span={6}>
                    <Card size="small">
                      <Statistic title="贡献者" value={800} />
                    </Card>
                  </Col>
                  <Col span={6}>
                    <Card size="small">
                      <Statistic title="开源版本" value="5.12" />
                    </Card>
                  </Col>
                </Row>

                {/* 表格预览 */}
                <Card title="组件列表" size="small" style={{ marginTop: 16 }}>
                  <Table
                    columns={tableColumns}
                    dataSource={tableData}
                    size="small"
                    pagination={{ pageSize: 5, showTotal: (total) => `共 ${total} 条` }}
                  />
                </Card>

                {/* 按钮和输入组件预览 */}
                <Card title="基础组件预览" size="small" style={{ marginTop: 16 }}>
                  <Space direction="vertical" size="large" style={{ width: '100%' }}>
                    <Space wrap>
                      <Button type="primary">主要按钮</Button>
                      <Button>默认按钮</Button>
                      <Button type="dashed">虚线按钮</Button>
                      <Button type="text">文本按钮</Button>
                      <Button danger type="primary">危险按钮</Button>
                      <Button type="primary" icon={<AppstoreOutlined />}>
                        带图标
                      </Button>
                    </Space>
                    <Space wrap>
                      <Input placeholder="输入内容" style={{ width: 200 }} />
                      <Input.Search placeholder="搜索" style={{ width: 250 }} />
                      <Tag color="blue">标签一</Tag>
                      <Tag color="green">标签二</Tag>
                      <Tag color="red">标签三</Tag>
                      <Tag color="purple">标签四</Tag>
                    </Space>
                  </Space>
                </Card>
              </Col>
            </Row>
          </Content>
        </Layout>
      </Layout>
    </ConfigProvider>
  );
};

export default App;

四、方案二:利用 less 变量实现主题切换(Ant Design 4.x 兼容方案)

如果你的项目还在使用 Ant Design 4.x,或者因为某些原因暂时无法升级到 5.x,也可以用 less 变量方案来实现主题切换。这个方案的核心思路是:提前把不同主题的样式编译好,然后在运行时动态加载对应的 CSS 文件。

这个方案有一个前提条件——你需要把 Ant Design 的样式改成可配置的形式。在 Ant Design 4.x 中,官方推荐使用动态引入 less 文件的方式来修改样式。

4.1 配置 less 变量入口

首先,在项目的构建工具(比如 Vite 或 Webpack)中配置 less 的修改规则:

// 技术栈:React + Ant Design 4.x + Vite
// 文件:vite.config.ts
// 作用:配置 Vite 以支持 Ant Design 4.x 的 less 变量覆盖

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  css: {
    preprocessorOptions: {
      less: {
        // 修改 JS 处理规则,让 less 能正确引用 antd 的 less 变量
        javascriptEnabled: true,
        // 修改 less 修改规则
        modifyVars: {
          // 你可以在这里直接设置全局的 less 变量覆盖
          // 但这些变量在构建时就固定了,无法在运行时修改
          // 所以运行时切换需要动态加载不同的 CSS 文件
        },
        // 让 less 能找到 antd 的样式源文件
        paths: [
          path.resolve(__dirname, 'node_modules'),
        ],
      },
    },
  },
});

4.2 预编译不同主题的样式

为每个主题创建对应的 less 文件,然后在构建时分别编译成 CSS 文件:

// 技术栈:React + Ant Design 4.x
// 文件:src/styles/themes/classic.less
// 作用:定义经典蓝主题的 less 变量

// 引入 Ant Design 的所有 less 变量定义
// 这样后续可以基于这些变量做修改
@import '~antd/dist/antd.less';

// 覆盖变量 - 经典蓝主题
@primary-color: #1677ff;
@success-color: #52c41a;
@warning-color: #faad14;
@error-color: #ff4d4f;
@info-color: #1677ff;

// 覆盖尺寸相关变量
@border-radius-base: 6px;
@border-radius-sm: 4px;
@border-radius-lg: 8px;

// 覆盖字体相关变量
@font-size-base: 14px;
@font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@line-height-base: 1.5715;

// 覆盖背景色变量
@body-background: #f5f5f5;
@component-background: #ffffff;

// 覆盖文字颜色变量
@text-color: rgba(0, 0, 0, 0.88);
@text-color-secondary: rgba(0, 0, 0, 0.65);
@text-color-disabled: rgba(0, 0, 0, 0.25);
// 技术栈:React + Ant Design 4.x
// 文件:src/styles/themes/ocean.less
// 作用:定义海洋蓝主题的 less 变量

@import '~antd/dist/antd.less';

// 覆盖变量 - 海洋蓝主题
@primary-color: #0958d9;
@success-color: #389e0d;
@warning-color: #d48806;
@error-color: #cf1322;
@info-color: #0958d9;

// 覆盖尺寸相关变量
@border-radius-base: 8px;
@border-radius-sm: 5px;
@border-radius-lg: 10px;

// 覆盖字体相关变量
@font-size-base: 14px;
@font-family: PingFang SC, "Microsoft YaHei", sans-serif;
@line-height-base: 1.6;

// 覆盖背景色变量
@body-background: #f0f5ff;
@component-background: #ffffff;

// 覆盖文字颜色变量
@text-color: #1e293b;
@text-color-secondary: #64748b;
@text-color-disabled: #cbd5e1;
// 技术栈:React + Ant Design 4.x
// 文件:src/styles/themes/midnight.less
// 作用:定义午夜黑主题的 less 变量

@import '~antd/dist/antd.less';

// 覆盖变量 - 午夜黑主题
@primary-color: #4096ff;
@success-color: #95de64;
@warning-color: #ffc53d;
@error-color: #ff7875;
@info-color: #4096ff;

// 覆盖尺寸相关变量
@border-radius-base: 6px;
@border-radius-sm: 4px;
@border-radius-lg: 8px;

// 覆盖字体相关变量
@font-size-base: 14px;
@font-family: PingFang SC, "Microsoft YaHei", sans-serif;
@line-height-base: 1.5715;

// 覆盖背景色变量 - 深色背景
@body-background: #141414;
@component-background: #1f1f1f;

// 覆盖文字颜色变量 - 浅色文字
@text-color: rgba(255, 255, 255, 0.85);
@text-color-secondary: rgba(255, 255, 255, 0.65);
@text-color-disabled: rgba(255, 255, 255, 0.35);

// 覆盖边框颜色
@border-color-base: #434343;
@border-color-split: #303030;
@disabled-bg: #434343;

4.3 构建脚本:编译多主题 CSS

创建一个脚本来分别编译每个主题的 less 文件:

#!/bin/bash
# 技术栈:Shell + Less + Ant Design 4.x
# 文件:build-themes.sh
# 作用:批量编译所有主题的 less 文件为 CSS

# 输出目录
OUTPUT_DIR="./src/styles/themes/compiled"

# 创建输出目录(如果不存在)
mkdir -p "$OUTPUT_DIR"

# 主题列表
THEMES=("classic" "ocean" "midnight")

echo "开始编译主题样式..."

# 遍历每个主题并编译
for theme in "${THEMES[@]}"; do
  echo "正在编译: $theme ..."
  lessc \
    --no-color \
    "src/styles/themes/${theme}.less" \
    "${OUTPUT_DIR}/${theme}.css"
  echo "  -> ${OUTPUT_DIR}/${theme}.css"
done

echo "主题编译完成!共 ${#THEMES[@]} 个主题"

4.4 运行时动态加载 CSS

编译完成后,在运行时根据用户选择动态加载对应的 CSS 文件:

// 技术栈:React + Ant Design 4.x + TypeScript
// 文件:src/components/ThemeManager.tsx
// 作用:管理多主题 CSS 的动态加载和切换

import React, { useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';

/**
 * 主题定义接口
 */
interface ThemeDef {
  name: string;      // 主题标识名
  label: string;     // 显示名称
  cssFile: string;   // 编译后的 CSS 文件路径
}

/**
 * 所有可用主题配置
 * cssFile 路径对应 build-themes.sh 编译后的输出
 */
const themeList: ThemeDef[] = [
  { name: 'classic', label: '经典蓝', cssFile: '/styles/themes/compiled/classic.css' },
  { name: 'ocean', label: '海洋蓝', cssFile: '/styles/themes/compiled/ocean.css' },
  { name: 'midnight', label: '午夜黑', cssFile: '/styles/themes/compiled/midnight.css' },
];

/**
 * 创建 CSS 链接元素并插入到 <head> 中
 * @param href - CSS 文件路径
 * @param id - 元素 ID,用于后续查找和替换
 */
function injectCSS(href: string, id: string): HTMLLinkElement {
  // 创建 link 元素
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = href;
  link.id = id;

  // 添加到页面头部
  document.head.appendChild(link);
  return link;
}

/**
 * 移除指定 ID 的 CSS 链接
 * @param id - 元素 ID
 */
function removeCSS(id: string): void {
  const existing = document.getElementById(id);
  if (existing) {
    existing.remove();
  }
}

/**
 * 清除所有主题 CSS
 */
function clearAllThemeCSS(): void {
  themeList.forEach((t) => removeCSS(`theme-css-${t.name}`));
}

/**
 * ThemeManager 组件
 *
 * 用法:
 *   <ThemeManager defaultTheme="classic">
 *     <App />
 *   </ThemeManager>
 *
 * 在应用内部通过 window 对象或状态管理来调用切换方法
 */
const ThemeManager: React.FC<{
  defaultTheme?: string;
  children: ReactNode;
}> = ({ defaultTheme = 'classic', children }) => {
  // 当前激活的主题名
  const [activeTheme, setActiveTheme] = useState(defaultTheme);

  // 初始化:加载默认主题的 CSS
  useEffect(() => {
    const theme = themeList.find((t) => t.name === defaultTheme);
    if (theme) {
      injectCSS(theme.cssFile, `theme-css-${theme.name}`);
    }
  }, []);

  // 切换主题
  const switchTheme = useCallback((themeName: string) => {
    // 清除所有已加载的主题 CSS
    clearAllThemeCSS();

    // 加载新主题的 CSS
    const theme = themeList.find((t) => t.name === themeName);
    if (theme) {
      injectCSS(theme.cssFile, `theme-css-${theme.name}`);
      setActiveTheme(themeName);
    }
  }, []);

  // 将切换函数暴露到全局,方便任何组件调用
  // 实际项目中建议用 React Context 或状态管理库替代
  useEffect(() => {
    (window as any).__switchTheme = switchTheme;
    (window as any).__getTheme = () => activeTheme;
    return () => {
      delete (window as any).__switchTheme;
      delete (window as any).__getTheme;
    };
  }, [switchTheme, activeTheme]);

  return <>{children}</>;
};

export default ThemeManager;

4.5 调用方式

在页面中使用上面的 ThemeManager 包裹应用,并提供切换按钮:

// 技术栈:React + Ant Design 4.x + TypeScript
// 文件:src/App.tsx
// 作用:使用 ThemeManager 包裹的应用示例

import React, { useState } from 'react';
import { Button, Card, Space, Table, Typography, Tag } from 'antd';
import ThemeManager from './components/ThemeManager';

const { Title } = Typography;

/**
 * 主题选择器按钮组
 * 点击按钮即可切换主题
 */
const ThemeButtons: React.FC = () => {
  const [current, setCurrent] = useState('classic');

  const handleClick = (name: string) => {
    setCurrent(name);
    // 调用全局暴露的切换函数
    (window as any).__switchTheme(name);
  };

  return (
    <Space>
      <Button
        type={current === 'classic' ? 'primary' : 'default'}
        onClick={() => handleClick('classic')}
      >
        经典蓝
      </Button>
      <Button
        type={current === 'ocean' ? 'primary' : 'default'}
        onClick={() => handleClick('ocean')}
      >
        海洋蓝
      </Button>
      <Button
        type={current === 'midnight' ? 'primary' : 'default'}
        onClick={() => handleClick('midnight')}
      >
        午夜黑
      </Button>
    </Space>
  );
};

/**
 * 主应用组件(被 ThemeManager 包裹后的部分)
 */
const MainApp: React.FC = () => {
  // 示例表格数据
  const columns = [
    { title: '名称', dataIndex: 'name', key: 'name' },
    { title: '状态', dataIndex: 'status', key: 'status' },
  ];

  const data = [
    { key: '1', name: '组件 A', status: <Tag color="green">正常</Tag> },
    { key: '2', name: '组件 B', status: <Tag color="red">异常</Tag> },
    { key: '3', name: '组件 C', status: <Tag color="green">正常</Tag> },
  ];

  return (
    <div style={
      // 根据当前主题名动态设置背景色
      // 这里需要额外处理,因为 less 变量方案不会自动更新 body 背景
      // 可以通过 window.__getTheme() 获取当前主题名
      { minHeight: '100vh', padding: 24 }
    }>
      <Title level={2}>Ant Design 4.x 主题切换演示</Title>
      <Card size="small" style={{ marginBottom: 16 }}>
        <ThemeButtons />
      </Card>

      <Card title="组件预览" size="small">
        <Space direction="vertical" size="large">
          <Space>
            <Button type="primary">主要按钮</Button>
            <Button>默认按钮</Button>
            <Button danger type="primary">危险按钮</Button>
          </Space>
          <Table
            columns={columns}
            dataSource={data}
            size="small"
          />
        </Space>
      </Card>
    </div>
  );
};

/**
 * 应用入口
 * ThemeManager 在最外层,为整个应用提供主题切换能力
 */
const App: React.FC = () => (
  <ThemeManager defaultTheme="classic">
    <MainApp />
  </ThemeManager>
);

export default App;

五、完整实战案例:一个支持多主题的前端应用

综合前面的内容,下面给一个完整的、可以直接运行的实战案例。这个案例基于 Ant Design 5.x,集成了主题持久化、系统偏好跟随、可视化切换面板等完整功能。

5.1 项目初始化

首先创建项目并安装依赖:

# 创建 Vite + React + TypeScript 项目
npm create vite@latest my-theme-app -- --template react-ts

# 进入项目目录
cd my-theme-app

# 安装 Ant Design 5.x 及图标库
npm install antd @ant-design/icons

# 安装 less(如果用方案二需要,方案一可选)
npm install less --save-dev

# 启动开发服务器
npm run dev

5.2 文件结构

建议按照以下结构组织项目文件:

src/
├── App.tsx                    # 根组件
├── main.tsx                   # 入口文件
├── theme/
│   └── presets.ts             # 主题预设配置
├── hooks/
│   └── useTheme.ts            # 主题管理 Hook
├── components/
│   ├── ThemeSwitcher.tsx      # 主题切换面板
│   ├── ThemeManager.tsx       # 主题管理器(方案二用)
│   └── Layout.tsx             # 页面布局组件
├── styles/
│   ├── global.css             # 全局样式
│   └── themes/                # 主题 less 文件(方案二用)
│       ├── classic.less
│       ├── ocean.less
│       └── midnight.less

5.3 全局样式配置

添加一些全局的基础样式,确保主题切换时页面整体风格协调:

/* 技术栈:React + Ant Design 5.x + CSS
   文件:src/styles/global.css
   作用:定义全局基础样式,确保主题切换时的过渡效果
*/

/* 基础重置 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* body 样式,使用 CSS 变量以支持动态切换 */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
    "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* 平滑过渡,让主题切换时不那么生硬 */
  transition: background-color 0.3s ease, color 0.3s ease;
}

/* 确保滚动条也能适应深色主题 */
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background-color: rgba(0, 0, 0, 0.2);
  border-radius: 4px;
}

/* 深色模式下的滚动条(通过 Ant Design 5.x 的 data-theme 属性判断) */
[data-theme='dark'] ::-webkit-scrollbar-thumb,
.ant-layout [data-theme='dark'] ::-webkit-scrollbar-thumb {
  background-color: rgba(255, 255, 255, 0.25);
}

/* 给所有元素加上过渡动画,让切换更丝滑 */
/* 注意:不要全局加 transition,性能影响很大 */
/* 只对特定属性做过渡 */
h1, h2, h3, h4, h5, h6, p, span, div {
  transition: color 0.25s ease, background-color 0.25s ease;
}

/* Ant Design 组件的过渡优化 */
.ant-card,
.ant-btn,
.ant-table,
.ant-input,
.ant-select {
  transition: background-color 0.25s ease, border-color 0.25s ease,
    color 0.25s ease;
}

5.4 在 main.tsx 中引入全局样式

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/main.tsx
// 作用:应用入口,挂载根组件

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css'; // 引入全局样式

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

5.5 进阶:支持自定义品牌色

除了预设主题,你还可以允许用户完全自定义品牌色。Ant Design 5.x 的 ConfigProvider 支持传入自定义 token:

// 技术栈:React + Ant Design 5.x + TypeScript
// 文件:src/components/CustomColorPicker.tsx
// 作用:允许用户自定义品牌主色

import React, { useState } from 'react';
import {
  Card,
  Input,
  ColorPicker,
  Button,
  Space,
  Typography,
  message,
} from 'antd';
import type { ColorPickerProps } from 'antd';
import { theme } from 'antd';

const { Text } = Typography;

interface CustomColorPickerProps {
  onColorChange: (color: string) => void;
}

/**
 * 自定义品牌色选择器
 * 用户可以输入十六进制颜色值,实时预览
 */
const CustomColorPicker: React.FC<CustomColorPickerProps> = ({
  onColorChange,
}) => {
  // 用户自定义的颜色值
  const [customColor, setCustomColor] = useState('#1677ff');

  // 颜色选择回调
  const handleChange: ColorPickerProps['onChange'] = (_, hex) => {
    setCustomColor(hex);
    onColorChange(hex);
    message.success(`品牌色已更新为 ${hex}`);
  };

  // 预设的快速选择色板
  const presetColors = [
    '#1677ff', // 蓝
    '#52c41a', // 绿
    '#fa8c16', // 橙
    '#722ed1', // 紫
    '#eb2f96', // 粉
    '#13c2c2', // 青
    '#f5222d', // 红
    '#fadb14', // 金
  ];

  return (
    <Card title="🎨 自定义品牌色" size="small">
      <Space direction="vertical" size="middle" style={{ width: '100%' }}>
        <div>
          <Text type="secondary">选择预设色</Text>
          <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
            {presetColors.map((color) => (
              <Button
                key={color}
                type="primary"
                style={{
                  backgroundColor: color,
                  borderColor: color,
                  width: 40,
                  height: 40,
                  padding: 0,
                }}
                onClick={() => {
                  setCustomColor(color);
                  onColorChange(color);
                }}
              />
            ))}
          </div>
        </div>

        <div>
          <Text type="secondary">或输入自定义颜色</Text>
          <Space style={{ marginTop: 8 }}>
            <ColorPicker value={customColor} onChange={handleChange} showText />
            <Input
              value={customColor}
              onChange={(e) => setCustomColor(e.target.value)}
              onPressEnter={() => onColorChange(customColor)}
              style={{ width: 120 }}
              placeholder="#1677ff"
            />
            <Button
              type="primary"
              onClick={() => onColorChange(customColor)}
            >
              应用
            </Button>
          </Space>
        </div>

        {/* 实时预览 */}
        <div>
          <Text type="secondary">效果预览</Text>
          <div
            style={
              {
                '--ant-color-primary': customColor,
                padding: 16,
                borderRadius: 8,
                background: 'var(--ant-color-primary)',
                color: '#fff',
                textAlign: 'center',
                marginTop: 8,
              } as React.CSSProperties
            }
          >
            <Text strong style={{ color: '#fff' }}>
              这是自定义颜色预览
            </Text>
            <br />
            <Text style={{ color: '#ffffffd9' }}>
              颜色值:{customColor}
            </Text>
          </div>
        </div>
      </Space>
    </Card>
  );
};

export default CustomColorPicker;

六、应用场景分析

动态主题切换不是一个花架子功能,它在很多实际业务场景中都派得上用场。下面分析一下几种典型的应用场景,帮助你判断自己的项目是否真的需要这个能力。

6.1 企业内部管理系统

这是最常见的场景之一。比如一个为多家公司提供服务的 SaaS 平台,每个公司的品牌色都不一样。如果给每个公司都单独部署一套系统,那开发和运维成本太高了。但如果能用一套代码,通过主题配置来区分不同公司的品牌风格,那就效率高了。

举个例子,假设你做了一个 HR 系统,服务于 A 公司和 B 公司。A 公司的主色是蓝色,B 公司的主色是绿色。你只需要在 ConfigProvider 中配置不同的 token,前端就能自动呈现出不同的品牌风格,用户登录时根据账号信息加载对应的主题配置即可。

6.2 面向 C 端的消费应用

消费类应用(比如电商、社交、内容平台)通常更重视个性化。用户可能想在白天使用浅色模式,在晚上切换到深色模式。深色模式不仅护眼,在暗光环境下也更方便操作,而且还能节省 OLED 屏幕的电量。

这类应用中,除了提供手动切换外,还应该支持"跟随系统"模式。当用户开启了系统的深色模式后,应用自动跟随切换,不需要用户额外操作。这就是前面代码中 prefers-color-scheme 监听的用途。

6.3 后台数据可视化平台

数据可视化平台通常需要长时间盯着屏幕看数据,对颜色对比度和眼睛舒适度要求很高。这类平台通常会提供多套主题,比如"白天模式"、"夜间模式"、"护眼模式"(绿色调)。有些还会根据当前时间自动切换主题,白天用浅色,晚上自动切换到深色。

6.4 需要国际化适配的应用

不同的文化和地区对颜色的偏好也不同。比如在中国,红色代表喜庆和好运;但在某些西方文化中,红色可能暗示警告或危险。如果你的应用面向全球用户,提供可切换的主题可以让不同地区的用户看到更"本地化"的配色。

6.5 无障碍辅助场景

无障碍(Accessibility)是经常被忽视但非常重要的场景。有些用户有视觉障碍,需要高对比度的配色才能正常阅读。通过提供"高对比度主题",可以满足这部分用户的需求。WCAG(Web Content Accessibility Guidelines)标准要求文本与背景的对比度至少达到 4.5:1,深色主题如果配置得当,对比度通常比浅色主题更好。

七、技术优缺点对比

把前面介绍的两个方案放在一起对比,帮你做出技术选型决策。

7.1 方案一:CSS 变量 + ConfigProvider(Ant Design 5.x)

优点:

  • 实时切换,无需刷新:这是最大的优势。由于底层是 CSS 变量,修改变量的值后所有引用它的样式会立即生效,用户完全感受不到延迟。
  • 配置集中管理:所有主题参数都通过 TypeScript 对象管理,类型安全,易于维护。IDE 可以提供自动补全和类型检查,减少配置错误。
  • 支持复杂主题算法:Ant Design 5.x 的 theme 算法支持亮色/暗色/紧凑模式等多种算法的叠加,可以灵活组合出丰富的视觉效果。
  • 与 Ant Design 深度集成:ConfigProvider 是 Ant Design 的核心组件之一,所有组件都原生支持。不需要额外处理组件的样式继承问题。
  • 支持自定义 Token:除了预设的 token,你还可以添加自定义的 token 来管理全局样式变量。
  • 性能优秀:CSS 变量修改的开销非常小,不会出现闪烁或延迟。

缺点:

  • 需要 Ant Design 5.x:如果你的项目还在用 4.x,需要升级。升级过程中可能会遇到一些 API 变化。
  • 样式穿透复杂:如果要深度定制某些组件的内部样式,CSS 变量的覆盖粒度可能不够细。可能需要配合 components token 或自定义 CSS 来实现。
  • 首次加载略有延迟:ConfigProvider 在运行时生成 CSS,首次渲染时会有短暂的延迟(通常几毫秒),在极慢的设备上可能能看到闪烁。
  • 调试不够直观:由于样式是动态生成的,在浏览器 DevTools 中查看样式时,CSS 变量的实际值需要展开才能看到,调试体验不如静态 CSS。

7.2 方案二:less 变量 + 动态 CSS 加载(Ant Design 4.x)

优点:

  • 兼容旧版本:适合还在使用 Ant Design 4.x 的项目,不需要升级。
  • 编译时检查:less 变量在编译时会检查,配置错误可以在构建阶段就发现。
  • 样式完全可控:你可以精确控制每一个 less 变量的值,包括 Ant Design 组件内部的细节样式。

缺点:

  • 切换需要加载 CSS 文件:每次切换主题都需要加载新的 CSS 文件,可能会产生短暂的闪烁或空白。
  • 体积较大:每个主题都需要编译一整套 CSS,如果主题数量多,总体积会膨胀。
  • 维护成本高:新增或修改主题需要重新编译,而且 less 变量的嵌套关系复杂,理解起来有门槛。
  • 不支持运行时动态计算:less 变量在编译后就固定了,不能像 CSS 变量那样在运行时计算。
  • 不支持主题算法叠加:比如"亮色主题 + 紧凑模式"这种组合,在 less 方案中很难实现。

7.3 选型建议

如果你的项目正在使用 Ant Design 5.x,或者正在计划升级到 5.x,强烈推荐方案一。CSS 变量方案是未来的方向,Ant Design 官方也在不断完善它的能力。

如果你暂时无法升级 Ant Design 版本,或者项目对兼容性有特殊要求,可以使用方案二。但要注意控制主题数量,避免 CSS 文件体积过大。

如果两个方案都不满足你的需求(比如需要支持更细粒度的主题定制),可以考虑自研主题系统,基于 CSS 变量 + React Context 来实现。但自研的成本较高,建议先用好 Ant Design 内置的能力。

八、实践中的注意事项

前面的代码示例已经展示了核心实现,但在实际项目中还有很多细节需要注意。下面总结一些容易踩的坑和最佳实践。

8.1 避免首屏闪烁(FOUC)

FOUC(Flash of Unstyled Content)是指页面加载时先显示未应用样式的默认内容,然后样式才加载完成。这在主题切换场景中尤其明显——如果用户在页面加载完成前切换了主题,可能会看到短暂的样式闪烁。

解决办法是在应用入口处尽快加载用户之前的主题选择,在 CSS 应用之前就把正确的主题配置注入到 ConfigProvider 中。在前面代码中,useTheme Hook 的初始状态就是从 localStorage 读取的,这样在第一次渲染时就已经应用了正确的主题,不会出现闪烁。

8.2 图片、图标、图表的颜色适配

Ant Design 组件的主题切换主要处理的是 CSS 样式层面的颜色。但是页面中的图片、SVG 图标、Canvas 图表等,可能需要额外处理。

比如,如果页面中有一个柱状图,在浅色模式下用深色柱子,切换到深色模式后,深色柱子在深色背景上就看不见了。解决办法是在图表配置中也读取当前主题信息,动态调整图表的颜色。

8.3 处理第三方组件的主题兼容

如果项目中使用了非 Ant Design 的第三方组件(比如某个地图库、某个富文本编辑器),这些组件可能不支持 Ant Design 的主题系统。你需要手动为这些组件适配主题。

一个通用的做法是在全局 CSS 中定义主题变量,然后在第三方组件的容器中通过 CSS 变量传递这些值:

/* 通用第三方组件主题适配示例 */
:root {
  --app-bg: #ffffff;
  --app-text: rgba(0, 0, 0, 0.88);
  --app-border: #d9d9d9;
}

[data-theme='dark'] {
  --app-bg: #141414;
  --app-text: rgba(255, 255, 255, 0.85);
  --app-border: #434343;
}

/* 第三方地图组件的容器 */
.map-container {
  background: var(--app-bg);
  color: var(--app-text);
  border: 1px solid var(--app-border);
}

8.4 性能优化

主题切换时,如果应用中有大量组件同时更新,可能会造成性能压力。虽然 CSS 变量修改本身很轻量,但 React 的重新渲染和 CSS 计算仍然有开销。

优化建议:

  • 使用 useMemo 缓存主题配置对象,避免每次渲染都创建新对象(前面的代码已经做了这一点)。
  • 对于不需要主题切换的子组件,可以考虑把它们从 ConfigProvider 的作用域中排除。
  • 在切换主题时,给页面元素加上 CSS 过渡动画(如前面 global.css 中的 transition),让视觉变化更平滑,同时减少用户对"切换瞬间"的感知。

8.5 主题持久化的版本管理

当你的应用版本更新,主题配置的结构可能发生变化(比如新增了一些 token、修改了某些变量的含义)。这时候如果用户的 localStorage 中保存了旧版本的配置,可能会不兼容。

解决办法是给存储键名加上版本号前缀(前面代码中用了 app-theme-v2),当版本升级时,旧版本的数据会被忽略,用户会回退到默认主题。如果需要更精细的迁移逻辑,可以检测旧版本键名并做数据迁移。

8.6 无障碍(a11y)检查

在实现自定义主题时,一定要检查颜色对比度是否符合无障碍标准。WCAG 2.1 要求:

  • 正常文本(小于 18px)与背景的对比度至少 4.5:1
  • 大文本(大于等于 18px)与背景的对比度至少 3:1

你可以使用在线工具(比如 WebAIM Contrast Checker)来检查你的主题配置是否达标。尤其是深色主题中,如果背景色和文字颜色的对比度不够,在光线下看可能很吃力。

8.7 服务端渲染(SSR)兼容性

如果你的应用使用了服务端渲染(比如 Next.js),需要注意主题系统在 SSR 环境下的行为。localStorage 和 window.matchMedia 在服务器端不可用,需要加判断:

// 技术栈:React + Ant Design 5.x + TypeScript(Next.js 场景)
// 作用:SSR 安全地读取主题偏好

function getSavedThemeSSR(): string {
  // 仅在客户端执行
  if (typeof window === 'undefined') {
    return 'classic'; // SSR 默认返回
  }
  const saved = localStorage.getItem('app-theme-v2');
  if (saved) return saved;
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    return 'midnight';
  }
  return 'classic';
}

九、文章总结

Ant Design 的动态主题切换能力,从 4.x 的 less 变量到 5.x 的 CSS 变量,经历了一次质的飞跃。CSS 变量方案让主题切换变得实时、无缝、优雅,配合 ConfigProvider 的类型安全配置,几乎不需要写额外的样式代码就能实现复杂的多主题切换。

从实践角度来看,我的建议是:如果你用的是 Ant Design 5.x,直接用 CSS 变量方案,这是最省事也最可靠的方式。把主题配置集中管理在单独的文件中,用自定义 Hook 封装切换逻辑,用 ConfigProvider 包裹应用根组件,基本就能搞定大部分场景。

如果你的项目还在用 Ant Design 4.x,可以考虑升级。升级到 5.x 的工作量通常不会太大,而且能享受到 CSS 变量方案带来的所有好处。如果暂时无法升级,用 less 变量 + 动态加载 CSS 的方案也能实现,只是体验上会差一些,比如切换时可能会有短暂闪烁。

不管用哪个方案,都要记住几个关键点:持久化用户选择(让用户刷新页面后不会丢失)、支持系统偏好跟随(自动适配用户的系统设置)、保证颜色对比度符合无障碍标准(尤其是深色主题)、处理第三方组件的主题兼容(别只盯着 Ant Design 自己的组件)。

动态主题切换不是一个"一次性写好了就完事"的功能,而是一个需要持续优化的体验优化点。随着用户反馈的积累,你可能会发现新的需求,比如增加更多主题预设、支持用户自定义颜色、添加更多无障碍选项等等。保持系统设计的灵活性和可扩展性,才能让主题系统在未来持续满足业务需求。