CSS变量与主题系统

What — 是什么

CSS 自定义属性(CSS Variables)是原生 CSS 的变量机制,是实现主题系统、暗色模式和设计令牌的基础。

核心概念:

  • 定义与使用--name: value 定义,var(--name) 使用
  • 作用域:变量遵循 CSS 层叠规则,可在任何选择器上定义
  • 默认值var(--name, fallback) 第二参数为默认值
  • 全局变量:在 :root 上定义,全文档可用
  • 运行时动态:JS 可通过 style.setProperty 实时修改

关键特性:

  • CSS 变量是运行时的,不是编译时的(与 Less/Sass 变量不同)
  • 变量可继承、可覆盖、可组合
  • 是实现 CSS 设计系统(Design Tokens)的原生方案

Why — 为什么

适用场景:

  • 主题系统(亮色/暗色/品牌色)
  • 设计令牌(间距、颜色、字号统一管理)
  • 组件样式定制(通过 CSS 变量暴露样式接口)
  • 运行时动态样式(用户偏好、A/B 测试)

对比替代方案:

维度CSS 变量Sass 变量Less 变量CSS-in-JS
运行时✅ 动态❌ 编译时❌ 编译时✅ 动态
主题切换原生支持需多套 CSS需多套 CSS运行时切换
浏览器支持全部(IE 除外)无关(编译后)无关(编译后)全部
性能无额外开销运行时开销

优缺点:

  • ✅ 优点:
    • 原生支持,无构建依赖
    • 运行时可变,主题切换零成本
    • 与层叠规则结合,灵活覆盖
  • ❌ 缺点:
    • IE 不支持(已淘汰,基本无影响)
    • 无法做编译时计算(需 Sass/PostCSS 辅助)

How — 怎么用

快速上手

/* 全局设计令牌 */
:root {
    /* 颜色 */
    --color-primary: #3b82f6;
    --color-primary-hover: #2563eb;
    --color-bg: #ffffff;
    --color-text: #1f2937;
    --color-border: #e5e7eb;

    /* 间距 */
    --spacing-xs: 4px;
    --spacing-sm: 8px;
    --spacing-md: 16px;
    --spacing-lg: 24px;
    --spacing-xl: 32px;

    /* 字号 */
    --font-size-sm: 0.875rem;
    --font-size-base: 1rem;
    --font-size-lg: 1.25rem;

    /* 圆角 */
    --radius-sm: 4px;
    --radius-md: 8px;
    --radius-lg: 12px;

    /* 阴影 */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}

/* 使用 */
.card {
    background: var(--color-bg);
    color: var(--color-text);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    padding: var(--spacing-md);
    box-shadow: var(--shadow-sm);
}

代码示例

暗色模式:

/* 方式1:media query(跟随系统) */
@media (prefers-color-scheme: dark) {
    :root {
        --color-bg: #1a1a2e;
        --color-text: #e5e7eb;
        --color-border: #374151;
        --color-primary: #60a5fa;
        --color-primary-hover: #93bbfd;
        --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
    }
}

/* 方式2:class 切换(用户手动选择) */
[data-theme="dark"] {
    --color-bg: #1a1a2e;
    --color-text: #e5e7eb;
    --color-border: #374151;
    --color-primary: #60a5fa;
}

/* 方式3:两者结合 */
:root { /* 亮色 */ }
:root[data-theme="dark"],
[data-theme="dark"] { /* 暗色 */ }
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) { /* 系统暗色但用户未选亮色 */ }
}

JS 切换主题:

// theme.ts
type Theme = 'light' | 'dark' | 'system';

