样式、布局与主题系统(多客户皮肤)
本节说明如何在本框架下编写一套可维护的 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 中):
- 先引入当前主题(具体文件由构建决定,见下文)。
- 再引入
tokens/_variables.scss、tokens/_mixins.scss。 - 最后引入第三方库覆盖、全局重置等。
组件内 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 的差异化与未来改造。