基于Ant Design和CSS变量实现前端动态主题

正文

快到年末了,最近总结了下今年做的一个大型后台项目重构工作。

整理过程中,感觉其中动态主题切换的功能有不少可以值得反思和挖掘的地方,于是在团队内进行了一次技术分享,也在这里用文本形式进行一个记录,便于日后温故知新。

注:本文基于V5版本的ant-design编写。

本文中的实践过程代码示例位于:

github.com/victorsonge...

在线代码示例位于(需科学上网):

ant-theme-demo.vercel.app

前端主题方案演变

前端主题最常见的应用就是在浅色与深色模式之间切换主题。当然,前端作为最能折腾的群体,其实很早就有主题切换的例子了,比如早年间的QQ空间,还有各种信息门户网站,其实都会提供给用户在各种主题色之间选择的功能。

我们可以大致将前端主题方案演变的发展历史划分为静态和动态两个时期。

静态主题时期

静态时期持续的时间很长,时至今日仍有不少网站和组件库使用静态主题方案。

静态方案的核心思路有两种:

其一,将不同主题的样式划分至对应的class下,在运行时基于用户选择,切换DOM上的class名,命中不同的主题CSS;

其二,是预先编写(或通过脚手架在编译阶段生成)所有可供选择主题样式文件,然后在运行时去动态加载当前主题对应的样式文件。

目前采用静态主题方案的前端组件库代表是Element UI,提供了多种方式来帮开发者获取不同主题的样式文件,通常只要根据情况去按需引入对应主题的样式文件,即可实现主题变更。

动态主题时期

时间节点来到2023年末的今天,使用动态主题切换方案的网站已经越来越多了。

动态主题的核心思路也有两种:

其一,通过CSS-in-JS库动态更新绑定在前端组件上的style

其二,基于CSS变量实现,关键逻辑在于运行时动态修改CSS变量的值,这样在开发阶段只要编写一份样式文件,理论上运行时可以映射出无数份实际的页面主题。

目前采用动态主题方案的前端组件库代表是ant-design,v5版本的ant-design内部是通过一套CSS-in-JS方案来处理动态主题的。

配置ant-design组件主题

已经了解如何修改ant-design组件主题的读者,可以跳过本节内容,直接跳转下一节CSS变量动态同步自定义组件与ant-design组件主题样式。

ant主题基础概念

token:在 5.0 版本中,ant- design把影响主题的最小元素称为 Design Token 。通过修改 Design Token,我们可以呈现出各种各样的主题或者组件。通过在 ConfigProvider 中传入 theme 属性,可以配置主题。

theme:传给ant- design的组件的props,通过 theme 中的 token 属性,可以修改一些主题变量。部分主题变量会引起其他主题变量的变化,我们把这些主题变量称为 Seed Token。

theme对象的结构如下:

属性 说明 类型 默认值
token 用于修改 Design Token AliasToken -
inherit 继承上层 ConfigProvider 中配置的主题。 boolean true
algorithm 用于修改 Seed Token 到 Map Token 的算法 (token: SeedToken) => MapToken ((token: SeedToken) => MapToken)[] defaultAlgorithm
components 用于修改各个组件的 Component Token 以及覆盖该组件消费的 Alias Token ComponentsConfig -

调配theme config json

ant官方提供了一个十分好用的在线主题编辑器:

ant.design/theme-edito...

这里建议仅对品牌色、基础背景色等基础变量(Seed Token)进行修改,修改了这几个基础项之后,其他的高阶配置项也会基于ant- design的算法自动更新为相匹配的值。

虽然ant开放了很多细节的颜色配置项,但是在没有专业设计基础的情况下,随意修改某些细节配置------

  • 容易造成整体色彩效果不和谐;
  • 可能导致某些场景下,出现难以阅读的背景与文本颜色组合

传入theme config

调配完成后,点击页面右上角的"主题配置",可以得到一个theme config JSON。

将这份JSON作为props.theme传入ant-design的<ConfigProvider>组件即可。

需要注意的是,我们在代码中使用这段配置时,需要手动将algorithm的值替换为相应的ant-design导出的算法对象。

代码示例如下:

jsx 复制代码
import { Button, ConfigProvider, Space, theme } from 'antd';
import React from 'react';

const App: React.FC = () => (
  <ConfigProvider
    theme={{
      token: {
        // Seed Token,影响范围大
        colorPrimary: '#de1b3a',
				colorInfo: 'de1b3a',
				colorBgBase: '#3c6796'
				colorTextBase: '#ffffff'

        // 派生变量,影响范围小
        colorBgContainer: '#f6ffed',
      },

			// 使用暗色算法
      algorithm: theme.darkAlgorithm,
    }}
  >
    <Space>
      <Button type="primary">Primary</Button>
      <Button>Default</Button>
    </Space>
  </ConfigProvider>
);

