Skip to main content

样式、布局与主题系统(多客户皮肤)

本节说明如何在本框架下编写一套可维护的 SCSS 样式与布局令牌,并通过按客户构建的主题/皮肤系统实现同一套代码、多套构建产物;颜色、间距、圆角、布局方式等均由主题统一控制,简化不同客户的前端 UI 与布局差异化改造。架构需同时运行于 Web、Android、iOS、TV 及 Hybrid App,因此样式单位也需统一处理,以在不同 DPI 与视口下保持展示与布局一致。

1. 目标与约束​

  • 同一套代码:业务逻辑与结构不变,仅通过「主题变量」区分客户视觉。
  • 按客户单独构建:构建时指定客户/皮肤,产出仅含该主题的静态资源,部署后无需运行时切换逻辑。
  • 便于后续改造:新客户或换肤 = 新增/替换一套变量文件 + 重新构建,避免在组件里散落颜色/尺寸硬编码。

2. 整体思路​

  • 设计令牌(Design Tokens):颜色、间距、圆角、阴影、字体、布局与盒子模型等用语义化 CSS 变量(如 --color-primary、--spacing-md、--layout-sidebar-width)表达,组件只引用令牌,不写具体色值或固定宽高。
  • 主题文件:每个客户一个主题文件,仅负责给令牌赋值(如客户 A:左中右布局 + 蓝色系,客户 B:上中下布局 + 红色系),视觉与布局均由主题统一控制。
  • 构建时选主题:通过 Angular 的 fileReplacements 或多 style 入口在构建时决定加载哪份主题,打出的包即该客户的皮肤与布局。

3. 推荐目录结构​

与 模块划分与分层结构 中的 core / shared / features 对齐,样式与主题建议放在 core(或 shared)下集中管理:

src/
app/
core/
styles/
tokens/
_variables.scss # 语义化变量声明(默认/fallback 值)
_mixins.scss # 通用 mixin(布局、响应式等)
themes/
_theme-default.scss # 默认主题(或占位)
_theme-client-a.scss # 客户 A 皮肤
_theme-client-b.scss # 客户 B 皮肤
styles.scss # 全局入口:先 theme,再 tokens、mixins、第三方
styles.scss # 根入口(可只 @import app/core/styles/styles)

导入顺序(在 app/core/styles/styles.scss 中):

  1. 先引入当前主题(具体文件由构建决定,见下文)。
  2. 再引入 tokens/_variables.scss、tokens/_mixins.scss。
  3. 最后引入第三方库覆盖、全局重置等。

组件内 SCSS 只引用 var(--color-primary)、var(--spacing-md) 或 mixin,不引入具体主题文件;主题由全局入口统一引入。

4. 设计令牌(_variables.scss)​

在 _variables.scss 中集中声明所有与视觉相关的令牌,并为每个变量给出默认值(fallback)。主题文件里只覆盖需要差异化的部分。

类别示例变量名说明
主色/品牌--color-primary, --color-primary-hover, --color-primary-contrast按钮、链接、强调
表面/背景--color-surface, --color-background, --color-surface-elevated卡片、抽屉、页面底
文字--color-text, --color-text-secondary, --color-text-disabled层级清晰
边框/分割--color-border, --color-divider表格、列表、输入框
状态--color-success, --color-warning, --color-error, --color-info提示、校验、状态标签
间距--spacing-xs ~ --spacing-xl统一 4/8/12/16/24/32 等
圆角--radius-sm, --radius-md, --radius-lg按钮、卡片、弹窗
阴影--shadow-sm, --shadow-md, --shadow-lg卡片、浮层
字体--font-family, --font-size-base, --line-height-base正文与标题可再细分
布局/盒子见下文「布局与盒子模型」页面与区域宽高、排列方式、留白,由主题统一控制

4.1 布局与盒子模型:统一控制与多客户差异化​

同一套系统里,不同客户可能对同一页面提出不同布局要求,例如:

  • 主区域:客户 A 要左-中-右三栏,客户 B 要上-中-下;下方区域再细分为左-右两栏。
  • 侧栏宽度、内容区最大宽度、各区域间距、头部高度等,不同客户有不同的数值。

因此,所有页面的盒子模型与布局方式也应通过设计令牌 + 主题统一控制:组件只使用「布局类名」或 mixin,不写死 width、flex-direction、grid-template-columns 等具体值;具体宽高、排列方式由 _variables.scss 给出默认值,各客户主题文件按需覆盖。

