首页
/ Material UI CSS 主题变量(CSS Theme Variables):原理、取舍与落地实践

Material UI CSS 主题变量(CSS Theme Variables):原理、取舍与落地实践

2026-09-06 11:50:44作者:宣聪麟

本文以 Material UI 官方 CSS 主题变量概览文档为主体,系统讲解 CSS theme variables 的核心价值、性能取舍,并结合 @mui/materialcreateTheme / ThemeProvider 源码,深入剖析 cssVariables 配置项、theme.vars 变量对象、colorSchemeSelector 切换策略与 SSR 闪烁(flickering)的成因与解法。读完后,你将能够为 Material UI 应用安全地启用 CSS 主题变量,配置深浅色模式自动/手动切换,并掌握 InitColorSchemeScripttheme.applyStyles() 等配套机制的正确用法。

为什么需要 CSS 主题变量

概览文档 指出,CSS 变量(CSS custom properties)是一项现代跨浏览器特性,允许你在 CSS 中声明变量并在其他属性中复用。Material UI 引入 CSS theme variables 的目的,是用它替换组件样式中的“裸值”(raw values),从而带来两个关键体验提升:

  1. 更直观的调试体验:在浏览器 DevTools 中,你会看到样式值引用的是哪个主题 token(如 var(--mui-palette-primary-main)),而不是一个孤立的颜色值;不仅开发者受益,团队中的设计师也能一眼对号入座。
  2. 构建期注入主题:借助 CSS 变量,你可以把主题注入应用的样式表中,在全应用渲染之前就应用用户已选择的配置,这正是解决深色模式 SSR 闪烁的基础。

如果第一次接触 CSS 变量,建议先熟悉 MDN 关于 CSS custom properties 的基础知识(声明用 --name: value,使用用 var(--name))。

优势:官方列举的六大收益

概览文档给出的优势清单可以归纳为五点:

  • 防止深色模式 SSR 闪烁:服务端渲染应用中,HTML 输出与浏览器 prefers-color-scheme 不一致时会发生“先亮后暗”的闪烁,CSS 变量方案通过在 <head> 中提前执行内联脚本写入 class/data 属性来规避;
  • 不受限的颜色方案:可以创建超越 light / dark 的任意多套颜色方案;
  • 更好的调试体验:变量名直接映射主题 token,方便开发与设计师排查样式;
  • 浏览器标签页间自动同步颜色方案:同一用户的多个标签页中模式选择保持一致;
  • 简化第三方工具集成:CSS 变量是全局可用的,第三方样式可以直接 var() 引用;
  • 减少嵌套 ThemeProvider 的使用:想给应用局部区域套上深色样式时,不再需要嵌套主题,直接给容器元素加选择器类名即可(见下文“强制局部颜色方案”)。

其中“标签页间同步”与“localStorage 持久化”并非凭空实现:ThemeProvider 暴露了 modeStorageKey(默认 'mui-mode')与 colorSchemeStorageKey(默认 'mui-color-scheme')等 props,并监听 storage 事件,这一点可以从 ThemeProvider 的类型定义 中确认。

取舍:HTML 体积、FCP 与 TTI

概览文档专门用一张表格说明服务端应用需要权衡的成本:

指标 与默认方式(无 CSS 变量)相比 原因
HTML 体积 更大 CSS 变量在构建时同时为 light 和 dark 两种模式生成
First Contentful Paint(FCP) 略长 HTML 体积更大,下载 HTML 到展示内容的时间稍长
Time to Interactive(TTI) 更短(深色模式下) 深浅模式之间切换时无需重新生成样式表,执行 JavaScript 的时间大幅减少

文档同时给出警告:上述对比对大型复杂应用未必适用,因为影响性能指标的因素非常多。可以这样理解:默认方式在运行时切换模式需要 JS 重新计算并插入新样式表(TTI 成本在深色模式切换时很高);CSS 变量方案把这部分成本前置到了构建期(HTML 变大),用“下载时间”换“交互时间”。是否值得,取决于你的页面结构、用户深色模式占比与网络环境。

源码视角:cssVariables 如何接管主题创建流程

启用 CSS 变量只需一行配置,但这个开关在源码中触发的分支值得理解。在 createTheme 入口 中:

  • cssVariables 的默认值是 false。当它为 false 且未显式提供 colorSchemes 时,走 createThemeNoVars(),行为与 v5 完全一致;
  • cssVariablestrue 或对象时,走 createThemeWithVars(),主题会生成 vars 节点与 colorSchemes 结构;
  • cssVariables 传对象时可以携带细粒度配置项。从类型定义 CssVarsConfigList 可以看到,这些配置项共有七个:colorSchemeSelectorrootSelectordisableCssColorSchemecssVarPrefixshouldSkipGeneratingVarnativeColor