export default App;

ant-design组件主题动态修改

由于ant-design组件等主题完全由运行时传入的theme JSON决定,开发者可以通过任意方式维护这份JSON,或者维护多份主题的JSON,通过全局状态(如Context或者Redux等状态管理库),甚至通过后端接口获取一个主题的JSON,拿到之后动态更新<ConfigProvider>theme属性即可。

代码示例:

jsx 复制代码
import React, { createContext, useState } from 'react';
import { ConfigProvider, Button } from 'antd';

const ThemeContext = createContext();

const themes = {
  default: {
    primaryColor: '#1890ff',
    secondaryColor: '#f0f2f5',
  },
  dark: {
    primaryColor: '#333',
    secondaryColor: '#000',
  },
};

const App = () => {
  const [theme, setTheme] = useState(themes.default);

  const changeTheme = (selectedTheme) => {
    setTheme(themes[selectedTheme]);
  };

  return (
    <ThemeContext.Provider value={theme}>
      <ConfigProvider theme={theme}>
        <div>
          <Button onClick={() => changeTheme('default')}>Default Theme</Button>
          <Button onClick={() => changeTheme('dark')}>Dark Theme</Button>
        </div>
        {/* 其他ant-design组件的使用 */}
      </ConfigProvider>
    </ThemeContext.Provider>
  );
};

export default App;

在这个示例中,我们首先创建了一个ThemeContext,用于存储当前主题的状态。然后,我们定义了两个预置的主题:default和dark,每个主题都有不同的颜色设置。

在App组件中,我们使用useState来管理当前主题的状态,并通过setTheme函数来实现主题切换。通过点击按钮,我们可以切换到不同的主题。

ConfigProvider组件中,我们将当前主题作为theme属性传递进去,从而实现动态修改ant-design组件的主题。

CSS变量动态同步自定义组件与ant-design组件主题样式

theme无法覆盖自定义组件样式的问题

实践过程中,我们会发现一个问题。

theme配置的影响范围只包括ant-design组件库中的组件,我们自定义的组件,或者我们自行编写的样式文件是无法直接使用当前主题的样式的。

ant官方给我们提供的解决方案是通过theme.useToken这个hook来动态获取当前<ConfigProvider>下的所有token,然后通过jsx直接绑定在jsx的style属性上,形式如下:

jsx 复制代码
import React from 'react';
import { Button, theme } from 'antd';

const { useToken } = theme;

const App: React.FC = () => {
  const { token } = useToken();

  return (
    <div
      style={{
        backgroundColor: token.colorPrimaryBg,
        padding: token.padding,
        borderRadius: token.borderRadius,
        color: token.colorPrimaryText,
        fontSize: token.fontSize,
      }}
    >
      使用 Design Token
    </div>
  );
};

这种解决方案很明显只适用于极少量的样式修改,如果项目中有大面积的自定义样式,这样处理会导致后期难以维护。

为了能优雅地消费这份动态的token,我们自然地能想到两种主流的动态样式方案:

  • CSS-in-JS
  • CSS变量

CSS-in-JS

Css-in-Js不是一个特定的库或者语言,而是对一系列在Js中编写CSS的方案的统称。常见的Css-in-Js的库有:

  • Styled Components:这是一个非常受欢迎的 CSS-in-JS 库,它提供了一种将 CSS 样式直接嵌入到组件中的方式。它使用标签模板字符串语法,使得编写和管理组件样式非常方便。
  • Emotion:Emotion 是另一个流行的 CSS-in-JS 库,它提供了类似于 Styled Components 的功能,但具有更高的性能和更好的开发者体验。它支持多种语法风格,包括对象样式和模板字符串。
  • CSS Modules:CSS Modules 不是一个纯粹的 CSS-in-JS 库,而是一种在 JavaScript 中使用 CSS 模块化的方法。它允许您将样式文件与组件绑定,以确保样式的局部作用域,并避免全局样式冲突。
  • styled-jsx:这是 Next.js 官方推荐的 CSS-in-JS 解决方案。它使用类似 CSS 的语法,并通过使用 Babel 插件将其转换为有效的 JavaScript。
  • Aphrodite:Aphrodite 是一个轻量级的 CSS-in-JS 库,它提供了一种简单的方式来定义和管理组件级别的样式。

ant-design v5内部实现动态主题的核心能力也是基于Css-in-Js方案,不过它并没有使用上述几种方案。

考虑到每次渲染都需要重新序列化style字符串,需要有一个合适的hash缓存方案来避免重复序列化造成的性能损耗。

