普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月10日首页

微前端(无界)样式架构重构方案

2026年2月10日 18:24

日期: 2025-02-10
影响范围: 主应用 apps/main、共享包 packages/ui、所有子应用


一、现状诊断

1.1 样式文件分布(主应用)

当前主应用存在 3 个互不关联的样式目录,没有统一入口:

apps/main/src/
├── assets/
│   ├── main.css              ← Vue 脚手架残留 + Tailwind 入口
│   ├── base.css              ← Vue 脚手架残留 CSS Reset + 变量
│   └── styles/
│       └── micro-app.css     ← 微前端"隔离"样式(!important 硬覆盖)
├── styles/
│   ├── index.scss            ← 样式入口(⚠️ 从未被任何文件引用!)
│   ├── var.css               ← 布局 + 主题 CSS 变量
│   ├── dark.css              ← 暗色主题变量
│   ├── resizeStyle.scss      ← 632 行巨型文件(EP 覆盖 + 布局 + 业务混杂)
│   ├── dialog.scss           ← 弹窗尺寸
│   ├── theme.scss            ← 空文件(全注释)
│   ├── variables.scss        ← 仅 2 个 SCSS 变量
│   └── global.module.scss    ← 导出命名空间给 JS

1.2 main.ts 样式导入链

main.ts
  ├── assets/main.css           → @import base.css + @import tailwindcss
  ├── assets/styles/micro-app.css
  ├── element-plus 组件样式 ×4(message-box / message / notification / loading)
  └── @cmclink/ui/styles        → variables + element-override + base + utilities + animations + tailwindcss