function getSystemTheme(): 'light' | 'dark' {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyTheme(theme: Theme) {
    const effective = theme === 'system' ? getSystemTheme() : theme;
    document.documentElement.setAttribute('data-theme', effective);
    // 同步 <meta name="theme-color"> 让浏览器 UI 配合
    document.querySelector('meta[name="theme-color"]')
        ?.setAttribute('content', effective === 'dark' ? '#1a1a2e' : '#ffffff');
    localStorage.setItem('theme', theme);
}

function initTheme() {
    const saved = (localStorage.getItem('theme') ?? 'system') as Theme;
    applyTheme(saved);

    // 监听系统主题变化
    window.matchMedia('(prefers-color-scheme: dark)')
        .addEventListener('change', () => {
            if (localStorage.getItem('theme') === 'system') {
                applyTheme('system');
            }
        });
}

// 防止闪烁:在 <head> 中内联执行
// <script>document.documentElement.setAttribute('data-theme',
//   localStorage.getItem('theme') === 'dark' ||
//   (!localStorage.getItem('theme') && matchMedia('(prefers-color-scheme:dark)').matches)
//     ? 'dark' : 'light')</script>

Vue 主题 Composable:

function useTheme() {
    const theme = ref<Theme>((localStorage.getItem('theme') as Theme) ?? 'system');

    const effectiveTheme = computed(() =>
        theme.value === 'system'
            ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
            : theme.value
    );

    watchEffect(() => {
        document.documentElement.setAttribute('data-theme', effectiveTheme.value);
        localStorage.setItem('theme', theme.value);
    });

    function toggle() {
        const next = { light: 'dark', dark: 'system', system: 'light' };
        theme.value = next[theme.value];
    }

    return { theme, effectiveTheme, toggle };
}

React 主题 Hook:

function useTheme() {
    const [theme, setTheme] = useState<Theme>(() =>
        (localStorage.getItem('theme') as Theme) ?? 'system'
    );

    const effectiveTheme = useMemo(() => {
        if (theme !== 'system') return theme;
        return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }, [theme]);

    useEffect(() => {
        document.documentElement.setAttribute('data-theme', effectiveTheme);
        localStorage.setItem('theme', theme);
    }, [theme, effectiveTheme]);

    return { theme, setTheme, effectiveTheme };
}

组件级样式定制:

/* 通过 CSS 变量暴露组件样式接口 */
.button {
    --btn-bg: var(--color-primary);
    --btn-color: white;
    --btn-radius: var(--radius-md);
    --btn-padding: var(--spacing-sm) var(--spacing-md);

    background: var(--btn-bg);
    color: var(--btn-color);
    border-radius: var(--btn-radius);
    padding: var(--btn-padding);
}

/* 使用时覆盖 */
.button-danger {
    --btn-bg: #ef4444;
}

.button-outline {
    --btn-bg: transparent;
    --btn-color: var(--color-primary);
    --btn-radius: var(--radius-lg);
}

/* 父级作用域覆盖 */
.card .button {
    --btn-radius: var(--radius-sm);
}

常见问题与踩坑

问题原因解决方案
暗色模式闪烁JS 在 body 之后才设置 data-theme<head> 内联脚本提前设置
var() 回调不生效默认值语法错误var(--x, #fff) 注意逗号后是完整值
变量未继承定义在选择器内部全局变量放在 :root
组件库变量冲突命名无前缀组件变量加前缀 --btn-*
计算不生效var() 不能直接参与计算calc(var(--x) * 2) 包裹

最佳实践

  • 全局设计令牌放 :root,组件变量加前缀 --btn-*
  • 暗色模式用 [data-theme] class 切换 + prefers-color-scheme 系统跟随
  • <head> 内联脚本防止暗色闪烁
  • 组件通过 CSS 变量暴露样式接口,而非不断增加 props
  • 用 CSS 变量管理设计令牌,Sass 处理编译时计算

面试题

Q1: CSS变量和Sass变量有什么区别?

CSS变量是运行时变量,浏览器原生支持,可通过JS动态修改,遵循层叠和继承规则;Sass变量是编译时变量,构建后变成固定值,无法运行时改变,但支持编译时计算和条件逻辑。两者可配合使用:Sass处理编译时逻辑,CSS变量处理运行时主题。

Q2: 如何实现暗色模式?

三种方式:1) @media (prefers-color-scheme: dark)跟随系统主题;2) [data-theme="dark"]通过class切换支持用户手动选择;3) 两者结合:默认跟随系统,用户选择后用class覆盖。关键:在<head>中内联脚本提前设置data-theme防止闪烁,配合CSS变量切换颜色令牌。

Q3: 如何用JS动态修改CSS变量?

使用element.style.setProperty('--name', 'value')修改,如document.documentElement.style.setProperty('--color-primary', '#ff0000')。读取用getComputedStyle(element).getPropertyValue('--name')。Vue/React中可通过响应式变量+watchEffect/useEffect自动同步。

Q4: CSS变量的默认值语法是什么?常见坑有哪些?

语法:var(--name, fallback),第二参数为默认值,当变量未定义时使用。常见坑:1) 默认值中的逗号不会被当作参数分隔符,如var(--font, 16px, sans-serif)的默认值是16px, sans-serif整段;2) 变量设为空串不会触发fallback;3) 不能直接参与计算,需用calc()包裹。


相关链接: