Old-School Front-End / 2026 ・ No.04

古法前端入门

第4讲 CSS ① ── 选择器与盒模型:用手写的 CSS 给申请列表穿上衣服

发布

加上 CSS 的申请列表。深蓝色页头里有系统名和导航,红色的“+ 新建申请”按钮,3 个白色小盒子显示本月汇总,表格带有横线和斑马纹。金额右对齐,状态是带颜色的圆角徽章
本讲的目标。用手写的 CSS 给第2讲的申请列表穿上衣服(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。从第4讲开始,终于轮到 CSS 了。
我们不用任何库,用自己写的 CSS,给第2讲做好的申请列表穿上衣服。
要掌握的是三件事:选中哪些元素(选择器)、冲突时谁胜出(层叠),
以及所有元素都是盒子(盒模型)。

一

本讲的目标

新井前辈,CSS 我也让 AI 写好了!不过我改了按钮的颜色,表格的颜色就乱了;改好表格,按钮又乱了……最后加上 !important,就全好了!

古井那个 !important,一共有几个?

新井……23 个。

古井这就像每次着火,都往所有房间喷一遍灭火器。今天我们一边手写,一边弄懂 CSS 是怎么决定“让谁胜出”的。

当前位置:第4讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾

这一讲,我们给第2讲的申请列表(index.html)加上一个叫 css/style.css 的 CSS 文件。HTML 这边的改动,只有读入 CSS 的一行和几个 class。外观的变化是这样的:

并排的两个申请列表。1 只有 HTML 的版本,只是从上往下排着带蓝色下划线的链接和黑色文字。2 加上 CSS 的版本,页头、按钮、小盒子和表格都很整齐
①没有 CSS。②在同样的 HTML 里用 link 元素读入 style.css。区别只有 head 里的一行(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。浏览器边框是为了说明而补画的)

①是没有读入 CSS 的状态。标题又粗又大、链接是带下划线的蓝色,这些都来自浏览器自带的默认样式。②是在上面叠加了我们自己的 CSS。CSS 就是一门“不改 HTML,只改外观”的语言。

在 Vibe Coding 里,CSS 是最容易堆积“不知为何就能用”的地方。看不见不按预期显示的原因,就只好不断追加设置去覆盖。这一讲的目标,是让你自己能读懂背后的原因。

二

添加 CSS 的方式

把 CSS 加到 HTML 上,有三种方式。

添加 CSS 的 3 种方式

1 style 属性 <p style="color:red"> 只对这一个元素有效 无法复用 临时试验・最后手段 2 style 元素 <style>   p { color: red; } </style> 只对这一页有效 每页都要重写 适合小实验 3 link 元素 <link rel="stylesheet" href="css/style.css"> 独立的 CSS 文件 所有页面共用 正式就用它 css/style.css index.html form.html approve.html

正式开发用③。在 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 规则

.summary li { width: 220px; } 选择器 选哪些元素 声明 改什么、怎么改 属性 改什么 值 改成什么 声明块 { } 规则 声明用 ; 隔开,可以写任意多条(最后一条也加上 ;) 注释写成 /* … */,和 HTML 的 <!-- --> 不一样

“选哪些元素(选择器)”“改什么(属性)”“改成什么(值)”。翻译成人话就是“把汇总里的 li 的宽度,设为 220px”。

NOTE

CSS 出错时,会一声不吭地忽略。比如 color: red 后面忘了 ;,下一行接着写 background: #fff;,浏览器看到的就是“color: red background: #fff”这样一条声明,两条都会被丢掉。不会出现任何错误提示。觉得“没生效”时,先检查 ; 和 { } 是否成对。

四

选择器:选中谁

选择器是从 HTML 的树(第2讲)里选出元素的写法。我们在申请列表的树上,确认一下哪些元素会被选中。

选择器选中的元素

申请列表的树(index.html) A B C D E body header p.site nav › ul li × 3 a × 3 p.user main h1 p › a.button section h2 ul.summary li × 3 section h2 table › … › td footer A h2 元素(类型)选择器 所有 h2 B .button 类选择器 class 里有 button 的元素 C .summary li 后代选择器(空格) 只选 .summary 里的 li D body > header 子选择器(>) body 正下方的 header E nav a:hover 伪类 只在鼠标悬停时

请注意 C 的 .summary li。导航里也有 li,但被选中的只有汇总里的 li。如果写成 li { width: 220px; },导航的链接也会变成 220px 宽。

写法名称选中什么本讲的用处
h2元素(类型)所有这种元素标题左边的红线
.button类class 里有这个名字的元素把链接做成按钮样式
#warnIDid 是这个名字的元素(一页一个)不用(太强,见第五章)
*通配所有元素设置 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 类等 元素名 p 0 0 1 .note 0 1 0 p.note 0 1 1 .summary li 0 1 1 nav a:hover 0 1 2 #warn 1 0 0 从左边的位开始比,1-0-0 比 0-9-9 更强 胜负的判定 !important 带它的声明最优先 1 style 属性 比选择器更强 2 优先级 左边的计分表 3 后写的 同分时它胜 4 分不出就进入下一级

选择器有一个叫优先级(特异性)的 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 属性都压了下去。

POINT

!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(外边距)。把汇总小盒子用实际数值画出来,是这样的:

汇总小盒子的盒模型

content 186 × 57.6 12 12 16 16 12 12 width: 220px border-box,所以到边框为止是 220px 186 + 16 × 2 + 1 × 2 = 220 汇总小盒子 .summary li(单位 px) margin 外边距 border 边框 padding 内边距 content 内容

这里的关键是:width: 220px 指的是从哪里到哪里的宽度。这由 box-sizing 决定。

content-box 与 border-box

content-box 初始值。width 是内容的宽度 16 content 220 16 = 254px width: 220px border-box 本次的设置。width 到边框为止 16 content 186 16 = 220px width: 220px 实测:同样设置的盒子,分别是 254px 和 220px(getBoundingClientRect 的值)

初始值 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 / #c0392b5.44 : 1
页头#ffffff / #1f4e6b8.9 : 1
表格说明(caption)#5b6b75 / #f4f6f95.1 : 1
审批中#8a5a00 / #fff4d65.41 : 1
已批准#1d6b3a / #e3f4e95.72 : 1
已退回#a93226 / #fdecea5.79 : 1
已报销#4d5b66 / #eceff26.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;
}
给申请列表加上红框和编号。1 页头,2 按钮样式的链接,3 标题,4 汇总小盒子,5 表格,6 金额列,7 状态徽章,8 页脚
用了 CSS 的地方。①body > header,②.button,③h2,④.summary li,⑤table・th・td,⑥.amount,⑦.status,⑧body > footer(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。浏览器边框是为了说明而补画的)
  • 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