关键发现styles/index.scss(包含 var.cssresizeStyle.scssdialog.scss在整个项目中没有被任何文件 import。这意味着 632 行的 Element Plus 覆盖样式、布局变量、暗色主题等可能完全没有生效,或者曾经生效但在某次重构中被遗漏。

1.3 核心问题清单

# 问题 严重程度 说明
1 Tailwind CSS 重复引入 🔴 高 assets/main.css@cmclink/ui/styles 各引入一次,产生重复样式
2 CSS Reset 重复执行 🔴 高 assets/base.css@cmclink/ui/base.scss 各做一次全局 reset
3 主色定义冲突 🔴 高 var.css#005aae@cmclink/ui#004889resizeStyle.scss→硬编码 #005aae,三处不一致
4 styles/index.scss 未被引用 🔴 高 632 行 EP 覆盖 + 布局变量 + 暗色主题可能完全未生效
5 body 样式双重定义 🟡 中 base.css@cmclink/ui/base.scss 都设置了 body 样式
6 微前端样式隔离 = !important 战争 🔴 高 micro-app.css!important 硬覆盖,没有利用 wujie CSS 沙箱
7 脚手架残留代码 🟡 中 base.css--vt-c-* 变量、main.css.green
8 废文件堆积 🟡 中 theme.scss(空)、variables.scss(2 行)、global.module.scss(价值存疑)
9 !important 泛滥 🟡 中 resizeStyle.scssmicro-app.css 大量使用
10 样式职责不清 🔴 高 EP 覆盖、页面布局、业务样式、主题变量全混在一个文件

1.4 @cmclink/ui 样式包分析

packages/ui/src/styles/ 已经有一套相对完整的设计体系:

  • variables.scss — 完整的设计令牌(颜色、字体、间距、圆角、阴影等)+ CSS 变量映射
  • mixins.scss — 响应式断点、文本截断、布局、按钮变体等混合器
  • base.scss — 全局 reset、标题、段落、表格、滚动条、打印、暗色、无障碍
  • element-override.scss — Element Plus 样式覆盖
  • utilities.scss — CMC 专用工具类(cmc-* 前缀)
  • animations.scss — 动画库(1250 行,含大量与 Tailwind 重复的工具类)

问题@cmclink/uianimations.scss 中有大量 .translate-*.scale-*.rotate-*.duration-* 等类名,与 Tailwind CSS 完全重复。


二、目标架构设计

2.1 设计原则

  1. 单一真相源(Single Source of Truth):设计令牌只在 @cmclink/ui 中定义一次
  2. 分层隔离:全局样式、主题变量、EP 覆盖、布局样式、业务样式严格分层
  3. 微前端天然隔离:依赖 wujie 的 CSS 沙箱(WebComponent + iframe),而非 !important
  4. Tailwind 唯一入口:整个主应用只在一个地方引入 Tailwind CSS
  5. 可维护性:每个文件职责单一,命名语义化,新人 5 分钟能理解结构

2.2 目标目录结构

apps/main/src/
├── styles/                          ← 主应用样式唯一目录
│   ├── index.scss                   ← ⭐ 唯一入口文件(main.ts 只 import 这一个)
│   ├── tailwind.css                 ← Tailwind CSS 唯一引入点
│   │
│   ├── tokens/                      ← 主应用级设计令牌(覆盖/扩展 @cmclink/ui)
│   │   ├── _variables.scss          ← SCSS 变量(供 SCSS 文件内部使用)
│   │   └── _css-variables.scss      ← CSS 自定义属性(布局变量、主应用专属变量)
│   │
│   ├── themes/                      ← 主题系统
│   │   ├── _light.scss              ← 亮色主题变量
│   │   └── _dark.scss               ← 暗色主题变量
│   │
│   ├── overrides/                   ← Element Plus 样式覆盖(主应用级)
│   │   ├── _button.scss             ← 按钮覆盖
│   │   ├── _table.scss              ← 表格覆盖
│   │   ├── _form.scss               ← 表单覆盖(filterBox 等)
│   │   ├── _dialog.scss             ← 弹窗覆盖
│   │   ├── _message-box.scss        ← 消息框覆盖
│   │   ├── _select.scss             ← 下拉框覆盖
│   │   ├── _tag.scss                ← 标签覆盖
│   │   ├── _tabs.scss               ← 标签页覆盖
│   │   └── _index.scss              ← EP 覆盖汇总入口
│   │
│   ├── layout/                      ← 布局样式
│   │   ├── _app-view.scss           ← AppViewContent / AppViewScroll
│   │   ├── _login.scss              ← 登录页样式
│   │   └── _index.scss              ← 布局汇总入口
│   │
│   ├── vendors/                     ← 第三方库样式适配
│   │   └── _nprogress.scss          ← NProgress 主题色适配
│   │
│   └── legacy/                      ← 遗留业务样式(逐步迁移到组件 scoped 中)
│       ├── _si-detail.scss          ← 出口单证详情
│       ├── _marketing.scss          ← 营销模块
│       └── _index.scss              ← 遗留样式汇总入口
│
├── assets/                          ← 仅保留静态资源
│   └── images/                      ← 图片资源
│       ├── avatar.gif
│       ├── cmc-logo.png
│       └── ...

2.3 入口文件设计

styles/index.scss(唯一入口):

// =============================================================================
// CMCLink 主应用样式入口
// 加载顺序严格按照优先级排列,请勿随意调整
// =============================================================================

// 1️⃣ Tailwind CSS(最先加载,作为基础原子类层)
@use './tailwind.css';

// 2️⃣ 主应用级设计令牌(覆盖/扩展 @cmclink/ui 的变量)
@use './tokens/css-variables';

// 3️⃣ 主题系统
@use './themes/light';
@use './themes/dark';

// 4️⃣ Element Plus 样式覆盖
@use './overrides/index';

// 5️⃣ 布局样式
@use './layout/index';

// 6️⃣ 第三方库适配
@use './vendors/nprogress';

// 7️⃣ 遗留业务样式(逐步清理)
@use './legacy/index';

main.ts(重构后):

// 样式:唯一入口
import './styles/index.scss'

// Element Plus 反馈组件样式(非按需导入的全局组件)
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'

// 组件库样式(@cmclink/ui 提供设计令牌 + 基础样式 + EP 覆盖)
import '@cmclink/ui/styles'

// 其余保持不变...

注意@cmclink/ui/styles 中的 Tailwind CSS 引入需要移除,改为只在主应用的 styles/tailwind.css 中引入一次。

2.4 样式加载顺序

┌─────────────────────────────────────────────────────────┐
│ 1. Tailwind CSS(原子类基础层)                           │
├─────────────────────────────────────────────────────────┤
│ 2. @cmclink/ui/styles                                   │
│    ├── variables.scss  → 设计令牌 + CSS 变量映射          │
│    ├── base.scss       → 全局 reset + 排版               │
│    ├── element-override.scss → 组件库级 EP 覆盖           │
│    ├── utilities.scss  → CMC 工具类                       │
│    └── animations.scss → 动画(需清理与 Tailwind 重复部分)│
├─────────────────────────────────────────────────────────┤
│ 3. Element Plus 反馈组件样式(message/notification 等)    │
├─────────────────────────────────────────────────────────┤
│ 4. 主应用 styles/index.scss                              │
│    ├── tokens/    → 主应用级变量(布局尺寸、主题色扩展)    │
│    ├── themes/    → 亮色/暗色主题                         │
│    ├── overrides/ → 主应用级 EP 覆盖                      │
│    ├── layout/    → 页面布局                              │
│    ├── vendors/   → 第三方库适配                          │
│    └── legacy/    → 遗留业务样式                          │
├─────────────────────────────────────────────────────────┤
│ 5. 组件 <style scoped>(组件级样式,天然隔离)             │
└─────────────────────────────────────────────────────────┘

三、微前端样式隔离策略

3.1 wujie CSS 沙箱机制

wujie 使用 WebComponent(shadowDOM)+ iframe 双重沙箱:

  • JS 沙箱:子应用 JS 运行在 iframe 中,天然隔离
  • CSS 沙箱:子应用 DOM 渲染在 WebComponent 的 shadowDOM 中,样式天然隔离

这意味着:

  • ✅ 子应用的样式不会泄漏到主应用
  • ✅ 主应用的样式不会侵入子应用(shadowDOM 边界)
  • ⚠️ 但是:Element Plus 的弹窗(Dialog/Drawer/MessageBox)默认挂载到 document.body,会逃逸出 shadowDOM

3.2 弹窗逃逸问题的正确解决方案

当前做法(错误):在主应用 micro-app.css 中用 !important 覆盖子应用弹窗样式。

正确做法:在子应用中配置 Element Plus 的 teleportedappend-to 属性,让弹窗挂载到子应用自身的 DOM 容器内,而非 document.body

方案 A:子应用全局配置(推荐)

在子应用的入口文件中配置 Element Plus 的全局属性:

// 子应用 main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'

const app = createApp(App)
app.use(ElementPlus, {
  // 弹窗不使用 teleport,保持在子应用 DOM 树内
  // 这样弹窗样式就在 shadowDOM 内,天然隔离
})

同时在子应用的弹窗组件中统一设置:

<!-- 子应用中使用 Dialog -->
<el-dialog :teleported="false">
  <!-- 内容 -->
</el-dialog>

<!-- 或者指定挂载到子应用容器 -->
<el-dialog append-to=".child-app-root">
  <!-- 内容 -->
</el-dialog>

方案 B:wujie 插件拦截(自动化)

通过 wujie 的插件系统,自动为子应用的弹窗组件注入配置:

// plugins/wujie.ts 中增加插件配置
import { setupApp } from 'wujie'

setupApp({
  name: 'child-app',
  // ... 其他配置
  plugins: [
    {
      // CSS loader:可以在子应用 CSS 加载时做处理
      cssLoader: (code: string) => code,
      // JS loader:可以在子应用 JS 加载时做处理
      jsLoader: (code: string) => code,
    }
  ]
})

方案 C:共享样式包(兜底方案)

如果确实需要主应用和子应用共享某些样式(如统一的弹窗规范),应该通过 @cmclink/ui 包来分发,而非在主应用中硬写覆盖:

packages/ui/src/styles/
├── shared/                    ← 可被子应用独立引入的共享样式
│   ├── dialog-standard.scss   ← 弹窗标准样式
│   └── form-standard.scss     ← 表单标准样式

子应用按需引入:

// 子应用 main.ts
import '@cmclink/ui/styles/shared/dialog-standard.scss'

3.3 样式隔离总结

场景 隔离机制 是否需要额外处理
子应用普通样式 wujie shadowDOM 天然隔离 ❌ 无需处理
子应用弹窗/抽屉 配置 :teleported="false" ✅ 子应用侧配置
主应用与子应用共享样式 通过 @cmclink/ui 包分发 ✅ 包级别管理
主应用全局样式 不会侵入子应用(shadowDOM) ❌ 无需处理

3.4 删除 micro-app.css

重构完成后,assets/styles/micro-app.css 应该被完全删除。其中的内容处理方式:

原内容 处理方式
.micro-app .el-dialog 覆盖 子应用配置 :teleported="false" 后不再需要
.micro-app-test.marketing 测试代码,直接删除
.test-paragraph / .test-box 测试代码,直接删除

四、主色统一方案

4.1 现状:三处主色定义

位置 主色值 说明
styles/var.css #005aae 主应用布局变量
@cmclink/ui/variables.scss #004889 组件库设计令牌
resizeStyle.scss #005aae(硬编码) 登录页等处直接写死

4.2 统一方案

@cmclink/ui 的设计令牌为唯一真相源

  1. 确认最终主色值(与设计团队对齐):

    • 如果设计稿是 #004889 → 主应用的 var.css 需要同步修改
    • 如果设计稿是 #005aae@cmclink/uivariables.scss 需要同步修改
  2. 主应用中所有硬编码的颜色值,改为引用 CSS 变量:

    // ❌ 错误
    background-color: #005aae;
    
    // ✅ 正确
    background-color: var(--el-color-primary);
    
  3. 主应用的 tokens/_css-variables.scss 只定义布局相关的变量,颜色变量全部继承 @cmclink/ui


五、文件迁移映射

5.1 需要删除的文件

文件 原因
assets/main.css 脚手架残留,Tailwind 迁移到 styles/tailwind.css
assets/base.css 脚手架残留,与 @cmclink/ui/base.scss 重复
assets/styles/micro-app.css !important 硬覆盖,改用 wujie 沙箱
styles/theme.scss 空文件
styles/variables.scss 仅 2 行,合并到 tokens/_variables.scss
styles/global.module.scss 价值存疑,如需保留可合并

5.2 需要拆分的文件

styles/var.css(154 行)→ 拆分为:

目标文件 内容
tokens/_css-variables.scss 布局变量(--left-menu-*--top-header-*--tags-view-*--app-content-* 等)
删除 颜色变量(--el-color-primary 等),改为继承 @cmclink/ui

styles/resizeStyle.scss(632 行)→ 拆分为:

目标文件 内容 行数(约)
overrides/_button.scss 按钮样式覆盖(L1-20) ~20
overrides/_tag.scss 标签样式(L29-33) ~5
overrides/_select.scss 下拉框样式(L36-38) ~3
overrides/_table.scss 表格样式(L41-80, L374-417) ~80
overrides/_message-box.scss 消息框样式(L83-98, L399-406) ~20
overrides/_form.scss filterBox 表单样式(L484-618) ~135
layout/_login.scss 登录页样式(L100-117) ~18
layout/_app-view.scss AppViewContent / AppViewScroll / tabPage(L119-306) ~190
legacy/_si-detail.scss 出口单证详情(index.scss L50-105) ~55
legacy/_marketing.scss 营销模块(L308-373, L418-438) ~70
删除 .container-selected-row(箱管选中行)→ 迁移到对应组件 scoped ~8

styles/index.scss(126 行)→ 拆分为:

目标文件 内容
vendors/_nprogress.scss NProgress 主题色适配(L22-38)
overrides/_form.scss append-click-input(L39-49)
legacy/_si-detail.scss si-detail-dialog / si-detail-tabs(L50-105)
删除 .reset-margin.el-popup-parent--hidden.el-scrollbar__bar → 评估是否仍需要

styles/dark.css(85 行)→ 迁移到:

目标文件 说明
themes/_dark.scss 完整保留暗色主题变量,清理注释掉的废代码

styles/dialog.scss(17 行)→ 迁移到:

目标文件 说明
overrides/_dialog.scss 弹窗尺寸规范

5.3 @cmclink/ui 需要调整的部分

文件 调整内容
styles/index.scss 移除 @use 'tailwindcss',Tailwind 只在应用层引入
styles/animations.scss 清理与 Tailwind 重复的工具类(.translate-*.scale-*.rotate-*.duration-* 等约 800 行)
styles/utilities.scss 保留 cmc-* 前缀的工具类,删除与 Tailwind 重复的部分

六、实施步骤

Phase 1:基础清理(低风险,可立即执行)

预计耗时:1-2 小时

  1. 创建新目录结构

    • 创建 styles/tokens/styles/themes/styles/overrides/styles/layout/styles/vendors/styles/legacy/
  2. 创建 styles/tailwind.css

    @import "tailwindcss";
    
  3. 删除废文件

    • styles/theme.scss(空文件)
  4. 统一主色

    • 与设计团队确认最终主色值
    • 更新所有硬编码颜色为 CSS 变量引用

Phase 2:文件拆分迁移(中风险,需逐步验证)

预计耗时:3-4 小时

  1. 拆分 resizeStyle.scss(632 行 → 8 个文件)
  2. 拆分 var.css(布局变量 → tokens/_css-variables.scss
  3. 迁移 dark.cssthemes/_dark.scss
  4. 迁移 dialog.scssoverrides/_dialog.scss
  5. 拆分 index.scss(NProgress → vendors,业务 → legacy)
  6. 创建新的 styles/index.scss 入口文件

Phase 3:入口重构(高风险,需完整回归测试)

预计耗时:1-2 小时

  1. 修改 main.ts

    • 移除 import './assets/main.css'
    • 移除 import './assets/styles/micro-app.css'
    • 添加 import './styles/index.scss'
    • 调整 @cmclink/ui/styles 的导入顺序
  2. 删除旧文件

    • assets/main.css
    • assets/base.css
    • assets/styles/micro-app.css
    • assets/styles/ 目录
  3. 完整回归测试

    • 登录页样式
    • 主布局(侧边栏、顶部导航、标签页)
    • 表格页面(筛选框、表格、分页)
    • 弹窗/抽屉
    • 暗色主题切换
    • 子应用加载和样式隔离

Phase 4:微前端隔离优化(需子应用配合)

预计耗时:2-3 小时(每个子应用)

  1. 子应用配置弹窗不逃逸

    • 全局配置 :teleported="false"append-to
    • @cmclink/micro-bootstrap 中提供统一配置能力
  2. 删除 micro-app.css

  3. 验证子应用样式隔离

    • 主应用样式不侵入子应用
    • 子应用样式不泄漏到主应用
    • 弹窗/抽屉样式正确

Phase 5:@cmclink/ui 优化(独立进行)

预计耗时:2-3 小时

  1. 移除 Tailwind CSS 引入(从 @cmclink/ui/styles/index.scss
  2. 清理 animations.scss 中与 Tailwind 重复的工具类(约 800 行)
  3. 审查 utilities.scss 中与 Tailwind 重复的部分

七、风险评估与回退方案

7.1 风险点

风险 概率 影响 缓解措施
styles/index.scss 未被引用但样式实际生效 先在浏览器 DevTools 确认哪些样式实际生效
拆分后样式加载顺序变化导致覆盖失效 严格按照原有顺序组织 @use
子应用弹窗 :teleported="false" 导致层级问题 逐个子应用测试,必要时用 z-index 调整
删除 base.css 后某些页面样式异常 @cmclink/ui/base.scss 已覆盖所有 reset

7.2 回退方案

每个 Phase 独立提交 Git,如果出现问题可以精确回退到任意阶段:

feat(styles): phase-1 基础清理和目录结构
feat(styles): phase-2 文件拆分迁移
feat(styles): phase-3 入口重构
feat(styles): phase-4 微前端隔离优化
feat(styles): phase-5 @cmclink/ui 优化

八、验证清单

8.1 样式正确性

  • 登录页样式正常(背景、表单、按钮)
  • 主布局样式正常(侧边栏展开/收起、顶部导航、面包屑)
  • 标签页样式正常(激活态、hover、关闭按钮)
  • 表格页面样式正常(筛选框、表头、行、分页)
  • 弹窗/抽屉样式正常(大/中/小尺寸)
  • 表单样式正常(输入框、下拉框、日期选择器)
  • 按钮样式正常(主要、链接、危险、禁用)
  • NProgress 进度条颜色正确
  • 暗色主题切换正常

8.2 微前端隔离

  • 子应用加载后样式正常
  • 主应用样式未侵入子应用
  • 子应用样式未泄漏到主应用
  • 子应用弹窗样式正确(不逃逸到主应用 body)
  • 多个子应用切换时样式无残留

8.3 构建产物

  • 构建无报错
  • CSS 产物体积不增长(预期减少 30%+)
  • 无重复的 Tailwind CSS 输出
  • 无重复的 CSS Reset

8.4 开发体验

  • HMR 样式热更新正常
  • 新增样式时知道该放在哪个文件
  • SCSS 变量和 CSS 变量可正常引用

九、预期收益

维度 现状 重构后
CSS 产物体积 Tailwind ×2 + Reset ×2 + 大量重复 减少约 30-40%
样式文件数 散落 3 个目录,8+ 个文件 1 个目录,清晰分层
入口文件 main.ts 导入 2 个样式文件 + 隐式依赖 main.ts 导入 1 个入口
新人上手 不知道样式该写在哪 5 分钟理解结构
微前端隔离 !important 硬覆盖 wujie 沙箱天然隔离
主色一致性 3 处定义,2 个不同值 1 处定义,全局统一
!important 使用 泛滥 仅在必要的 EP 覆盖中使用
昨天以前首页

Sass实现,蛇形流动布局

2026年2月9日 14:59

效果展示

image.png

image.png

使用Sass构建智能流程图式网格布局系统

项目背景

在民宿康养微实训管理平台的学生模块中,我们需要实现一个直观的流程图式界面,用于展示实训任务的执行步骤。传统的CSS布局难以满足这种复杂的连线需求,因此我们设计了一套基于Sass的智能网格布局系统。

核心设计思路

1. 蛇形流动布局

系统采用蛇形流动设计,奇数行从左到右,偶数行从右到左,形成自然的视觉引导路径。

2. 伪元素连线技术

利用CSS伪元素(::before, ::after)动态生成连接线和箭头,避免额外的DOM元素。

3. 数学计算驱动

通过Sass的数学函数和循环,实现布局的自动化计算。

关键技术实现

Sass变量定义

$grid-columns: 4;                    // 网格列数
$max-grid-items: 30;                 // 最大网格项数
$gap-y: 34px;                        // 垂直间距
$line-ab-top: 20px;                  // 连线顶部偏移

智能位置计算

@for $i from 1 through $max-grid-items {
  $row: math.ceil(math.div($i, $grid-columns));           // 计算行号
  $col_in_row: $i - ($row - 1) * $grid-columns;           // 计算列号
  $direction: if($row % 2 == 1, $col_in_row, $grid-columns - $col_in_row + 1); // 蛇形方向
}

四种连线模式处理

1. 单行中间项(水平右向)
// 水平虚线 + 右箭头
&::after { border-top: 1px dashed #626c85; }
&::before { content: $arrow-right-svg; }
2. 双行中间项(水平左向)
// 水平虚线 + 左箭头(反向)
&::after { left: calc((100% - $item-inner-width - $line-gap) * -1); }
&::before { content: $arrow-left-svg; }
3. 单行最右侧(垂直向下转弯)
// 垂直弯曲线 + 下箭头
&::after { 
  height: calc(100% + $gap-y);
  border-radius: 0 12px 12px 0;
  border-left: none;
}
4. 双行最左侧(垂直向上转弯)
// 垂直弯曲线 + 上箭头
&::after {
  height: calc(100% + $gap-y);
  border-radius: 12px 0 0 12px;
  border-right: none;
}

技术亮点

1. Data URI图标嵌入

使用Data URI格式嵌入SVG箭头,减少HTTP请求:

$arrow-right-svg: url("data:image/svg+xml;utf8,<svg>...</svg>");

2. 响应式计算

所有尺寸都基于变量计算,便于维护和调整:

width: calc(100% - $item-inner-width - ($line-gap * 2));

3. 边界条件处理

自动识别首尾项,避免多余的连线:

&:last-of-type::after { content: none; }
&:last-of-type::before { content: none; }

Vue组件集成

模板结构

<template>
  <div class="bg-[#016cff1a] p-20">
    <div class="lk-grid pl-40!">
      <div v-for="item in 15" :key="item" class="grid-item">
        <div class="inner">展示{{ item }}</div>
      </div>
    </div>
  </div>
</template>

样式作用域

使用scoped属性确保样式隔离,避免全局污染。

性能优化考虑

  1. 伪元素性能:CSS伪元素比额外DOM元素性能更好
  2. 计算缓存:Sass编译时完成所有计算,运行时无性能开销
  3. 图标优化:内联SVG避免图标加载延迟

扩展性设计

系统支持通过修改变量轻松调整:

  • 修改$grid-columns改变列数
  • 调整$max-grid-items支持更多步骤
  • 更改颜色变量适配不同主题

总结

这套Sass网格布局系统成功解决了复杂流程图的可视化需求,通过数学计算和CSS伪元素的巧妙结合,实现了高度可定制且性能优良的布局方案。该技术方案可以广泛应用于工作流、进度跟踪、步骤引导等场景。

技术栈:Vue 3 + Sass + CSS Grid + 伪元素技术 适用场景:流程图、工作流、步骤引导、进度展示

完整代码

<style lang="scss" scoped>
@use 'sass:math';
$grid-columns: 7;
$max-grid-items: 30; //线条滚动的个数
$gap-y: 34px;
$line-ab-top: 20px;
// 右箭头:Data URI 格式(直接复制到 content 中使用)
$arrow-right-svg: url("data:image/svg+xml;utf8,<svg viewBox='0 0 24 24' fill='%23626c85' xmlns='http://www.w3.org/2000/svg'><path d='M8 5v14l11-7z'/></svg>");
// 左箭头:Data URI 格式(直接复制到 content 中使用)
$arrow-left-svg: url("data:image/svg+xml;utf8,<svg  viewBox='0 0 24 24' fill='%23626c85' xmlns='http://www.w3.org/2000/svg'><path d='M16 19l-7-7 7-7v14z'/></svg>");
$arrow-width: 24px;
$arrow-height: 24px;

$item-height: 116px;
$item-inner-width: 50px;

// 线段留下的缝隙
$line-gap: 10px;

@mixin grid-container() {
  display: grid;
  grid-template-columns: repeat($grid-columns, 1fr);
  row-gap: $gap-y;
}

@mixin grid-item() {
  height: $item-height;
  width: 100%;
  position: relative;
  // outline: 1px solid #ff0000;
}
@mixin grid-items-layout() {
  // 先线性布局,给每一个item确定位置
  @for $i from 1 through $max-grid-items {
    $row: math.ceil(math.div($i, $grid-columns)); //确定几行
    $col_in_row: $i - ($row - 1) * $grid-columns; //确定在第几行的第几列
    // 使用 % 操作符进行模运算判断奇偶性
    $direction: if($row % 2 == 1, $col_in_row, $grid-columns - $col_in_row + 1); //单数正x,双数反x
    &:nth-child(#{$i}) {
      grid-column: #{$direction};
      grid-row: #{$row};

      // 伪元素,绘制线段箭头
      // 初始化
      &:last-of-type::after {
        content: none;
      }
      &:last-of-type::before {
        content: none;
      }

      // 单行,除了左右侧的
      @if $i % ($grid-columns * 2) > 0 and $i % ($grid-columns * 2) < $grid-columns {
        // 线段
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc($item-inner-width + $line-gap);
          display: block;
          width: calc(
            100% - $item-inner-width - ($line-gap * 2)
          ); //占满就是100% - $item-inner-width;但我们左右留个缝隙
          border-top: 1px dashed #626c85;
        }
        // 箭头
        &::before {
          content: $arrow-right-svg;
          position: absolute;
          // top: calc($line-ab-top - ($arrow-height/2));
          // right: calc(10px - ($arrow-width/2));
          top: $line-ab-top;
          right: $line-gap; //留的缝隙
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(50%, -50%);
        }
      }
      // 双行,除了最左侧的
      @if $i % ($grid-columns * 2) > $grid-columns and $i % ($grid-columns * 2) < $grid-columns * 2
      {
        &:after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc((100% - $item-inner-width - $line-gap) * -1);
          display: block;
          width: calc(
            100% - $item-inner-width - ($line-gap * 2)
          ); //占满就是100% - $item-inner-width;但我们左右留个缝隙
          border-top: 1px dashed #626c85;
        }
        &::before {
          content: $arrow-left-svg;
          position: absolute;
          left: calc(-100% + $line-gap + $item-inner-width);
          top: $line-ab-top;
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, -50%);
        }
      }
      // 单行最右侧的
      @if $i % ($grid-columns * 2) == $grid-columns {
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc($item-inner-width + $line-gap);
          display: block;
          width: 50%; //这个看着改
          height: calc(100% + $gap-y);
          border-radius: 0 12px 12px 0; //弧度看着改
          border: 1px dashed #626c85;
          border-left: none;
        }
        &::before {
          content: $arrow-left-svg;
          position: absolute;
          left: calc($item-inner-width + $line-gap);
          bottom: calc(($gap-y + $line-ab-top) * -1);
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, 50%);
        }
      }
      // 双行最左侧
      @if $i % ($grid-columns * 2) == 0 {
        $box-width: 30%; //看着改
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc(($box-width + $line-gap) * -1);
          display: block;
          width: $box-width; //这个看着改
          height: calc(100% + $gap-y);
          border-radius: 12px 0 0 12px; //弧度看着改
          border: 1px dashed #626c85;
          border-right: none;
        }
        &::before {
          content: $arrow-right-svg;
          position: absolute;
          left: calc($line-gap * -1);
          bottom: calc(($gap-y + $line-ab-top) * -1);
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, 50%);
        }
      }
    }
  }
}
.lk-grid {
  @include grid-container();
  .grid-item {
    @include grid-item();
    @include grid-items-layout();
    .inner {
      width: $item-inner-width;
      height: 50px;
      background-color: #016cff;
    }
  }
}
</style>
<template>
  <div class="bg-[#016cff1a] p-20">
    <div class="lk-grid pl-40!">
      <div v-for="item in 15" :key="item" class="grid-item">
        <div class="inner">展示{{ item }}</div>
      </div>
    </div>
  </div>