ant-design官方称其CSS-in-JS方案为"组件级别的 CSS-in-JS ",并且解释了为什么这套方案仅适用于组件库场景,而不适用于应用级项目。详情可以参考官方的这篇博客:

ant.design/docs/blog/c...

CSS变量简介

考虑到目前团队没有成形的CSS-in-JS的方案,而且整体编码风格更倾向于在样式文件(.css或.less)中处理页面样式,最终决定基于CSS变量实现ant组件库与自定义组件间的样式同步。

CSS变量的使用很简单,其实就是把原先CSS的值改为用 var(----xxx)这种形式替换就行。----xxx可以在任意时期定义,或者修改,变更后会实时反映在页面样式上。

一个简单的代码示例:

jsx 复制代码
<!DOCTYPE html>
<html>
<head>
<style>
:root {
  --main-color: red;
}

#app {
	--main-color: blue;
}

h1 {
  color: var(--main-color);
}
</style>
</head>
<body>
	<div id="app">
		<h1>This is a heading</h1>
	</div>

</body>
</html>

在这个例子中,我们定义了一个名为--main-color的CSS变量,并将其设置为红色。然后,在#app元素内部,我们重新定义了同名的CSS变量,并将其设置为蓝色。

由于CSS变量具有层级作用域,所以在h1元素中使用var(--main-color)时,它会先查找最近的祖先元素中是否有同名的CSS变量,如果找到则使用祖先元素中的值。因此,h1标签的文本颜色将是蓝色,而不是红色。

如果你想通过JavaScript实时修改CSS变量的值,可以使用setProperty方法。例如,你可以添加以下JavaScript代码来实现动态修改CSS变量:

jsx 复制代码
const app = document.getElementById('app');
app.style.setProperty('--main-color', 'green');

这段代码将会把--main-color的值修改为绿色。这样,页面上的标题文本颜色会立即改变为绿色。

通过CSS变量同步ant-design组件与自定义组件的样式

这里的核心是要实现一个自定义的<CssVariableSetter />组件,并包裹所有需要同步样式的DOM元素。

jsx 复制代码
import React from "react";
import { Card, ConfigProvider, ThemeConfig, theme } from "antd";
import AntdComponentSample from "@/app/components/AntdComponentSample";
import CssVariableSetter from "@/app/components/CssVariableSetter";

import "./styles.css";

const levelTwoTheme: ThemeConfig = {
  algorithm: theme.defaultAlgorithm,
  inherit: false,
  token: {
    colorPrimary: "#EAB75A",
    colorBgBase: "#C8EADC",
  },
};

const LevelTwo: React.FunctionComponent = () => {

  return (
    <ConfigProvider theme={levelTwoTheme}>
			{/* 用于同步样式的核心组件,放置于ConfigProvider内部,并包裹所有需要同步样式的元素 */}
      <CssVariableSetter>
        <div className="levelTwo">
          <h2>LevelTwo</h2>

          <Card>
            <AntdComponentSample></AntdComponentSample>
          </Card>
        </div>
      </CssVariableSetter>
    </ConfigProvider>
  );
};

export default LevelTwo;

useCssVariables的核心逻辑

这个组件的核心是渲染传入其中的children,并运行一个自定义hook useCssVariables

CssVariableSetter源码

jsx 复制代码
import useCssVariables from "./useCssVariables";
import React, { FC } from "react";

const CssVariableSetter: FC<{
  isGlobal?: boolean;
  children?: React.ReactNode;
}> = ({ isGlobal, children }) => {
	// 执行自定义hook进行CSS变量的生成与写入,并获取此层级对应的容器id
	// 本次生成的CSS变量只会影响该容器下传入的children中的元素样式
  const { cssVarContainerID } = useCssVariables(isGlobal);
	// 如果声明此次同步是全局生效的,则可以不需要生成一个实际的容器div,用fragment包裹即可
  if (isGlobal) {
    return <>{children}</>;
  }

  return (
    <div
      ref={(el) => {
        if (el) {
          el.id = cssVarContainerID;
        }
      }}
    >
      {children}
    </div>
  );
};
export default CssVariableSetter;

useCssVariables将当前所处的<ConfigProvider>下的ant design token映射为CSS变量字符串,写入一个对应的style标签。

useCssVariables源码

jsx 复制代码
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { theme } from "antd";
import { generate } from "@ant-design/colors";

// 可以基于项目需要的规则,过滤出自己想要用的token
const filterTokenKey = (tokenName: string) => {
  return (
    tokenName.startsWith("color") ||
    tokenName.startsWith("control") ||
    tokenName === "boxShadow"
  );
};

// 生成随机字符串
function generateRandomString(length: number) {
  let result = "";
  const characters =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  const charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
}

