02 · CSS 与视觉实现(详解版)
一句话定位:CSS 是「把抽象结构渲染成视觉」的语言。它同时管三件事:布局(元素放在哪)、绘制(长什么样)、状态过渡(怎么变)。
读完本篇你应该能做到
- 拿到一张复杂设计稿,能说出该用 Grid 还是 Flex、断点怎么切、哪些地方会踩坑;
- 遇到「样式不生效 / z-index 失效 / 元素撑破容器」,能在 5 分钟内定位原因;
- 解释一次样式改动会触发渲染管线的哪几个阶段,以及为什么它可能卡顿。
0. 领域导论
0.1 这个领域解决什么问题
前端 80% 的「看起来不对」都出在 CSS。但 CSS 的难不在语法,而在它的三个反直觉特性:
- 层叠(cascade):同一个元素可能被十几条规则命中,最终生效的是「权重 + 顺序 + 来源」共同决定的结果。你以为写了一条样式,实际有另一条更强的规则在压着它。
- 依赖上下文:
width: 100%是相对谁?position: absolute相对谁定位?z-index: 9999为什么不管用?答案都不在元素自己身上,而在祖先、兄弟、格式化上下文里。 - 成本看不见:改一下
left和改一下transform,视觉结果一样,但一个触发重排、一个只走合成,性能差一个数量级。
0.2 一个类比
把浏览器想成一家印刷厂:
- 布局(layout) 是「排版师傅算每个字放在哪一行哪一列」——最贵的一道工序,改动要重新算。
- 绘制(paint) 是「上色师傅把颜色填到纸上」——中等成本。
- 合成(composite) 是「把几张透明胶片叠起来」——最便宜,GPU 擅长。
你的 CSS 写法决定「这次改动要让哪几位师傅返工」。只改颜色 = 上色师傅重做;只改位移 = 胶片挪一下;改宽度 = 排版全部重算。
0.3 学习地图
02 CSS
├─ 前置:01 HTML(DOM 结构)
├─ 紧接着:05 浏览器原理(渲染管线细节)
├─ 交叉:11 性能(渲染成本)、16 可视化(Canvas/SVG 与 CSS 的分工)
└─ 长期:17 设计系统(令牌与主题)
建议顺序:1(盒模型/选择器/定位)→ 2(Flex/Grid/BFC)→ 立刻用 Grid 复刻一个真实页面 → 3(渲染成本)→ 4(主题与治理)。
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 能还原设计稿,布局不出错 | 常规页面与表单样式 | 能解释盒模型与定位行为 |
| L2 进阶 | 掌握现代布局体系,能架构样式组织 | 组件样式方案、响应式页面 | 能解释层叠与 BFC,能用 Grid 完成复杂布局 |
| L3 高级 | 理解渲染成本,能做主题与工程化治理 | 设计令牌体系、样式平台、性能专项 | 能解释一次重排的完整路径 |
| L4 专家 | 定义视觉实现标准与跨端一致性策略 | 组织级样式规范、多品牌主题架构 | 能在规范与实现差异间做出可辩护的取舍 |
1. L1 基础:先把「盒子」想明白
1.1 盒模型:所有布局的计算起点
每个元素都是一个矩形盒子,由四层从内到外组成:
┌─────────────────────────────────┐ ← margin(外边距,透明,不参与背景)
│ ┌─────────────────────────────┐ │ ← border(边框)
│ │ ┌─────────────────────────┐ │ │ ← padding(内边距)
│ │ │ content │ │ │ ← 内容区
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
关键在于 box-sizing 决定 width 包含哪几层:
/* 默认值:width 只算 content,加 padding/border 会变宽 */
.box-a {
box-sizing: content-box;
width: 100px;
padding: 10px;
border: 1px solid;
}
/* 实际占宽 = 100 + 20 + 2 = 122px */
/* 推荐:全局设为 border-box,width 就是最终宽度 */
*,
*::before,
*::after {
box-sizing: border-box;
}
.box-b {
width: 100px;
padding: 10px;
border: 1px solid;
}
/* 实际占宽 = 100px,内容区 = 78px */
为什么推荐 border-box:设计稿标注的尺寸通常是「元素在视觉上占多宽」,border-box 与人的直觉一致,也避免「加了 padding 就撑破 100% 宽度」这类问题。
常见坑:网上老教程默认
content-box,直接抄配置会出现「宽度对不上 22px」的诡异现象。 自检:两个 div 都设width: 100px; padding: 10px,为什么实际宽度不同?——默认box-sizing: content-box时,width: 100px只算内容区,实际占宽是100 + 10×2 = 120px;设了border-box时width已包含 padding 与 border,仍是 100px。两者不同通常是一个被全局* { box-sizing: border-box }覆盖、另一个没有(如伪元素、第三方组件或内联样式)。
1.2 选择器与权重:为什么我的样式不生效
样式冲突时,浏览器按权重(specificity) 决定谁赢:
| 类型 | 权重 | 例子 |
|---|---|---|
| 行内样式 | (1,0,0,0) | style="color: red" |
| ID | (0,1,0,0) | #header |
| 类 / 属性 / 伪类 | (0,0,1,0) | .btn、[type="text"]、:hover |
| 元素 / 伪元素 | (0,0,0,1) | div、::before |
| 通配符 / 继承 | (0,0,0,0) | * |
比较规则:从左往右逐位比较,不是简单相加。#a .b (0,1,1,0) 赢 .c .d .e(0,0,3,0),即使后者「数量更多」。
!important 会跳过上述比较,是优先级失控的信号。它的正确处理方式不是「再加一个 !important」,而是降低冲突源的权重(用 @layer,见 L2)。
伪类书写顺序(LVHA):
a:link { } /* 未访问 */
a:visited { } /* 已访问 */
a:hover { } /* 悬停 */
a:active { } /* 按下 */
顺序错了会出现「hover 样式被 visited 覆盖」等怪现象。
自检:
.list .item和.item.is-active谁权重高?为什么?——两者特异性都是0,2,0,权重相同,胜负由源码顺序决定:后写的赢。所以正确答案是「一样高」,.list .item不会因为选择器更长而更高;要稳定覆盖应调整顺序或再加一层特异性,而不是堆类名。
1.3 层叠与继承:哪些属性会「传下去」
继承:某些属性会自动从父元素传给子元素。记住分类:
- 会继承:字体相关(
font-*)、文本(color、line-height、text-align、letter-spacing)、列表样式(list-style)、visibility、cursor、white-space。 - 不继承:盒模型相关(
margin、padding、border、width、height)、背景、定位、display、overflow。
四个关键字控制继承行为:
.child {
color: inherit; /* 强制继承父级计算值 */
color: initial; /* 恢复成规范定义的初始值(注意不是浏览器默认样式) */
color: unset; /* 可继承属性=inherit,不可继承=initial */
color: revert; /* 回退到浏览器默认样式(常用于重置第三方样式) */
}
常见坑:
color会继承,但<button>不会自动继承字体——因为浏览器默认样式给按钮设了font: 400 13px Arial。这是「按钮字体和页面不一致」的原因,需要显式font: inherit。 自检:为什么给body设font-size: 16px,表单控件里的字却不是 16px?——因为浏览器默认样式(UA stylesheet)给表单控件设了font: 400 13px Arial之类的简写,控件不继承body的字体。显式写font: inherit(或font-size/font-family: inherit)才能让按钮、输入框与页面字体一致。
1.4 定位体系:absolute 到底相对谁
| 值 | 行为 |
|---|---|
static | 默认,不参与定位,top/left 无效 |
relative | 相对自己原来的位置偏移,原位置仍占空间 |
absolute | 脱离文档流,相对最近的已定位祖先(position 不是 static)的 padding box 定位 |
fixed | 相对视口定位,滚动不动 |
sticky | 滚动到阈值前是 relative,之后变 fixed 效果,限制在父容器内 |
「已定位祖先」有两个例外,很多人不知道:
/* transform / filter / perspective / will-change 也会创建包含块 */
.parent {
transform: translateZ(0);
}
.child {
position: absolute; /* 现在相对 .parent 定位,而不是更外层的 relative */
}
这就是「position: fixed 在某些页面变成相对某个容器固定」的原因——祖先有 transform,fixed 的包含块被改变了。
sticky 失效的三个高频原因:
- 父容器
overflow: hidden/auto(滚动容器变了); - 没有设
top/bottom等阈值; - 父容器高度等于元素高度(没有可粘滞的空间)。
自检:一个
absolute元素没写top/left,它会出现在哪?——它会停在「静态位置」(static position),即假设它仍是position: static时本该出现的位置,但已脱离文档流、不再占空间;宽度按内容收缩(shrink-to-fit),除非显式设width。
1.5 Flex:一维布局的主力
.row {
display: flex;
gap: 12px; /* 推荐:比 margin 拼接更可控 */
align-items: center; /* 交叉轴对齐 */
justify-content: space-between; /* 主轴分布 */
flex-wrap: wrap; /* 允许换行 */
}
最容易混淆的概念:主轴与交叉轴会随 flex-direction 旋转。flex-direction: column 时,justify-content 管垂直方向。
flex 简写展开:
.item { flex: 1; } /* = flex: 1 1 0% 可放大、可缩小、基准为 0 */
.item { flex: auto; } /* = flex: 1 1 auto 基准为内容宽度 */
.item { flex: none; } /* = flex: 0 0 auto 不伸缩 */
必记的坑:flex 子项里的长文本会把容器撑破。原因是
min-width的初始值是auto(不允许小于内容最小宽度)。修复:给子项加min-width: 0(列方向是min-height: 0)。
.flex-item {
flex: 1;
min-width: 0; /* 允许收缩到比内容更窄,配合 text-overflow 才能生效 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
自检:为什么
flex: 1的子元素里放一段很长的不换行文本,会撑破父容器?——因为 flex item 的min-width默认是auto,即「不小于内容的最小尺寸」;一段不能换行的长文本把最小内容宽度撑得很大,item 就被撑开并溢出父容器。给该 item 设min-width: 0(或overflow: hidden)即可让flex: 1正常收缩。
1.6 单位:px、em、rem、vw 什么时候用哪个
| 单位 | 相对什么 | 适用 |
|---|---|---|
px | 绝对像素 | 边框、固定间距、图标尺寸 |
em | 当前元素或父元素的 font-size | 组件内相对缩放(注意嵌套会连乘) |
rem | 根元素(html)的 font-size | 全局字号、间距体系(无障碍友好) |
% | 父元素的对应维度 | 宽度、高度(高度需父级有确定高度) |
vw/vh | 视口宽高 | 全屏区块、大屏适配 |
dvh/svh/lvh | 动态/小/大视口高度 | 移动端地址栏收起展开时的稳定高度 |
em 的连乘陷阱:
.parent { font-size: 16px; }
.parent .child { font-size: 1.5em; } /* 24px */
.parent .child .grand { font-size: 1.5em; } /* 36px,不是 24px */
移动端 100vh 问题:
/* 错误:iOS Safari 地址栏展开时 100vh 过大,底部按钮被截 */
.hero { height: 100vh; }
/* 正确:dvh 跟随动态视口 */
.hero { height: 100dvh; }
自检:为什么「字号用 rem、间距用 px」是一个常见且合理的组合?——
rem跟随根字号,用户的浏览器字号设置与页面缩放能生效,是可访问性的基本要求;间距与边框用px则让视觉节奏稳定,不会因为用户放大字号而把留白一起放大,且与设计稿标注一一对应。「字号相对、间距绝对」是兼顾可访问性与视觉稳定的折中。
1.7 颜色、背景、渐变
.card {
/* 颜色写法演进:rgb → hsl → oklch(感知均匀,适合生成配色) */
background: linear-gradient(135deg, #5ec8f5, #a78bfa);
color: oklch(0.65 0.18 250); /* 亮度更接近人眼感知 */
border: 1px solid rgb(255 255 255 / 0.12); /* 现代语法:空格分隔 + 斜杠透明度 */
}
/* 多重背景,先写的在上层 */
.hero {
background-image: url(overlay.png), linear-gradient(#000, #000);
background-size: cover, auto;
}
background-clip: text 做渐变文字时,记得加 color: transparent 作为降级兜底。
1.8 过渡与变换:为什么动画只该改这两个属性
.btn {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.btn:hover {
transform: translateY(-2px) scale(1.02);
opacity: 0.9;
}
transform和opacity可以只触发合成,不触发布局与重绘(在独立合成层上)。width、height、top、left、margin会触发重排,整页重新计算布局。background-color、box-shadow触发重绘,不重排。
transition 不能作用于 display: none → block。现代方案:
/* 方案一:用 @starting-style(新特性) */
.dialog {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
}
.dialog.open {
opacity: 1;
transform: none;
}
@starting-style {
.dialog.open { opacity: 0; transform: translateY(8px); }
}
/* 方案二:display 过渡(新版 Chrome 支持 allow-discrete) */
.el {
transition: opacity 0.2s, display 0.2s allow-discrete;
}
自检:为什么「用
left做位移」比「用transform做位移」更容易掉帧?——left是布局属性,每帧改动都会触发重排(layout)→ 重绘 → 合成,主线程容易被占满;transform不参与布局与绘制,改的是合成阶段的变换矩阵,能在合成线程上完成,甚至不必等主线程。
1.9 媒体查询:移动优先
/* 移动优先:先写小屏,再用 min-width 逐步增强 */
.card { padding: 12px; }
@media (min-width: 768px) {
.card { padding: 20px; }
}
@media (min-width: 1200px) {
.card { padding: 28px; }
}
断点不要照抄框架,要按内容需要定:当布局开始拥挤时就是断点。
两个常被忽略的媒体特性:
@media (prefers-color-scheme: dark) { /* 跟随系统暗色 */ }
@media (prefers-reduced-motion: reduce) { /* 用户要求减少动效 */ }
prefers-reduced-motion 不是「可选项」——前庭功能障碍用户会因动画头晕恶心。正确做法是提供不依赖运动的效果(淡入替代位移)。
1.10 box-sizing 的影响面:不只是宽度
box-sizing 改的是「width 指向哪个盒子」。它影响的不止 width,还有 min/max-width、flex-basis、inline-size,以及百分比参照。
| 属性 | content-box | border-box |
|---|---|---|
width | 内容区 | 内容 + padding + border |
min-width | 内容区下限 | 整盒下限 |
flex-basis | 按内容区计算 | 按整盒计算 |
padding 百分比 | 按包含块宽度,自身宽度无关 | 同左(易被误认为按自身) |
width:100% + padding | 溢出父容器 | 不溢出 |
/* 全局统一,避免逐个组件心算 padding */
*, *::before, *::after { box-sizing: border-box; }
两个反直觉的点:
padding的百分比始终按包含块宽度算,即使上下方向。所以padding-top: 50%会等于「父容器宽度的一半」,这是「正方形占位」的老技巧,也是「图片容器高度乱跳」的常见原因。- 负 margin 合法且有用:抵消栅格间距、让子元素突破父容器内边距。但它会让元素在视觉上重叠而不改变布局占位,排查「为什么点了没反应」时要注意上层元素是否覆盖。
1.11 特异性与层叠层:为什么样式「不生效」
特异性不是玄学,是四元组 (a, b, c, d) 的字典序比较:
| 位 | 含义 | 示例 |
|---|---|---|
| a | 内联样式 | style="..." |
| b | ID 选择器数量 | #app |
| c | 类/属性/伪类数量 | .btn、[type=text]、:hover |
| d | 元素/伪元素数量 | div、::before |
(a, b, c, d) 按位比较,前一位大就赢,不做十进制加和。三个易错点:
/* 1. :is() 取内部最高特异性 */
:is(#app, .panel) .title { } /* 特异性等于 #app .title(0,1,1,0) */
/* 2. :where() 特异性恒为 0,适合写「可被覆盖的默认样式」 */
:where(.panel) .title { } /* (0,0,1,0) */
/* 3. :not() 取内部参数的最高特异性 */
.btn:not(#submit) { } /* (0,1,1,0) —— 比 .btn 高,容易意外胜出 */
!important 与 @layer 的真实顺序(从强到弱):
@layer中带!important的声明(层越靠前越强,与普通声明相反);- 内联样式带
!important; - 普通
!important; - 内联样式;
@layer内普通声明(层越靠后越强);- 无层声明(优先于所有层内普通声明)。
结论:想「可控地覆盖第三方样式」,用
@layer把第三方放低层,比堆!important可维护得多。
1.12 继承与五个全局关键字
| 关键字 | 含义 | 典型用途 |
|---|---|---|
inherit | 取父元素计算值 | 让 border 继承颜色、color 显式继承 |
initial | 取属性在规范里的初始值 | 把 all: initial 前的单项重置 |
unset | 可继承属性 = inherit,否则 = initial | 一次性清掉组件内的继承污染 |
revert | 回退到用户代理/用户样式层 | 想恢复浏览器默认外观 |
revert-layer | 回退到上一个层 | 分层主题的兜底 |
不继承的属性常被误以为继承:margin、padding、border、background、width/height、display、position。会继承的:color、font-*、line-height、letter-spacing、text-align、visibility、cursor、list-style。
/* 组件内部彻底隔离继承影响 */
.widget { all: revert-layer; }
1.13 定位五值的包含块归属
position | 包含块是谁 | 是否脱离文档流 | 参照点 |
|---|---|---|---|
static | 不适用(top 等无效) | 否 | — |
relative | 自身原位置 | 否(保留占位) | 自身原位置 |
absolute | 最近的非 static 祖先的内容盒 | 是 | 该祖先的 padding 盒内沿 |
fixed | 视口(除非祖先进化,见 3.1) | 是 | 视口 |
sticky | 最近的滚动祖先 | 否 | 滚动祖先的边界 |
sticky 失效的五个原因(按出现频率排序):
- 任一祖先设了
overflow: hidden/auto/scroll(即使没有滚动条); - 没有设置
top/bottom之一(缺阈值等于没粘); - 父容器高度等于元素自身高度(没有可粘的行程);
- 元素在 flex 布局中被拉伸成
align-items: stretch,高度撑满导致无处可粘; - 祖先有
transform/filter/contain,改变了滚动参照。
/* 正确的最小 sticky 写法 */
.toolbar {
position: sticky;
top: 0; /* 阈值必须有 */
z-index: 10; /* 与浮层共存时必加 */
background: #fff; /* 否则内容会从底下透出 */
}
1.14 Flex 的三条隐藏规则
规则一:flex: 1 等于 1 1 0%,即 flex-grow:1; flex-shrink:1; flex-basis:0%。flex-basis:0 与 0% 在部分场景表现不同(取决于是否设置了 min-width)。
规则二:min-width: auto 导致「压不下去」。flex 项的最小尺寸默认取内容的 min-content,所以长英文单词或 pre 会撑破布局:
/* 让文字可以省略,必须同时处理三项 */
.ellipsis {
min-width: 0; /* 解除 min-content 下限 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
规则三:align-items: stretch 与显式 height 冲突。设置了 height 后不再拉伸,此时想让子项撑满要用 align-self: stretch 或去掉 height。
收缩的加权计算示例:容器 300px,两个子项 flex-basis 各 200px、flex-shrink 分别为 1 和 3。溢出 100px,按 shrink × basis 加权分配:
子项 A 权重 = 1 × 200 = 200
子项 B 权重 = 3 × 200 = 600
A 收缩 = 100 × 200/800 = 25 → 175px
B 收缩 = 100 × 600/800 = 75 → 125px
1.15 视口单位与移动端地址栏
| 单位 | 含义 | 移动端表现 |
|---|---|---|
100vh | 大视口高度(地址栏收起时) | 地址栏展开时内容被截断 |
100dvh | 动态视口,随地址栏变化 | 布局会随滚动抖动 |
100svh | 小视口(地址栏展开时) | 首屏安全,不抖动 |
100lvh | 大视口 | 与 vh 接近 |
选择原则:首屏铺满用 svh,全屏弹层用 dvh,需要视觉稳定用 svh + min-height。用 vh 做全屏在 iOS Safari 上几乎必然出问题。
.hero {
min-height: 100svh; /* 首屏稳定,不被地址栏截断 */
}
.sheet {
height: 100dvh; /* 弹层跟随可视区,避免底部按钮被遮 */
padding-bottom: env(safe-area-inset-bottom);
}
env(safe-area-inset-*) 需要 viewport-fit=cover 才生效:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
1.16 颜色的现代写法:currentColor、color-mix() 与 oklch
| 需求 | 写法 | 说明 |
|---|---|---|
| 跟随文字色 | border-color: currentColor | 图标描边最常用 |
| 混色 | color-mix(in oklab, #1677ff 20%, white) | 生成悬停底色,无需预置变量 |
| 感知均匀的取色 | oklch(70% 0.12 250) | 调亮度不影响色相饱和度 |
| 文字渐变 | background-clip: text + color: transparent | 需配 -webkit- 前缀兼容 |
| 半透明遮罩 | rgb(0 0 0 / 45%) | 比 rgba() 更现代 |
/* 文字渐变的安全写法:不支持时退回纯色,保证可读 */
.gradient-text {
color: #1677ff;
background-image: linear-gradient(90deg, #1677ff, #722ed1);
-webkit-background-clip: text;
background-clip: text;
}
@supports (background-clip: text) or (-webkit-background-clip: text) {
.gradient-text {
color: transparent;
}
}
1.17 过渡、变换与「为什么动画卡」
只有 transform 与 opacity 能在合成线程完成,不触发布局与重绘,所以动画要围绕这两个属性设计。
| 改动属性 | 触发的阶段 | 帧率风险 |
|---|---|---|
width/height/top/left | 布局 → 重绘 → 合成 | 高 |
background-color/box-shadow | 重绘 → 合成 | 中 |
transform/opacity | 合成 | 低 |
filter/backdrop-filter | 合成(但 GPU 开销大) | 中高 |
will-change 的正确用法是「即将动画前加上、动画结束后移除」,长期挂着会让合成层常驻,显存与内存双涨:
/**
* 在动画前后动态提升合成层,避免长期占用显存。
* @param {HTMLElement} el
*/
async function animateWithHint(el) {
el.style.willChange = 'transform';
el.style.transform = 'translateX(0)';
await el.animate(
[{ transform: 'translateX(-24px)' }, { transform: 'translateX(0)' }],
{ duration: 240, easing: 'cubic-bezier(.2,.8,.2,1)' },
).finished;
el.style.willChange = 'auto';
}
transition 对 display 无效:display 是离散属性,none ↔ block 之间没有中间值。解决办法有三种:
/* 方案 1:用 opacity + visibility 代替 display */
.fade { transition: opacity .2s, visibility .2s; }
/* 方案 2:用 @starting-style 让「首次出现」也有过渡(现代浏览器) */
.dialog[open] { opacity: 1; transition: opacity .2s; }
@starting-style { .dialog[open] { opacity: 0; } }
/* 方案 3:用 Web Animations API 在 JS 里编排入场/出场 */
1.18 断点怎么取:为什么用 em 而不是 px
固定断点(768/1024/1440)只是起点,真正该做的是「缩到内容开始变丑时断开」。
/* 移动优先:默认单列,够宽再变两列 */
.grid { display: grid; grid-template-columns: 1fr; }
@media (min-width: 40em) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
em 作为断点单位比 px 好:用户放大字号时断点会跟着变化,布局与字号同步。
除宽度外的三个重要媒体特性:
| 特性 | 用途 | 写法 |
|---|---|---|
prefers-reduced-motion | 关闭位移动画,防眩晕 | @media (prefers-reduced-motion: reduce) |
prefers-color-scheme | 暗色模式 | 配合 color-scheme: light dark |
prefers-contrast | 高对比需求 | @media (prefers-contrast: more) |
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
1.19 L1 小结与自检
| 知识点 | 最该记住的一句 |
|---|---|
| 盒模型 | 全局 border-box,width 才是最终宽度 |
| 权重 | 逐位比较,!important 是失控信号 |
| 继承 | 字体/文本继承,盒模型不继承 |
| 定位 | 包含块由最近的已定位祖先(含 transform)决定 |
| Flex | 主轴随方向旋转;min-width: 0 解决撑破 |
| 单位 | 字号用 rem,固定尺寸用 px,全屏用 dvh |
| 动画 | 只动 transform / opacity,避免重排 |
| 媒体查询 | 移动优先,断点按内容定 |
自检题
- 元素设了
width: 100%和padding: 20px,为什么撑出横向滚动条?——box-sizing是content-box。 position: absolute找不到已定位祖先时相对谁?——初始包含块(视口大小的块)。z-index: 9999为什么还是被盖住?——不在同一层叠上下文(见 L2)。
2. L2 进阶:现代布局与样式架构
2.1 Grid:二维布局的正确打开方式
.dashboard {
display: grid;
grid-template-columns: 240px 1fr; /* 侧边栏 + 主区域 */
grid-template-rows: 64px 1fr;
grid-template-areas:
"sidebar header"
"sidebar main";
gap: 16px;
min-height: 100dvh;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
fr 的分配规则:先减去固定尺寸与 gap,剩余空间按 fr 比例分配。
自适应卡片列表(不需要媒体查询):
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
auto-fit:空轨道会被压缩(卡片撑满整行);auto-fill:保留空轨道(卡片保持宽度、右侧留白)。minmax(240px, 1fr):每个卡片最小 240px,最大平分剩余空间。
自检:
auto-fit和auto-fill在只有两张卡片时表现有什么不同?——只有两张卡片时,auto-fit会把空轨道折叠,卡片拉伸填满整行;auto-fill保留空轨道,卡片维持minmax()给出的宽度,右侧留白。前者适合「卡片少时要占满」,后者适合「保持固定列宽」。
2.2 Flex 深度:那些必须记住的第二层规则
/* 1. flex-basis 与 width 的优先级 */
.item { width: 200px; flex-basis: 100px; } /* 主轴尺寸用 flex-basis,width 在 row 方向被覆盖 */
/* 2. 单个子项独立对齐 */
.row { display: flex; align-items: flex-start; }
.row .special { align-self: flex-end; }
/* 3. 多行时的行间距与整体分布 */
.grid-like {
display: flex;
flex-wrap: wrap;
align-content: space-between; /* 多行之间的分布 */
gap: 12px;
}
/* 4. 顺序控制(谨慎使用:会改变读屏与 Tab 顺序) */
.item-last { order: 1; }
order、flex-direction: row-reverse 会改变视觉顺序但不改变DOM 顺序,导致键盘 Tab 顺序与视觉不匹配——这是可访问性的严重问题,只能用 flex-direction 做非交互元素的排列。
2.3 格式化上下文(BFC/IFC):一个盒子如何影响另一个盒子
BFC(块级格式化上下文)是一个「独立的布局环境」,内部布局不影响外部。触发方式(任一):
.bfc {
overflow: auto; /* 最常用 */
display: flow-root; /* 最干净,专门为此设计 */
float: left;
position: absolute;
display: inline-block / flex / grid;
}
BFC 的三大用途:
- 包裹浮动:父元素塌陷(高度为 0)时,
flow-root让它包含浮动子元素。 - 阻止 margin 折叠:父子 margin 折叠时,父元素加 BFC 可隔开。
- 避免被浮动元素覆盖:BFC 盒子不会与浮动元素重叠。
margin 折叠(collapsing margins) 四种情况:
/* 1. 相邻兄弟:取较大值,不是相加 */
.a { margin-bottom: 20px; }
.b { margin-top: 30px; } /* 间距 = 30px */
/* 2. 父子:父无 padding/border 时,子 margin 会「穿透」到父外 */
.parent { }
.child { margin-top: 20px; } /* 父元素整体下移,而不是内部留白 */
/* 3. 空元素自身上下 margin 折叠 */
/* 4. 负 margin 参与计算(取和) */
解决父子折叠:给父元素加 padding-top 或 border-top,或 display: flow-root。
IFC(行内格式化上下文)决定文字怎么排:行盒(line box)、基线(baseline)、line-height 的实际作用、vertical-align 的对齐对象。理解 IFC 才能解释「图片底部为什么有 3px 空隙」(图片是 inline 元素,按基线对齐,基线下方留出 descender 空间)。
/* 修复图片底部空隙的三种方式 */
img { display: block; } /* 最常用 */
img { vertical-align: middle; } /* 改变对齐基线 */
.parent { line-height: 0; } /* 消除行高影响(副作用多) */
自检:两个相邻 div 的 margin 分别是 20px 和 30px,间距是多少?为什么?——间距是 30px,不是 50px:普通流中相邻块级元素的垂直外边距会合并(margin collapsing),取两者较大值。合并只发生在没有被 border / padding / 行内内容隔开的相邻垂直外边距之间。
2.4 层叠上下文:z-index 失效的真正原因
z-index 只在同一个层叠上下文内比较。创建新层叠上下文的常见方式:
.stacking-context {
position: relative; z-index: 0; /* 显式 z-index 且非 auto */
opacity: 0.99; /* 小于 1 */
transform: translateZ(0); /* 非 none */
filter: blur(0); /* 非 none */
will-change: transform; /* 任意值 */
isolation: isolate; /* 专门用于创建隔离上下文 */
contain: paint;
display: flex/grid 且子元素 z-index 非 auto;
}
问题场景:
<div class="modal" style="z-index: 9999">弹窗</div>
<div class="card" style="transform: translateY(0)">
<div class="badge" style="z-index: 10000">角标</div>
</div>
角标永远盖住弹窗——因为 .card 创建了新层叠上下文,.badge 的 z-index 只在 .card 内部比较,而 .card 自身的层级低于 .modal 的父上下文。
排查方法:DevTools 的 Layers 面板 + 逐层检查 transform/opacity/filter。
isolation: isolate 是「我需要一个隔离上下文,但不需要改层级」的最干净方案。
自检:一个弹窗
z-index: 9999被一个z-index: 10的元素盖住,可能是什么原因?——弹窗被关在某个层叠上下文里了:z-index只在同一上下文内比较。祖先若有transform、filter、opacity < 1、will-change、isolation: isolate等,会创建新上下文,9999 只在里面最大;外层z-index: 10的元素处在更高层的上下文,自然盖住它。排查时用 DevTools 逐层向上找创建上下文的祖先,或把弹窗 Portal 到body。
2.5 响应式进阶:容器查询比媒体查询更贴近组件
媒体查询看视口,容器查询看父容器:
.card-wrapper {
container-type: inline-size; /* 开启容器查询:按宽度响应 */
container-name: card;
}
.card { padding: 12px; }
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
padding: 20px;
}
}
为什么容器查询重要:同一个卡片组件放在侧边栏(窄)和主区域(宽)时,应该长得不一样。媒体查询做不到(视口是同一个),容器查询可以。
其他响应式能力:
/* 流体字号:视口变化时平滑缩放,避免断点跳变 */
.title { font-size: clamp(1.25rem, 1rem + 1.5vw, 2rem); }
/* 宽高比:图片/视频/占位符 */
.thumb { aspect-ratio: 16 / 9; object-fit: cover; }
2.6 样式架构:四种方案的真实取舍
| 方案 | 作用域 | 运行时成本 | 动态主题 | 适合 |
|---|---|---|---|---|
| BEM 命名 | 靠约定 | 无 | 易 | 传统项目、需要可读性 |
| CSS Modules | 构建期哈希 | 无 | 易 | 组件化项目(Vue/React 都支持) |
| 原子化(Tailwind) | 类名复用 | 无(构建期生成) | 易 | 快速迭代、强约束 |
| CSS-in-JS | 运行时注入 | 有(解析 + 插入) | 中 | 需要 JS 动态计算样式 |
@layer 解决第三方样式覆盖问题(现代 CSS 的「优先级分组」):
/* 声明层级顺序:后面的层优先级更高 */
@layer reset, base, components, utilities;
@layer base {
button { background: #eee; }
}
/* 第三方样式即使权重更高,也会被后面的层压过 */
@layer third-party {
#app .btn { background: red !important; } /* 仍在 third-party 层内 */
}
@layer 改变了优先级规则:层顺序 > 权重。这让「覆盖第三方样式」不再需要拼权重或 !important。
2.7 CSS 变量:运行时的样式参数
:root {
--color-primary: #1677ff;
--space-4: 16px;
--radius-md: 10px;
}
.button {
background: var(--color-primary);
padding: var(--space-4, 16px); /* 第二个参数是回退值 */
border-radius: var(--radius-md);
}
/* 作用域覆盖:局部换主题 */
.dark-panel {
--color-primary: #69b1ff;
}
关键限制:CSS 变量默认是「字符串替换」,浏览器不知道它是颜色还是长度,所以不能过渡/动画。要用 @property 注册类型才行:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.gradient-border {
background: conic-gradient(from var(--angle), #5ec8f5, #a78bfa);
transition: --angle 1s; /* 注册类型后才能过渡 */
}
变量还带来主题切换的运行时能力:切换 data-theme="dark" 时只改一组变量,无需重新加载样式。
2.8 字体与文本:最影响观感的细节
/* 字体加载策略 */
@font-face {
font-family: 'Brand';
src: url('/fonts/brand.woff2') format('woff2');
font-display: swap; /* 先用回退字体渲染,加载完替换(会有字体闪动) */
unicode-range: U+4E00-9FFF; /* 只对中文范围生效,按语言拆分文件 */
}
/* optional:不阻塞、不闪动,但可能整页都用回退字体 */
@font-face {
font-family: 'Body';
src: url('/fonts/body.woff2') format('woff2');
font-display: optional;
}
font-display 选择:
| 值 | 行为 | 适用 |
|---|---|---|
swap | 立即用回退字体,加载完替换 | 品牌字体、可接受闪动 |
optional | 极短等待,超时本次就用回退 | 追求零 CLS |
block | 短暂隐藏文字(最多 3s) | 几乎不用(体验差) |
fallback | 介于 block 与 swap | 折中 |
减少字体闪动:用 size-adjust、ascent-override 让回退字体的度量接近品牌字体,减少布局跳动:
@font-face {
font-family: 'Brand-fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
}
多行省略:
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
2.9 图片与视觉效果的成本
.glass {
backdrop-filter: blur(12px); /* 好看,但与屏幕内容耦合,代价高 */
background: rgb(255 255 255 / 0.1);
}
.mask {
-webkit-mask-image: linear-gradient(#000, transparent); /* 遮罩也走合成 */
}
.shape {
clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%);
}
backdrop-filter在低端机上非常贵(每帧读取背后像素做模糊),不要用在大面积滚动区域。box-shadow的大模糊半径会显著增加绘制面积。filter会创建新层叠上下文(影响z-index)与新的合成层。
自检:一个页面滚动时卡顿,怀疑是
backdrop-filter,怎么验证?——分三步验证:① Performance 面板录一段滚动,看帧里是否大量 Paint / Composite、有没有长帧;② Rendering 面板开 Paint flashing,看是否整块区域反复重绘;③ 临时把backdrop-filter改成none做 A/B 对比帧率与掉帧数。必要时再用 CPU 4× 降速或低端机复现,确认因果。
2.10 Grid 的深水区
auto-fit 与 auto-fill 的差别只有一句话:空轨道是否塌缩。
/* auto-fill:保留空轨道,卡片靠左排,右侧留白 */
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
/* auto-fit:空轨道塌缩,卡片拉伸填满 */
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
三个高频坑:
| 现象 | 原因 | 修法 |
|---|---|---|
| 隐式网格撑破容器 | 子项数超出定义列数,自动新建列按 auto 宽度 | grid-auto-columns: 0 或显式 grid-column 定位 |
| 子项内容溢出格子 | 子项 min-width: auto | 子项加 min-width: 0 |
1fr 不收缩 | 1fr 的最小值为 auto | 用 minmax(0, 1fr) |
grid-template-areas 做响应式重排,可读性远高于改 order:
.layout {
display: grid;
grid-template-areas:
"header header"
"nav main"
"footer footer";
grid-template-columns: 200px minmax(0, 1fr);
}
@media (max-width: 48em) {
.layout {
grid-template-areas:
"header"
"main"
"nav"
"footer";
grid-template-columns: minmax(0, 1fr);
}
}
subgrid 让子网格继承父网格的轨道,适合「卡片内部与卡片之间对齐」——但注意它要求父级显式定义轨道,且部分旧版本浏览器不支持,需要 @supports 兜底。
2.11 外边距合并的精确条件
只有三个条件同时满足才会合并:
- 都是块级盒子(行内、flex/grid 项、
float、absolute都不合并); - 处于同一 BFC;
- 垂直方向相邻(兄弟相邻,或父子的贴边)。
| 场景 | 结果 |
|---|---|
兄弟 margin-bottom: 20px + margin-top: 30px | 取 30px(不是 50px) |
父无 border/padding,子 margin-top: 30px | 合并后父元素整体下移 30px(「父元素被推下去」) |
父子都有 margin-top | 取较大值 |
| 负值参与 | 正负相加:20 + (-30) = -10 |
阻止合并的六种方法:加 padding/border、父元素建立 BFC(overflow:hidden、display:flow-root)、display:flex/grid、float、position:absolute、display:inline-block。
推荐:
display: flow-root是「只为建立 BFC」设计的关键字,语义最清晰,不要再用overflow:hidden这种副作用写法。
2.12 层叠上下文:z-index 失效的唯一解释
z-index 只在同一个层叠上下文内比较。跨上下文时,比较的是两个上下文的根,而不是元素自己。
会创建层叠上下文的常见条件:
| 条件 | 说明 |
|---|---|
| 根元素 | html 本身 |
position 非 static + z-index 非 auto | 最经典 |
opacity < 1 | 常被忽略 |
transform/filter/perspective/backdrop-filter 非 none | 极高频 |
will-change 指定上述属性 | 隐性创建 |
contain: paint/layout | 隐性创建 |
isolation: isolate | 主动创建,用于隔离 |
/* 症状:弹层 z-index: 9999 仍被 z-index: 1 的侧栏盖住 */
.sidebar { position: relative; z-index: 1; transform: translateZ(0); } /* 创建了上下文 */
.modal { position: fixed; z-index: 9999; } /* 若 .modal 在 .sidebar 内部,永远盖不上去 */
/* 修法:把弹层挂到 body,或用 isolation 明确分层 */
排查步骤:在 DevTools 的 Layers 面板 / 「层叠上下文」标记里确认祖先链上谁创建了上下文,再决定是「提升弹层到 body」还是「重构层级」。
2.13 容器查询与断点的语义差异
媒体查询问的是「视口多宽」,容器查询问的是「我被放进多宽的容器」。组件库应该用后者,否则同一个卡片放在侧栏与主区会得到同样的布局。
.card-wrap {
container-type: inline-size; /* 开启尺寸查询,inline-size 最常用 */
container-name: card;
}
@container card (min-width: 360px) {
.card { display: grid; grid-template-columns: 120px 1fr; }
}
container-type | 查询维度 | 代价 |
|---|---|---|
size | 宽高都可查 | 需要确定尺寸,可能导致塌陷 |
inline-size | 只查宽度 | 影响较小 |
normal | 不可查(但仍可作为命名容器) | 无 |
顺序陷阱:容器查询与媒体查询混用时,后写的规则不一定胜出——它们都只是普通声明,仍按源顺序与特异性竞争,不要指望「容器查询优先级更高」。
2.14 四套样式方案的真实失效场景
| 方案 | 优势 | 失效场景 |
|---|---|---|
| CSS Modules | 局部作用域、零运行时 | 跨组件复用需 :global,动态主题靠变量 |
| CSS-in-JS(运行时) | 与 props 强耦合 | SSR 抽取、首屏注入、包体积、并发渲染下的样式插入顺序 |
| 原子化(Tailwind) | 体积随复用下降、约束强 | 动态拼接类名被 purge、复杂选择器表达力不足 |
原生 CSS + 变量 + @layer | 零依赖、可预测 | 需要团队约定命名,缺少编译期检查 |
判断依据不是「哪个流行」,而是三个问题:是否需要运行时主题切换?是否有 SSR?样式体积是否已成为瓶颈?
2.15 @property 与可动画的 CSS 变量
未注册类型的自定义属性是「字符串」,无法插值,所以不能过渡;注册后才有类型与初始值:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.loader {
background: conic-gradient(from var(--angle), #1677ff, transparent);
animation: spin 1s linear infinite;
}
@keyframes spin {
to { --angle: 360deg; }
}
配套规则:
| 场景 | 写法 | 坑 |
|---|---|---|
| 需要数值运算 | 变量必须带单位 | calc(var(--gap) * 2) 中无单位变量会失效 |
| 提供兜底 | var(--gap, 8px) | 兜底值本身不能含未定义变量 |
| 防止继承泄漏 | inherits: false | 默认 inherits: true,会污染子树 |
| 主题切换 | 在 :root 定义语义变量 | 不要在组件内覆盖语义变量 |
2.16 字体度量与 CLS:最容易被忽略的抖动源
字体加载会导致两次布局:swap 前用回退字体、加载后再换。中文字体尤其明显,因为体积大、回退字体度量差异大。
| 手段 | 作用 | 写法 |
|---|---|---|
font-display: swap | 先显示回退字体 | 内容可见但有跳动 |
font-display: optional | 慢网不换字体 | 无抖动,可能用回退字体 |
size-adjust | 让回退字体度量接近 | 配合 @font-face 的 src: local(...) |
| 预加载关键字体 | 缩短等待 | preload as="font" crossorigin |
/* 用本地字体做尺寸对齐,减少换字体的抖动 */
@font-face {
font-family: 'BrandFallback';
src: local('PingFang SC'), local('Microsoft YaHei');
size-adjust: 105%;
}
中文字体优化要点:子集化 + unicode-range 分片,把 8MB 的字体拆成按常用字/生僻字的多片,首屏只下载用到的片。
2.17 图片与视觉效果的渲染成本
| 属性 | 作用 | 代价/坑 |
|---|---|---|
aspect-ratio | 预留比例,防 CLS | 与 width/height 冲突时以显式尺寸为准 |
object-fit: cover | 裁切填充 | 需搭配 object-position 控制裁切区域 |
filter: blur() | 毛玻璃 | 大面积极耗 GPU,移动端可能掉帧 |
backdrop-filter | 背景模糊 | 更贵,滚动时可能整层重绘 |
box-shadow | 阴影 | 大扩散半径的重绘成本高,可用伪元素 + 预渲染图替代 |
原则:装饰性视觉效果要能被降级。弱机型或 prefers-reduced-transparency 下退回纯色背景,体验损失远小于卡顿。
2.18 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| Grid | 二维用 Grid,自适应用 auto-fit + minmax |
| Flex | 主轴随方向旋转,min-width: 0 解决撑破 |
| BFC | flow-root 最干净,解决塌陷与 margin 折叠 |
| 层叠上下文 | 祖先的 transform/opacity/filter 会截断 z-index |
| 容器查询 | 组件级响应式优先于媒体查询 |
@layer | 层顺序大于权重,覆盖第三方的利器 |
| CSS 变量 | 默认不可动画,需 @property 注册类型 |
| 视觉效果 | 模糊/遮罩/阴影都有实打实的绘制成本 |
自检题
auto-fit与auto-fill的区别?——空轨道是否被压缩。- 为什么
@layer能解决!important战争?——层顺序优先于权重。 - 图片底部为什么有缝隙?——inline 元素按基线对齐,留出 descender 空间。
3. L3 高级:渲染成本与样式工程
3.1 包含块链:定位问题的终极答案
一个元素的「包含块」决定它的百分比宽度、绝对定位参照、fixed 的行为。规则:
static/relative:包含块是最近的块容器祖先的内容区。absolute:包含块是最近的position非static祖先,或transform/filter/perspective/will-change/contain: paint的祖先,或初始包含块。fixed:包含块是视口——除非祖先有transform/filter/perspective,此时变成该祖先。sticky:受最近滚动容器限制。
/* 想让弹窗覆盖全屏,必须挂在 body 下或用 portal */
/* 如果父级有 transform,fixed 会退化成相对父级 */
.modal-root { position: fixed; inset: 0; } /* inset = top/right/bottom/left 简写 */
3.2 Inline 布局与基线
.text-box {
line-height: 1.5; /* 行盒高度 = 1.5 × font-size */
vertical-align: middle; /* 相对父级基线对齐 */
}
- 行盒高度由
line-height决定,但文字实际高度由字体度量(ascent/descent)决定,所以「文字没有垂直居中」是常见问题。 vertical-align作用于 inline/inline-block/table-cell,对 block 元素无效。- 中英文混排时,
line-height取较小值会被拉丁字母的 ascender 撑开,导致「中文行距看起来不对」。
3.3 现代选择器与能力
/* :has() —— 父选择器,能根据子元素状态改变父级 */
.card:has(img:hover) { box-shadow: 0 8px 24px rgb(0 0 0 / 0.2); }
.form:has(input:invalid) .submit { opacity: 0.5; }
/* :is() / :where() —— 简化选择器,:where 权重为 0 */
:where(h1, h2, h3) { margin-block: 0; } /* 不增加权重,方便覆盖 */
:is(.dark, .high-contrast) .card { border-color: currentColor; }
/* subgrid —— 子网格共享父网格轨道,解决嵌套对齐 */
.layout { display: grid; grid-template-columns: repeat(3, 1fr); }
.layout .panel { display: grid; grid-template-columns: subgrid; grid-column: span 3; }
/* @scope —— 限定样式作用域,减少命名冲突 */
@scope (.card) to (.card__footer) {
img { border-radius: 8px; }
}
:has() 的代价:它会让浏览器在「任何可能影响匹配的 DOM 变化」时重新计算,大规模列表里滥用会拖慢样式计算。
3.4 Houdini:把 CSS 引擎的一部分交给 JS
/**
* 注册一个绘制工作单元:画波浪背景。
*/
class WavePainter {
/**
* @param {CanvasRenderingContext2D} ctx 绘制上下文
* @param {Array<{width: number, height: number}>} geometry 元素几何信息
*/
paint(ctx, geometry) {
ctx.fillStyle = '#5ec8f5';
ctx.beginPath();
ctx.moveTo(0, geometry.height * 0.7);
for (let x = 0; x <= geometry.width; x += 10) {
ctx.lineTo(x, geometry.height * 0.7 + Math.sin(x / 30) * 8);
}
ctx.lineTo(geometry.width, geometry.height);
ctx.lineTo(0, geometry.height);
ctx.fill();
}
}
// @ts-ignore —— paintWorklet 不在标准 TS DOM 类型中
CSS.paintWorklet.addModule('/wave-painter.js');
.wave {
background: paint(wave);
}
Houdini 的四个 Worklet:paint(自定义绘制)、layout(自定义布局,支持度差)、animation(滚动驱动动画,替代滚动监听)、typed OM(用对象操作样式值,减少字符串解析)。
现实建议:除了 paintWorklet 与 Typed OM,其他在生产中要谨慎——Safari 支持滞后。
3.5 滚动与视口:移动端体验的关键
/* 阻止滚动穿透:弹窗打开时锁住背景 */
body.modal-open { overflow: hidden; }
/* 更精准:只阻止链式滚动 */
.sheet { overscroll-behavior: contain; }
/* 滚动吸附轮播 */
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel > * { scroll-snap-align: start; }
/* 跳过屏外渲染:长页面首屏优化利器 */
.section {
content-visibility: auto;
contain-intrinsic-size: 0 600px; /* 占位高度,避免滚动条跳变 */
}
/* 限制重排范围 */
.widget { contain: layout paint; }
content-visibility: auto 的副作用:屏外内容不渲染,导致「打印」「Ctrl+F 查找」「锚点跳转」行为变化,需要评估。
3.6 渲染成本模型:一次改动的代价
| 触发的属性 | 管线阶段 | 成本 |
|---|---|---|
transform、opacity(在合成层) | Composite | 低 |
color、background-color、box-shadow、border-color | Paint + Composite | 中 |
width/height/margin/padding/top/left/font-size | Layout → Paint → Composite | 高 |
读取 offsetHeight 后立即写样式 | 强制同步布局(layout thrashing) | 极高 |
强制同步布局的典型写法:
// 错误:每次循环都读一次布局,浏览器必须立刻重算
for (const el of items) {
el.style.width = `${el.offsetWidth + 10}px`; // 读 → 写 → 读 → 写
}
// 正确:先批量读,再批量写
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => {
el.style.width = `${widths[i] + 10}px`;
});
3.7 样式性能:选择器匹配的真实成本
浏览器从右向左匹配选择器:.list .item .title 会先找所有 .title,再向上验证祖先。因此:
- 右侧选择器越具体越好(
.item-title优于.title); - 避免深层嵌套(
div div div span); - 通配符
*在大量节点上代价明显; :has()、:not()复杂组合会显著增加匹配成本。
现代浏览器对选择器做了大量优化,真正的瓶颈通常是 DOM 规模与样式重算范围,而不是选择器写法本身。
3.8 主题与令牌:三层结构
primitive(原始值) semantic(语义) component(组件)
--blue-500: #1677ff → --color-primary: var(--blue-500) → --button-bg: var(--color-primary)
--gray-900: #111 → --text-primary: var(--gray-900) → --button-text: var(--color-white)
为什么分三层:
- 改品牌色只动 primitive;
- 改语义映射(如暗色模式下
--text-primary指向浅色)只动 semantic; - 组件只依赖语义令牌,不关心具体色值。
:root {
--color-primary: var(--blue-500);
--color-bg: var(--gray-50);
}
[data-theme='dark'] {
--color-primary: var(--blue-300);
--color-bg: var(--gray-950);
}
/* 高对比模式 */
@media (forced-colors: active) {
.button { border: 1px solid ButtonText; }
}
暗色模式的两个常见问题:
- 纯黑背景 + 纯白文字对比过强,长文阅读疲劳,建议
#0b0e14类深灰。 - 切换闪烁:用
<script>在<head>里同步读取偏好并设置data-theme,避免首屏白闪。
3.9 打印与导出
@media print {
.no-print { display: none; }
.card { break-inside: avoid; } /* 避免卡片被分页切断 */
a[href]::after { content: " (" attr(href) ")"; } /* 打印时显示链接地址 */
}
PDF 导出的常见限制:自定义字体可能不嵌入、背景色默认不打印(需 print-color-adjust: exact)、position: fixed 在分页时表现异常。生产级导出更推荐服务端渲染(Puppeteer/Playwright)而不是依赖浏览器打印。
3.10 包含块判定链:fixed 不再相对视口的完整原因
position: fixed 的包含块本应是视口,但只要祖先链上存在以下任一属性,它就会退化为「相对该祖先」:
| 触发属性 | 说明 | 常见来源 |
|---|---|---|
transform 非 none | 最常见 | 入场动画、translateZ(0) 提层 |
filter 非 none | 同上 | 灰度、模糊效果 |
perspective 非 none | 3D 容器 | 卡片翻转动效 |
backdrop-filter 非 none | 毛玻璃 | 吸顶导航 |
contain: paint/layout | 渲染隔离 | 性能优化时加的 |
will-change 指定上述属性 | 提前提层 | 动画预热 |
排查步骤(三分半解决):
- 在 DevTools 选中「跑偏」的元素,看它的
offsetParent; - 沿祖先链逐级取消
transform/filter/will-change(用样式面板临时勾掉); - 定位到具体祖先后再决定:把弹层挂到
body(推荐),或去掉该祖先的副作用属性。
/**
* 打印元素到根的祖先链,标出可能改变包含块的属性,用于排查 fixed 定位跑偏。
* @param {HTMLElement} el
*/
function traceContainingBlock(el) {
/** 会改变包含块的属性名 */
const triggers = ['transform', 'filter', 'perspective', 'backdropFilter', 'contain', 'willChange'];
let node = el.parentElement;
while (node && node !== document.documentElement) {
const style = getComputedStyle(node);
const hits = triggers.filter((name) => {
const value = style[name];
return value && value !== 'none' && value !== 'normal';
});
if (hits.length) {
console.log(node, '→', hits.map((k) => `${k}: ${style[k]}`).join('; '));
}
node = node.parentElement;
}
}
3.11 行内布局与基线:那 4px 到底哪来的
行盒(line box)的高度由「最高的行内盒」决定,而图片、inline-block、输入框都按基线对齐,基线下方要留出字体的降部空间。
┌─────────────────────────┐ ← 行盒顶部
│ ↑ 字体升部 │
│ ───── 基线 ───── │ ← 图片底边对齐这里
│ ↓ 字体降部(约 0.2em) │ ← 这就是「图片下面那条缝」
└─────────────────────────┘
line-height 的计算:line-height: 1.5 时,内容区高度之外多出的部分叫「行距(leading)」,上下各分一半。所以实际行盒高度 = line-height,而文字垂直居中的位置由半行距决定。
/* 图片底部间隙的三种修法,按推荐度排序 */
img { display: block; } /* 1. 退出行内布局,最彻底 */
img { vertical-align: middle; } /* 2. 改参照基线,仍有小数偏差 */
.container { font-size: 0; } /* 3. 消灭降部空间,副作用大 */
vertical-align 的有效范围常被误解:
| 关键字 | 参照物 |
|---|---|
baseline(默认) | 父行盒基线 |
middle | 父基线 + x-height/2 |
text-top / text-bottom | 父元素内容区顶/底 |
top / bottom | 行盒顶/底 |
| 长度值 | 相对基线偏移 |
关键限制:vertical-align 只对行内级元素与表格单元格有效。元素一旦成为 flex/grid 项,该属性直接失效——这就是「加了 flex 之后图标错位」的原因。
3.12 现代选择器::has() 与 @scope
:has() 让「父元素根据子元素状态改样式」不再需要 JS:
/* 表单有错误时整块变红,无需给父元素加类 */
.field:has(input:user-invalid) {
border-color: #ff4d4f;
background: #fff2f0;
}
/* 卡片内有图片时取消内边距,实现「图满宽」 */
.card:has(> img) { padding-top: 0; }
性能边界::has() 会让选择器匹配变得「反向依赖」,不要在 :has() 的参数里写复杂后代选择器,也不要在超大列表的每一项上用带 :has() 的规则——浏览器需要为每个候选项维护额外的失效关系。
@scope 提供「带边界的样式作用域」,比 BEM 更接近组件语义:
@scope (.article) to (.comment) {
p { margin-block: 1em; } /* 只作用于 .article 内、.comment 外的 p */
}
3.13 滚动:吸附、边界与滚动条占位
scroll-snap 的吸附点计算有三个要素:容器 scroll-snap-type、子项 scroll-snap-align、以及是否有 scroll-padding。
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory; /* mandatory 强制停靠,proximity 更宽松 */
scroll-padding-inline: 16px; /* 让吸附点避开容器内边距 */
scrollbar-width: none; /* 隐藏滚动条但保留滚动 */
}
.carousel > .item {
flex: 0 0 80%;
scroll-snap-align: center;
}
| 属性 | 作用 | 坑 |
|---|---|---|
overscroll-behavior: contain | 阻止滚动链传到父级 | 弹层内滚动会带动页面滚动时必须加 |
scrollbar-gutter: stable | 预留滚动条宽度 | 避免内容因滚动条出现而横向跳动 |
scroll-behavior: smooth | 平滑滚动 | 需尊重 prefers-reduced-motion |
scroll-margin-top | 锚点跳转留白 | 与吸顶导航配合的必备项 |
3.14 渲染成本:content-visibility 的收益与陷阱
content-visibility: auto 让屏幕外的子树跳过布局与绘制,长页面收益明显;但它有两个必须知道的副作用:
| 副作用 | 表现 | 处理 |
|---|---|---|
未渲染内容无法被 find-in-page 定位 | 页内搜索「搜不到」 | 关键内容不要加 |
未渲染内容尺寸按 contain-intrinsic-size 估算 | 滚动条跳动、锚点偏移 | 显式给 contain-intrinsic-size |
.long-list > .section {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* 估算高度,减少滚动条抖动 */
}
触发阶段与属性对照(决定优化从哪下手):
| 阶段 | 触发者 | 成本量级 | 优化手段 |
|---|---|---|---|
| 样式重算 | 类名/属性变化 | 中 | 减少选择器复杂度、避免大面积失效 |
| 布局 | 尺寸/位置变化 | 高 | 只改 transform、批量读写分离 |
| 绘制 | 颜色/阴影/背景 | 中 | 减少大面积重绘、用 will-change 精准提层 |
| 合成 | transform/opacity | 低 | 控制合成层数量 |
3.15 主题令牌的三层结构与换肤
/* 第一层:原始值(palette),只定义,不在组件里直接用 */
:root {
--blue-6: #1677ff;
--gray-1: #ffffff;
--gray-9: #1f1f1f;
}
/* 第二层:语义(semantic),随主题切换 */
:root {
--color-bg: var(--gray-1);
--color-text: var(--gray-9);
--color-primary: var(--blue-6);
color-scheme: light;
}
[data-theme='dark'] {
--color-bg: #141414;
--color-text: #e6e6e6;
color-scheme: dark;
}
/* 第三层:组件(component),只引用语义层 */
.button {
background: var(--color-primary);
color: #fff;
}
三层的好处是可枚举:换主题只改第二层,新增组件只引用第二层,视觉调整只改第一层。反例是组件里直接写 #1677ff,一处改动要全库搜索。
light-dark() 让单条声明同时适配两套主题(需 color-scheme: light dark):
:root { color-scheme: light dark; }
.card { background: light-dark(#fff, #1f1f1f); }
3.16 打印与导出
| 需求 | 写法 |
|---|---|
| 隐藏交互元素 | @media print { .no-print { display: none } } |
| 分页控制 | break-inside: avoid(卡片不跨页)、break-before: page |
| 页边距 | @page { margin: 16mm } |
| 背景色打印 | 默认不打印,需 print-color-adjust: exact |
| 展开折叠内容 | @media print { details { open: true } }(部分引擎不支持,需 JS 兜底) |
导出 PDF 的实践结论:别指望浏览器打印完美还原屏幕样式,导出场景应单独写一套打印样式表,去掉阴影、动画、悬浮效果,改为线框与纯色。
3.17 L3 小结与自检
- 为什么
transform祖先会让fixed失效?——包含块规则。 content-visibility: auto的副作用?——查找/打印/锚点行为变化。- 三层令牌的好处?——变更影响面可控、组件不绑死具体值。
4. L4 专家:规范、治理与跨端
4.1 规范演进与浏览器实现差异
CSS 是按模块演进的(Grid Level 2、Cascade Level 5、Color Level 5 各自独立)。判断能力可用性要看三件事:
- 规范状态(WD / CR / REC);
- 各引擎实现(Chrome/Safari/Firefox);
- Baseline 状态(跨浏览器可用)。
实际差异举例:
| 能力 | Chrome | Safari | Firefox | 应对 |
|---|---|---|---|---|
subgrid | 117+ | 16+ | 71+ | 老版本降级为普通 grid |
@scope | 118+ | 17.4+ | 未支持 | 用 BEM 命名替代 |
:has() | 105+ | 15.4+ | 121+ | 特性检测 @supports selector(:has(*)) |
backdrop-filter | 76+ | 需要 -webkit- | 103+ | 加前缀 + 降级为纯色 |
@supports selector(:has(*)) {
.card:has(img) { padding: 0; }
}
@supports not (backdrop-filter: blur(1px)) {
.glass { background: rgb(20 24 32 / 0.95); }
}
4.2 跨引擎渲染差异治理
真实项目中必须建立的验证机制:
- 真机矩阵:至少覆盖 iOS Safari(最保守)、Android Chrome、桌面 Chrome/Safari,按用户分布排优先级。
- 视觉回归:截图对比(Playwright + 基线),允许阈值(字体渲染差异)。
- 已知差异清单:
100vh、position: fixed与软键盘、iOS 滚动惯性、gap在旧 flex 的行为。
4.3 大规模样式治理
十万行 CSS 的典型病症:!important 泛滥、权重战争、废弃样式无人敢删、改一处崩三处。
分阶段治理路径:
- 度量:统计
!important数量、选择器最大深度、未使用样式比例(Coverage 面板)、重复色值数量。 - 止血:禁止新增
!important(stylelint 规则)、新增样式必须走@layer。 - 收敛:把色值/间距收敛成令牌,替换硬编码。
- 迁移:按路由/组件渐进迁移到模块化方案(CSS Modules / 原子化),用绞杀者模式。
- 门禁:CI 加样式体积、重复度、未使用率检查。
4.4 设计系统的视觉基础设施
Figma 变量
↓ 导出(Tokens Studio / 自研脚本)
tokens.json(primitive / semantic / component)
↓ 生成
Web CSS 变量 / 小程序变量 / RN 常量 / 暗色主题包
关键工程问题:
- 令牌命名规范:按「语义」而非「外观」命名(
color-danger而不是color-red),否则换品牌就废了。 - 主题运行时切换:避免 FOUC(首屏闪白),在 HTML 解析阶段就确定主题。
- 跨端映射损耗:小程序的 CSS 能力是子集(无
:has()、无backdrop-filter),设计系统要标注「能力等级」。
4.5 性能与体验的权衡决策
| 视觉手段 | 收益 | 成本 | 决策建议 |
|---|---|---|---|
大面积 backdrop-filter | 高级质感 | 每帧读取背景 + 模糊 | 限制在静态小区域 |
大半径 box-shadow | 层次感 | 绘制面积大 | 用 filter: drop-shadow 或预渲染图片 |
| 滚动视差 | 沉浸感 | 滚动帧预算 | 用 transform + rAF,低端机关闭 |
复杂 clip-path 动画 | 视觉冲击 | 每帧重算几何 | 用 mask 或预渲染 |
组织级标准:定义「低端机判定」(navigator.hardwareConcurrency、deviceMemory、帧率采样),并提供统一的 prefers-reduced-motion + 设备能力的降级开关。
4.6 无障碍与视觉
- 对比度:正文 4.5:1、大字 3:1(WCAG AA)。用工具自动校验,不要靠肉眼。
- 焦点环:
outline: none是重罪;用:focus-visible只对键盘用户显示。 - 缩放 200%:布局不能出现内容裁切或横向滚动(WCAG 1.4.4 / 1.4.10)。
forced-colors:Windows 高对比模式下,自定义颜色会被系统覆盖,需用系统颜色关键字兜底。- 不只是颜色:错误状态除了红色,要有图标或文字。
/* 高对比模式下保证边框可见 */
@media (forced-colors: active) {
.card { border: 1px solid CanvasText; }
.icon { forced-color-adjust: none; } /* 谨慎使用 */
}
4.7 规范演进与选型:@supports 优于 UA 判断
CSS 的特性没有「版本号」,只有「支持度」。可靠的做法是特性检测 + 渐进增强,而不是嗅探浏览器。
| 判断方式 | 可靠性 | 说明 |
|---|---|---|
| UA 字符串 | 低 | 会被伪装、版本号不可枚举 |
@supports (prop: value) | 高 | 编译期判断,零运行时成本 |
CSS.supports('prop', 'value') | 高 | JS 侧判断,适合动态场景 |
| Baseline 数据 | 高(选型阶段) | 决定是否要写兜底 |
/* 渐进增强的标准骨架:先给所有人可用的基础样式,再给支持者增强 */
.card { display: flex; flex-direction: column; }
@supports (container-type: inline-size) {
.card-wrap { container-type: inline-size; }
@container (min-width: 30em) {
.card { flex-direction: row; }
}
}
L4 的判断力体现在「知道哪些特性不用兜底」:flex、grid、gap、aspect-ratio 已是 Baseline 广泛可用,为它们写降级是浪费;subgrid、@scope、anchor-position 仍需兜底。
4.8 跨引擎差异治理:用截图回归兜住
布局差异多出现在「度量」与「自动尺寸」上,靠肉眼对比不可持续。可行机制是截图回归。
| 差异类型 | 典型表现 | 治理 |
|---|---|---|
| 字体度量 | 同一 font-size 行高不同,文本换行位置不同 | 固定 line-height、关键文案用 min-height 兜底 |
| 自动轨道 | Grid 隐式行高在引擎间略有差异 | 显式 grid-auto-rows |
| Flex 基线 | 混合字体/图片时基线对齐偏移 | 用 align-items: center 替代基线对齐 |
| 滚动条占位 | 覆盖式与占位式滚动条导致宽度差 | scrollbar-gutter: stable |
| 圆角裁剪 | overflow:hidden + border-radius 的抗锯齿差异 | 加 isolation: isolate 或 transform: translateZ(0) |
截图回归的落地要点:固定视口尺寸、固定字体文件、固定 DPR、屏蔽动画与随机内容。否则每天的差异都是噪音。
4.9 大规模样式治理:三个可量化指标
| 指标 | 怎么算 | 健康区间 | 超标后果 |
|---|---|---|---|
| 样式体积 | 构建产物 CSS 原始体积 | 首屏关键 CSS < 50KB | 首屏样式阻塞变长 |
| 重复率 | 相同声明块的重复次数 | 越高越差 | 改一处要改多处 |
| 未使用率 | 首屏未命中的选择器占比 | < 50% | 白下载 |
治理动作按投入产出排序:
- 删死代码(未使用样式检测 + 页面下线时同步清理);
- 提取令牌(把散落的颜色/间距收敛到变量);
- 拆分层级(
@layer reset, base, components, utilities,固定覆盖顺序); - 限制选择器深度(约定不超过三层,降低特异性战争);
- 建立变更评审(样式改动也要 review,尤其是全局变量)。
/* 用 @layer 固定覆盖顺序,从根上消灭 !important 军备竞赛 */
@layer reset, base, layout, components, utilities;
4.10 设计系统的视觉基础设施工件
| 工件 | 内容 | 维护方式 |
|---|---|---|
| 比例尺 | 间距 4/8 基数、字号阶梯 12/14/16/20/24/32 | 与设计稿同源,代码生成 |
| 令牌 | 三层变量(见 3.15) | 设计工具导出 → 构建期生成 CSS |
| 组件 API | 只暴露语义化 prop(tone、size) | 禁止透传任意样式 |
| 视觉回归 | 每个组件多状态截图 | CI 阻断 |
| 变更日志 | 破坏性变更标注 | 语义化版本 |
反模式:把设计稿的绝对像素直接写进组件(margin: 13px)。这类值在设计调整时会全面失效,且无法被令牌体系覆盖。
4.11 性能与体验的权衡:给「降级」定规则
| 场景 | 完整体验 | 降级方案 | 触发条件 |
|---|---|---|---|
| 大面积毛玻璃 | backdrop-filter | 半透明纯色 | 低端机 / 滚动时 |
| 入场位移动画 | transform 动画 | 仅淡入 | prefers-reduced-motion |
| 阴影层次 | 多层 box-shadow | 单层细边 | 长列表项 |
| 图片效果 | 模糊占位 + 高清替换 | 直接显示缩略图 | 弱网 |
| 长列表 | 富交互卡片 | 精简信息行 | 低内存设备 |
规则化比临时判断可靠:把降级条件写成 CSS 媒体特性或运行时标记(data-perf="low"),而不是在每个组件里 if 判断设备型号。
4.12 L4 自检
- 5 条产品线、3 个品牌、明暗两套主题,样式架构怎么设计?——用三层 token(基础/语义/组件),把品牌与主题做成两个正交维度(
data-brand×data-theme)只改语义层映射;组件只消费语义 token,构建期按品牌输出变量包,运行时切属性,避免为 6 种组合各写一套选择器。 - iOS Safari 滚动卡顿,排查顺序是什么?——先看滚动容器是否嵌套且用了
overflow/-webkit-overflow-scrolling,再查滚动回调里是否读写布局造成强制同步布局,然后查滤镜、模糊、大阴影与background-attachment: fixed,最后查图片解码与合成层数量,用 Web Inspector 时间线确认。 - 引入原子化 CSS 的收益与代价,用什么数据说服团队?——收益是 Purge 后的产物体积、样式冲突与命名成本下降、设计约束更一致;代价是 HTML 可读性、学习成本、动态类名要 safelist。用「迁移两个页面前后的 CSS 体积、构建时间、样式类 bug 数、开发耗时」做对照。
5. 综合实战:还原一张复杂设计稿
任务:一个「数据看板」页面,包含顶部导航、左侧折叠菜单、四个指标卡、一个带表头的可滚动表格、右下角悬浮客服。
步骤 1 · 拆分布局层级
body (grid)
├─ header (sticky, 64px)
├─ .layout (grid: 240px 1fr)
│ ├─ aside (可折叠 → 64px)
│ └─ main (flex column, gap 16px)
│ ├─ .metrics (grid auto-fit minmax(240px,1fr))
│ └─ .table-wrap (overflow auto, 固定表头 sticky)
└─ .fab (position fixed)
步骤 2 · 先写结构与令牌,不写具体值
:root {
--space-1: 4px; --space-2: 8px;
--space-3: 12px; --space-4: 16px;
--space-6: 24px;
--radius-card: 12px;
--color-bg: #0b0e14;
--color-surface: #10141d;
--color-border: #242b3a;
--color-text: #e6e9f0;
--color-text-dim: #9aa4b8;
}
步骤 3 · 处理三个高频难点
- 表格固定表头:
.table-wrap { max-height: 420px; overflow: auto; }
.table-wrap thead th {
position: sticky;
top: 0;
background: var(--color-surface); /* 必须有不透明背景,否则内容会透出来 */
z-index: 1;
}
- 侧边栏折叠动画(只动宽度会重排,可接受但要注意):
aside {
width: 240px;
transition: width 0.2s ease;
overflow: hidden; /* 折叠时内容不溢出 */
}
aside.collapsed { width: 64px; }
- 悬浮按钮不遮挡焦点:滚动到底部时给内容加
padding-bottom: 96px,并在:focus-visible时提高z-index或用scroll-margin-bottom。
步骤 4 · 断点与容器查询
- 视口 < 768px:侧边栏改为抽屉,指标卡单列;
- 指标卡内部用容器查询决定「图标在上还是左」。
步骤 5 · 性能与无障碍验收
- 首屏无
backdrop-filter大面积使用 - 动画只用
transform/opacity - 暗色模式对比度 ≥ 4.5:1
- 焦点环可见、Tab 顺序与视觉一致
-
prefers-reduced-motion下无位移动画
5.1 像素级还原的验收矩阵
设计稿与实现之间的偏差,绝大多数集中在下面六类。逐行过一遍,比「肉眼看看差不多了」可靠。
| 检查项 | 具体判断 | 常见偏差原因 |
|---|---|---|
| 1px 边框 | 是否在某些缩放下消失或变粗 | 用 border 而非 transform: scaleY、DPR 舍入 |
| 字体度量 | 行高、字间距是否与稿一致 | line-height 用了数值而非百分比、缺 letter-spacing |
| 文本换行点 | 换行位置是否与设计稿一致 | 容器宽度差 1–2px、字体子集不同 |
| 图标对齐 | 图标与文字的光学中心是否齐 | 按基线对齐而非 align-items: center + 微调 |
| 间距节奏 | 是否落在 4/8 栅格上 | 手写 13px、17px |
| 阴影与圆角 | 模糊半径、扩散、圆角连续性 | 多层阴影叠加顺序、border-radius 与 overflow 裁剪 |
| 暗色模式 | 同一组件两套主题是否都成立 | 硬编码颜色、阴影在深色下过重 |
| 缩放 200% | 布局是否溢出、文字是否截断 | 固定高度、overflow: hidden |
补充两个工程化检查:
/**
* 检查页面上是否存在不在 4px 栅格上的间距,用于还原验收前的自动扫描。
* 只做提示,不自动修复——很多偏差是刻意的光学修正。
*/
function auditSpacingGrid(root = document.body) {
/** 4px 栅格,容忍 1px 的光学修正 */
const GRID = 4;
const TOLERANCE = 1;
const offenders = [];
root.querySelectorAll('*').forEach((el) => {
const style = getComputedStyle(el);
['marginTop', 'marginLeft', 'paddingTop', 'paddingLeft'].forEach((prop) => {
const value = parseFloat(style[prop]);
if (!value) return;
if (Math.abs(value % GRID) > TOLERANCE) {
offenders.push({ el, prop, value });
}
});
});
return offenders;
}
5.2 三遍走查:断样式、断动画、极端字号
- 断样式:禁用全部 CSS,检查 DOM 顺序是否仍符合阅读顺序——这决定了「CSS 加载失败时页面是否还能用」。
- 断动画:开启系统「减弱动态效果」,确认没有元素依赖动画才可见(常见于
animation-fill-mode: forwards控制的显隐)。 - 极端字号:把根字号调到 24px(约 150%),检查固定高度容器是否截断文字、按钮是否溢出。
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
「z-index 越大越在上面」 | 只在同一层叠上下文内有效,祖先 transform 会截断比较范围 |
「!important 能快速修复」 | 它把问题推给下一个人,是优先级失控的信号 |
「rem 一定比 px 好」 | 无障碍缩放场景 rem 更友好,固定视觉尺寸 px 更可控 |
「will-change 越多越流畅」 | 每个声明都可能新建合成层,显存与光栅成本上升 |
| 「媒体查询就是响应式」 | 组件级响应式应优先用容器查询 |
「opacity: 0 就是隐藏」 | 元素仍可被读屏朗读、仍可聚焦 |
| 「Flex 比 Grid 新所以更好」 | 一维用 Flex,二维用 Grid,不是替代关系 |
「box-sizing 只影响宽度」 | 同时影响 min/max-width、flex-basis 与百分比高度的计算基准 |
「z-index 越大越靠前」 | 只在同一层叠上下文内比较,祖先创建了上下文就另开战场 |
「position: fixed 一定相对视口」 | 祖先有 transform/filter/will-change 时改为相对该祖先 |
「margin 会叠加」 | 同一 BFC 内垂直相邻会合并,取较大值(含负值相加) |
「flex: 1 就是平分」 | 它是 1 1 0%,还受 min-width:auto 与内容宽度影响 |
「line-height: 1.5 与 line-height: 150% 等价」 | 前者按自身字号计算并继承系数,后者继承的是计算后的绝对值 |
「隐藏用 visibility: hidden 就行」 | 占位仍在、仍参与动画,且子元素可被设为 visible 重新显形 |
「:has() 能随便用」 | 参数复杂时匹配开销上升,超大列表逐项使用会拖慢样式重算 |
「vh 在移动端就是视口高度」 | iOS 地址栏会变化,全屏场景应用 dvh/svh |
| 「内存里样式多不影响性能」 | 样式表体积影响解析与匹配,未使用样式也会占用样式重算成本 |
| 「打印样式可以不写」 | 发票、报表类页面必须单独写打印样式,否则导出即事故 |
7. 面试高频题(附答题思路)
Q1:说说 BFC 及其应用。
定义(独立布局环境)→ 触发方式(overflow/flow-root/float/绝对定位/flex-grid)→ 三个用途(包浮动、防 margin 折叠、防浮动覆盖)→ 推荐用 display: flow-root。
Q2:如何实现水平垂直居中?
至少给四种:flex(justify-content + align-items)、grid(place-items: center)、绝对定位 + transform、margin: auto(绝对定位 + inset 0)。并说明各自适用场景。
Q3:z-index 为什么会失效?
层叠上下文概念 → 创建条件 → 祖先 transform/opacity/filter 截断 → 用 isolation: isolate 显式隔离。
Q4:重排与重绘的区别?如何减少?
管线阶段差异 → 哪些属性触发哪个阶段 → 优化手段(transform/opacity、批量读写、contain、content-visibility)。
Q5:CSS 变量和 Sass 变量的区别?
运行时 vs 编译时;作用域与级联;能否动态切换主题;@property 注册类型后才能动画。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 布局模型 | 能否说清「谁在什么上下文里布局」 | 区分 BFC/IFC/FFC/GFC | 「flex 就是弹性布局」 |
| 层叠 | 是否理解层叠上下文是「独立坐标系」 | 能解释祖先 transform 截断 | 「加 z-index: 9999」 |
| 渲染成本 | 知道属性触发哪个管线阶段 | 区分 layout/paint/composite | 「用 will-change 就快了」 |
| 兼容与降级 | 知道新特性的边界 | 给 @supports 降级 | 「现在都支持」 |
| 可维护性 | 是否用 token/约定而非魔法数 | 语义化变量、层级规范 | 到处 !important |
8.2 追问链
链 1 · BFC → margin 折叠 → 格式化上下文家族
- 面试官:说说 BFC。
- 要点:块级格式化上下文,内部布局与外部隔离;触发方式
display: flow-root、overflow非 visible、float、绝对定位、flex/grid 子项等;用途:包住浮动、阻止 margin 折叠、防止被浮动元素覆盖。 - 追问:margin 折叠发生在什么情况?怎么阻止?
- 要点:相邻兄弟、父子(无 border/padding/内容分隔)、空元素自折叠;阻止方式:父级建 BFC(
flow-root)、加padding/border、overflow: hidden。 - 再追问:为什么推荐
flow-root而不是overflow: hidden?- 要点:
flow-root只创建 BFC,不产生滚动容器副作用、不影响position: sticky;overflow: hidden会裁剪溢出内容并影响滚动行为。
- 要点:
- 要点:相邻兄弟、父子(无 border/padding/内容分隔)、空元素自折叠;阻止方式:父级建 BFC(
- 追问:除了 BFC,还有哪些格式化上下文?
- 要点:IFC(行内)、FFC(flex)、GFC(grid);它们各自决定子项的布局规则,BFC 只是其中一种。
- 要点:块级格式化上下文,内部布局与外部隔离;触发方式
链 2 · 层叠上下文 → z-index 失效 → 包含块
- 面试官:
z-index为什么会失效?- 要点:
z-index只在同一层叠上下文内比较;祖先创建了新上下文(transform、opacity < 1、filter、will-change、position: fixed/sticky等)会把子元素的层级「关在里面」。 - 追问:
position: fixed为什么不是相对视口定位了?- 要点:祖先有
transform/filter/perspective/will-change时,会成为 fixed 元素的包含块,于是相对祖先定位;这是「弹窗错位」的常见原因。 - 再追问:怎么排查?
- 要点:在 Elements 面板逐级看是否有上述属性;用
isolation: isolate显式建上下文来「圈定」层级,而不是无脑加z-index。
- 要点:在 Elements 面板逐级看是否有上述属性;用
- 要点:祖先有
- 追问:
z-index相同谁在上面?- 要点:同一上下文中按文档顺序,后者在上;负值在背景之上、内容之下;
z-index: auto不创建上下文。
- 要点:同一上下文中按文档顺序,后者在上;负值在背景之上、内容之下;
- 要点:
链 3 · 响应式 → 容器查询 → 移动端适配
- 面试官:怎么实现响应式?
- 要点:媒体查询按视口、容器查询按父容器宽度、
clamp()做流体字号、Grid 的auto-fit/minmax做自适应列。 - 追问:媒体查询和容器查询怎么选?
- 要点:页面级布局用媒体查询;组件级(同一组件在侧栏/主区域宽度不同)用容器查询,组件真正「自包含」。
- 再追问:容器查询的前提是什么?
- 要点:父容器要设
container-type: inline-size;查询的是容器宽度,不是视口;注意它与contain的渲染隔离副作用。
- 要点:父容器要设
- 追问:移动端 1px 边框和刘海屏怎么处理?
- 要点:1px 用
transform: scaleY(0.5)伪元素或border-image;安全区用env(safe-area-inset-*);100vh用100dvh避免地址栏抖动。
- 要点:1px 用
- 要点:媒体查询按视口、容器查询按父容器宽度、
8.3 手写题
题 1 · 三栏布局(两侧固定,中间自适应)
考察点:是否知道「中间内容优先渲染」、三种实现及其代价。
/* 方案 A:Grid(现代首选,最简单) */
.layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr) 200px;
gap: 16px;
}
/* minmax(0, 1fr) 防止长内容把中间列撑破 */
/* 方案 B:Flex(兼容好) */
.layout { display: flex; }
.side { flex: 0 0 200px; }
.main { flex: 1 1 auto; min-width: 0; } /* min-width: 0 是关键 */
/* 方案 C:圣杯布局(float + 负 margin,中间列 DOM 在前) */
.layout { padding: 0 200px; overflow: hidden; }
.main, .left, .right { float: left; }
.main { width: 100%; }
.left { width: 200px; margin-left: -100%; position: relative; left: -200px; }
.right { width: 200px; margin-left: -200px; position: relative; right: -200px; }
追问:
- 为什么 Flex 里要
min-width: 0?→ flex item 默认min-width: auto,内容(长单词/表格)会把列撑开,导致溢出。 - 圣杯布局的负 margin 原理?→ 先让三列同处一行,再用负 margin 把左右列「拉回」到 padding 留白区。
- 双飞翼和圣杯的区别?→ 双飞翼在中间列内部再套一层容器加 margin,不需要相对定位。
题 2 · 纯 CSS 三角形与 tooltip
/* 利用 border 交界处的斜边:相邻边框各占一半,内容区为 0 时形成三角形 */
.triangle {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid #1677ff;
}
/* tooltip:用伪元素做箭头,配合绝对定位 */
.tooltip { position: relative; }
.tooltip::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #333;
}
追问:为什么 width: 0 时必须设?→ 有宽高时边框只是「斜角切边」,不会形成完整三角形。
8.4 场景设计题
题:设计一个「卡片列表」,要求自适应 1–4 列、支持深浅主题、长列表滚动流畅、移动端 1px 边框与安全区。
评估点:布局选型、token 设计、渲染性能、兼容细节。
参考方案:
/* 1) 布局:auto-fit + minmax,无需媒体查询断点 */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
gap: var(--space-4);
}
/* 2) 主题:语义 token,组件不写死颜色 */
:root {
--color-bg: #fff;
--color-text: #1f1f1f;
--color-border: rgba(0, 0, 0, 0.08);
}
[data-theme='dark'] {
--color-bg: #141414;
--color-text: #e6e6e6;
--color-border: rgba(255, 255, 255, 0.12);
}
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}
/* 3) 长列表:跳过屏外渲染,注意占位高度避免滚动条抖动 */
.card-section {
content-visibility: auto;
contain-intrinsic-size: 320px;
}
/* 4) 移动端 1px:伪元素缩放,避免高 DPR 下变粗 */
.hairline { position: relative; }
.hairline::after {
content: '';
position: absolute;
inset: 0;
border: 1px solid var(--color-border);
transform: scale(0.5);
transform-origin: 0 0;
width: 200%;
height: 200%;
pointer-events: none;
}
/* 5) 安全区 */
.page { padding-bottom: calc(16px + env(safe-area-inset-bottom)); }
加分:说明 auto-fit 与 auto-fill 的区别(前者拉伸空列,后者保留空列);指出 content-visibility 会影响页内锚点与查找;主题切换用 color-scheme 让表单控件跟随。
减分:写死断点与颜色;用 transform: scale 改变布局尺寸;忽略 min-width: 0。
8.5 最容易答错的点
| 说法 | 纠正 |
|---|---|
「BFC 只能靠 overflow: hidden」 | flow-root 更干净,无裁剪副作用 |
「z-index 越大越在上」 | 只在同一层叠上下文中比较 |
「transform 不影响布局所以随便加」 | 它会创建包含块,改变 fixed 子元素定位 |
「1fr 一定能收缩」 | 默认 min-width: auto,需显式 minmax(0, 1fr) |
| 「媒体查询能替代容器查询」 | 组件在侧栏与主区宽度不同,需容器查询 |
「will-change 越多越流畅」 | 会创建合成层,滥用增加内存 |
「vh 在移动端很稳」 | 地址栏导致抖动,用 dvh/svh |
「!important 能解决优先级」 | 只会让后续维护更难,应管理层叠与作用域 |
8.6 代码级细节题
题 1 · 等高卡片,底部按钮对齐
/* 方案 A:Grid 行内拉伸 + 卡片自身 flex 列 */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
height: 100%; /* 关键:让卡片填满网格行高 */
}
.card__body {
flex: 1; /* 正文吸收剩余空间,把按钮推到底 */
}
.card__actions {
margin-top: auto; /* 双保险:即使 body 没设 flex,也能贴底 */
}
追问:
- 为什么
height: 100%在 Grid 里有效?→ 网格项默认stretch,行高由最高项决定;显式100%让卡片跟随行高,align-self: start则会破坏等高。 - Flex 布局里怎么做?→ 容器
align-items: stretch(默认),卡片display: flex; flex-direction: column,正文flex: 1。 - 多行文本截断后高度还会一致吗?→ 需要
-webkit-line-clamp+ 固定行数,否则内容差异仍会撑高。
题 2 · 组件级响应式:容器查询 + 流体字号
/* 父容器声明可查询维度;inline-size 只关心宽度,避免循环依赖 */
.card-wrap {
container-type: inline-size;
container-name: card;
}
.card {
padding: clamp(12px, 2cqi + 8px, 24px);
font-size: clamp(14px, 1.6cqi + 10px, 18px);
}
/* 同一组件在侧栏与主区域表现不同,不需要页面级断点 */
@container card (min-width: 420px) {
.card__media {
float: left;
width: 40%;
margin-right: 16px;
}
}
追问:
cqi与vw的区别?→cqi相对最近的查询容器宽度,组件可移植;vw永远相对视口。- 为什么用
container-type: inline-size而不是size?→size会同时约束高宽,导致容器高度依赖内容、可能形成循环;inline-size只隔离行内方向。 - 容器查询会不会影响性能?→ 会建立 containment,长列表里大量容器要谨慎,可配合
content-visibility。
题 3 · z-index 失效的最小修复
/* 问题:弹窗 z-index: 9999 仍被侧边栏盖住 */
.layout__aside {
transform: translateZ(0); /* 无意中创建了层叠上下文 */
}
/* 修复一:去掉不必要的 transform,或改为不影响层叠的写法 */
.layout__aside {
transform: none;
}
/* 修复二:若必须保留,则把弹窗挂到 body 下(Portal),脱离该上下文 */
.modal-root {
position: fixed;
inset: 0;
z-index: 1000;
}
/* 修复三:用 isolation 显式圈定层级,避免全局数字竞赛 */
.layout {
isolation: isolate;
}
追问:
- 哪些属性会创建层叠上下文?→
position+z-index非 auto、transform、filter、opacity < 1、will-change、isolation: isolate、contain: paint等。 - 为什么改成 Portal 就好了?→ 弹窗不再是那个层叠上下文的子元素,
z-index重新在根上下文比较。 - 用
9999有什么问题?→ 只是把冲突推迟,后续总会有人写10000;应建立层级 token(如--z-modal: 1000)。
8.7 模拟面试:45 分钟全流程
| 阶段 | 时长 | 面试官动作 | 你要给出的信号 |
|---|---|---|---|
| 开场 | 3 分钟 | 让你讲一个做过的复杂页面 | 说清布局难点与你验证的方式 |
| 基础快问 | 10 分钟 | 盒模型、position、flex: 1、层叠 | 定义 + 一个反例 |
| 追问深挖 | 15 分钟 | 围绕 BFC 或层叠连续追问三层 | 落到规范、包含块或渲染阶段 |
| 白板题 | 12 分钟 | 三栏布局 / 居中 / 层叠修复 | 先讲约束与取舍,再写最小代码 |
| 反问 | 5 分钟 | 让你提问 | 问设计稿还原流程与浏览器兼容基线 |
评分表(满分 100):
| 维度 | 权重 | 及格线 | 加分表现 |
|---|---|---|---|
| 布局模型 | 25 | 能解释谁在什么上下文里布局 | 能指出方案的失效条件 |
| 层叠与定位 | 20 | 能定位 z-index 失效原因 | 用 isolation 而非堆数字 |
| 现代特性 | 20 | 会用 Grid / 容器查询 | 知道兼容边界与降级 |
| 性能意识 | 20 | 知道哪些属性触发哪一阶段 | 能给出量化验证方式 |
| 表达与取舍 | 15 | 结构清晰 | 主动说明代价与替代方案 |
自检:如果「布局模型」低于 15 分,说明你还在背属性而不是理解格式化上下文;如果「性能意识」低于 12 分,说明你没在 DevTools 里验证过自己的方案。
8.8 现场追问:从「歪了 2px」推到规范层
链 1 · 「这个弹窗在弹层里定位不对」
- 第一问:怎么快速定位?
- 要点:先看
offsetParent,再沿祖先链找transform/filter/will-change/contain,这些会让fixed的包含块从视口变成该祖先。
- 要点:先看
- 第二问:为什么加了
transform: translateZ(0)之后才出问题?- 要点:那是常见的「提层」技巧,创建了包含块与层叠上下文,两个副作用一起出现:定位参照变了、
z-index比较范围也变了。
- 要点:那是常见的「提层」技巧,创建了包含块与层叠上下文,两个副作用一起出现:定位参照变了、
- 第三问:规范上这是怎么规定的?
- 要点:CSS Transforms 规定,带非
none变换的元素为其后代的fixed/absolute定位元素建立包含块;这不是 bug,是明确的规范行为。
- 要点:CSS Transforms 规定,带非
- 第四问:怎么修最稳?
- 要点:弹层通过 Portal 挂到
body(结构性隔离)优于「逐个去掉祖先属性」(脆弱,新增动画可能再次引入)。
- 要点:弹层通过 Portal 挂到
链 2 · 「设计稿 12px,实现出来像 14px」
- 第一问:先确认什么?
- 要点:是否有缩放(
zoom/transform: scale)、DPR 是否为非整数、是否被line-height撑高。
- 要点:是否有缩放(
- 第二问:为什么行高会影响「看起来的间距」?
- 要点:行盒高度 =
line-height,额外的行距上下各分一半,视觉上等价于给文字加了上下 padding。
- 要点:行盒高度 =
- 第三问:为什么
line-height: 1.5和line-height: 150%表现不同?- 要点:数值型继承的是「系数」,随各自字号重新计算;百分比继承的是「计算后的长度」,子元素字号变化时不再跟随。
- 第四问:怎么让文字高度可预测?
- 要点:关键文案用
line-height数值 +min-height;跨端一致性要求高时,用固定line-height的 px 值而非相对值。
- 要点:关键文案用
链 3 · 「暗色模式下这块发灰」
- 第一问:可能的原因?
- 要点:颜色硬编码(
#fff背景)、阴影在深色背景下过重、半透明叠加导致实际色偏、opacity叠加导致文字发灰。
- 要点:颜色硬编码(
- 第二问:为什么半透明叠加会偏?
- 要点:多层半透明背景按 alpha 合成,最终颜色是各层混合结果,设计稿给的是单层值。
- 第三问:规范上有什么更可靠的办法?
- 要点:用
color-mix()或light-dark()显式声明两套值;用color-scheme让表单控件与滚动条跟随;令牌分三层,暗色只改语义层。
- 要点:用
8.9 手写题:把「调试能力」写成代码
题 1 · 找出造成水平溢出的元素
考察点:scrollWidth 与 clientWidth 的差异、边界遍历、性能意识。
/**
* 找出导致页面横向溢出的元素。
* 思路:从可疑节点开始,判断其右边界是否超出根元素内容区。
* @param {HTMLElement} [root=document.documentElement]
* @returns {Array<{ el: Element, right: number }>} 溢出的元素及右边界坐标
*/
function findHorizontalOverflow(root = document.documentElement) {
const limit = root.clientWidth;
const result = [];
// 只遍历元素节点,避免文本节点带来的噪音
root.querySelectorAll('*').forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return; // 跳过不可见元素
if (rect.right > limit + 1) { // 1px 容差,抗亚像素
result.push({ el, right: Math.round(rect.right) });
}
});
return result;
}
追问清单:
| 追问 | 要点 |
|---|---|
为什么用 getBoundingClientRect 而不是 offsetWidth | 前者含 transform 后的真实位置,后者是布局尺寸 |
| 1px 容差从哪来 | 亚像素舍入,DPR 非整数时普遍存在 |
为什么会漏掉 position: fixed 元素 | fixed 元素挂在视口,不在文档流内,需要单独检查 |
| 遍历上万个节点会不会卡 | 会,实际应限制在可疑容器内做二分排查 |
题 2 · 计算元素实际生效的样式来源
考察点:getComputedStyle 的局限、样式规则遍历、特异性判断。
/**
* 打印某个元素上「谁在生效」:遍历样式表规则,筛出匹配该元素的规则。
* 注意:getComputedStyle 只给最终值,无法告诉你是哪条规则生效的。
* @param {Element} el 目标元素
* @param {string} property 例如 'color'、'margin-top'
*/
function traceCascade(el, property) {
const matched = [];
for (const sheet of document.styleSheets) {
let rules;
try {
// 跨域样式表访问 cssRules 会抛 SecurityError,必须捕获
rules = sheet.cssRules;
} catch {
continue;
}
const walk = (list) => {
for (const rule of list) {
if (rule.cssRules) { // @media / @supports / @layer 需要递归
walk(rule.cssRules);
continue;
}
if (!rule.selectorText) continue;
if (!el.matches(rule.selectorText)) continue;
const value = rule.style.getPropertyValue(property);
if (value) matched.push({ selector: rule.selectorText, value });
}
};
walk(rules);
}
return matched;
}
| 追问 | 要点 |
|---|---|
| 为什么跨域样式表读不到 | 同源策略限制,需要服务端加 crossorigin 与 CORS 头 |
| 怎么判断谁最终生效 | 按 @layer → 特异性 → 源顺序排序,取最强者 |
为什么不直接用 getComputedStyle | 它只返回计算值,排查「谁覆盖了谁」必须知道来源 |
@media 内未命中的规则要跳过吗 | 要,递归时必须判断条件是否成立,否则会误报 |
9. 延伸资源
- MDN CSS Reference:属性级权威文档。
- web.dev Learn CSS:从盒模型到布局的系统课程。
- CSS Tricks "A Complete Guide to Flexbox / Grid":速查神器。
- spec:CSS Cascading and Inheritance Level 5(理解
@layer)。 - Ahmad Shadeed 的容器查询与逻辑属性文章:实战导向。
- 工具:Chrome DevTools 的 Layers / Rendering 面板、Stylelint、CSS Stats、PurgeCSS。
下一步练习:找一个你喜欢的网站,用 DevTools 把它的首页「拆」成 Grid/Flex 结构,然后不看源码复刻出来。这个练习比读十篇文章更能建立布局直觉。