用伪类,可以按“鼠标悬停时”“用键盘选中时”这样的状态来改变外观。

并排的两张截图。1 鼠标悬停在表格第 2 行上,只有这一行变成浅橙色。2 用 Tab 键移到新建申请按钮,按钮周围出现黄色的框
①:hover ── 只有鼠标悬停的那一行变色。②:focus-visible ── 用 Tab 键选中的按钮出现黄色的框(笔者的演示页面,在云端的 Chromium 中截图。2026年10月17日。浏览器边框是为了说明而补画的)
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 都没有错误。

POINT

请 AI 修改 CSS 时,也建议先在开发者工具里看清“为什么”。知道“这个颜色输给了 .note”,对 AI 的要求也会具体成“改 p.note 的颜色”,还能避免 !important 越堆越多。懂原因的人做 Vibe Coding,又快又不会崩。

结 — 一页总结与下讲预告

  1. CSS 用 link 元素作为独立文件读入。把内容(HTML)和外观(CSS)分开,改一个地方就够了。
  2. 规则是 选择器 { 属性: 值; }。出错会被默默忽略,要注意 ; 和 { }。
  3. 选择器以 class 为基础。名字按作用起,不按外观起。用后代、子、伪类缩小选中的范围。
  4. 冲突时按 !important → style 属性 → 优先级 → 后写的 的顺序决定胜负。把优先级统一压低,不依赖 !important。文字的颜色和字体会被继承。
  5. 所有元素都是 content・padding・border・margin 组成的盒子。用 box-sizing: border-box 让 width 成为到边框为止的宽度。上下的 margin 会折叠。

新井简直焕然一新!我这就用手机打开,给科长看看。

古井……用手机打开之前,先读下一讲。第5讲是布局与手机适配,要拿出真正排列盒子的工具了。

参考

页面截图是笔者制作的演示页面,于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

← 返回文章列表