前段时间接手的一件事,是把一套跑了两年的后台系统,从单品牌改成多品牌。产品给的需求特别朴素:三个品牌,配色、圆角、字号各不一样,业务逻辑还是那一套,UI 换层皮就行。听着像是改几个变量的活,真动手才发现,原来的样式里到处是写死的十六进制色值、bg-blue-500、rounded-lg,光是靠全局搜索把这些东西找全就花了一整天。这就是Tailwind CSS 自定义主题最真实的门槛——难点从来不是"怎么写配置",而是"怎么让配置收敛得住"。
这篇文章想聊的,就是从零搭一套多品牌风格适配的主题体系,包含设计决策、令牌拆分、Tailwind 配置写法、运行时切换、构建期出包,以及我在这个过程里踩过的坑。它适合已经用过 Tailwind、但一碰到"多套皮肤"就靠复制配置文件硬顶的同学;也适合完全没接触过主题体系、想知道一套 UI 换皮方案到底该长什么样的小白。读完之后,你至少能判断出自己项目该走哪条路线,以及那条路线上有哪些坑在等你。
1. 多品牌主题这件事,难的不是换色而是收敛
一开始我也天真,觉得不就是tailwind.config.js里换几个颜色嘛。但真正做下来,主题体系的成败其实在动手写第一行代码之前就定了——你怎么划分品牌之间的差异,决定了后面三个月你是在维护配置还是在到处救火。
1.1 先把"品牌差异"拆成可枚举的维度
多品牌最怕的就是"差异无边界"。今天品牌 A 说按钮要方一点,明天品牌 B 说链接颜色要跟主色区分开,后天品牌 C 说表头背景要浅灰。如果每次都靠新加一个自定义类去覆盖,三个月后你的样式表会变成一锅谁也说不清的东西。
我的做法是,在写配置之前先跟设计侧把所有差异收敛成几个固定维度,写进一张表里,后面所有实现都严格贴着这张表走。
| 维度 | 是否随品牌变化 | 说明 | 实现方式 |
|---|---|---|---|
| 主色 / 辅助色 / 语义色 | 是 | 每个品牌一套色阶 | CSS 变量 + colors 映射 |
| 中性色(背景、文字、分割线) | 是 | 明暗对比度差异较大 | 语义化 token |
| 圆角 | 是 | 有的品牌偏直角,有的偏柔和 | 单个半径变量 |
| 字体族与字号阶梯 | 部分 | 标题字体可能不同 | fontFamily + fontSize |
| 间距刻度 | 否 | 全品牌统一 | 固定 scale |
| 阴影 | 是 | 柔和度差异 | boxShadow 变量 |
| 动效时长 | 否 | 统一 | 固定值 |
这张表最大的价值是"否"那一列。把不随品牌变化的东西钉死,等于给主题体系划了一条边界线,后面不管谁来提需求,只要落在"否"这一列上,直接拒绝,理由是"这会破坏一致性"。听起来有点硬,但真省事。
顺带说一句,颜色维度我建议直接按语义而不是按色相命名。也就是说配置里出现的应该是brand、success、danger、surface、muted,而不是blue、green、gray。原因很简单:品牌 A 的"主色"是蓝的,品牌 B 的"主色"可能是紫的,如果你按色相命名,品牌 B 的配置里就会出现一堆bg-blue-500但实际渲染成紫色的诡异代码,接手的人会疯。
1.2 三条技术路线的取舍:复制配置、构建期多份、运行时变量
把差异拆清楚之后,接下来就是选实现路线。我实际评估过三条,各自的成本差别很大。
第一条:每个品牌一份 tailwind.config.js,复制粘贴改颜色。上手最快,半小时能出效果,但代价在后面。三份配置意味着三份content扫描、三套类名,一旦有个新的共用组件要加样式,你得改三遍,而且极容易出现"品牌 B 的配置忘了同步"这种问题。我们当时评估过,如果品牌数量超过两个,这条路基本没法维护。它适合的场景只有一个:品牌之间差异巨大,大到根本不像同一个产品。
第二条:构建期多份产物,一套源码编译出三套 CSS。好处是每套产物体积最小,只包含该品牌用到的类。代价是构建时间线性增长(三个品牌就是三倍构建),而且运行时要切换品牌就得换整份 CSS 文件,用户体验上会有一个明显的重新加载。这条路适合"部署即定品牌"的场景,比如同一个系统给不同客户私有化部署,每个客户现场只跑一个品牌。
第三条:一套 CSS + CSS 变量,运行时切换。这是我最终选的方案。原理是把所有随品牌变化的量抽成 CSS 自定义属性,Tailwind 的配置只负责把这些变量"接"进来。切换品牌时改的是根节点上的>// tailwind.config.js module.exports = { content: ['./src/**/*.{html,js,jsx,ts,tsx,vue}'], theme: { extend: { colors: { brand: { 50: 'rgb(var(--brand-50) / <alpha-value>)', 100: 'rgb(var(--brand-100) / <alpha-value>)', 200: 'rgb(var(--brand-200) / <alpha-value>)', 300: 'rgb(var(--brand-300) / <alpha-value>)', 400: 'rgb(var(--brand-400) / <alpha-value>)', 500: 'rgb(var(--brand-500) / <alpha-value>)', 600: 'rgb(var(--brand-600) / <alpha-value>)', 700: 'rgb(var(--brand-700) / <alpha-value>)', 800: 'rgb(var(--brand-800) / <alpha-value>)', 900: 'rgb(var(--brand-900) / <alpha-value>)', 950: 'rgb(var(--brand-950) / <alpha-value>)', }, primary: 'rgb(var(--color-primary) / <alpha-value>)', 'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)', surface: 'rgb(var(--color-surface) / <alpha-value>)', 'surface-raised': 'rgb(var(--color-surface-raised) / <alpha-value>)', muted: 'rgb(var(--color-muted) / <alpha-value>)', }, }, }, };
为什么一定要写成rgb(var(--x) / <alpha-value>)而不是直接var(--x)?因为 Tailwind 的透明度修饰符(bg-primary/50这种)需要在构建期把颜色拆成通道值再拼上 alpha。如果你直接写var(--x),Tailwind 拿不到通道信息,/50这类修饰符会直接失效或者被忽略。古典的绕过方式是额外定义一个--color-primary-rgb存三段数字,但那等于维护两份变量名,很容易忘同步。<alpha-value>占位符是官方给的解法,只要你的变量值本身是三段空格分隔的数字(比如31 111 235),透明度就能正常工作。
对应地,CSS 变量必须写成三段数字,不能写#1f6feb:
/* theme/brands/alpha.css */ [data-brand='alpha'] { --brand-50: 239 246 255; --brand-500: 31 111 235; --brand-600: 24 92 200; --brand-900: 18 52 110; --color-primary: var(--brand-500); --color-primary-hover: var(--brand-600); --color-surface: 255 255 255; --color-surface-raised: 248 250 252; --color-muted: 100 116 139; }var()嵌套在 Tailwind 里能不能用?对于rgb(var(--a) / <alpha-value>)这种最外层必须是数字通道的情况,中间层套一层var(--brand-500)是没问题的,因为浏览器最终解析出来还是三段数字。但要注意别套太深,某些构建工具在处理多层var()嵌套时会比较保守,实测下来两层是安全的。
2.2 非颜色令牌:字体、圆角、间距、阴影
很多人做多品牌主题只考虑颜色,结果品牌 B 那边一上来就说"我们的圆角要用 4px 不是 8px",整套 UI 立刻显得不协调。圆角这东西对观感的影响其实比颜色还大,颜色还能靠"反正都是蓝"糊过去,圆角从 16 改成 4,整个产品的气质就变了。
所以非颜色令牌也要变量化,但要注意区别对待:
theme: { extend: { borderRadius: { // 语义化的圆角,只留三档,避免业务随便用 sm: 'var(--radius-sm)', DEFAULT: 'var(--radius-base)', lg: 'var(--radius-lg)', }, boxShadow: { card: 'var(--shadow-card)', pop: 'var(--shadow-pop)', }, fontFamily: { sans: ['var(--font-sans)', 'system-ui', 'sans-serif'], display: ['var(--font-display)', 'var(--font-sans)', 'sans-serif'], }, }, }这里有个取舍:不要把所有圆角档位都变量化。Tailwind 默认的rounded-sm/md/lg/xl/2xl/3xl有六七档,如果全部变量化,等于把"随便用哪档"的自由又还给了业务代码。我最后只保留三档语义圆角:sm给标签和徽标、DEFAULT给按钮和输入框、lg给卡片和弹层。品牌差异通过这三个变量调,业务组件永远只用这三档,一致性自然就有了。
字体这块要多说一句。fontFamily变量化有个坑:字体文件的加载策略没法用 CSS 变量控制。品牌 A 用一个自托管字体,品牌 B 用系统字体,这个切换不能只靠变量,得配合@font-face的按需加载或者font-display的设置。我当时的处理是,把所有品牌可能用到的字体都在构建期打包,但用unicode-range或者单独的 CSS 文件按>/* src/styles/index.css */ @tailwind base; @tailwind components; @tailwind utilities; @import './tokens/_base.css'; @import './tokens/_alpha.css'; @import './tokens/_beta.css'; @import './tokens/_gamma.css';
注意@import必须放在@tailwind之后(准确说是放在文件末尾的普通 CSS 区域),否则 PostCSS 处理顺序会出问题,变量可能在 Tailwind 生成样式时还不可用。这个顺序我第一次就写反了,表现是部分工具类拿不到颜色,排查了半小时才反应过来。
_base.css里放的是全品牌共享、但确实需要用变量的东西,比如:
:root { --radius-sm: 4px; --radius-base: 8px; --radius-lg: 12px; --font-sans: 'Inter var', system-ui, sans-serif; --font-display: var(--font-sans); --shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1); --focus-ring-width: 2px; --focus-ring-offset: 2px; }然后品牌文件里只覆盖需要变的:
/* src/styles/tokens/_beta.css */ [data-brand='beta'] { --brand-500: 124 58 237; --brand-600: 109 40 217; --color-primary: var(--brand-500); --color-surface: 250 250 252; --radius-base: 4px; --radius-lg: 6px; --font-display: 'Noto Serif SC', serif; --shadow-card: 0 1px 2px rgb(76 29 149 / 0.08); }这样每加一个品牌,新增的就是一个小文件,几十行,不会碰主配置。这个隔离性在做多品牌的时候非常重要,它让"品牌 X 的样式坏了"这种问题变成一个局部问题。
3.2 用 CSS 变量做中转层
这里展开讲一下"中转层"这个设计到底解决了什么。如果直接把 Tailwind 的colors.primary写成一个具体色值,那就是构建期确定的,切换品牌必须重新构建。加了 CSS 变量这一层中转之后,链条变成:
构建期:Tailwind 类名 → 语义 token 名 → CSS 变量名运行期:CSS 变量值 → 由>// src/theme/applyTheme.js const DEFAULT_BRAND = 'alpha'; export function applyBrand(brand) { const next = brand || DEFAULT_BRAND; document.documentElement.setAttribute('data-brand', next); document.documentElement.setAttribute('data-theme', getColorScheme()); } function getColorScheme() { const saved = localStorage.getItem('color-scheme'); if (saved) return saved; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
切换就是重新调用一次,配合一个下拉或者设置面板:
export function switchBrand(brand) { applyBrand(brand); localStorage.setItem('brand', brand); // 如果图表库等第三方组件需要跟随主题,在这里派发事件 window.dispatchEvent(new CustomEvent('themechange', { detail: { brand } })); }那个自定义事件不是可有可无的。我们的项目里有图表、有富文本编辑器,它们拿不到 Tailwind 的类名,必须靠事件监听重新读变量值再重绘。这一步如果不做,切完品牌图表还是老颜色,视觉上非常割裂。
如果品牌和明暗两个维度都要支持,不要用两套属性互相冲突,直接用两个独立的>[data-brand='beta'][data-theme='dark'] { --color-surface: 24 24 27; --color-surface-raised: 39 39 42; --color-muted: 161 161 170; }
这种组合写法比"每个品牌各写一套暗色变量"更可控,因为暗色里跟品牌无关的部分(比如灰度阶梯)可以复用。
3.4 构建期多品牌产物的做法
虽然主路线是运行时切换,但有些场景绕不开构建期出包——比如私有部署,客户现场只跑一个品牌,把三套变量全发过去是浪费,而且安全上也不希望客户看到其他品牌的配置。
我的做法是加一个环境变量控制构建,只在打包时保留目标品牌的 CSS 文件:
// build/theme-plugin.js(PostCSS 插件思路示意) module.exports = (opts = {}) => { const target = process.env.BRAND || 'all'; return { postcssPlugin: 'filter-brand-tokens', Once(root) { if (target === 'all') return; root.walkRules((rule) => { const sel = rule.selector; const match = sel.match(/^\[data-brand='([\w-]+)'\]/); if (match && match[1] !== target) rule.remove(); }); }, }; };用插件比用content配置排除文件更可靠,因为 CSS 变量的定义在 CSS 文件里,content只影响类名扫描,管不到变量。这个方案我实测下来,单品牌产物比全量小 30% 到 40%(三个品牌的变量文件加起来其实不大,主要是每个品牌各自的字体和特殊组件样式占大头)。
如果品牌之间还有结构差异(不只是颜色,某些区块的排布都不同),那就不是主题层能解决的了,得上升到组件层用配置开关控制,别硬塞进主题里。
4. 踩过的坑与排查清单
这一节是我这段时间攒下的"血泪清单",基本上每个坑都让我花了半小时以上。
4.1 透明度、渐变、阴影失效的三类原因
透明度和渐变失效是这套方案里最高频的问题,我总结下来就三类原因。
第一类:变量值格式不对。rgb(var(--x) / <alpha-value>)要求--x是R G B三段空格分隔的数字。如果你不小心写成#3b82f6或者rgb(59,130,246)(带rgb()包裹),透明度修饰符会静默失效——注意是静默,不报错,就是颜色不透明。这种问题最难受,因为构建没报错,你只能靠肉眼对比。我的做法是在主题文件顶部加一行注释明确写死格式,然后写个校验脚本,正则匹配所有变量值必须是^\d{1,3} \d{1,3} \d{1,3}$。
第二类:渐变里的from-*类。渐变色用到from-primary/50之类时,如果颜色是通过变量中转的,某些旧版本里渐变停止点不会正确应用 alpha。解法是确保渐变用的颜色也走<alpha-value>占位符,并且避免在渐变里用var()直接嵌套超过两层。
第三类:阴影颜色。shadow-*里的颜色不参与 Tailwind 的透明度修饰符,需要单独定义。如果想让阴影跟随品牌色,只能自己定义boxShadow的变量值把颜色写进去,或者单独定义一个--shadow-color然后在boxShadow里用rgb(var(--shadow-color) / 0.1)这种形式。这块很容易漏,漏了的表现是深色主题下阴影几乎看不见。
4.2 首屏闪烁与 SSR 不一致
如果是纯客户端渲染,品牌切换会在页面加载后几十毫秒才生效,用户能看到一下默认品牌配色再跳成目标配色。这个闪烁的解法是在 HTML 的<html>标签上直接写><script> (function () { try { var b = localStorage.getItem('brand') || 'alpha'; document.documentElement.setAttribute('data-brand', b); var s = localStorage.getItem('color-scheme'); if (s) document.documentElement.setAttribute('data-theme', s); } catch (e) {} })(); </script>
这段必须放在 CSS 引入之前,否则样式已经按默认品牌算过一轮了。try/catch是必要的,因为隐私模式下localStorage访问可能抛异常。
SSR 场景更麻烦,因为服务端不知道用户的品牌偏好。我的处理是:服务端按默认品牌渲染,客户端 hydration 之前用上面的脚本修正。这会有一个极短暂的不一致期,但只要脚本在 CSS 之前执行,用户基本感知不到。绝对不要在useEffect里才去设置>// 错误示范 const cls = `bg-${brand}-500`;
在单品牌场景下大家会写成映射表,多品牌场景下更容易踩这个坑,因为你会下意识觉得"品牌名是个变量"。解法有两个:一是穷举完整类名做成映射对象,二是用safelist把动态类名注册进去。
// 正确做法:穷举 const brandBg = { alpha: 'bg-alpha-500', beta: 'bg-beta-500', gamma: 'bg-gamma-500', };但更推荐的做法是压根不要按品牌命名类名。如果你按前面说的语义分层,业务代码里只会出现bg-primary,而primary是个变量,压根不需要动态拼类名。这个设计上的选择顺带把动态类名的问题消灭了,这是我坚持语义命名的另一个理由。
体积方面还有个小坑:safelist用起来很方便,但很容易越加越多。我们一度加了上百条,最后 CSS 里全是没用到的类。后来改成只在确实无法静态分析的地方(比如从后端接口拿到配置来拼类名)才用 safelist,其他地方一律靠穷举映射。
下面这张表是我整理的问题速查:
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
bg-primary/50不透明 | 变量值不是三段数字 | 检查变量格式,正则校验 |
| 切品牌后图表颜色不变 | 第三方库未监听事件 | 派发 themechange 事件 |
| 首屏闪一下才变色 | 品牌注入晚于 CSS | 内联脚本放 head 前置 |
| 某品牌 danger 色异常 | 变量漏覆盖 | 跑变量完整性校验脚本 |
| hover 色阶不生效 | 变量写了hsl()嵌套 | 改用三段数值 |
| 深色下阴影看不见 | 阴影变量未随主题覆盖 | 阴影与 surface 成对定义 |
| 打包后部分类缺失 | 动态拼接类名 | 改为穷举映射或 safelist |
提示:排查这类问题时,先在浏览器开发者工具里选中根节点,看
--color-primary的实际计算值。如果它是空的,问题在变量定义;如果有值但渲染不对,问题在 Tailwind 的映射写法。这一步能省掉大量猜测。
5. 主题的长期维护:契约、校验与版本迁移
主题体系一旦上线,麻烦就转移到"一年后还有人维护吗"这个层面。这节聊的是怎么让它别烂掉。
5.1 给品牌定一份配置契约
我的做法是定义一个"主题契约"——一份声明式的清单,明确规定每个品牌必须提供哪些变量,缺一不可。
// theme/contract.js export const REQUIRED_TOKENS = [ '--brand-500', '--brand-600', '--color-primary', '--color-primary-hover', '--color-surface', '--color-surface-raised', '--color-muted', '--radius-base', '--radius-lg', '--font-sans', '--shadow-card', ];这份清单的价值在于,它把"主题完整性"从一个口口相传的约定变成了可执行的检查。新增一个品牌时,把清单跑一遍,缺什么立刻知道,不用等设计师在验收时一眼看出"这个品牌的禁用态颜色还是蓝的"。
顺便说,契约还应该包含对比度要求。品牌色是客户给的,很多时候在浅色背景上对比度不够。我在契约里加了一条规则:--color-primary与--color-surface的对比度必须达到 4.5:1,不达标就在构建时警告。这个检查拦下过两个品牌的主色,后来都调整了。
5.2 从 v3 往 v4 迁移时的配置差异
如果你的项目还在 v3,未来会遇到 v4 的写法差异。核心变化是配置从 JS 文件搬到了 CSS 里,用@theme直接定义:
@import 'tailwindcss'; @theme { --color-primary: var(--brand-500); --color-surface: var(--color-surface-raw); --radius-base: var(--radius-brand); --font-sans: var(--font-sans-brand), system-ui, sans-serif; }这个变化对我们这套方案来说是利好的,因为 Tailwind 的令牌本身就是 CSS 变量了,中转层少了一层映射。但要注意两个细节:
第一,v4 里颜色的透明度处理改成了用color-mix(),<alpha-value>占位符不再需要。这意味着原来那套三段数值的约定可以简化成直接写颜色值。不过如果你还要兼容 v3(比如某些产品线升级节奏不一致),那就得保留三段数值的写法,或者维护两套主题文件。
第二,v4 的@theme里定义的是构建期变量,如果要在运行时覆盖,需要在@theme之外用普通 CSS 变量重新赋值。这个边界我第一次搞混了,把品牌变量写进了@theme,结果运行时切换完全没有效果——因为@theme里的东西会被内联成静态值,不在级联里参与覆盖。
迁移这件事我的建议是:不要一次性全改,先在新项目上验证,把主题契约和校验脚本沿用过去,等写法跑通了再回头改老项目。
5.3 回归校验脚本
最后一个我觉得必须有、但很少人做的东西:一个跑在 CI 里的主题校验脚本。
// scripts/check-theme.js import fs from 'node:fs'; import path from 'node:path'; const tokenDir = path.resolve('src/styles/tokens'); const contract = JSON.parse( fs.readFileSync('theme/contract.json', 'utf8') ).required; let failed = false; for (const file of fs.readdirSync(tokenDir)) { if (file.startsWith('_base')) continue; const css = fs.readFileSync(path.join(tokenDir, file), 'utf8'); for (const token of contract) { const re = new RegExp(`${token}\\s*:\\s*([^;]+);`); const m = css.match(re); if (!m) continue; // 允许继承默认值 const value = m[1].trim(); if (token.startsWith('--color-') || token.startsWith('--brand-')) { if (!/^\d{1,3}( \d{1,3}){2}$/.test(value) && !value.startsWith('var(')) { console.error(`[${file}] ${token} 格式不合法: ${value}`); failed = true; } } } } if (failed) process.exit(1);这个脚本干的事很朴素:格式校验 + 契约完整性。听着不起眼,但它拦下的问题比任何设计评审都多。最典型的一次是某个同事为了图省事,把一个变量写成了#fff,本地看着一切正常,因为那个地方刚好没用到透明度;结果上线后那个品牌的按钮 hover 态完全没变化,因为/60的透明度修饰符静默失效了。
我在实际维护这套主题的过程中最大的体会是:多品牌主题的技术难度不高,难的是让人不去绕开它。只要有人开始写style={{ color: '#3b82f6' }},这套体系就开始漏了。所以除了配置本身,还得配一条 lint 规则,禁止在组件里写十六进制颜色和行内颜色样式,把口子堵死。这个规则加上前面的校验脚本,基本能保证半年后接手的人不用骂人。