Old-School Front-End / 2026 ・ No.06
古法前端入门
第6讲 CSS ③ ── Bootstrap:懂了原理,再用前辈们的工具箱
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第6讲讲的是 Bootstrap。
第4、5讲亲手写的东西,只要加上 class 就能得到──这就是这个工具箱。
这一讲我们用 Bootstrap 重写申请列表和申请表单,确认每个 class 相当于我们手写的哪段 CSS。
便利背后的分量,以及 AI 常踩的坑,也都实际测了一下。
本讲的目标
新井前辈,Bootstrap 就是 AI 经常用的那个吧?写一堆 class,就像模像样了。
古井对,是一群“再也不想每次都写一遍同样的 CSS”的人做的工具箱。2011 年由 Twitter 公司的开发者公开,如今已经是标配了。
新井那一开始就教 Bootstrap 不就好了……
古井不懂原理就用,反而会被工具牵着走。第4、5讲亲手写过之后,你现在看到 class 的名字,应该就能想到“哦,是那段 CSS”。
当前位置:第6讲
这次用的是写作时的稳定版 Bootstrap 5.3.8(2025 年 8 月 26 日发布)。2026 年 10 月 8 日,下一个大版本 6.0.0 的第一个试作版(alpha.1)已经发布,但还处于试作阶段,业务系统用 5.3 系列。先把第5讲手写 CSS 的版本拿来并排看看。
外观几乎一样,区别在里面。①自己写的 CSS 有 205 行(不计空行,下同),②在 HTML 里加上 Bootstrap 的 class,自己的 CSS(custom.css)只有 31 行。不写 CSS,而是挑选 class,这就是 Bootstrap 的用法。
读入 Bootstrap
Bootstrap 的真面目,是一整套起好名字的 CSS 规则(bootstrap.min.css),以及负责导航开合等动作的 JavaScript(bootstrap.bundle.min.js)。
读入的顺序
文件的放法有两种。这次把用 npm 取来的文件放进了文件夹(vendor/bootstrap/)。只在公司内网运行的业务系统,这样更放心。
<!-- 放在自己的文件夹里(本讲的做法) --> <link rel="stylesheet" href="vendor/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="css/custom.css"> … <script src="vendor/bootstrap/js/bootstrap.bundle.min.js"></script> <!-- 从 CDN(分发服务)读入时 --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
使用 CDN 时的 integrity 意思是:“送来的文件如果和这个指纹(哈希)不一致,就不要用”。即使分发方的文件被篡改,也不会在不知情的情况下读入。上面的值是笔者用 npm 的 5.3.8 文件算出来的,并确认与从 jsDelivr 取来的文件一致。
读懂 class
Bootstrap 的 class 有两种:navbar、card 这样的组件,以及 mb-3、d-flex 这样只做一件事的小工具(工具类)。工具类的名字,只要懂了规则就能读懂。
拆解工具类的名字
把第4、5讲手写的 CSS 和 Bootstrap 的 class 对应起来,是这样的:
| 手写的 CSS(第4、5讲) | Bootstrap 的 class |
|---|---|
| body > header { display: flex; … } | navbar navbar-expand-md + container |
| .user { margin-left: auto; } | ms-md-auto |
| .page-head { display: flex; justify-content: space-between; … } | d-flex flex-wrap justify-content-between align-items-center gap-3 |
| .summary { display: grid; … } | row g-3 + col-12 col-sm-6 col-lg-4 |
| .summary li { border: 1px solid; border-radius: 8px; … } | card + card-body |
| .table-wrap { overflow-x: auto; } | table-responsive |
| tbody tr:nth-child(even)、tr:hover | table-striped、table-hover |
| .amount { text-align: right; white-space: nowrap; } | text-end text-nowrap |
| .status-approved { color: …; background: …; } | badge rounded-pill text-bg-success |
| @media (min-width: 768px) { … } | class 名里的 -md- |
新井真的耶……d-flex 就是 display: flex,ms-md-auto 就是 768px 以上时 margin-left: auto。再也不像咒语了!
古井这就是亲手写过的奖励。
栅格与屏幕宽度
第5讲我们用 grid 的 auto-fit 写了“能放几个放几个”。Bootstrap 的栅格思路不同:把横向分成 12 列,用 class 指定占几列。
12 列的栅格
col-12 col-sm-6 col-lg-4 读作:“平时占 12 列(一行一个),sm 以上占 6 列(两个并排),lg 以上占 4 列(三个并排)”。sm、lg 是 Bootstrap 定好的屏幕宽度分界(断点)。数一下 bootstrap.css 里 min-width 的值,共有 576、768、992、1200、1400px 这 5 处。下面是实际改变宽度测得的外观:
屏幕宽度的分界与实测外观
container 是把内容宽度按各个分界统一成固定值的盒子,代替了第5讲写的 max-width: 960px。导航的 navbar-expand-md 意思是“md(768px)以上横向展开,更窄时收进三条线的按钮”。
使用组件:导航・卡片・表格・徽章・按钮
组件只要按固定的组合写 HTML,外观和动作就一并到手。在手机上,导航会收进三条线的按钮(俗称汉堡按钮)。
开合的动作光靠 CSS 做不到,由在 body 末尾读入的 bootstrap.bundle.min.js 负责。按钮上的 data-bs-toggle="collapse" 和 data-bs-target="#mainNav" 就是“点击后开合 #mainNav”的暗号。JavaScript 的内容,从第7讲开始我们自己写。
- card ── 带边框、圆角和留白的盒子,内容放进 card-body。用 h-100 让并排卡片的高度一致
- table ── 只要加上 table 就有横线和留白。Bootstrap 把表格标题(caption)放在表格下方,为了放到上方,加了 caption-top
- badge ── 状态徽章。用 text-bg-success 等,背景色和与之相配的文字色一起决定
- btn ── 按钮。有 btn-primary 等颜色种类,这次想用品牌红,所以在 custom.css 里做了 btn-brand
Bootstrap 5.3 的按钮把颜色放在 --bs-btn-bg 等 CSS 变量(第4讲)里。所以只要覆盖变量,就能做出新颜色的按钮。
.btn-brand {
--bs-btn-color: #fff;
--bs-btn-bg: #c0392b; /* 平时的背景 */
--bs-btn-hover-bg: #a93226; /* 鼠标悬停时 */
…
}
Bootstrap 配色组合的对比度,也用 WCAG 公式算了一遍:
| class | 文字 / 背景(实测) | 对比度 |
|---|---|---|
| text-bg-warning | #000000 / #ffc107 | 12.88 : 1 |
| text-bg-success | #ffffff / #198754 | 4.53 : 1 |
| text-bg-danger | #ffffff / #dc3545 | 4.53 : 1 |
| text-bg-secondary | #ffffff / #6c757d | 4.69 : 1 |
| btn-primary(参考) | #ffffff / #0d6efd | 4.50 : 1 |
| btn-brand(自己做的) | #ffffff / #c0392b | 5.44 : 1 |
都满足普通文字 4.5:1 的标准(btn-primary 刚好达标)。不过徽章的字很小,自己改颜色时,注意别让这个数字降下来。
表单也用 Bootstrap
第3讲的申请表单也重写了。输入框用 form-control,下拉选择用 form-select,单选框和复选框用 form-check 的组合。label 和 name 的写法与第3讲相同。HTML 的正确性,换成 Bootstrap 也不会变。
<form action="/api/expenses" method="post" enctype="multipart/form-data">
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<fieldset class="col-md-7">
<legend class="h6 section-title">申请内容</legend>
<label for="title" class="form-label">事由(必填)</label>
<input type="text" class="form-control" id="title" name="title" required maxlength="50">
<div class="invalid-feedback">请输入事由。</div>
</fieldset>
<fieldset class="col-md-5">
<legend class="h6 section-title">支付方式</legend>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="payment" id="pay-personal" value="personal" checked>
<label class="form-check-label" for="pay-personal">个人垫付</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="payment" id="pay-corporate" value="corporate">
<label class="form-check-label" for="pay-corporate">公司卡</label>
</div>
</fieldset>
</div>
</div>
</div>
…
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="agree" name="agree" value="yes" required>
<label class="form-check-label" for="agree">已阅读费用报销规定(必填)</label>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="submit" class="btn btn-outline-secondary" name="action" value="draft" formnovalidate>保存草稿</button>
<button type="submit" class="btn btn-brand" name="action" value="submit">提交申请</button>
</div>
</form>
Bootstrap 还准备了用颜色显示输入检查结果的外观。form 加上 was-validated 这个 class 后,输入框会按浏览器检查(第3讲的 required 等)的结果变红或变绿。
截图是只填了事由、再加上 was-validated 的状态。值得注意的是,像明细第 2、3 行这样空着的选填栏,也会打上绿色对勾。“没填也算 OK?”会让用户困惑,实际使用时需要下点功夫,比如只给填过的栏打标记。而且,was-validated 要在“点击提交按钮时”加上,这是 JavaScript 的工作。后续在第9讲的输入校验里讲。
重写后的 HTML
Bootstrap 版的 index.html(表格只显示第 1 行):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>申请列表 | 报销系统</title>
<link rel="stylesheet" href="vendor/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="css/custom.css">
</head>
<body class="bg-body-tertiary">
<header class="navbar navbar-expand-md navbar-dark brand-bar">
<div class="container">
<a class="navbar-brand fw-bold" href="/"><img src="img/logo.svg" alt="" width="32" height="32"> 报销系统</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="打开菜单">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<nav aria-label="主菜单">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link active" aria-current="page" href="/">申请列表</a></li>
<li class="nav-item"><a class="nav-link" href="approve.html">审批</a></li>
<li class="nav-item"><a class="nav-link" href="dashboard.html">仪表盘</a></li>
</ul>
</nav>
<p class="navbar-text mb-0 ms-md-auto">新井 太郎(申请人)</p>
</div>
</div>
</header>
<main class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<h1 class="h3 mb-0">申请列表</h1>
<a class="btn btn-brand" href="form.html">+ 新建申请</a>
</div>
<section class="mb-4">
<h2 class="h5 section-title mb-3">本月汇总</h2>
<ul class="row g-3 list-unstyled">
<li class="col-12 col-sm-6 col-lg-4"><div class="card h-100"><div class="card-body">本月申请:4 笔</div></div></li>
<li class="col-12 col-sm-6 col-lg-4"><div class="card h-100"><div class="card-body">待审批金额:<strong class="d-block fs-4 text-danger">32,400 日元</strong></div></div></li>
<li class="col-12 col-sm-6 col-lg-4"><div class="card h-100"><div class="card-body">被退回:1 笔</div></div></li>
</ul>
</section>
<section>
<h2 class="h5 section-title mb-3">申请一览</h2>
<div class="table-responsive" tabindex="0" role="region" aria-label="申请一览">
<table class="table table-striped table-hover align-middle caption-top wide-table mb-0">
<caption>2026 年 10 月的申请(按时间倒序)</caption>
<thead class="table-light">
<tr>
<th scope="col">编号</th>
<th scope="col">申请日期</th>
<th scope="col">事由</th>
<th scope="col" class="text-end">金额(日元)</th>
<th scope="col">状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>0007</td>
<td class="text-nowrap"><time datetime="2026-10-16">2026-10-16</time></td>
<td><a href="detail.html">大阪出差(交通费・住宿费)</a></td>
<td class="text-end text-nowrap">32,400</td>
<td><span class="badge rounded-pill text-bg-warning">审批中</span></td>
</tr>
…
</tbody>
</table>
</div>
</section>
</main>
<footer class="py-4 text-center text-body-secondary">
<small>© 2026 KIJI 报销系统(练习用)</small>
</footer>
<script src="vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
然后,自己的 CSS 只有这些:
/* 在 Bootstrap 之后读入,只补上品牌色 */
/* 页头色带的颜色 */
.brand-bar {
background-color: #1f4e6b;
}
/* 品牌色的按钮(覆盖 Bootstrap 按钮的变量) */
.btn-brand {
--bs-btn-color: #fff;
--bs-btn-bg: #c0392b;
--bs-btn-border-color: #c0392b;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #a93226;
--bs-btn-hover-border-color: #a93226;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #922b21;
--bs-btn-active-border-color: #922b21;
--bs-btn-focus-shadow-rgb: 192, 57, 43;
font-weight: 700;
}
/* 标题左边的红线(与第4讲相同) */
.section-title {
padding-left: 10px;
border-left: 4px solid #c0392b;
}
/* 表格不再变窄(窄屏上横向滚动) */
.wide-table {
min-width: 640px;
}
.line-items {
min-width: 720px;
}
两者用 Nu Html Checker 检查都没有错误。HTML 稍微变长,CSS 几乎消失了。可以理解为外观的设置,从 CSS 文件搬到了 HTML 的 class 里。
常见的坑
Bootstrap 特有的绊脚石,我做了两个实验。
# ① 把 custom.css 放在 Bootstrap 之前读入时(.btn-brand 计算后的值) color → rgb(33, 37, 41) (顺序正确时为 rgb(255, 255, 255)) background-color → rgba(0, 0, 0, 0) 透明(顺序正确时为 rgb(192, 57, 43)) # ② 混入 Bootstrap 4 的写法时(在 Bootstrap 5.3.8 的页面上) ml-auto(v4) → margin-left 0px 名字的左边缘 502px ms-md-auto(v5) → margin-left 316.484px 名字的左边缘 819px data-toggle(v4) → 点击按钮后 aria-expanded="false"、菜单的高度 0px data-bs-toggle(v5) → 点击按钮后 aria-expanded="true"、菜单的高度 160px
- 读入顺序 ── Bootstrap 的 .btn 和自己的 .btn-brand 优先级都是 0-1-0。同分时后写的胜出(第4讲),所以先写 custom.css,.btn 的“背景透明”就赢了。自己的 CSS 一定要放在 Bootstrap 后面
- 版本差异 ── Bootstrap 4 和 5 之间,好几个 class 名和属性名都变了。旧写法不报错,只是不生效
| Bootstrap 4 的写法 | Bootstrap 5 的写法 |
|---|---|
| ml-*、mr-*、pl-*、pr-* | ms-*、me-*、ps-*、pe-*(left/right → start/end) |
| text-left、float-right | text-start、float-end |
| data-toggle、data-target | data-bs-toggle、data-bs-target |
| custom-select | form-select |
| form-group | 取消了(改用 mb-3 等间距类) |
| sr-only | visually-hidden |
| 需要 jQuery | 不再需要 jQuery |
网上大量残留着 Bootstrap 4 时代的写法,AI 会若无其事地把它们混进 5 的页面。看到 ml-auto 或 data-toggle 就要警惕。一开始就告诉 AI“用的是 Bootstrap 5.3”,生成的 class 再去官方文档核对,这就是古法的相处之道。
分量与“Bootstrap 脸”
便利的工具箱也有分量。下面是文件大小,以及用 Chromium 的覆盖率功能(统计 CSS 中在该页面实际用到的部分)测得的值:
文件大小与实际用到的比例(实测)
申请列表用到的只有 bootstrap.min.css 的 7.4%,其余是给其他组件准备的规则。传输时经 gzip 压缩约 31KB,读入一次后浏览器会缓存下来,对公司内部的业务系统来说,这个分量一般不成大问题。不过“为了一个按钮引入整个 Bootstrap”,还是值得想一想。
新井可是前辈,用 Bootstrap 做的页面,是不是都长得似曾相识?
古井就是所谓的“Bootstrap 脸”。业务系统的话,看惯了的脸反而让人安心。如果是面向客户的门面页面,就在 custom.css 里好好改改颜色和形状,或者干脆手写。工具要按目的来选。
结 — 一页总结与下讲预告
- Bootstrap 是 一整套起好名字的 CSS 规则 + 负责动作的 JS。不写 CSS,而是挑选 class。
- 自己的 CSS 一定要在 Bootstrap 之后读入。优先级相同时,后写的胜出。
- 工具类按 改什么・哪一边・从哪个宽度起・多少来读(例:ms-md-auto)。栅格 12 列,分界是 576・768・992・1200・1400px。
- 按钮颜色可以通过覆盖 CSS 变量来做。对比度、caption 位置等默认值要自己确认。
- Bootstrap 4 的写法(ml-auto、data-toggle)在 5 里默默失效。先确认版本再用。
新井这样外观就基本完成了!……不过,明细的合计金额,我还是自己按计算器写上去的。
古井让它自己算,靠的就是下一块肌肉。从第7讲开始是 JavaScript,先从计算合计的函数写起。
参考
- Bootstrap — Get started with Bootstrap (v5.3)
- Bootstrap — Breakpoints
- Bootstrap — Grid system
- Bootstrap — Spacing
- Bootstrap — Navbar
- Bootstrap — Buttons
- Bootstrap — Tables
- Bootstrap — Validation
- Bootstrap — Migrating to v5
- npm — bootstrap
- MDN — Subresource Integrity
- Chrome for Developers — Coverage: Find unused JavaScript and CSS
页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。手机是用 Playwright 的手机模式(isMobile)再现的,并非真机画面。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。Bootstrap 使用的是 npm 上 bootstrap 5.3.8 的文件,版本的发布日期依据 npm 的记录。文件大小、覆盖率、间距等级、container 的宽度、卡片的排列、按钮与徽章的颜色,以及读入顺序和 Bootstrap 4 写法的实验结果,都是在同一个 Chromium 中测得的实际值。对比度按 WCAG 2.2 的公式计算。HTML 和 custom.css 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第6讲 — CSS ③:Bootstrap / 2026.10