SCSS 样式系统设计

本文从一个前端架构师的视角,聊聊企业级项目中 SCSS 样式系统该怎么设计。不局限于语法,更关注分层、组织和可维护性。

一、为什么需要样式系统

很多项目初期样式代码写得很爽,到了中期就开始失控:

  • 同一个颜色值散落在十多个文件中
  • 改一个圆角要搜遍全项目
  • 想调间距不敢动,怕影响其他地方
  • 新页面写的样式和已有组件格格不入

样式系统要做的事就三件:统一变量、约束模式、提升复用

Tip

不是说用了 SCSS 就有了样式系统。SCSS 只是工具,样式系统是架构层面的设计。

二、目录结构设计

企业级项目推荐按「分层 + 模块」组织 SCSS 文件:

src/
├─ styles/
│  ├─ _variables.scss         # 设计令牌:颜色、字号、间距、阴影等
│  ├─ _mixins.scss            # 混合宏:响应式、布局、工具类
│  ├─ _functions.scss         # 自定义函数(可选)
│  ├─ _reset.scss             # 浏览器默认样式重置
│  ├─ _theme.scss             # 主题变量(亮色/暗色)
│  ├─ _typography.scss        # 全局排版
│  ├─ index.scss              # 入口文件,集中导出以上所有
│  │
│  ├─ base/                   # 基础层
│  │  └─ _grid.scss           # 网格/布局系统
│  │
│  ├─ components/             # 组件级样式(全局组件)
│  │  └─ _button.scss
│  │
│  └─ views/                  # 页面级样式
│     └─ _login.scss

├─ App.vue
└─ main.ts

命名约定:

前缀含义示例
_ 前缀SCSS 局部文件(partial),不单独编译_variables.scss
index.scss入口汇总文件用于 main.ts 全局导入
Tip

为什么用 _ 前缀? SCSS 的 @use / @forward 机制会跳过带 _ 的文件(partial),避免生成多余的 CSS 文件。这是 SCSS 本身的设计模式。

三、设计令牌层(Design Tokens)

设计令牌是样式系统的「原子单位」,所有样式值都从这里取。

3.1 _variables.scss

// ========== 颜色 ==========
// 品牌色
$color-primary: #409eff;
$color-primary-light: #66b1ff;
$color-primary-dark: #2a6db5;

// 功能色
$color-success: #67c23a;
$color-warning: #e6a23c;
$color-danger: #f56c6c;
$color-info: #909399;

// 中性色
$color-text-primary: #303133;
$color-text-regular: #606266;
$color-text-secondary: #909399;
$color-text-placeholder: #c0c4cc;

$color-border: #dcdfe6;
$color-border-light: #e4e7ed;

$color-bg: #f5f7fa;
$color-bg-white: #ffffff;

// ========== 字体 ==========
$font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-size-xs: 12px;
$font-size-sm: 13px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 20px;
$font-size-2xl: 24px;
$font-size-3xl: 30px;

// ========== 间距 ==========
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-base: 16px;
$spacing-lg: 20px;
$spacing-xl: 24px;
$spacing-2xl: 32px;
$spacing-3xl: 48px;

// ========== 圆角 ==========
$radius-sm: 4px;
$radius-base: 8px;
$radius-lg: 12px;
$radius-round: 9999px;

// ========== 阴影 ==========
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
$shadow-base: 0 1px 4px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
$shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);

// ========== 层级 ==========
$z-index-dropdown: 100;
$z-index-modal: 1000;
$z-index-notification: 2000;
Warning

变量命名原则:语义优先,不要写死视觉值。

// ❌ 不推荐:写死了具体颜色
$blue: #409eff;

// ✅ 推荐:表达用途
$color-primary: #409eff;
$color-primary-light: #66b1ff;

将来换主题时,改变量名比改视觉值痛苦得多。

3.2 _functions.scss —— 让变量更灵活

// 通过色阶取色:color-tint($color-primary, 10%) → 混合 10% 白色
@function color-tint($color, $percentage) {
  @return mix(white, $color, $percentage);
}

// 通过色阶取色:color-shade($color-primary, 10%) → 混合 10% 黑色
@function color-shade($color, $percentage) {
  @return mix(black, $color, $percentage);
}

// 单位换算:px-to-rem(16px) → 1rem
@function px-to-rem($px, $base: 16px) {
  @return math.div($px, $base) * 1rem;
}

// z-index getter:避免硬编码层级
@function z($key) {
  $z-index-map: (
    'dropdown': 100,
    'modal': 1000,
    'notification': 2000,
  );
  @return map.get($z-index-map, $key);
}