推荐的布局相关令牌(在 _variables.scss 中声明默认值,主题中按客户覆盖):

用途示例变量名说明
页面整体--layout-page-direction主方向:row(左右排) / column(上下排)
主内容区--layout-main-direction主内容内部方向:row / column
侧栏--layout-sidebar-width, --layout-sidebar-min-width侧栏宽度,便于客户改为更宽/更窄
内容区--layout-content-max-width, --layout-content-min-width内容区最大/最小宽度
区域间距--layout-region-gap各主要区域之间的 gap(如 header 与 main、main 与 footer、左中右三栏之间)
头部/底部--layout-header-height, --layout-footer-height顶栏、底栏高度,便于统一调整
子区域布局--layout-sub-direction某一块内部方向,如「下方区域」内为 row 表示左-右布局

这样,左中右、上中下、上下布局且下方再左右等差异,均可通过主题中覆盖 --layout-* 实现,无需改组件 HTML 结构。

示例:_variables.scss 中的布局默认值(宽高使用 rem,与 4.2 单位策略一致)

// 布局与盒子模型(默认:左-中-右 主布局)
:root {
--layout-page-direction: row;
--layout-main-direction: row;
--layout-sidebar-width: 15rem;
--layout-sidebar-min-width: 12.5rem;
--layout-content-max-width: 75rem;
--layout-content-min-width: 20rem;
--layout-region-gap: var(--spacing-md);
--layout-header-height: 3.5rem;
--layout-footer-height: 3rem;
--layout-sub-direction: row;
}

示例:客户 B 主题改为上-中-下,且下方区域为左-右

// themes/_theme-client-b.scss
:root {
// 颜色略...
--layout-page-direction: column;
--layout-main-direction: column;
--layout-region-gap: 1.5rem;
--layout-sub-direction: row; // 下方区域内部为左右
}

组件中如何使用:通过类名或 mixin 引用上述令牌,不写死数值。例如:

.page-container {
display: flex;
flex-direction: var(--layout-page-direction);
gap: var(--layout-region-gap);
}
.sidebar {
width: var(--layout-sidebar-width);
min-width: var(--layout-sidebar-min-width);
}
.content-main {
max-width: var(--layout-content-max-width);
min-width: var(--layout-content-min-width);
}
.sub-region {
display: flex;
flex-direction: var(--layout-sub-direction);
gap: var(--layout-region-gap);
}

这样,全站所有页面的宽高、排列方式都来自同一套布局令牌,换客户只需在主题中改布局变量即可,无需改业务组件。

4.2 单位统一与跨设备(DPI)一致性​

本架构需运行于 Web、Android、iOS、TV 以及 Hybrid App(同一套样式可能被 WebView 承载)。不同设备的 DPI、视口、缩放策略各异,若使用固定 px,同一设计在不同设备上会出现「有的过大、有的过小」或布局错位。因此需要统一单位策略,使元素尺寸与布局随「基准」等比缩放,在各类设备上保持视觉与布局一致。

推荐做法:以 rem 为尺寸单位,由根字体控制缩放

  • 与尺寸相关的设计令牌(间距、圆角、字号、行高、侧栏宽度、内容区宽高、头部/底部高度等)统一使用 rem,不在令牌中写 px。
  • 在根节点(html)上设置 基准字号(如 font-size: 16px),或通过 CSS 变量 --rem-base 由主题/平台覆盖。所有 rem 均相对该基准计算。
  • 这样,仅调整根字号或 --rem-base,即可在 Web、Hybrid 的 WebView、或不同 DPI 的 Android/iOS/TV 上实现整体缩放,无需改组件或令牌数值。

基准变量示例(_variables.scss 或主题)

:root {
--rem-base: 16; /* 1rem = 16px,主题或 Hybrid 壳层可按设备覆盖 */
}
html {
font-size: calc(var(--rem-base) * 1px);
}

令牌中统一用 rem 的示例

/* 间距:避免 8px,改用 0.5rem(当 --rem-base=16 时等价 8px) */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;

/* 圆角 */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;

/* 字号与行高 */
--font-size-base: 0.875rem;
--line-height-base: 1.5;

/* 布局:侧栏、内容区、头部高度等 */
--layout-sidebar-width: 15rem;
--layout-content-max-width: 75rem;
--layout-header-height: 3.5rem;