</template>

记录overflow:hidden和scrollIntoView导致的页面问题

作者 EchoEcho
2026年2月9日 08:54

问题描述:

在一个编辑器中开发页面组件,组件内部对子元素设置了position:absolute定位,并且元素内容区域设置了overflow:hidden属性。

启动项目后,可以在编辑器中可以对该组件进行相关设置和修改。当切换选中内容时,页面会自动滚动,将选中组件显示到浏览器视口中,修改对应属性也会重新渲染对应组件。

第一次渲染时UI展示正常。但是当对该组件切换选中元素或者对设置了定位的子元素设置新属性时都会导致下图中子元素的定位异常。但是在调试面板中查询该元素属性值,也没有任何改变。尝试重新在控制面板中赋值对应的top值,模型又会显示到指定位置。

图片

解决过程

尝试使用内容监听器在组件被选中后,重新赋值对应的topleft值无法解决此问题。

后来通过浏览器断点调试,发现在触发监听器之前,该组件执行了scrollIntoView方法,见下图

图片

相关分析:

在执行ScrollIntoView期间会多次重绘【reflow/repaint】页面布局。而子元素中定位相关属性值会在重绘时基于父元素的当前视口上下文重新计算,导致位置偏移,比如上图中的子元素底部与父元素对齐现象。

  1. 平滑滚动动画(behavior: 'smooth'):
  • 动画过程会逐步改变滚动位置,触发多次布局计算。
  • 如果父元素有 overflow: hidden,子元素超出部分在动画中可能被“拉回”或重定位。
  1. block: 'center' 配置:
  • 这会尝试将父元素置于视口中心,如果父元素高度不是固定值(例如依赖内容或响应式),百分比 top 会基于新滚动位置重新计算,导致子元素“滑动”到底部对齐。
  1. 绝对定位的参考点变化:
  • absolute 元素依赖最近的 position: relative 祖先。在滚动动画中,如果祖先的可见区域变化,子元素的计算位置会偏移。
  1. 浏览器特定行为:
  • Chrome/Safari 在 smooth scroll 时有时会错误处理百分比定位,尤其是结合 overflow: hidden 时。