import { ThemeProvider, createTheme } from '@mui/material/styles';

const theme = createTheme({ cssVariables: true });

function App() {
  return <ThemeProvider theme={theme}>{/* ...your app */}</ThemeProvider>;
}

一个值得注意的兼容细节:createThemecolorSchemes 的默认值在“未提供 palette”时是 { light: true },即默认只生成浅色方案;要同时生成深浅两套变量,需显式写 colorSchemes: { light: true, dark: true }(或依赖 palette.mode 推导默认方案)。

渲染完成后,:root 样式表中会出现扁平化并带 --mui 前缀的变量:

:root {
  --mui-palette-primary-main: #1976d2;
  --mui-palette-primary-light: #42a5f5;
  --mui-palette-primary-dark: #1565c0;
  --mui-palette-primary-contrastText: #fff;
  /* ...other variables */
}

ThemeProvider 本身也是一个“路由器”:在 ThemeProvider.tsx 中,它先判断主题是否为函数式或“非 CSS 变量主题”(无 colorSchemes),是则走 ThemeProviderNoVars,否则委托给 CssVarsProvider(内部即 @mui/systemunstable_createCssVarsProvider,见 ThemeProviderWithVars.tsx)。这意味着同一个 ThemeProvider 入口同时承载了传统主题与 CSS 变量主题两条路径,无需再单独引入旧的实验性 API。

另外,如果你在使用旧的实验性 CssVarsProvider API,文档明确要求将其替换为 ThemeProviderCssVarsProvider 曾有的一切能力如今都在 ThemeProvider 中可用(CssVarsProvider 导出已标记废弃,源码中保留了迁移警告)。

使用 theme.vars 与原生 var() 消费变量

启用 CSS 变量后,主题对象会新增一个 vars 节点,它的结构与可序列化主题一一对应,每个值对应一个 CSS 变量。消费方式有两种:

1. theme.vars(推荐):在组件或 styled 中引用,值会被编译为 var(...)

const Button = styled('button')(({ theme }) => ({
  backgroundColor: theme.vars.palette.primary.main, // var(--mui-palette-primary-main)
  color: theme.vars.palette.primary.contrastText, // var(--mui-palette-primary-contrastText)
}));

若组件可能在 ThemeProvider(CSS 变量模式)之外渲染,需要加回退值:

backgroundColor: (theme.vars || theme).palette.primary.main;

2. 原生 CSS:拿不到 theme 对象时(例如纯 CSS 文件),直接使用 var()

/* external-scope.css */
.external-section {
  background-color: var(--mui-palette-grey-50);
}

TypeScript 注意vars 的类型默认不启用,需要在被 tsconfig.json 包含的任意文件中引入模块增强(仓库中对应 themeCssVarsAugmentation 模块):

import type {} from '@mui/material/themeCssVarsAugmentation';
import { styled } from '@mui/material/styles';

const StyledComponent = styled('button')(({ theme }) => ({
  // ✅ typed-safe
  color: theme.vars.palette.primary.main,
}));

颜色通道 token:生成半透明色

启用 cssVariables 后会自动生成“通道 token”(channel tokens),用于构造半透明色。它们是不含 alpha 分量、以空格分隔的颜色空间通道值,属性名以 Channel 结尾:

const theme = createTheme({ cssVariables: true });

console.log(theme.palette.primary.mainChannel); // '25 118 210'
// 该 token 由 theme.colorSchemes.light.palette.primary.main 生成

用它构造半透明色的正确写法是/ 而不是逗号分隔透明度(因为通道值本身以空格分隔,逗号会与 rgba(r, g, b, a) 的传统语法冲突):

const theme = createTheme({
  cssVariables: true,
  components: {
    MuiChip: {
      styleOverrides: {
        root: ({ theme }) => ({
          variants: [
            {
              props: { variant: 'outlined', color: 'primary' },
              style: {
                backgroundColor: `rgba(${theme.vars.palette.primary.mainChannel} / 0.12)`,
              },
            },
          ],
        }),
      },
    },
  },
});
`rgba(${theme.vars.palette.primary.mainChannel}, 0.12)`, // 🚫 不工作
`rgba(${theme.vars.palette.primary.mainChannel} / 0.12)`, // ✅ 始终使用 /

通道 token 的类型声明可以在 createThemeFoundation.ts 中查证,它同时为 primarybackgroundtext 等 palette 节点声明了对应的 *Channel 类型。

自定义前缀、rootSelector 与额外 token

自定义变量前缀:默认前缀是 --mui,通过 cssVarPrefix 修改:

