02

CSS 与视觉实现

2,349 行约 121 分钟91 个代码块46 张表格更新于 2026-11-03

02 · CSS 与视觉实现(详解版)

一句话定位:CSS 是「把抽象结构渲染成视觉」的语言。它同时管三件事:布局(元素放在哪)、绘制(长什么样)、状态过渡(怎么变)。

读完本篇你应该能做到

  1. 拿到一张复杂设计稿,能说出该用 Grid 还是 Flex、断点怎么切、哪些地方会踩坑;
  2. 遇到「样式不生效 / z-index 失效 / 元素撑破容器」,能在 5 分钟内定位原因;
  3. 解释一次样式改动会触发渲染管线的哪几个阶段,以及为什么它可能卡顿。

0. 领域导论

0.1 这个领域解决什么问题

前端 80% 的「看起来不对」都出在 CSS。但 CSS 的难不在语法,而在它的三个反直觉特性:

  1. 层叠(cascade):同一个元素可能被十几条规则命中,最终生效的是「权重 + 顺序 + 来源」共同决定的结果。你以为写了一条样式,实际有另一条更强的规则在压着它。
  2. 依赖上下文:width: 100% 是相对谁?position: absolute 相对谁定位?z-index: 9999 为什么不管用?答案都不在元素自己身上,而在祖先、兄弟、格式化上下文里。
  3. 成本看不见:改一下 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 失效的三个高频原因:

  1. 父容器 overflow: hidden/auto(滚动容器变了);
  2. 没有设 top/bottom 等阈值;
  3. 父容器高度等于元素高度(没有可粘滞的空间)。

自检:一个 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-boxborder-box
width内容区内容 + padding + border
min-width内容区下限整盒下限
flex-basis按内容区计算按整盒计算
padding 百分比按包含块宽度,自身宽度无关同左(易被误认为按自身)
width:100% + padding溢出父容器不溢出
/* 全局统一,避免逐个组件心算 padding */
*, *::before, *::after { box-sizing: border-box; }

两个反直觉的点:

  1. padding 的百分比始终按包含块宽度算,即使上下方向。所以 padding-top: 50% 会等于「父容器宽度的一半」,这是「正方形占位」的老技巧,也是「图片容器高度乱跳」的常见原因。
  2. 负 margin 合法且有用:抵消栅格间距、让子元素突破父容器内边距。但它会让元素在视觉上重叠而不改变布局占位,排查「为什么点了没反应」时要注意上层元素是否覆盖。

1.11 特异性与层叠层:为什么样式「不生效」

特异性不是玄学,是四元组 (a, b, c, d) 的字典序比较:

位含义示例
a内联样式style="..."
bID 选择器数量#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 的真实顺序(从强到弱):

  1. @layer 中带 !important 的声明(层越靠前越强,与普通声明相反);
  2. 内联样式带 !important;
  3. 普通 !important;
  4. 内联样式;
  5. @layer 内普通声明(层越靠后越强);
  6. 无层声明(优先于所有层内普通声明)。

结论:想「可控地覆盖第三方样式」,用 @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 失效的五个原因(按出现频率排序):

  1. 任一祖先设了 overflow: hidden/auto/scroll(即使没有滚动条);
  2. 没有设置 top/bottom 之一(缺阈值等于没粘);
  3. 父容器高度等于元素自身高度(没有可粘的行程);
  4. 元素在 flex 布局中被拉伸成 align-items: stretch,高度撑满导致无处可粘;
  5. 祖先有 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,避免重排
媒体查询移动优先,断点按内容定

自检题

  1. 元素设了 width: 100% 和 padding: 20px,为什么撑出横向滚动条?——box-sizing 是 content-box。
  2. position: absolute 找不到已定位祖先时相对谁?——初始包含块(视口大小的块)。
  3. 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 的三大用途:

  1. 包裹浮动:父元素塌陷(高度为 0)时,flow-root 让它包含浮动子元素。
  2. 阻止 margin 折叠:父子 margin 折叠时,父元素加 BFC 可隔开。
  3. 避免被浮动元素覆盖: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 外边距合并的精确条件

只有三个条件同时满足才会合并:

  1. 都是块级盒子(行内、flex/grid 项、float、absolute 都不合并);
  2. 处于同一 BFC;
  3. 垂直方向相邻(兄弟相邻,或父子的贴边)。
场景结果
兄弟 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 解决撑破
BFCflow-root 最干净,解决塌陷与 margin 折叠
层叠上下文祖先的 transform/opacity/filter 会截断 z-index
容器查询组件级响应式优先于媒体查询
@layer层顺序大于权重,覆盖第三方的利器
CSS 变量默认不可动画,需 @property 注册类型
视觉效果模糊/遮罩/阴影都有实打实的绘制成本

自检题

  1. auto-fit 与 auto-fill 的区别?——空轨道是否被压缩。
  2. 为什么 @layer 能解决 !important 战争?——层顺序优先于权重。
  3. 图片底部为什么有缝隙?——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-colorPaint + Composite中