四、混合宏层(Mixins)

4.1 _mixins.scss

@use 'sass:map';
@use 'sass:math';

// ========== 响应式断点 ==========
$breakpoints: (
  'xs': 480px,
  'sm': 768px,
  'md': 1024px,
  'lg': 1280px,
  'xl': 1440px,
);

// 向上适配(mobile-first)
@mixin respond-up($bp) {
  @media (min-width: map.get($breakpoints, $bp)) {
    @content;
  }
}

// 向下适配
@mixin respond-down($bp) {
  @media (max-width: map.get($breakpoints, $bp) - 1px) {
    @content;
  }
}

// 区间适配
@mixin respond-between($min, $max) {
  @media (min-width: map.get($breakpoints, $min)) and (max-width: map.get($breakpoints, $max) - 1px) {
    @content;
  }
}

// ========== 布局 ==========
// flex 居中
@mixin flex-center {
  display: flex;
  align-items: center;
  justify-content: center;
}

// flex 垂直居中
@mixin flex-align-center {
  display: flex;
  align-items: center;
}

// 文本溢出省略
@mixin text-ellipsis($lines: 1) {
  @if $lines == 1 {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  } @else {
    display: -webkit-box;
    -webkit-line-clamp: $lines;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
}

// 绝对定位填充
@mixin absolute-fill {
  position: absolute;
  inset: 0;
}

// 滚动条美化
@mixin scrollbar($size: 6px) {
  &::-webkit-scrollbar {
    width: $size;
    height: $size;
  }

  &::-webkit-scrollbar-thumb {
    background-color: #c0c4cc;
    border-radius: $size;

    &:hover {
      background-color: #909399;
    }
  }

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

4.2 使用示例

<script setup lang="ts">
import { ref } from 'vue'

const list = ref(Array.from({ length: 100 }, (_, i) => `Item ${i + 1}`))
</script>

<template>
  <div class="dashboard">
    <header class="dashboard__header">
      <h1>Dashboard</h1>
    </header>

    <aside class="dashboard__sidebar">
      <nav>Sidebar</nav>
    </aside>

    <main class="dashboard__content scrollable">
      <div v-for="item in list" :key="item" class="dashboard__card">
        {{ item }}
      </div>
    </main>
  </div>
</template>

<style lang="scss">
@use '@/styles/mixins' as *;

.dashboard {
  display: grid;
  grid-template-areas:
    'header header'
    'sidebar content';
  grid-template-columns: 240px 1fr;
  grid-template-rows: 60px 1fr;
  height: 100vh;

  &__header {
    grid-area: header;
    @include flex-align-center;
    padding: 0 $spacing-xl;
    border-bottom: 1px solid $color-border;
  }

  &__sidebar {
    grid-area: sidebar;
    padding: $spacing-base;
    border-right: 1px solid $color-border;
  }

  &__content {
    grid-area: content;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: $spacing-base;
    padding: $spacing-base;
    @include scrollbar;
  }

  &__card {
    padding: $spacing-base;
    background: $color-bg-white;
    border-radius: $radius-base;
    box-shadow: $shadow-sm;

    @include respond-up(md) {
      padding: $spacing-lg;
    }
  }
}
</style>
Tip

为什么用 @use 替代 @import

  • @import 在 Dart Sass 中已标记为弃用,将在未来版本移除
  • @use 有命名空间隔离,不会造成变量冲突
  • @use 只导入一次,避免重复

五、Reset 层

每个项目都需要一套基础样式重置,消除浏览器差异。

5.1 _reset.scss

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  -webkit-text-size-adjust: 100%;
  -moz-tab-size: 4;
  tab-size: 4;
}

body {
  font-family: $font-family;
  font-size: $font-size-base;
  line-height: 1.6;
  color: $color-text-primary;
  background-color: $color-bg;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

img,
svg,
video {
  display: block;
  max-width: 100%;
}

a {
  color: inherit;
  text-decoration: none;
}

button,
input,
textarea,
select {
  font: inherit;
  color: inherit;
}

ul,
ol {
  list-style: none;
}

六、主题切换方案

主题切换的核心思路:SCSS 变量定义色值 → CSS 自定义属性承载 → 组件中统一用 var() 消费。这样切主题只需改 CSS 自定义属性的值,SCSS 只负责编译时产出两套变量。

6.1 设计原则

  • 颜色语义化:不写 --blue: #409eff,写 --color-primary: #409eff,表达用途而非具体色值
  • 分层定义:品牌色、功能色、中性色、背景色、边框色、阴影色分层管理
  • 双主题全覆盖:每个颜色变量在亮色和暗色下都有对应值,不遗漏

6.2 _theme.scss —— 亮色 + 暗色完整配置

// ========== 品牌色 ==========
// 亮色主题(默认)
:root {
  // 品牌色
  --color-primary: #409eff;
  --color-primary-hover: #66b1ff;
  --color-primary-active: #2a6db5;
  --color-primary-bg: rgba(64, 158, 255, 0.08);

  // 功能色
  --color-success: #67c23a;
  --color-success-bg: rgba(103, 194, 58, 0.08);
  --color-warning: #e6a23c;
  --color-warning-bg: rgba(230, 162, 60, 0.08);
  --color-danger: #f56c6c;
  --color-danger-bg: rgba(245, 108, 108, 0.08);
  --color-info: #909399;
  --color-info-bg: rgba(144, 147, 153, 0.08);

  // 中性色 — 文本
  --color-text-primary: #303133;
  --color-text-regular: #606266;
  --color-text-secondary: #909399;
  --color-text-placeholder: #c0c4cc;
  --color-text-inverse: #ffffff;

  // 中性色 — 背景
  --color-bg: #f5f7fa;
  --color-bg-white: #ffffff;
  --color-bg-page: #f0f2f5;
  --color-bg-overlay: rgba(0, 0, 0, 0.45);

  // 中性色 — 边框
  --color-border: #dcdfe6;
  --color-border-light: #e4e7ed;
  --color-border-lighter: #ebeef5;

  // 中性色 — 填充
  --color-fill: #f0f2f5;
  --color-fill-hover: #e5e7eb;

  // 阴影
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
  --shadow-base: 0 1px 4px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
  --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);
}

// ========== 暗色主题 ==========
[data-theme='dark'] {
  // 品牌色(暗色下亮度提升)
  --color-primary: #66b1ff;
  --color-primary-hover: #85c4ff;
  --color-primary-active: #409eff;
  --color-primary-bg: rgba(102, 177, 255, 0.12);

  // 功能色
  --color-success: #85ce61;
  --color-success-bg: rgba(103, 194, 58, 0.15);
  --color-warning: #eebb5e;
  --color-warning-bg: rgba(230, 162, 60, 0.15);
  --color-danger: #f78989;
  --color-danger-bg: rgba(245, 108, 108, 0.15);
  --color-info: #a6a9ad;
  --color-info-bg: rgba(144, 147, 153, 0.15);

  // 中性色 — 文本(暗色下降低对比度,柔和护眼)
  --color-text-primary: #e5e5e5;
  --color-text-regular: #b0b0b0;
  --color-text-secondary: #7a7a7a;
  --color-text-placeholder: #545454;
  --color-text-inverse: #1d1d1d;

  // 中性色 — 背景
  --color-bg: #141414;
  --color-bg-white: #1d1d1d;
  --color-bg-page: #0f0f0f;
  --color-bg-overlay: rgba(0, 0, 0, 0.65);

  // 中性色 — 边框
  --color-border: #333333;
  --color-border-light: #3a3a3a;
  --color-border-lighter: #424242;

  // 中性色 — 填充
  --color-fill: #262626;
  --color-fill-hover: #333333;

  // 阴影(暗色下阴影透明度降低,避免过重)
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-base: 0 1px 4px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
}

6.3 组件中消费主题变量

组件中统一使用 CSS 变量(var()),不直接引用 SCSS 变量:

.card {
  background: var(--color-bg-white);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-base);
  transition: background 0.3s, color 0.3s, border-color 0.3s;
}