px 的使用边界

  • 1px 边框:若需严格 1 物理像素线,可保留 1px(或通过设备像素比用 transform 实现),其余尺寸不建议用 px。
  • 阴影模糊半径等若用 px,在高 DPI 下可能偏细,可改为 rem 以随整体缩放。

Hybrid App 中的配合

  • 同一套 CSS 在 Native 的 WebView 中加载时,由 Native 在注入的页面或配置中设置 html { font-size: ... } 或 --rem-base,即可按设备密度或业务需求做一次整体缩放,无需为不同端维护多套样式数值。
  • 若 Native 已提供「密度无关像素 dp」到「Web 根字号」的映射,只需在壳层设置好根字号,前端继续使用 rem 即可。

视口相对单位(vw / vh)

  • 仅在对整屏或大块区域做视口相对布局时使用(如全屏背景、首屏高度),避免用于按钮、字号、间距等小组件,否则不同宽高比的设备上比例会失调。
  • 布局令牌仍以 rem 为主,vw/vh 可作为少量补充。

按上述方式统一单位后,同一套设计令牌与主题即可在 Web、Android、iOS、TV 与 Hybrid 下保持展示与布局一致,并便于后续按 DPI 或平台做整体缩放。

4.3 可选:使用 px 转 rem 工具(与原型稿单位对齐)​

当前主流原型/设计工具(Figma、Sketch、Axure 等)标注仍以 px 为主,几乎没有 rem。若希望开发时直接按设计稿的 px 写样式,再由构建阶段自动转为 rem,可引入 px → rem 的构建时转换,无需手算。本节为可选方案,默认仍建议在令牌与样式中直接使用 rem;若团队更习惯「设计稿是多少 px 就写多少 px」,再按下面方式接入。

常用方案:PostCSS + postcss-pxtorem

  • 在构建管线中接入 PostCSS,并启用 postcss-pxtorem:在 CSS/SCSS 编译后、打包前,将样式里的 px 按比例换算成 rem。
  • rootValue:与项目中的 --rem-base 一致(如 16),这样 1rem = 16px,转换结果与 4.2 的 rem 基准一致。
  • propList:需要转换的属性列表,如 ['*'] 表示所有带 px 的属性都转;也可排除 1px 边框等(见下)。
  • exclude:可排除 node_modules,避免把第三方库的 px 也改掉;若设计令牌文件已全部写 rem,也可不排除,仅对组件样式做转换。

Angular 中的接入思路

  • Angular 默认使用其内置的样式管道;要接入 PostCSS 需通过自定义构建(例如 @angular-builders/custom-webpack 或 Angular 18+ 的 experimental 构建)在样式处理链中插入 postcss-loader,并在项目根或构建配置目录下添加 postcss.config.js,在其中配置 postcss-pxtorem。
  • 若使用 Vite 构建,可直接在 vite.config.ts 中配置 postcss 插件;若使用 esbuild,需确认当前 Angular 版本是否暴露了 PostCSS 扩展点。

postcss.config.js 示例

module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 16, // 与 --rem-base 一致
unitPrecision: 5,
propList: ['*'],
selectorBlackList: [], // 不转换的类名(可按需排除第三方组件)
replace: true,
mediaQuery: false, // 媒体查询里的 px 一般不转,避免影响断点
minPixelValue: 2 // 小于 2px 不转,可保留 1px 边框
}
}
};

使用时的注意点

  • 设计令牌(_variables.scss):建议继续手写 rem,不依赖 postcss-pxtorem 转换,这样令牌语义清晰、且与 --rem-base 一一对应;组件和业务 SCSS 里若写 px,再由插件转成 rem 即可。
  • 1px 边框:若希望保留物理 1 像素线,可通过 minPixelValue: 2 让 1px 不被转换,或在该属性上使用 /* no */ 注释(视插件是否支持)。
  • 媒体查询:一般保持 mediaQuery: false,断点继续用 px(如 768px),避免 rem 媒体查询与根字号耦合导致行为难控。

按需接入上述工具后,开发可以「按设计稿 px 写」,构建产物仍为 rem,与 4.2 的跨设备策略兼容;若不需要,保持全站手写 rem 即可。

