Old-School Front-End / 2026 ・ No.04
古法前端入门
第4讲 CSS ① ── 选择器与盒模型:用手写的 CSS 给申请列表穿上衣服
发布
不靠 AI,一行一行亲手写的“古法”前端入门。从第4讲开始,终于轮到 CSS 了。
我们不用任何库,用自己写的 CSS,给第2讲做好的申请列表穿上衣服。
要掌握的是三件事:选中哪些元素(选择器)、冲突时谁胜出(层叠),
以及所有元素都是盒子(盒模型)。
本讲的目标
新井前辈,CSS 我也让 AI 写好了!不过我改了按钮的颜色,表格的颜色就乱了;改好表格,按钮又乱了……最后加上 !important,就全好了!
古井那个 !important,一共有几个?
新井……23 个。
古井这就像每次着火,都往所有房间喷一遍灭火器。今天我们一边手写,一边弄懂 CSS 是怎么决定“让谁胜出”的。
当前位置:第4讲
这一讲,我们给第2讲的申请列表(index.html)加上一个叫 css/style.css 的 CSS 文件。HTML 这边的改动,只有读入 CSS 的一行和几个 class。外观的变化是这样的:
①是没有读入 CSS 的状态。标题又粗又大、链接是带下划线的蓝色,这些都来自浏览器自带的默认样式。②是在上面叠加了我们自己的 CSS。CSS 就是一门“不改 HTML,只改外观”的语言。
在 Vibe Coding 里,CSS 是最容易堆积“不知为何就能用”的地方。看不见不按预期显示的原因,就只好不断追加设置去覆盖。这一讲的目标,是让你自己能读懂背后的原因。
添加 CSS 的方式
把 CSS 加到 HTML 上,有三种方式。
添加 CSS 的 3 种方式
正式开发用③。在 head 里写一行 link 元素。
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>申请列表 | 报销系统</title> <link rel="stylesheet" href="css/style.css"> </head>
href 是从这个 HTML 文件出发看到的位置(相对路径)。按照第1讲定好的文件夹结构,是这样的:
expense/
├─ index.html ← 从这里出发
├─ form.html
├─ css/
│ └─ style.css ← css 文件夹里的 style.css
└─ img/
└─ logo.svg
把 CSS 集中到一个文件里,form.html 和之后要做的审批页面,只要写同样的 link 就会是同样的外观。想改按钮的颜色时,只需要改 style.css 的一个地方。
古井CSS 普及之前,大家是写 <font color="red">,给每段文字一个一个地上色。
新井那要换颜色的时候……
古井打开所有页面,全部改一遍。CSS 是 1994 年 10 月提出的,1996 年 12 月成为 W3C 推荐标准(CSS1)。把内容和外观分开,是前辈们吃尽苦头才换来的智慧。
规则的结构
CSS 由一条条规则排列而成。一条规则的结构,就只有这些:
拆解一条 CSS 规则
“选哪些元素(选择器)”“改什么(属性)”“改成什么(值)”。翻译成人话就是“把汇总里的 li 的宽度,设为 220px”。
CSS 出错时,会一声不吭地忽略。比如 color: red 后面忘了 ;,下一行接着写 background: #fff;,浏览器看到的就是“color: red background: #fff”这样一条声明,两条都会被丢掉。不会出现任何错误提示。觉得“没生效”时,先检查 ; 和 { } 是否成对。
选择器:选中谁
选择器是从 HTML 的树(第2讲)里选出元素的写法。我们在申请列表的树上,确认一下哪些元素会被选中。
选择器选中的元素
请注意 C 的 .summary li。导航里也有 li,但被选中的只有汇总里的 li。如果写成 li { width: 220px; },导航的链接也会变成 220px 宽。
| 写法 | 名称 | 选中什么 | 本讲的用处 |
|---|---|---|---|
| h2 | 元素(类型) | 所有这种元素 | 标题左边的红线 |
| .button | 类 | class 里有这个名字的元素 | 把链接做成按钮样式 |
| #warn | ID | id 是这个名字的元素(一页一个) | 不用(太强,见第五章) |
| * | 通配 | 所有元素 | 设置 box-sizing(第六章) |
| .summary li | 后代(空格) | 里面的元素(不管隔几层) | 汇总小盒子 |
| body > header | 子(>) | 只选正下方一层 | 整个页面的页头 |
| th, td | 分组(,) | 其中任何一个 | 表格单元格的内边距 |
| a:hover、tr:nth-child(even) | 伪类(:) | 按状态或位置选 | 鼠标悬停的行、偶数行 |
基本原则是:想加外观的部件就给它加 class,用 class 来选。这次在 HTML 里加的 class 有 site、user、button、summary、amount、status 共 6 种。名字按“作用”来起,而不是按“外观”。不叫 red-text,而叫 amount。这样就算颜色改成蓝色,名字和内容也不会对不上。
新井用 ID 一下就能选中,好像更快吧?
古井不是快慢的问题,是 ID 太强了。下一章就来讲这个“强弱”。
冲突时,谁胜出
一个元素被好几条规则命中,是很常见的事。颜色的设置冲突了,谁胜出?决定这件事的机制,就是 CSS 名字里的那个“C”──层叠(Cascading,一级一级往下流)。
优先级与胜负的判定
选择器有一个叫优先级(特异性)的 3 位分数:ID 的个数、类・伪类・属性的个数、元素名的个数。从左边的位开始比,只要有 1 个 ID,叠多少个类都赢不了。
真的是这样决定的吗?我做了一个实验页面,把浏览器计算出的颜色取了出来。
# 实验用的 CSS
p { color: gray; }
.note { color: blue; }
p.note { color: green; }
#warn { color: red; }
.later { color: blue; }
.later { color: orange; }
.vip { color: teal !important; }
# 每个元素由浏览器计算出的颜色(getComputedStyle)
<p>
→ gray rgb(128, 128, 128) 只有 p 命中
<p class="note">
→ green rgb(0, 128, 0) p.note(0-1-1)胜过 .note(0-1-0)
<p id="warn" class="note">
→ red rgb(255, 0, 0) #warn(1-0-0)胜出
<p class="note" style="color: purple">
→ purple rgb(128, 0, 128) style 属性胜过选择器
<p class="later">
→ orange rgb(255, 165, 0) 同分,所以后写的 .later 胜
<span class="vip" style="color: purple">
→ teal rgb(0, 128, 128) !important 胜过 style 属性
结果和图一样。第 2 个是“绿还是蓝”的对决,p.note 胜出;第 5 个分数相同,所以后写的 orange 胜出。第 6 个则是!important 连 style 属性都压了下去。
!important 等于一步跳上胜负阶梯的最顶端。一旦用了,想覆盖它就又得用 !important,于是开始比拼分数。开头的“23 个”正是这种状态。AI 写的 CSS 里如果有很多 !important、ID 选择器或 style 属性,就把它当成靠分数硬拼来解决胜负的痕迹来读。亲手写的时候,以 1 个 class(0-1-0)为基础,把优先级统一压低,样式才能长久好维护。
继承:从父元素那里继承的东西
还有一种机制,规则没有命中,值也会被决定,那就是继承。
# 给父元素指定 color、border、font-family,子元素 p 什么都不写
.parent { color: #1f4e6b; border: 3px solid #1f4e6b; font-family: serif; }
子元素的 color → rgb(31, 78, 107)(继承)
子元素的 font-family → serif(继承)
子元素的 border-top-width → 0px(不继承)
文字的颜色和字体会被子元素继承,边框不会。记住“和文字有关的(color、font-family、font-size、line-height 等)会继承,和盒子有关的(border、padding、margin、background 等)不继承”,基本就不会错。这次的 CSS 只在 body 上写了一次文字颜色和字体,就是在利用继承。
盒模型:一切都是盒子
浏览器把所有元素都当作矩形的盒子来排列。盒子从内到外有 4 层:content(内容)、padding(内边距)、border(边框)、margin(外边距)。把汇总小盒子用实际数值画出来,是这样的:
汇总小盒子的盒模型
这里的关键是:width: 220px 指的是从哪里到哪里的宽度。这由 box-sizing 决定。
content-box 与 border-box
初始值 content-box 时,width 只是内容的宽度。加上 padding 和 border,明明写的是 220px,盒子却变成了 254px。“想并排放 3 个 220px 的盒子,结果挤出去了”,是自古以来的经典事故。所以这次的 CSS 一开头就写了:
*, *::before, *::after {
box-sizing: border-box;
}
这是把所有元素(*)以及它们前后的伪元素都设为 border-box 的常用写法。这样“width 就是到边框为止的宽度”,计算也符合直觉。再看看在实验页面里测到的值:
# ① width: 220px; padding: 12px 16px; border: 1px 的盒子,实际宽度 box-sizing: content-box → 254px(220 + 16×2 + 1×2) box-sizing: border-box → 220px(内容缩小为 186px) # ② margin 16px 的段落下面,紧接 margin 24px 的段落 两个段落之间的间距 → 24px(不是 16 + 24 = 40)
②是外边距折叠。上下排列的盒子之间的上下 margin 不会相加,只保留较大的那个。觉得“间距比想象的窄”时,就要怀疑它。
| display | 排列方式 | width・上下留白 | 本讲的例子 |
|---|---|---|---|
| block | 纵向堆叠(占满整行) | 有效 | p、h2、section、ul(初始值) |
| inline | 在文字中流动 | width 无效,上下留白不会撑开周围 | a、span、strong(初始值) |
| inline-block | 像文字一样横向排列 | 有效 | .button、.summary li、.status、导航的 li |
a 原本是 inline,直接加 padding 也不会变成像按钮的方块。用 display: inline-block 请浏览器“把它当成盒子”,是做按钮样式链接的诀窍。汇总小盒子也是用 inline-block 横向排列的(更方便的排列方法,下一讲讲布局时再说)。
单位・颜色・变量
| 单位 | 含义 | 本讲的用处 |
|---|---|---|
| px | 以屏幕上点的大小为基准 | 留白、边框、盒子的宽度 |
| rem | 以 html 的字号为基准(实测 16px,0.875rem = 14px) | 文字大小 |
| em | 以这个元素自己的字号为基准 | (本讲不用) |
| % | 以父元素的大小为基准 | 表格宽度(width: 100%) |
文字大小用 rem 写,用户在浏览器设置里调大字号时,页面的文字也会跟着变大。颜色可以写成 #1f4e6b(用 16 进制表示红・绿・蓝)、rgb(31, 78, 107),或 gray 这样的名字。
同一种颜色要写在很多地方时,就给它起个名字,做成 CSS 变量(自定义属性)。
:root {
--brand: #1f4e6b; /* 名字以 -- 开头 */
--accent: #c0392b;
}
body > header { background: var(--brand); } /* 用 var() 调用 */
.button { background: var(--accent); }
:root 指的是页面最外层的元素(html)。变量也会被继承,所以写在这里,页面任何地方都能用。公司的品牌色变了,也只需要改一行。
定好颜色后,要确认文字与背景的对比度。无障碍指南 WCAG 中,普通文字的标准是 4.5:1 以上。本讲用到的组合,我用 WCAG 的公式计算了一遍:
| 位置 | 文字 / 背景 | 对比度 |
|---|---|---|
| 新建申请按钮 | #ffffff / #c0392b | 5.44 : 1 |
| 页头 | #ffffff / #1f4e6b | 8.9 : 1 |
| 表格说明(caption) | #5b6b75 / #f4f6f9 | 5.1 : 1 |
| 审批中 | #8a5a00 / #fff4d6 | 5.41 : 1 |
| 已批准 | #1d6b3a / #e3f4e9 | 5.72 : 1 |
| 已退回 | #a93226 / #fdecea | 5.79 : 1 |
| 已报销 | #4d5b66 / #eceff2 | 6.06 : 1 |
状态徽章在用颜色区分的同时,也保留了文字。每个人看到的颜色不尽相同,不能只靠颜色传达含义(比如“红色就是被退回”),这也是 WCAG 的规定之一。
完成申请列表
用目前学到的工具,把申请列表做完。HTML 这边的改动摘出来,就只有这些:
…
<link rel="stylesheet" href="css/style.css">
…
<p class="site"><a href="/"><img src="img/logo.svg" alt="" width="32" height="32"> 报销系统</a></p>
…
<p class="user">新井 太郎(申请人)</p>
…
<p><a class="button" href="form.html">+ 新建申请</a></p>
…
<ul class="summary">
…
<th scope="col" class="amount">金额(日元)</th>
…
<td class="amount">32,400</td>
<td><span class="status status-submitted">审批中</span></td>
…
然后是 css/style.css 的全部内容。按“全局约定 → 页头 → 主体 → 表格 → 页脚”的顺序,也就是页面从上到下的顺序排列。
/* ===== 1. 全局约定(颜色变量・盒子尺寸的算法・文字) ===== */
:root {
--ink: #1f2b2a;
--muted: #5b6b75;
--brand: #1f4e6b;
--accent: #c0392b;
--line: #d7dee5;
--bg: #f4f6f9;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
line-height: 1.6;
color: var(--ink);
background: var(--bg);
}
/* ===== 2. 页头 ===== */
body > header {
background: var(--brand);
color: #fff;
padding: 12px 24px;
}
body > header a {
color: #fff;
text-decoration: none;
}
.site {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.site img {
vertical-align: middle;
}
nav ul {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
nav li {
display: inline-block;
margin-right: 16px;
}
nav a:hover,
nav a:focus-visible {
text-decoration: underline;
}
.user {
margin: 4px 0 0;
font-size: 0.875rem;
opacity: 0.85;
}
/* ===== 3. 主体与标题 ===== */
main {
max-width: 960px;
margin: 0 auto;
padding: 24px;
}
h1 {
margin: 0 0 16px;
font-size: 1.5rem;
}
h2 {
margin: 32px 0 12px;
padding-left: 10px;
border-left: 4px solid var(--accent);
font-size: 1.125rem;
}
/* 看起来像按钮的链接 */
.button {
display: inline-block;
padding: 8px 16px;
border-radius: 6px;
background: var(--accent);
color: #fff;
font-weight: 700;
text-decoration: none;
}
.button:hover {
background: #a93226;
}
.button:focus-visible {
outline: 3px solid #f5b041;
outline-offset: 2px;
}
/* 汇总:把小盒子横向排列 */
.summary {
list-style: none;
margin: 0;
padding: 0;
}
.summary li {
display: inline-block;
width: 220px;
margin: 0 12px 12px 0;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
vertical-align: top;
}
.summary strong {
display: block;
color: var(--accent);
font-size: 1.25rem;
}
/* ===== 4. 表格 ===== */
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--line);
background: #fff;
}
caption {
padding-bottom: 8px;
color: var(--muted);
font-size: 0.875rem;
text-align: left;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
}
thead th {
background: #eef2f6;
color: var(--muted);
font-size: 0.875rem;
}
tbody tr:nth-child(even) {
background: #fafbfc;
}
tbody tr:hover {
background: #fdf2e9;
}
.amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* 状态徽章(不只靠颜色,文字也要保留) */
.status {
display: inline-block;
padding: 2px 10px;
border: 1px solid;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 700;
}
.status-submitted { color: #8a5a00; background: #fff4d6; }
.status-approved { color: #1d6b3a; background: #e3f4e9; }
.status-returned { color: #a93226; background: #fdecea; }
.status-paid { color: #4d5b66; background: #eceff2; }
/* ===== 5. 页脚 ===== */
body > footer {
padding: 24px;
color: var(--muted);
text-align: center;
}
- body > header ── 只选整个页面的页头。即使 section 里也写了 header,也不会被波及
- h2 的 border-left ── 只在左边画一条粗线,作为标题的标记。border 可以分别设置上下左右
- border-collapse: collapse ── 把表格单元格的边框合并成一条线。没有它,相邻单元格之间会并排两条线
- tbody tr:nth-child(even) ── 只给偶数行加浅色,形成斑马纹。横向很长的行,眼睛更容易跟上
- .amount ── 金额右对齐,再用 font-variant-numeric: tabular-nums 让数字等宽。位数对齐,大金额一眼就能看出来
- .status-… ── 共同的形状写在 .status 里,只把颜色分到 .status-approved 等里面。一个元素上加了 2 个 class
新井一个 !important 都没有呢。
古井也没有 ID 选择器和 style 属性。不用高分的东西,就不会打起来。
按状态改变::hover 与 :focus-visible
用伪类,可以按“鼠标悬停时”“用键盘选中时”这样的状态来改变外观。
tbody tr:hover {
background: #fdf2e9; /* 鼠标悬停的行 */
}
.button:focus-visible {
outline: 3px solid #f5b041; /* 用键盘选中时 */
outline-offset: 2px;
}
:focus-visible 只在浏览器判断“应该显示框”的焦点状态下生效。用 Tab 键移过去时会显示,用鼠标点击时一般不显示。
新井这个焦点框是不是有点土?我问了 AI,说用 outline: none 就能去掉。
古井有人不用鼠标,只用键盘操作。对他们来说,那个框是表示“现在在哪里”的唯一标记。
新井就像把地图上“当前位置”的图钉拔掉?
古井没错。要去掉,就一定要换上别的标记。WCAG 也规定了焦点必须可见。
查清“为什么会这样”
CSS 不按预期显示时,在追加设置之前,先问问浏览器原因。在元素上右键,选择“检查”,就会打开开发者工具。
- Styles(样式) ── 命中这个元素的规则按胜出顺序排列,输掉的声明会被画上删除线。“输给了哪条规则”一眼就能看出来
- Computed(计算样式) ── 最终确定的值一览,通过继承决定的值也能在这里看到,还会显示盒模型的图
- 当场修改 ── 点击值就能在不改文件的情况下试验。成功后再抄回 style.css
写好的 CSS 的语法,可以用 W3C 的 CSS 验证服务,或第2讲用过的 Nu Html Checker(加上 --css)来检查。这次的 style.css 和 index.html 都没有错误。
请 AI 修改 CSS 时,也建议先在开发者工具里看清“为什么”。知道“这个颜色输给了 .note”,对 AI 的要求也会具体成“改 p.note 的颜色”,还能避免 !important 越堆越多。懂原因的人做 Vibe Coding,又快又不会崩。
结 — 一页总结与下讲预告
- CSS 用 link 元素作为独立文件读入。把内容(HTML)和外观(CSS)分开,改一个地方就够了。
- 规则是 选择器 { 属性: 值; }。出错会被默默忽略,要注意 ; 和 { }。
- 选择器以 class 为基础。名字按作用起,不按外观起。用后代、子、伪类缩小选中的范围。
- 冲突时按 !important → style 属性 → 优先级 → 后写的 的顺序决定胜负。把优先级统一压低,不依赖 !important。文字的颜色和字体会被继承。
- 所有元素都是 content・padding・border・margin 组成的盒子。用 box-sizing: border-box 让 width 成为到边框为止的宽度。上下的 margin 会折叠。
新井简直焕然一新!我这就用手机打开,给科长看看。
古井……用手机打开之前,先读下一讲。第5讲是布局与手机适配,要拿出真正排列盒子的工具了。
参考
- MDN — CSS selectors
- MDN — Introduction to the CSS cascade
- MDN — Specificity
- MDN — !important
- MDN — Inheritance
- MDN — The box model
- MDN — box-sizing
- MDN — Mastering margin collapsing
- MDN — Using CSS custom properties (variables)
- MDN — :focus-visible
- MDN — font-variant-numeric
- W3C WAI — Understanding SC 1.4.3: Contrast (Minimum)
- W3C WAI — Understanding SC 1.4.1: Use of Color
- W3C WAI — Understanding SC 2.4.7: Focus Visible
- Chrome for Developers — Chrome DevTools
- W3C — CSS Validation Service
- W3C — Cascading Style Sheets, designing for the Web: The CSS saga
页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示(您的环境中字体会不同)。优先级实验的颜色用 getComputedStyle()、盒子宽度和间距用 getBoundingClientRect(),都是从同一个 Chromium 中取出的实际值。对比度按 WCAG 2.2 的相对亮度公式计算。index.html 和 style.css 已用 Nu Html Checker(vnu)确认没有错误。CSS 历史的年月依据 W3C 的资料。登场人物均为虚构。
古法前端入门 第4讲 — CSS ①:选择器与盒模型 / 2026.10