width/height/margin/padding/top/left/font-sizeLayout → 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; }
}

暗色模式的两个常见问题:

  1. 纯黑背景 + 纯白文字对比过强,长文阅读疲劳,建议 #0b0e14 类深灰。
  2. 切换闪烁:用 <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 非 none3D 容器卡片翻转动效
backdrop-filter 非 none毛玻璃吸顶导航
contain: paint/layout渲染隔离性能优化时加的
will-change 指定上述属性提前提层动画预热

排查步骤(三分半解决):

  1. 在 DevTools 选中「跑偏」的元素,看它的 offsetParent;
  2. 沿祖先链逐级取消 transform/filter/will-change(用样式面板临时勾掉);
  3. 定位到具体祖先后再决定:把弹层挂到 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 小结与自检

  1. 为什么 transform 祖先会让 fixed 失效?——包含块规则。
  2. content-visibility: auto 的副作用?——查找/打印/锚点行为变化。
  3. 三层令牌的好处?——变更影响面可控、组件不绑死具体值。

4. L4 专家:规范、治理与跨端

4.1 规范演进与浏览器实现差异

CSS 是按模块演进的(Grid Level 2、Cascade Level 5、Color Level 5 各自独立)。判断能力可用性要看三件事:

  1. 规范状态(WD / CR / REC);
  2. 各引擎实现(Chrome/Safari/Firefox);
  3. Baseline 状态(跨浏览器可用)。

实际差异举例:

能力ChromeSafariFirefox应对
subgrid117+16+71+老版本降级为普通 grid
@scope118+17.4+未支持用 BEM 命名替代
:has()105+15.4+121+特性检测 @supports selector(:has(*))
backdrop-filter76+需要 -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 泛滥、权重战争、废弃样式无人敢删、改一处崩三处。

分阶段治理路径:

  1. 度量:统计 !important 数量、选择器最大深度、未使用样式比例(Coverage 面板)、重复色值数量。
  2. 止血:禁止新增 !important(stylelint 规则)、新增样式必须走 @layer。
  3. 收敛:把色值/间距收敛成令牌,替换硬编码。
  4. 迁移:按路由/组件渐进迁移到模块化方案(CSS Modules / 原子化),用绞杀者模式。
  5. 门禁: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%白下载

治理动作按投入产出排序:

  1. 删死代码(未使用样式检测 + 页面下线时同步清理);
  2. 提取令牌(把散落的颜色/间距收敛到变量);
  3. 拆分层级(@layer reset, base, components, utilities,固定覆盖顺序);
  4. 限制选择器深度(约定不超过三层,降低特异性战争);
  5. 建立变更评审(样式改动也要 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 自检

  1. 5 条产品线、3 个品牌、明暗两套主题,样式架构怎么设计?——用三层 token(基础/语义/组件),把品牌与主题做成两个正交维度(data-brand × data-theme)只改语义层映射;组件只消费语义 token,构建期按品牌输出变量包,运行时切属性,避免为 6 种组合各写一套选择器。
  2. iOS Safari 滚动卡顿,排查顺序是什么?——先看滚动容器是否嵌套且用了 overflow/-webkit-overflow-scrolling,再查滚动回调里是否读写布局造成强制同步布局,然后查滤镜、模糊、大阴影与 background-attachment: fixed,最后查图片解码与合成层数量,用 Web Inspector 时间线确认。
  3. 引入原子化 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 · 处理三个高频难点

  1. 表格固定表头:
.table-wrap { max-height: 420px; overflow: auto; }
.table-wrap thead th {
  position: sticky;
  top: 0;
  background: var(--color-surface); /* 必须有不透明背景,否则内容会透出来 */
  z-index: 1;
}
  1. 侧边栏折叠动画(只动宽度会重排,可接受但要注意):
aside {
  width: 240px;
  transition: width 0.2s ease;
  overflow: hidden;   /* 折叠时内容不溢出 */
}
aside.collapsed { width: 64px; }
  1. 悬浮按钮不遮挡焦点:滚动到底部时给内容加 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 三遍走查:断样式、断动画、极端字号

  1. 断样式:禁用全部 CSS,检查 DOM 顺序是否仍符合阅读顺序——这决定了「CSS 加载失败时页面是否还能用」。
  2. 断动画:开启系统「减弱动态效果」,确认没有元素依赖动画才可见(常见于 animation-fill-mode: forwards 控制的显隐)。
  3. 极端字号:把根字号调到 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 会裁剪溢出内容并影响滚动行为。
    • 追问:除了 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。
    • 追问: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 避免地址栏抖动。

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,是明确的规范行为。
  • 第四问:怎么修最稳?
    • 要点:弹层通过 Portal 挂到 body(结构性隔离)优于「逐个去掉祖先属性」(脆弱,新增动画可能再次引入)。

链 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 结构,然后不看源码复刻出来。这个练习比读十篇文章更能建立布局直觉。