// tokens/_variables.scss
:root {
--rem-base: 16;
// 主色
--color-primary: #1976d2;
--color-primary-hover: #1565c0;
--color-primary-contrast: #fff;

// 表面与背景
--color-surface: #fafafa;
--color-background: #fff;
--color-surface-elevated: #fff;

// 文字
--color-text: #212121;
--color-text-secondary: #757575;
--color-text-disabled: #bdbdbd;

// 边框
--color-border: #e0e0e0;
--color-divider: #eee;

// 状态
--color-success: #2e7d32;
--color-warning: #ed6c02;
--color-error: #c62828;
--color-info: #0288d1;

// 间距(rem,便于跨 Web/Android/iOS/TV/Hybrid 一致缩放)
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;

// 圆角
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;

// 阴影(偏移/模糊用 rem)
--shadow-sm: 0 0.0625rem 0.125rem rgba(0, 0, 0, 0.05);
--shadow-md: 0 0.25rem 0.375rem rgba(0, 0, 0, 0.07);
--shadow-lg: 0 0.625rem 0.9375rem rgba(0, 0, 0, 0.1);

// 字体
--font-family: system-ui, sans-serif;
--font-size-base: 0.875rem;
--line-height-base: 1.5;

// 布局与盒子模型(见 4.1,宽高用 rem)
--layout-page-direction: row;
--layout-main-direction: row;
--layout-sidebar-width: 15rem;
--layout-sidebar-min-width: 12.5rem;
--layout-content-max-width: 75rem;
--layout-content-min-width: 20rem;
--layout-region-gap: var(--spacing-md);
--layout-header-height: 3.5rem;
--layout-footer-height: 3rem;
--layout-sub-direction: row;
}
/* 根字号由 --rem-base 控制,便于 Hybrid/多端统一缩放 */
html { font-size: calc(var(--rem-base) * 1px); }

5. 主题文件示例​

每个客户一个主题文件,仅包含 :root { ... },为上述令牌赋客户专属值;不写具体组件样式。

客户 A(蓝色系)

// themes/_theme-client-a.scss
:root {
--color-primary: #1976d2;
--color-primary-hover: #1565c0;
--color-primary-contrast: #fff;
--color-surface: #fafafa;
--color-background: #fff;
--color-text: #212121;
--color-border: #e0e0e0;
// 仅覆盖与默认不同的 token,其余继承 _variables.scss
}

客户 B(红色系)

// themes/_theme-client-b.scss
:root {
--color-primary: #c62828;
--color-primary-hover: #b71c1c;
--color-primary-contrast: #fff;
--color-surface: #fff5f5;
--color-background: #fff;
--color-text: #1a1a1a;
--color-border: #ffcdd2;
}

若某客户需要不同圆角、间距或布局方式(如改为上中下、侧栏更宽等),只在该主题文件中覆盖对应 token 即可。例如客户 B 要求整体上下布局、下方区域内部左右分布:

// themes/_theme-client-b.scss(续:布局覆盖)
:root {
// ... 颜色等
--layout-page-direction: column;
--layout-main-direction: column;
--layout-sub-direction: row;
--layout-region-gap: 1.5rem;
}

6. 全局样式入口与构建时选主题​

6.1 全局入口(app/core/styles/styles.scss)​

// 1. 当前主题(具体文件由构建时 fileReplacements 或多入口决定)
@import 'themes/theme-current';

// 2. 设计令牌与 mixin
@import 'tokens/variables';
@import 'tokens/mixins';

// 3. 第三方、重置等
// @import '...';

为实现「构建时选主题」,需要一份占位主题供默认构建使用,并在按客户构建时被替换。

  • 复制一份 _theme-default.scss 为 _theme-current.scss(或建一个仅做转发的 _theme-current.scss:@import 'themes/theme-default';)。
  • 默认构建使用 theme-current;客户 A 构建时通过 fileReplacements 将 theme-current 替换为 theme-client-a(见下)。

6.2 方式 A:fileReplacements(推荐)​

在 angular.json 中为每个客户建一个 configuration,用 fileReplacements 把「当前主题」文件替换成该客户的主题文件:

{
"projects": {
"your-app": {
"architect": {
"build": {
"configurations": {
"production": { ... },
"client-a": {
"fileReplacements": [
{
"replace": "src/app/core/styles/themes/_theme-current.scss",
"with": "src/app/core/styles/themes/_theme-client-a.scss"
}
]
},
"client-b": {
"fileReplacements": [
{
"replace": "src/app/core/styles/themes/_theme-current.scss",
"with": "src/app/core/styles/themes/_theme-client-b.scss"
}
]
}
}
}
}
}
}
}