const useCssVariables = (isGlobal?: boolean) => {
  const [cssVarContainerID] = useState(
    `css-var-container-${generateRandomString(6)}`
  );

  const styleEleId = useMemo(() => {
    return isGlobal ? "style-is-global" : `style-${cssVarContainerID}`;
  }, [cssVarContainerID, isGlobal]);
	
  // 获取所处最近的<ConfigProvider>下的所有的design tokens
  const { token } = theme.useToken();

  const setCssVariables = useCallback(() => {
    console.log('setCssVariables token= /n', token);
    let styleEle = document.getElementById(styleEleId) as HTMLStyleElement;
    const isAlreadyHaveStyleEle = Boolean(styleEle);
    if (!isAlreadyHaveStyleEle) {
      // 如果当前的themeContainer没有对应head中的style标签
      // 则新建一个,并加入head
      styleEle = document.createElement("style");
      styleEle.id = styleEleId;
      styleEle.setAttribute("type", "text/css");
      document.head.append(styleEle);
    }

    // 创建css变量字符串
    let cssVariablesString = "";

    // 遍历antd的token并为每一个值添加对应的css变量
    const colorTokenArr = Object.keys(token)
      .filter(filterTokenKey)
      .map((tokenName) => ({
        tokenName,
        tokenValue: token[tokenName as keyof typeof token],
      }));
    colorTokenArr.forEach(({ tokenName, tokenValue }) => {
      cssVariablesString = cssVariablesString.concat(
        `--${tokenName}: ${tokenValue};`
      );
      // 如果是全局的configProvider,则还需要添加对应的--global-xxxx
      // 使整个项目的任何位置都能引用到此css变量
      if (isGlobal) {
        cssVariablesString = cssVariablesString.concat(
          `--global-${tokenName}: ${tokenValue};`
        );
      }
    });

    // 中性色
    // 只需要写入全局的style中即可
    // 所有configProvider下用到的变量都是一致的
    if (isGlobal) {
      const colorGroupNeutral = generate("#c7c7c7");
      colorGroupNeutral.forEach((color, index) => {
        cssVariablesString = cssVariablesString.concat(
          `--neutral-color-${index}: ${color};`
        );
      });
    }

    // 定义这些css变量生效的作用域(css选择器)
    const styleRootSelector = isGlobal ? ":root" : `#${cssVarContainerID}`;

    // 将选择器与css变量字符串拼接
    cssVariablesString = `${styleRootSelector} { ${cssVariablesString} }`;

    // 将完整的css变量(包括选择器)内容写入对应的style标签
    styleEle.innerHTML = cssVariablesString;
  }, [cssVarContainerID, isGlobal, styleEleId, token]);

  const clearCssVariables = useCallback(() => {
    let styleEle = document.getElementById(styleEleId) as HTMLStyleElement;
    styleEle.remove?.();
  }, [styleEleId]);

  useEffect(() => {
    setCssVariables();
		// 组件销毁时,清除对应的style标签
    return () => {
      clearCssVariables();
    };
  }, [clearCssVariables, setCssVariables]);

  return {
    cssVarContainerID,
  };
};

export default useCssVariables;

这样一来,页面的<head>下就会塞入一个形如下图的<style>标签:

同时,任何位于 #css-var-container-0TBXHm 容器下的(也就是上文中<CssVariableSetter>下的)元素,都能消费这些token :

总结

至此,我们已经完成了基于ant-design和CSS变量实现前端动态主题的完整实践过程。

完整的实践流程可以总结如下:

  • 通过ant-design主题编辑器调配theme config JSON
  • <ConfigProvider>传入theme config
  • 将需要同步样式的自定义组件元素放置于<CssVariableSetter>组件下
  • theme.useToken()获取当前<ConfigProvider>下所有的token,
  • 将token映射为CSS变量,写入<head>下的<style>标签
相关推荐
sunbyte35 分钟前
Tailwind CSS v4 主题化实践入门(自定义 Theme + 主题模式切换)✨
前端·javascript·css·tailwindcss
湛海不过深蓝2 小时前
【css】css统一设置变量
前端·css
BillKu2 小时前
CSS实现图片垂直居中方法
前端·javascript·css
想睡好6 小时前
圆角边框 盒子阴影 文字阴影
前端·css·html
zfyljx6 小时前
2048 html
前端·css·html
神仙别闹6 小时前
基于HTML+JavaScript+CSS实现教学网站
javascript·css·html
python_chai7 小时前
CSS从入门到精通:全面解析CSS核心知识体系
前端·css
2401_837088509 小时前
CSS opacity
前端·css
前端小巷子10 小时前
CSS渲染性能优化
前端·css·面试·性能优化
未脱发程序员14 小时前
【前端】每日一道面试题3:如何实现一个基于CSS Grid的12列自适应布局?
前端·css