而这里遇到的问题就是在组件相关容器中设置了overflow:hidden

解决:

overflow:hidden改成overflow:clip就解决此问题了。

解析overflow:hiddenoverflow:clip

  • overflow: hidden
    • 隐藏超出元素边界的内容,但内容在内部仍然“存在”。
    • 不显示滚动条,但可以通过JavaScript(如 element.scrollLeft)或嵌套滚动访问隐藏内容。
    • 这是较早的标准值,广泛支持所有现代浏览器。
  • overflow: clip
    • 完全“剪切”超出边界的内容,就好像超出部分不存在一样。
    • 不允许任何形式的滚动访问(即使通过JS),内容被彻底丢弃。
    • 这是CSS Overflow Module Level 3 中的新值(引入于 2020 年左右),浏览器支持较新(Chrome 90+、Firefox 75+、Safari 15+)。在旧浏览器中可能回退到 hidden

2. 关键区别

方面 overflow: hidden overflow: clip
内容可见性 隐藏超出部分,但内容仍可通过JS 滚动访问。 完全剪切超出部分,无法通过任何方式访问。
滚动行为 创建一个隐形的滚动容器;滚动事件可冒泡到父元素。 不创建滚动容器;滚动事件直接传递给父元素,不被捕获。
性能影响 可能导致浏览器计算隐藏内容的布局和渲染(较低性能)。 优化性能:浏览器忽略超出内容的渲染和布局(更快,尤其在复杂页面)。
定位/粘性影响 支持 position: sticky 等行为;创建新的块格式化上下文 (BFC)。 不支持 position: sticky(元素不会粘性);不创建 BFC
JS 交互 可以用JS 修改滚动位置(如 scrollTo())。 无法用 JS 滚动;超出内容被视为不存在。
浏览器支持 所有现代浏览器(IE6+)。 较新浏览器;需检查兼容性(polyfill 有限)。
用例 适合需要隐藏但可能内部滚动的场景(如裁剪图片但允许JS动画)。 适合纯静态剪切场景(如性能敏感的游戏/UI),或防止意外滚动。
  • 核心差异总结:hidden 是“隐藏但可访问”的(像盖了个盖子),而 clip 是“彻底删除超出部分”的(像用剪刀剪掉)。clip 更严格,旨在提高性能,但牺牲了一些灵活性。

告别“玄学”UI:从“删代码碰运气”到“控制 BFC 结界”

作者 im_AMBER
2026年2月8日 20:56

 序:告别“玄学”UI 

之前我认为UI是不需要费心写的,因此也忽略了许多有关CSS等样式的细节,现在看来实在是基础不牢地动山摇,这是错误的——只会将 UI 需求直接丢给 AI,像开盲盒一样等待结果,全都交给了AI“外包”。

当布局错位时,我往往通过视觉描述增加提示词来“碰运气”,把AI写的代码又丢给AI改,结果却常导致 AI 为了调整一个局部参数而重写整个文件,逻辑越改越乱,自相矛盾,到处都是冲突的补丁......

CSS 布局不是样式的堆砌,而是物理逻辑的构建。

今天,我通过对已有的博客、网页等页面的“破坏性实验”和 F12 深度调试,意识到 UI 开发的核心不在于具体的样式代码,而在于对容器约束与布局环境的掌控。


一、 溢出与约束:滚动的物理本质

1. BUG : 预览组件不能滚动查看全部内容

在开发文档预览组件时,我遇到了一个典型问题:

文本内容被截断,只能显示一半,且没有滚动条。不是理想的可以滚动的预览查看的状态。 中间的内容区域溢出(Overflow)但没有触发滚动机制。

2. 从“无限增长”到“边界意识”

在默认状态下,块级容器遵循内容流向。

如果父容器没有设定明确的高度(Height),它会随着内容无限延伸。

浏览器认为容器高度等于内容高度,因此不存在“溢出”,自然不会触发滚动条。

我的修复路径:为父容器显式声明高度边界(如 max-h-[90vh]h-[500px])。

overflow - CSS:层叠样式表 | MDN

溢出处理 (Overflow)

官方定义overflow 属性指定如果内容溢出一个元素的框(其内容区、内边距区或边框区)时,会发生什么。

深度理解:滚动不是内容的属性,而是内容与容器边界的冲突。必须先有“墙”(约束),溢出才有意义。


二、 Flex 布局:环境决定属性

1. 权力等级:为什么 flex: 1 会失效?

在博客页面实战中,我曾尝试强行在子元素写入 flex: 1 以期望它填满剩余空间,但在浏览器 F12 中,该属性显示为灰色。

  • 发现:子元素对空间的分配权(Flex item properties)必须建立在父级开启了 FFC(Flex Formatting Context) 的前提下。
  • CSS 页面是由一个个“上下文(Context)”组成的。 display: flex 开启了 FFC,overflow: auto 开启了 BFC。 之所以在 F12 里 flex: 1 是灰色的,就是因为我试图在“普通文档流”里运行“弹性分配协议”,这属于协议不匹配
  • 如果父级不是 display: flex,子元素的弹性属性将由于缺乏上下文环境而无法被引擎解析。

2. 动态分配:对话区的“铺满”逻辑

在设计 Sidebar + 对话区的布局( 例如这种左右分栏的页面 )中,AI 写 UI 常给出固定高度,导致右侧对话区无法随窗口自适应。

  • 逻辑重构:删掉死高度,将对话区设为 flex-1
  • 在 Flex 纵向布局中,这意味着它会“吸收”掉父容器中除去 Header 和 Footer 之外的所有剩余空间。

3.守地盘: min-h-0

在 Flex 容器中,子元素的 min-height 默认值是 auto

这意味着内容会倾向于撑开容器。

如果不手动设置 min-h-0,当内容非常多时,即使你设置了 flex-1,容器也可能被内容“撑爆”而不会出现内部滚动。


三、 空间隔离:BFC 结界与绝对定位

1. BFC:独立的渲染自治区

通过 F12 观察网页,我深刻理解了 BFC 的重要性。

BFC (Block Formatting Context)

官方定义:块级格式化上下文。它是 Web 页面的可视化 CSS 渲染的一部分,是布局过程中生成块级盒子的区域。

工程含义

  • 内部自治:BFC 内部的元素布局不会影响到外部,反之亦然。
  • 包含浮动:BFC 容器可以自动计算内部浮动元素的高度(解决高度塌陷)。
  • 消除重叠:BFC 区域不会与浮动(float)盒子重叠。

在工程实践中,我们最常用的触发 BFC 的方式是设置 overflow: hiddendisplay: flexdisplay: grid

这也解释了为什么当我为了解决溢出(Overflow)而给父容器加了高度限制和滚动属性时,其实也顺便开启了 BFC,从而解决了内部布局的一些奇怪偏移。

2. Position Absolute:脱离文档流的参照系

absolute 的定位并不是绝对的坐标,它是一种 “寻找祖先” 的行为。

  • 它脱离了常规文档流(不占位),像幽灵般漂浮。 编辑
  • 它的坐标参照系是最近的一个 positionstatic 的祖先元素。如果找不到,它会一直回溯到根节点。通常我们把父级设为 relative,就是为了给 absolute 的子元素立一个“参照桩”,防止它一路回溯到 body 导致“幽灵”乱飘。

四、 小结

会用AI开发的前提是能看懂AI的代码,而不是把所有都只是交给AI代劳。

用AI代替思考的开发者只会被AI取代,这是我对自己的警醒。

AI 只能提供样式的结果,但无法感知容器间的压力。

  • 当内容溢出时,AI 会盲目增加 h-full,但它不知道这会导致容器突破父级的物理边界。
  • 当布局错位时,AI 会堆砌 !important,但它不知道这破坏了 CSS 的权重优先级。

只有理解了 “封闭性原理”“环境决定属性” ,你才能在 AI 给出错误补丁时,一眼看出那个导致崩盘的“虚假约束”。

我想作为一个开发者,开发的思考是最珍贵的,写代码只是把思考落地的方式。AI其实也许只是一个把脑子里的想法帮忙写出来,或者帮忙提供思路,而不是代替开发者的脑子。

这次解决滚动问题的核心逻辑:

  • 封闭性原理:一个容器如果不“封顶”(没写 h-fullmax-h),它就是无限高的。无限高的东西永远不会滚动。
  • 分配法则(Flexbox)flex-1 是“抢地盘”,而 min-h-0 是“守地盘”。如果没有 min-h-0,内容会撑爆布局。
  • BFC(块级格式化上下文) :本质上就是给容器划了一块“自治区”,里面的东西怎么排,不影响外面。

实战排查流,学会用浏览器调试:

  • F12 调试:手动设置背景色等,在dev tools里面大胆调试,追溯父子元素。
  • Computed 计算值:不要只看写的 CSS,要看浏览器最终“算出来”的高宽。

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文献

区块格式化上下文 - CSS:层叠样式表 | MDN

盒模型 - 学习 Web 开发 | MDN

Scroll Area – Radix Primitives

查看和更改 CSS  |  Chrome DevTools  |  Chrome for Developers

height - Sizing - Tailwind CSS

Adding custom styles - Core concepts - Tailwind CSS

弹性盒子 - 学习 Web 开发 | MDN

flex-grow - Flexbox & Grid - Tailwind CSS

将 Props 传递给组件 – React 中文文档

解构 - JavaScript | MDN

箭头函数表达式 - JavaScript | MDN

闭包 - JavaScript | MDN

display - CSS:层叠样式表 | MDN

格式化上下文简介 - CSS:层叠样式表 | MDN

flex-basis - Flexbox & Grid - Tailwind CSS

应用或脱离流式布局 - CSS:层叠样式表 | MDN

Visual formatting model

CSS盒模型实战:用代码透视 `border-box`与 `content-box`的天壤之别

作者 Lee川
2026年2月8日 14:57

CSS盒模型实战:用代码透视 border-boxcontent-box的天壤之别

理解CSS盒模型是前端布局的必修课,而 box-sizing属性则是掌控盒模型计算规则的钥匙。本文将通过您文档中生动的代码示例,直观展示其核心区别。

场景一:标准盒模型的“扩张”困扰(content-box

在默认的 content-box模型下,您为元素设置的 widthheight仅作用于其内容区域。让我们看一个例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.content-box {
            width: 200px;       /* 仅指内容的宽度 */
            height: 100px;      /* 仅指内容的高度 */
            padding: 20px;      /* 内边距 */
            border: 5px solid black; /* 边框 */
            margin: 20px;       /* 外边距 */
            box-sizing: content-box; /* 这是默认值,也可不写 */
            background-color: lightgreen;
        }
    </style>
</head>
<body>
    <div class="box content-box">Box with content-box</div>
</body>
</html>

关键代码分析

  • width: 200px; height: 100px;:这里定义的仅仅是绿色内容区域的尺寸。
  • 添加的 paddingborder向外扩张盒子的总尺寸。

计算结果

  • 盒子的总宽度 = 200(width) + 20 * 2(padding) + 5 * 2(border) = 250px
  • 盒子的总高度 = 100(height) + 20 * 2(padding) + 5 * 2(border) = 150px

此时,盒子在页面上的实际占位是 250px * 150px,远大于你直觉上认为的 200px * 100px。这在多列布局时极易导致意外换行或溢出。

场景二:怪异盒模型的“收缩”智慧(border-box

为了解决上述问题,border-box模型采用了更直观的计算方式:你设定的 widthheight直接定义了这个盒子的总边框盒尺寸。对比示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.border-box {
            width: 200px;       /* 指整个盒子的总宽度! */
            height: 100px;      /* 指整个盒子的总高度! */
            padding: 20px;
            border: 5px solid black;
            margin: 20px;
            box-sizing: border-box; /* 核心:切换为 border-box */
            background-color: lightblue;
        }
    </style>
</head>
<body>
    <div class="box border-box">Box with border-box</div>
</body>
</html>

关键代码分析

  • 同样的 width: 200px; height: 100px;声明,但因为 box-sizing: border-box;的存在,这里的 200px 和 100px 被解释为包含内容、内边距和边框的总尺寸
  • 添加的 paddingborder向内挤压内容区域的空间。

计算结果

  • 盒子的总宽度 = 200px(由 width直接定义)
  • 盒子的总高度 = 100px(由 height直接定义)
  • 内容区域的实际宽度 = 200 - 20 * 2 - 5 * 2 = 150px
  • 内容区域的实际高度 = 100 - 20 * 2 - 5 * 2 = 50px

无论你如何调整 paddingborder,这个浅蓝色盒子的外轮廓都严格保持为你设定的 200px * 100px,这使得精确控制布局变得轻而易举。

实战应用:为什么 border-box是布局神器

让我们看一个经典应用场景——创建两个等宽并列的盒子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .container {
            width: 1200px;
            margin: 0 auto;
        }
        .box {
            box-sizing: border-box; /* 使用 border-box 模型 */
            width: 580px; /* 总宽580px */
            height: 100px;
            margin: 0 10px; /* 左右外边距各10px */
            border: 1px solid #000; /* 边框 */
            padding: 5px; /* 内边距 */
            display: inline-block;
            background-color: green;
        }
        .box:nth-child(2) {
            background-color: yellow;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="box">1</div><div class="box">2</div>
    </div>
</body>
</html>

核心优势解析

  1. 尺寸可预测:每个 .box的总宽度是明确的 580px,无论其 borderpadding如何变化。

  2. 布局计算简单

    • 单个盒子占位:580px(width) + 10 * 2(margin) = 600px
    • 两个盒子总占位:600px + 600px = 1200px
    • 容器宽度为 1200px,完美容纳。

如果此处使用 content-box,会发生什么?

每个盒子的实际总宽度会变成:580(width) + 5 * 2(padding) + 1 * 2(border) = 592px,再加上左右 margin各10px,单个盒子就占用了 612px,两个盒子就需要 1224px,会立即撑破 1200px的容器,导致第二个盒子掉到下一行。border-box彻底避免了这种烦人的计算。

总结与最佳实践

通过以上代码的对比演示,可以清晰地看到:

  • **content-box** 是“加法模型”(实际尺寸 = 设定尺寸 + padding + border),易导致布局失控。
  • **border-box** 是“减法模型”(内容尺寸 = 设定尺寸 - padding - border),让元素的占位尺寸完全可预测。

因此,在现代前端开发中,一个公认的最佳实践是在CSS起始位置就全局应用 border-box模型:

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

这条简单的规则,能让你在后续的整个开发过程中,彻底告别因 paddingborder导致的布局尺寸计算烦恼,将更多精力投入到创意和逻辑的实现中。

React 与 Vue 的 CSS 模块化深度实战指南:从原理到实践,彻底告别样式“打架”

作者 AAA阿giao
2026年2月6日 19:35

引言

在前端开发的日常中,我们常常会遇到一个令人抓狂的问题:为什么我只改了一个组件的样式,结果整个页面都乱了?

这背后的根本原因,就是 CSS 的全局作用域特性。默认情况下,所有 .button.header.txt 这样的类名在整个 HTML 文档中都是共享的——你在一个地方定义了 .txt { color: red; },另一个组件用了同样的类名,也会被染红!

为了解决这个问题,现代前端框架如 ReactVue 都提供了强大的 CSS 模块化(Scoped Styling) 能力。它们虽然思路不同,但目标一致:让每个组件的样式只作用于自己,互不干扰

本文将带你深入剖析 React 与 Vue 是如何实现 CSS 模块化的,并逐行解读真实代码,确保你不仅“会用”,更“懂原理”。全文内容详尽、结构清晰,适合初学者入门,也适合进阶开发者查漏补缺。


一、问题的根源:CSS 为何“容易打架”?

CSS(层叠样式表)的设计初衷是全局生效。这意味着:

  • 类名没有作用域;
  • 后加载的样式可能覆盖前面的;
  • 相同类名在不同组件中会互相污染。

比如:

.txt {
  color: red;
}

如果你在两个不同的组件里都用了 <div class="txt">,那么它们都会变成红色——即使你只想让其中一个变红。

这就是我们需要“模块化”的根本原因


二、React 的 CSS 模块化方案

React 社区推崇“显式优于隐式”的哲学,因此它提供了多种模块化方案。我们重点讲解两种:styled-components(CSS-in-JS)CSS Modules(原生 CSS 模块化)

2.1 方案一:styled-components —— 样式即组件

以下正是使用 styled-components 的典型示例:

import {
  useState 
} from 'react';
import styled from 'styled-components';  // 样式组件 

// 样式组件
const  Button = styled.button`
background:${props => props.primary?'blue': 'white'};
color:${props => props.primary?'white': 'blue'};
border: 1px solid blue;
padding: 8px 16px;
border-radius: 4px;
`
console.log(Button);

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

export default App

它是如何工作的?

  • styled.button 创建了一个新的 React 组件,内部是一个 <button> 元素;
  • 所有写在反引号中的 CSS 会被注入到 <style> 标签中;
  • 关键点:每个 styled 组件都会生成一个唯一的类名(如 sc-abc123-def456 ,确保样式不会冲突;
  • 通过 props 实现动态样式(如 primary 控制颜色);
  • console.log(Button) 会输出一个 React 组件函数,说明它本质是 JS 对象。

浏览器实际渲染效果(简化版):

<style>
.sc-abc123-def456 {
  background: white;
  color: blue;
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
}
.sc-abc123-xyz789 {
  background: blue;
  color: white;
}
</style>

<button class="sc-abc123-def456">默认按钮</button>
<button class="sc-abc123-xyz789">主要按钮</button>

💡 优点:样式与逻辑紧密耦合,支持动态主题、媒体查询、嵌套等;
缺点:运行时注入样式,略微增加 bundle 体积;不适合大型静态样式库。


2.2 方案二:CSS Modules —— 原生 CSS 的模块化革命

以下内容详细描述了 CSS Modules 的机制:

  • 文件名后面添加 .module.css
  • 类名会被编译为 AnotherButton_button__12345
  • 通过 import styles from './Button.module.css' 导入
  • JSX 中使用 {styles.button} 引用

示例:创建一个模块化 CSS 文件

/* Button.module.css */
.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
  border-radius: 4px;
}

在 React 组件中使用

import styles from './Button.module.css';

function Button({ children }) {
  return <button className={styles.button}>{children}</button>;
}

构建时发生了什么?

假设你的文件路径是 src/components/Button.module.css,构建工具(如 Webpack 或 Vite)会在打包时:

  1. .button 重命名为类似 Button_button__abc123 的唯一字符串;

  2. 生成一个 JavaScript 对象:

    // 编译后的 styles 对象
    const styles = {
      button: "Button_button__abc123"
    };
    
  3. 注入对应的 CSS 到页面中。

优势总结:

  • 完全隔离:每个类名全局唯一,零冲突;
  • 类型安全:配合 TypeScript 可获得自动补全和错误检查;
  • 性能优秀:无运行时开销,纯静态 CSS;
  • 可组合:支持 composes 复用样式(见下文)。

进阶技巧:样式复用(composes

/* base.module.css */
.baseBtn {
  padding: 8px 16px;
  border-radius: 4px;
}

/* Button.module.css */
.primary {
  composes: baseBtn from './base.module.css';
  background: blue;
  color: white;
}

这样,.primary 自动继承了 .baseBtn 的所有样式。


三、Vue 的 CSS 模块化方案:scoped 属性

相比 React 的“显式导入”,Vue 的方案更加“隐形而优雅”——只需在 <style> 标签上加一个 scoped 属性。

以下 Vue 代码完美展示了这一点:

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
<div>
  <h1 class="txt">Hello txt</h1>
  <h1 class="txt2">Hello txt2</h1>
  <HelloWorld />
</div>
</template>

<style scoped>
.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}
.txt {
  color: red;
  background-color: orange;
}
.txt2 {
  color: pink;
}
</style>

以及子组件 HelloWorld.vue

<script setup>
</script>

<template>
  <div>
    <h1 class="txt">你好</h1>
    <h1 class="txt2">你好2</h1>
    该子组件中无样式内容,跟随父组件的样式
    如果子组件中需要自定义样式,需要使用scoped属性
    此时,子组件中的样式只作用于当前组件,不会影响到其他组件
    如果不加scoped属性,子组件中的样式会影响到其他组件
  </div>
</template>

<style scoped>
.txt {
  color: blue;
  background-color: green;
  font-size: 30px;
}
.txt2 {
  color: orange;
}
</style>

scoped 是如何实现隔离的?

Vue 在编译阶段会:

  1. 为当前组件生成一个唯一的 hash,例如 data-v-f3f3ec42
  2. 给组件内所有根元素(或指定元素)添加该属性;
  3. 重写 <style scoped> 中的选择器,加上属性限制。

编译后效果(简化):

父组件样式

.txt[data-v-f3f3ec42] { color: red; }
.txt2[data-v-f3f3ec42] { color: pink; }

子组件样式

.txt[data-v-7ba5bd90] { color: blue; }
.txt2[data-v-7ba5bd90] { color: orange; }

HTML 渲染结果

<div data-v-f3f3ec42>
  <h1 class="txt" data-v-f3f3ec42>Hello txt</h1>
  <h1 class="txt2" data-v-f3f3ec42>Hello txt2</h1>
  <div data-v-7ba5bd90>
    <h1 class="txt" data-v-7ba5bd90>你好</h1>
    <h1 class="txt2" data-v-7ba5bd90>你好2</h1>
  </div>
</div>

结果:尽管类名相同,但因为 data-v-xxx 不同,样式完全隔离!

注意事项:深度选择器

如果你希望父组件的样式能影响子组件(比如定制第三方 UI 库),可以使用 :deep()

<style scoped>
.parent :deep(.child) {
  color: purple;
}
</style>

📌 Vue 2 中使用 /deep/::v-deep,Vue 3 推荐使用 :deep()


四、React vs Vue:CSS 模块化对比全景图

维度 React (CSS Modules) React (styled-components) Vue (scoped)
实现方式 类名哈希化 动态生成唯一类名 + 注入 <style> 属性选择器 ([data-v-xxx])
样式位置 独立 .module.css 文件 写在 JS/TSX 中 写在 .vue 单文件组件内
类名可读性 开发时需 styles.xxx,运行时为哈希 开发时直观,运行时为哈希 开发和运行时均为原始类名
作用域强度 ⭐⭐⭐⭐⭐(绝对隔离) ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐(依赖属性,可被绕过)
动态样式 需结合 JS 条件拼接 原生支持 props 需绑定动态 class
TypeScript 支持 完美(自动类型推导) 良好 有限
学习成本 中等(需理解模块导入) 低(直观) 极低(加个 scoped 即可)
适用场景 大型项目、团队协作、静态样式多 快速原型、动态主题、UI 库开发 中小型项目、快速开发、Vue 生态

五、为什么需要 CSS 模块化?—— 真实痛点解析

场景 1:多人协作项目

想象一个 10 人团队同时开发一个后台系统。A 写了 .card { padding: 10px; },B 也写了 .card { margin: 20px; }。如果不模块化,最终 .card 会同时有 padding 和 margin,甚至可能因加载顺序导致样式错乱。

模块化后:A 的 .card 变成 PageA_card__abc,B 的变成 PageB_card__def,互不影响。

场景 2:开源组件库

如果你发布一个 React 组件库,使用普通 CSS,用户很容易因为类名冲突导致样式异常。而使用 CSS Modules 或 styled-components,就能保证“开箱即用,零污染”。

场景 3:微前端架构

在微前端中,多个子应用共存于同一页面。若都使用全局 CSS,冲突几乎是必然的。模块化是微前端样式的安全基石


六、最佳实践建议

React 项目推荐

  • 中小型项目:优先使用 styled-components,开发体验极佳;
  • 大型企业级应用:采用 CSS Modules + TypeScript,兼顾性能与可维护性;
  • 避免:直接使用全局 CSS(除非是 reset/normalize)。

Vue 项目推荐

  • 默认开启 scoped:所有组件样式都加上 scoped
  • 全局样式单独管理:如 assets/styles/global.css,用于 reset、变量、通用类;
  • 慎用深度选择器:仅在必要时(如覆盖 Element Plus 样式)使用 :deep()

七、结语:选择适合你的“样式盔甲”

  • React 的 CSS Modules 像一套精密的“锁链铠甲”——每一块甲片(类名)都有唯一编号,严丝合缝,坚不可摧;
  • styled-components 则像一件“魔法斗篷”——样式随组件而生,动态变幻,灵活自如;
  • Vue 的 scoped 更像一层“隐形护盾”——你看不见它,但它默默守护着你的样式不被污染。

🎯 记住:技术没有绝对优劣,只有是否适合当前项目。
但无论你选择哪一种,请坚持一致性——团队统一规范,才是长期可维护的关键。

现在,回看开头那个“按钮莫名变蓝”的问题,你已经有能力彻底解决它了!

为什么我说CSS-in-JS是前端“最佳”的糟粕设计?

2026年2月6日 14:49

如果你是一名前端开发者,特别是React开发者,你一定听说过或使用过CSS-in-JS方案。从Styled-components到Emotion,这些库在短短几年内迅速流行,被无数项目采用。

但今天,我要冒着被喷的风险说一句:CSS-in-JS是个糟糕的设计,它解决了不存在的问题,却创造了真实的新问题。


一、CSS-in-JS的“美好”承诺

支持者们会告诉你CSS-in-JS有多棒:

  • 组件化:样式与组件绑定,不再担心样式污染
  • 动态样式:基于props的动态样式轻而易举
  • 自动处理前缀:不再需要手动写-webkit-
  • 代码简洁:不再需要在不同文件间跳转

听起来很美好,不是吗?但这些“好处”背后,隐藏着巨大的代价。


二、现实中的七宗罪

运行时开销:性能的隐形杀手

CSS-in-JS在运行时解析样式、生成类名、注入到文档中。这意味着用户访问你的网站时,JavaScript必须完成这些额外工作才能显示样式。

对比一下:

  • 传统CSS:浏览器直接解析和应用样式
  • CSS-in-JS:JavaScript执行 → 解析样式 → 生成类名 → 注入样式 → 浏览器应用

在慢速设备或网络条件下,这种差异尤为明显。而这一切,只是为了实现原本浏览器原生就能处理的事情。

开发体验的倒退

“在JavaScript中写CSS”听起来很酷,直到你真正开始使用:

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  
  &:hover {
    background: ${props => props.primary ? 'darkblue' : 'lightgray'};
  }
  
  @media (max-width: 768px) {
    font-size: 14px;
    padding: 8px 16px;
  }
`;

这段代码里,你失去了:

  • CSS语法高亮(除非额外安装插件)
  • CSS自动补全
  • CSS linting检查
  • 浏览器DevTools的直接编辑能力

可维护性噩梦

当样式逻辑复杂时,你最终会得到这样的代码:

const ComplexComponent = styled.div`
  ${({ theme, variant, size, disabled }) => {
    // 一大堆JavaScript逻辑
    let styles = '';
    if (variant === 'primary') {
      styles += `background: ${theme.colors.primary};`;
    }
    if (size === 'large') {
      styles += `padding: 20px; font-size: 18px;`;
    }
    if (disabled) {
      styles += `opacity: 0.5; cursor: not-allowed;`;
    }
    return styles;
  }}
`;

这不再是“在JS中写CSS”,而是“用JS逻辑生成CSS字符串”。可读性和可维护性急剧下降。

学习成本陡增

新开发者需要学习:

  1. CSS本身
  2. JavaScript
  3. React
  4. 特定CSS-in-JS库的语法和API
  5. 如何调试这个独特的系统

而他们学到的大多数知识,在离开这个特定技术栈后毫无用处。

SSR和静态生成的复杂性

服务器端渲染变得复杂:

  • 需要收集使用的样式
  • 需要在HTML中注入样式
  • 需要处理hydration不匹配
  • 增加了包大小和内存使用

而这一切对于纯CSS来说,都是不存在的。

调试困难

在浏览器DevTools中,你会看到这样的类名:.sc-1a2b3c4d。想根据类名找到对应的组件?祝你好运。

想了解某个样式来自哪个组件?你需要:

  1. 打开DevTools
  2. 找到元素
  3. 查看混乱的类名
  4. 在代码中搜索这个生成的类名
  5. 或者安装专门的浏览器扩展

三、更好的替代方案

CSS-in-JS试图解决的问题,其实有更优雅的解决方案:

方案一:CSS Modules(真正的组件化CSS)

/* Button.module.css */
.button {
  background: blue;
  color: white;
}

.primary {
  background: darkblue;
}

.button:hover {
  background: lightblue;
}
import styles from './Button.module.css';

function Button({ primary }) {
  return (
    <button className={`${styles.button} ${primary ? styles.primary : ''}`}>
      Click me
    </button>
  );
}

优点

  • 真正的局部作用域
  • 零运行时开销
  • 保持CSS原生能力
  • 易于调试

方案二:Utility-First CSS(如Tailwind)

function Button({ primary }) {
  return (
    <button className={`
      px-4 py-2 rounded
      ${primary 
        ? 'bg-blue-600 text-white hover:bg-blue-700' 
        : 'bg-gray-200 text-gray-800 hover:bg-gray-300'
      }
    `}>
      Click me
    </button>
  );
}

优点

  • 极小的CSS输出
  • 高度一致的设计系统
  • 极少的上下文切换
  • 优秀的性能特性

方案三:纯CSS + 现代特性

现代CSS已经解决了大多数“CSS难题”:

/* 使用CSS自定义属性实现主题 */
:root {
  --primary-color: blue;
  --spacing-unit: 8px;
}

.button {
  background: var(--primary-color);
  padding: calc(var(--spacing-unit) * 2);
}

/* 容器查询 - 即将成为标准 */
@container (max-width: 400px) {
  .button {
    font-size: 14px;
  }
}

四、历史的教训

我们见过这种模式:

  1. 过度抽象:为了解决“复杂”的CSS,我们创建了更复杂的系统
  2. 技术债积累:短期便利,长期维护噩梦

CSS-in-JS可能最终会像其他过度抽象的技术一样,在热情消退后,留下技术债务和后悔的开发者。


结语

有时候,最简单的解决方案就是最好的解决方案。CSS已经存在了25年,浏览器厂商投入了无数资源优化它。也许,我们应该相信这些专家,而不是试图在JavaScript中重新发明轮子。

前端开发的进步,不应该以牺牲Web的根本原则为代价。

简洁、可维护、高性能的代码,才是对我们用户和同事的真正尊重。


互动话题:你在项目中使用过CSS-in-JS吗?遇到了哪些问题?欢迎在评论区分享你的经验!

关注我,获取更多前端技术文章

拯救UI美感!纯CSS实现「幽灵」滚动条:悬停显示、贴边优雅

2026年2月5日 17:59

浏览器系统默认的滚动条样式又粗又黑,非常影响观感,为此特意做了一版样式优化。

实现以下需求:

  1. 默认不显示,鼠标移入滑动范围内才显示。(保证页面无需滑动时不臃肿);
  2. 使用简单,添加到全局样式中后,使用时只需在class中添加类名即可。;
  3. 滚动条贴边(需要div内部样式准确);
  4. 调整粗细和颜色,增强使用体验!
.commonScroll {
  scrollbar-color: transparent transparent;
  scrollbar-width: 8px;

  & ::-webkit-scrollbar-thumb,
  &::-webkit-scrollbar-thumb {
    background-color: transparent;
    border-radius: 2px;

    &:horizontal {
      background-color: transparent;
      border-radius: 2px;
    }
  }

  &:hover {
    scrollbar-color: var(--g-scroll-bar-color) transparent;

    & ::-webkit-scrollbar-thumb,
    &::-webkit-scrollbar-thumb {
      background-color: var(--g-scroll-bar-color);
      border-radius: 2px;

      &:horizontal {
        background-color: var(--g-scroll-bar-color);
        border-radius: 2px;
      }
    }
  }

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


//  dark.css:    --g-scroll-bar-color:  #565657;
// light.css:    --g-scroll-bar-color: #CACBCC;

使用时

<div class="searchWrapper  commonScroll">

最终效果如下:

image.png

基线对齐:让文字和图标“看起来齐”的那门细节功夫

作者 hypoy
2026年2月5日 15:24

基线对齐:让文字和图标“看起来齐”的那门细节功夫

在做 UI 的时候,我们经常遇到一种“明明对齐了但看起来不齐”的情况:

  • 图标和文字放在一行,图标总像是飘着或下沉了一点
  • 不同字号的文字放一起,视觉上像“高低不平”
  • 按钮里的 icon + 文本,怎么调 padding 都不舒服

很多时候,问题不在于你没对齐,而在于你没对“基线”(Baseline)。

这篇文章会把基线对齐讲清楚:它是什么、为什么重要、在 Web/CSS 里怎么用、常见坑怎么躲。


1. 什么是“基线”(Baseline)

在排版里,基线可以理解为:一行文字“站着”的那条隐形线。
大部分字母(比如 a、e、n)会“坐”在这条线上,而像 g、p 这种有下行部件的字母会“掉”到基线下面。

你可以把它想成小学写字本上的那条线:字不一定都一样高,但它们的“落脚点”一致。

关键点:

  • 基线不是元素的底边(bottom)
  • 基线和字体度量(font metrics)强相关
  • 不同字体/字号的基线位置不同,但浏览器可以把它们对齐

2. 为什么“基线对齐”比“居中对齐”更自然

很多人第一反应是 align-items: center;
center 对齐是几何对齐,而人眼对一行内容的感知往往是排版对齐

举个典型场景:左边图标 + 右边文字

  • center 对齐:图标中心对文字盒子中心
  • baseline 对齐:图标“落脚点”跟文字基线对齐

当文字字号变大、字体换了、行高不同,center 对齐更容易出现“看着不齐”。

经验结论:
同一行里只要有文字参与,默认优先考虑基线对齐,会更“像排版”,更稳。


3. CSS 里怎么做基线对齐

3.1 Flex 布局:align-items: baseline

.row {
  display: flex;
  align-items: baseline;
  gap: 8px;
}

这会让同一行内的 flex items 的基线对齐。

注意:

  • 对齐的“基线”来自每个 flex item 内部的第一行文本(first baseline)
  • 如果某个 item 没文本,或者是 replaced element(比如 img),它的基线规则会比较特殊(下面会讲)

3.2 Grid 布局:align-items: baseline / align-self: baseline

Grid 也支持 baseline,但各浏览器实现细节可能略有差异。一般在组件里,Flex 更常见。

3.3 Inline/inline-block 的天然基线对齐

如果你用的是 display: inline-block;,它默认就会按基线对齐。
这也是为什么“图片底下总有一条缝”的经典 bug 会出现:img 作为 inline 内容时,会给基线留空隙(用于字母下行部件)。


4. 图标/图片为什么总是对不齐?

4.1 img 的基线和“底部留缝”

img(以及很多 replaced elements)参与 inline 排版时,默认会以基线对齐,但它的基线往往表现为“底部边缘附近”,再加上行高空间,就会出现底部缝隙或视觉偏移。

常见解决:

img, svg {
  vertical-align: middle; /* 或 baseline/top/bottom 视情况 */
}

或者直接把图标变成 flex item,由 flex baseline 控制。

4.2 SVG 图标更推荐的做法

如果你是图标 + 文本组合,强烈建议:

  • 图标用 svg(内联或 icon font 也行)
  • 外层用 flex
  • 用 baseline 对齐
  • 必要时给图标一个微调(这在真实项目里很常见,不丢人)
.icon {
  width: 16px;
  height: 16px;
  transform: translateY(1px); /* 微调 0~2px 很常见 */
}

为什么需要微调?
因为不同图标的视觉重心不同、字体的 x-height 不同,“数学上的 baseline 对齐”不一定等于“视觉上的舒适对齐”。


5. 基线对齐的常见坑

坑 1:flex item 没有文本,baseline 对齐不生效或很怪

如果某个 item 只是一个纯容器(里面没有文本第一行),它的 baseline 可能会退化成某种边缘对齐,结果整行就漂了。

解决思路:

  • 让对齐基准的元素内部有文字(哪怕是隐藏文本不推荐)
  • 或者把“需要对齐的那层”单独包一层,确保 baseline 来自文字那层
  • 或者对没有文字的 item 用 align-self 单独处理

坑 2:行高(line-height)过大导致“看着不齐”

行高决定了文字盒子高度,但基线位置不一定在盒子正中。
如果你把 line-height 设得很大,再用 center 对齐,就更容易“看起来不齐”。

建议:

  • 文本和图标混排时,优先 baseline
  • 控制合理的 line-height(比如 1.2~1.6 视字号和字体而定)

坑 3:不同字体/中英混排导致基线差异

中文字体和英文字体混用、fallback 字体切换、数字字体不同,都可能影响基线与视觉重心。

应对策略:

  • 关键 UI 字体尽量统一(尤其按钮/表单)
  • 数字显示可用 font-variant-numeric(比如 tabular-nums)提升稳定性
  • 必要时组件级微调(icon translateY、padding)

6. 实战建议:什么时候用 baseline,什么时候用 center?

优先用 baseline:

  • icon + 文本在一行(按钮、标签、菜单项)
  • 不同字号文本同一行(标题 + 数值、强调词)
  • 表格/列表中需要“像排版一样齐”的场景

优先用 center:

  • 两个都是“块状视觉元素”(比如头像 + 圆点状态)
  • 没有文字参与,或者文字只是次要(比如纯图标按钮)
  • 你追求的是几何居中(比如图标在圆形容器中)

一句话总结:
有字就想 baseline;没字就想 center;不舒服就微调。


7. 一个推荐的“图标+文本”组件模板

你可以把它当成项目里通用的模式:

.inline-with-icon {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
}

.inline-with-icon .icon {
  width: 1em;
  height: 1em;            /* 跟随字号缩放 */
  transform: translateY(0.08em); /* 轻微下压,更贴基线 */
}

优点:

  • 1em 让图标自动跟着字体大小变化
  • baseline 让整体更像“文字的一部分”
  • translateY 用 em 做相对微调,字号变了也更稳

结语:对齐不是“数学问题”,而是“视觉问题”

基线对齐本质上是在借用排版系统的规则,让 UI 更自然、更舒服。
而当规则解决不了视觉差异时,微调就是工程的一部分——别害怕那 1px,它常常是质感的来源。

❌
❌