入口中写 @import 'themes/theme-current';。构建时执行:

  • ng build -c=client-a → 打出客户 A 的皮肤;
  • ng build -c=client-b → 打出客户 B 的皮肤。

6.3 方式 B:多 style 入口​

为每个客户一个样式入口,在对应 configuration 的 styles 中指定:

  • styles-client-a.scss:先 @import 'themes/theme-client-a'; 再引公共 tokens、mixins 等。
  • styles-client-b.scss:先 @import 'themes/theme-client-b'; 再引公共部分。

在 angular.json 的 options.styles 中不写死一个文件,而是通过 configuration 覆盖:

"configurations": {
"client-a": {
"styles": [
"src/app/core/styles/styles-client-a.scss"
]
}
}

(具体需根据你使用的 Angular 版本是否支持在 configuration 中覆盖 styles 做调整;若不支持,可保留 fileReplacements 方式。)

要点:主题只在构建时注入一次,运行时无需加载多套主题 CSS。

7. 组件内 SCSS 规范(与皮肤兼容)​

  • 禁止在组件样式中写具体色值/像素(如 #333、16px),改为使用 token:color: var(--color-text);、padding: var(--spacing-md);。
  • 布局与盒子:禁止写死 width、flex-direction、grid-template-columns 等;改用布局令牌(如 var(--layout-sidebar-width)、var(--layout-page-direction))或使用基于这些令牌的 mixin/类名,保证全站布局由主题统一控制。
  • 复杂或重复片段用 _mixins.scss 中的 mixin,mixin 内部也使用 var(...)。
  • 页面/组件 SCSS 仅使用 core/styles/tokens 中的变量与 mixin,不引入具体主题文件(主题由全局入口统一引入)。

示例:组件使用 token 与 mixin

// features/dashboard/dashboard.scss
@import 'core/styles/tokens/variables';
@import 'core/styles/tokens/mixins';

.card {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
}

.title {
color: var(--color-text);
font-size: var(--font-size-base);
margin-bottom: var(--spacing-sm);
}

@include respond-to('tablet') {
.card {
padding: var(--spacing-lg);
}
}

mixins 示例(tokens/_mixins.scss)

@mixin respond-to($breakpoint) {
@if $breakpoint == 'tablet' {
@media (min-width: 768px) { @content; }
} @else if $breakpoint == 'desktop' {
@media (min-width: 1024px) { @content; }
}
}

这样组件与业务 SCSS 只依赖「令牌名」,与「哪个客户」解耦;换客户只需换主题文件并重新构建。

8. 与 Material / UnoCSS 的配合​

  • Angular Material:在主题文件中通过 Material 的 theming API(如 mat.m2-define-theme)或覆盖 --mat-* 变量,与上述 :root 令牌统一,避免两套颜色体系。例如将 --color-primary 同步到 Material 的主色变量。
  • UnoCSS:若已采用 UnoCSS 集成,Uno 的 theme 可与 SCSS 令牌对齐:在 uno.config.ts 的 theme.colors / theme.spacing 中引用同一套设计意图,或通过构建时生成 Uno 配置,避免「SCSS 用 token A、Uno 用另一套」的分歧。若暂未用 Uno,可先只做 SCSS 令牌 + 主题,后续再统一。

9. 小结​

项目做法
样式基础语义化设计令牌(_variables.scss)+ 通用 mixin(_mixins.scss),组件只引用 token
单位与跨端尺寸类令牌统一用 rem,根字号由 --rem-base/主题或 Hybrid 壳层控制,Web/Android/iOS/TV/Hybrid 下展示与布局一致
布局统一控制布局与盒子模型也使用令牌(--layout-*),全站页面宽高、排列方式(左中右/上中下/上下+下内左右等)由主题覆盖,组件不写死宽高与 direction
皮肤定义每客户一个 _theme-xxx.scss,仅给 :root 的 CSS 变量赋值(含颜色与布局,必要时含 --rem-base)
按客户构建通过 angular.json 的 configuration + fileReplacements 或多 style 入口,构建时选定主题
后续改造新客户 = 新主题文件 + 新 configuration;改 UI 或改布局 = 改 token 或该客户主题文件,不改业务组件

按上述方式编写 SCSS 并落地皮肤体系,可以在同一套系统中通过「主题变量 + 构建配置」统一控制颜色、间距、圆角、布局与单位,在 Web、Android、iOS、TV 与 Hybrid 下保持一致性,并简化多客户前端 UI 的差异化与未来改造。