.button {
  &--primary {
    background: var(--color-primary);
    color: var(--color-text-inverse);

    &:hover {
      background: var(--color-primary-hover);
    }
  }
}
Tip

为什么不用 SCSS 变量做主题? SCSS 变量在编译时就被替换为具体色值,无法在运行时切换。CSS 自定义属性可以在运行时覆盖,配合 data-theme 属性实现无缝切换。

6.4 切换策略

方案一:属性切换(推荐)

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

const ThemeKey = 'app-theme'

export function getTheme(): Theme {
  return (localStorage.getItem(ThemeKey) as Theme) || 'light'
}

export function setTheme(theme: Theme) {
  // document.documentElement 即 <html> 根元素
  // 设置 data-theme 属性 → CSS [data-theme='dark'] 选择器匹配
  document.documentElement.setAttribute('data-theme', theme)
  localStorage.setItem(ThemeKey, theme)
}

export function toggleTheme() {
  const next = getTheme() === 'light' ? 'dark' : 'light'
  setTheme(next)
}

方案二:跟随系统偏好

// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

// 初始化
setTheme(mediaQuery.matches ? 'dark' : 'light')

// 系统主题变化时自动跟随
mediaQuery.addEventListener('change', (e) => {
  setTheme(e.matches ? 'dark' : 'light')
})