createTheme({ cssVariables: { cssVarPrefix: 'any' } });
// 生成 --any-palette-primary-main: ...;

createTheme({ cssVariables: { cssVarPrefix: '' } });
// 空字符串表示去掉前缀:--palette-primary-main: ...;

自定义全局选择器rootSelector(默认 :root)决定了全局(与颜色方案无关的)变量挂到哪个选择器上,Shadow DOM 场景下可设为 :host

新增主题 token:theme 输入中的其他键值对会一并生成为 CSS 变量,而且可以在值里互相引用变量本身:

const theme = createTheme({
  cssVariables: true,
  colorSchemes: {
    light: {
      palette: {
        gradient:
          'linear-gradient(to left, var(--mui-palette-primary-main), var(--mui-palette-primary-dark))',
        border: {
          subtle: 'var(--mui-palette-neutral-200)',
        },
      },
    },
    dark: {
      palette: {
        gradient:
          'linear-gradient(to left, var(--mui-palette-primary-light), var(--mui-palette-primary-main))',
        border: {
          subtle: 'var(--mui-palette-neutral-600)',
        },
      },
    },
  },
});

之后即可通过 theme.vars.palette.border.subtle 或 CSS 中的 var(--mui-palette-gradient) 使用。注意两点:使用了自定义前缀时要替换掉硬编码的 --mui;TypeScript 项目需要扩展 PaletteOptionsPalette 两个接口:

declare module '@mui/material/styles' {
  interface PaletteOptions {
    gradient: string;
    border: { subtle: string };
  }
  interface Palette {
    gradient: string;
    border: { subtle: string };
  }
}

如果你希望更精细地控制“哪些键不生成变量”,可以使用 shouldSkipGeneratingVar(keys, value) 回调;它内部默认跳过 cssVarPrefixcolorSchemeSelectormodularCssLayersrootSelectortypography 等配置与结构节点,实现见 shouldSkipGeneratingVar.ts

深浅色模式:colorSchemeSelector 的四种策略

当内置深色模式与 cssVariables 同时启用时,light 与 dark 两套 CSS 变量会按默认策略生成。colorSchemeSelector 决定变量挂在哪个选择器下,从 createThemeWithVars 的类型定义 看,取值共有四类:

  • media(默认):使用 @media (prefers-color-scheme: dark) 生成。该方式无需额外配置即可配合 SSR 工作,但用户无法手动切换模式——样式完全跟随浏览器媒体查询。
  • class:向 <html> 添加 class,生成 .light { ... } / .dark { ... }
  • data:向 <html> 添加 data 属性,生成 [data-light] { ... } / [data-dark] { ... }
  • 自定义字符串:必须以 . 开头(class)或 [](data 属性)结尾带 %s 占位符,例如:
createTheme({
  colorSchemes: { light: true, dark: true },
  cssVariables: {
    colorSchemeSelector: '.theme-%s',
  },
});
// 生成 .theme-light { ... } 与 .theme-dark { ... }

选择器字符串最终如何被解析,可以在 createGetSelector.ts 中查看。

手动切换模式:useColorScheme

配置好非 media 选择器后,用 useColorScheme hook 实现切换:

import { useColorScheme } from '@mui/material/styles';

function ModeSwitcher() {
  const { mode, setMode } = useColorScheme();

  if (!mode) {
    return null;
  }

  return (
    <select
      value={mode}
      onChange={(event) => {
        setMode(event.target.value);
        // TypeScript 中需断言:event.target.value as 'light' | 'dark' | 'system'
      }}
    >
      <option value="system">System</option>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

React 完成 hydrate 之后,mode 会被设为 system,以跟随用户系统偏好。用 systemMode 属性可以读出系统实际是 light 还是 dark——但仅当 modesystem systemMode 才有值,显式选了 light/dark 时 systemModeundefined

强制局部颜色方案

想让应用某一部分“永远是深色”,不必嵌套主题,直接把选择器类名/属性加到容器元素上即可(对应概览文档“减少嵌套主题”的优势):

// 选择器配置为 '.mode-%s' 时
<div className="mode-dark">
  <Paper sx={{ p: 2 }}>
    <TextField label="Email" type="email" margin="normal" />
    <TextField label="Password" type="password" margin="normal" />
    <Button>Sign in</Button>
  </Paper>
</div>
// 选择器配置为 '[data-mode-%s]' 时
<div data-mode-dark>
  {/* 内部组件全部呈现深色 */}
</div>

防止 SSR 闪烁:theme.applyStyles()InitColorSchemeScript

这是 CSS 变量方案最重要的实战点。SSR 应用在服务端无法得知用户选择的模式,若代码中存在 theme.palette.mode === 'dark' 这类条件分支,客户端 hydrate 阶段就会发生从浅色到深色(或反之)的闪烁。解法分两步:

第一步:消灭 theme.palette.mode 条件,改用 theme.applyStyles()

 import Card from '@mui/material/Card';

 function App() {
   return (
     <Card
-      sx={(theme) => ({
-        backgroundColor: theme.palette.mode === 'dark' ? '#000' : '#fff',
-        '&:hover': {
-          backgroundColor: theme.palette.mode === 'dark' ? '#333' : '#f5f5f5',
-        },
-      })}
+      sx={[
+        {
+          backgroundColor: '#fff',
+          '&:hover': {
+            backgroundColor: '#f5f5f5',
+          },
+        },
+        (theme) =>
+          theme.applyStyles('dark', {
+            backgroundColor: '#000',
+            '&:hover': {
+              backgroundColor: '#333',
+            },
+          }),
+      ]}
     />
   );
 }

applyStyles('dark', styles) 会把样式编译到对应颜色方案的选择器(@media (prefers-color-scheme: dark) 或你配置的 class/data 选择器)作用域内,从而让样式表在构建期就同时包含两套模式。

第二步:非 media 选择器必须引入 InitColorSchemeScript,在 <head> 中于应用内容之前执行,把存储的模式写入 <html> 的 class/data 属性。注意其 attribute 必须与 colorSchemeSelector 匹配(如选择器为 classattribute="class")。框架接法:

Next.js App Router(root layout):

import InitColorSchemeScript from '@mui/material/InitColorSchemeScript';

export default function RootLayout(props) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        {/* 必须位于 <main> 元素之前 */}
        <InitColorSchemeScript attribute="class" />
        <main>{children}</main>
      </body>
    </html>
  );
}

<html> 上必须加 suppressHydrationWarning,否则会因为 InitColorSchemeScript 修改了该元素而出现 “Extra attributes from the server” 警告。

Next.js Pages Router(pages/_document.js):

import Document, { Html, Head, Main, NextScript } from 'next/document';
import InitColorSchemeScript from '@mui/material/InitColorSchemeScript';

export default class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>...</Head>
        <body>
          {/* 必须位于 <Main> 元素之前 */}
          <InitColorSchemeScript attribute="class" />
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Gatsby(gatsby-ssr.js):

import * as React from 'react';
import InitColorSchemeScript from '@mui/material/InitColorSchemeScript';

export function onRenderBody({ setPreBodyComponents }) {
  setPreBodyComponents([<InitColorSchemeScript attribute="class" />]);
}

其他行为开关

禁用 CSS color-scheme 属性:默认 createTheme() 会根据 palette 模式附加 CSS color-scheme(影响浏览器原生控件、滚动条等的外观)。设置 disableCssColorScheme: true 即可关闭,生成的媒体块中不再包含 color-scheme 声明:

createTheme({ cssVariables: { disableCssColorScheme: true } });
 @media (prefers-color-scheme: dark) {
   :root {
-    color-scheme: dark;
     --mui-palette-primary-main: #90caf9;
     ...
   }
 }

切换模式时禁用 CSS 过渡:模式切换瞬间大量元素的过渡动画会显得“拖影”,给 ThemeProviderdisableTransitionOnChange 即可在切换时临时关闭过渡(该特性的 documentNode 默认取 document):

<ThemeProvider disableTransitionOnChange />

对应演示可参考 DisableTransitionOnChange

强制跨模式重渲染cssVariables: true 时,ThemeProvider 默认在 light/dark 切换时不重渲染子树(因为样式由 CSS 变量自动切换,无需 JS 参与——这也正是 TTI 收益的来源)。若确有组件依赖 JS 重算主题值,可用 forceThemeRerender 退出该优化:

<ThemeProvider forceThemeRerender />

ThemeProvider 的类型注释 可以看到,启用后切换模式时 theme.colorSchemes.{mode}.* 节点会被浅合并到主题顶层。SSR 场景下还有一个相关约束:若设置 noSsr(ThemeProvider 不重渲染、mode 初始值直接来自 localStorage),必须保证服务端渲染输出与客户端首次渲染输出一致,否则会出现 hydration 不匹配。

相关文档与源码索引

围绕本文主题,仓库中以下文件可作为继续深入的入口:

适用前提小结:CSS 主题变量面向的是能接受“HTML 体积换交互性能”的服务端/静态站点场景;纯 CSR 小应用对 FCP 体积不敏感时,传统 createTheme 路径同样可用(cssVariables: falsecreateTheme 的行为与 v5 完全一致)。启用前建议先在真实页面环境用 Lighthouse 实测 FCP/TTI,再结合上表的取舍判断是否值得。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388