两个方案可以结合:用户手动选择优先,未选择时跟随系统。

6.5 过渡动画

给主题切换加过渡,避免切换时生硬闪烁:

// 全局平滑过渡
* {
  // 颜色和背景色变化时添加过渡
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}

// 页面加载时禁用过渡(避免首屏闪一下)
.preload * {
  transition: none !important;
}
// 页面加载完成后移除 preload 类
document.addEventListener('DOMContentLoaded', () => {
  document.documentElement.classList.remove('preload')
})

七、Vite 全局配置

vite.config.ts 中配置全局 SCSS 变量注入,避免每个组件手动 @use

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  css: {
    preprocessorOptions: {
      scss: {
        // additionalData 会在每个 SCSS 文件顶部自动注入这段代码
        additionalData: `
          @use '@/styles/variables' as *;
          @use '@/styles/mixins' as *;
          @use '@/styles/functions' as *;
        `,
      },
    },
  },
  resolve: {
    alias: {
      '@': '/src',
    },
  },
})
Tip

additionalData 的注意事项:

  • 只会注入变量和 mixin 这种「不产生实际 CSS 输出」的内容
  • _reset.scss 和样式类文件不要注入,否则每个文件都会输出一份
  • 注入顺序:先 variablesfunctionsmixins,因为 mixin 可能依赖变量

八、入口文件与全局导入

8.1 styles/index.scss

@forward 统一导出,外部只需导入这一个文件:

@forward 'variables';
@forward 'functions';
@forward 'mixins';
@forward 'reset';
@forward 'theme';
@forward 'typography';

8.2 main.ts 全局导入

import { createApp } from 'vue'
import App from './App.vue'
import './styles/index.scss'   // 全局样式
import './styles/theme'        // 主题变量(CSS 自定义属性)

createApp(App).mount('#app')

九、命名规范 —— BEM

企业项目中推荐 BEM(Block Element Modifier)命名,配合 SCSS 的 & 嵌套,可读性和隔离性都很好。

// Block:组件名
.card {
  // Element:子元素,用 __ 连接
  &__header {
    font-size: $font-size-lg;
  }

  &__body {
    padding: $spacing-base;
  }

  &__footer {
    border-top: 1px solid $color-border;
    padding-top: $spacing-sm;
  }

  // Modifier:变体,用 -- 连接
  &--compact {
    padding: $spacing-sm;
  }

  &--disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}

生成 CSS:

.card {}
.card__header {}
.card__body {}
.card__footer {}
.card--compact {}
.card--disabled {}
Info

BEM 的好处:组件间样式隔离,无类名冲突。配合 SCSS 嵌套不用手写完整类名,开发体验好。


十、完整集成示例

把所有文件串起来,在实际项目中的完整结构:

src/
├─ styles/
│  ├─ _variables.scss       ← 设计令牌
│  ├─ _mixins.scss          ← 混合宏
│  ├─ _functions.scss       ← 函数
│  ├─ _reset.scss           ← 重置
│  ├─ _theme.scss           ← 主题
│  ├─ index.scss            ← 汇总导出
│  │
│  ├─ components/
│  │  └─ _button.scss       ← 全局按钮样式
│  └─ views/
│     └─ _login.scss        ← 登录页样式

├─ components/
│  └─ BaseCard.vue           ← 组件内部使用 <style lang="scss">
├─ App.vue
├─ main.ts
└─ vite.config.ts

使用流程:

  1. 设计令牌_variables.scss 定义所有变量
  2. 工具层_mixins.scss + _functions.scss 封装复用逻辑
  3. Vite 注入additionalData 自动注入变量和 mixin
  4. 组件使用<style lang="scss"> 中直接使用变量和 mixin
  5. 主题 → 通过 data-theme + CSS 变量切换

写在最后

样式系统不是一蹴而就的。先搭好变量层和 mixin 层,后续有需要再加。关键是要守住两条底线:

  1. 所有样式值从变量取 —— 不写魔法数字
  2. 复用逻辑封装成 mixin —— 不复制粘贴

守住了这两条,项目大了之后样式就不会乱。