Old-School Front-End / 2026 ・ No.05
古法前端入门
第5讲 CSS ② ── 布局与手机:用 flex 和 grid 排列,适配屏幕宽度
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第5讲讲的是布局。
上一讲穿好衣服的申请列表,用手机一打开……惨不忍睹。
这一讲我们用 flex(排成一列)、grid(排进格子)和媒体查询(按屏幕宽度切换),
让同一份 HTML 和 CSS,在电脑和手机上都能看得清楚。
本讲的目标
新井前辈……我用手机给科长看,他说“状态这一栏,怎么跟对联似的”。
古井所以我说了,用手机打开之前先读第5讲。
新井我让 AI “适配一下手机”,结果 CSS 变成了两倍长,我都不知道该改哪儿了……
古井排列的工具其实只有 3 种。弄懂了它们,手机适配就不可怕。
当前位置:第5讲
先看看现状。我用手机(宽 390px)打开了申请列表。
②是保持上一讲 CSS 的状态。表格被硬塞进手机的宽度,事由每几个字就折一行,状态徽章变成一个字一个字地竖着排。①是在此基础上,再删掉 HTML 里的 meta viewport。③是这一讲的目标。HTML 的改动只是加上 2 个外框 div,剩下的都用 CSS 解决。
了解手机屏幕:meta viewport
还记得从第2讲开始就写在 head 里的这一行吗?
<meta name="viewport" content="width=device-width, initial-scale=1">
没有它,手机浏览器会认为“这是给电脑看的页面”,假装有一块 980px 宽的屏幕来排版,再缩小显示。这是在手机屏幕上打开后测得的值:
# 在手机(390×844)上打开测得的值 没有 meta 第4讲的原样 第5讲 排版宽度 980px 390px 390px 显示倍率 0.398 1 1 表格宽度 912px 342px 640px(外框 358px,横向滚动) 事由列的宽度 357px 73px 251px 状态徽章的高度 27px 68px 27px
有无 meta viewport
没有 meta 时,页面被缩小到 0.398 倍,16px 的文字只有约 6.4px,不用手指放大根本看不清。width=device-width 的意思是“按设备屏幕的宽度排版”,initial-scale=1 是“一开始按原尺寸显示”。手机适配的第一步,就是这一行。
不过,即使有这一行,也会像②那样乱掉。meta viewport 只负责“按手机的宽度排版”,让内容收进这个宽度,是 CSS 的工作。
排列盒子的 3 种方式
第4讲我们学到“所有元素都是盒子”。排列这些盒子的方式,大致分为 3 种。
排列盒子的 3 种方式
- 普通流 ── 什么都不指定时的排列方式。block(p、h2、section 等)纵向堆叠,inline(a、span 等)在文字中流动。到第4讲为止的申请列表,几乎只用了这一种
- flex ── 给父元素写 display: flex,子元素就沿一个方向排成一列。擅长页头内容、标题与按钮这类“横向一行”
- grid ── 给父元素写 display: grid,先定好列和行的格子,再把子元素放进去。擅长把卡片或盒子排成好几列
古井没有 flex 和 grid 的年代,我们用 float──本来是“让文字绕着图片排”的技术──硬做分栏。
新井用文字绕图的技术,排整个页面?
古井对。所以各栏高度对不齐,还得念一段收尾的咒语(clearfix)。现在老老实实用 flex 和 grid 就行。古法的苦,就留在古代吧。
flex:排成一列
在电脑的宽度下,想把纵向堆了 3 层的页头排成横向一行。这正是 flex 的用武之地。
把页头设为 flex
body > header {
display: flex; /* 子元素排成一列 */
flex-wrap: wrap; /* 放不下就折行 */
align-items: center; /* 纵向居中对齐 */
gap: 8px 32px; /* 纵向间距 8px,横向间距 32px */
}
.user {
margin: 0 0 0 auto; /* 吃掉左边剩余的全部空间,靠到最右边 */
}
flex 有两个方向。子元素排列的方向是主轴(默认横向),与它垂直的是交叉轴(纵向)。主轴上的排列用 justify-content,交叉轴上的对齐用 align-items。justify-content 的值不同,子元素的位置会怎样变化?我在实验页面上测了一下:
justify-content 与子元素的位置(实测)
页头用到的 margin-left: auto 是 flex 的经典小技巧。给 flex 的子元素加上 auto 的 margin,剩余的空间会全部被这个 margin 吃掉,于是只有这个子元素被推到最右边。“logo 和导航靠左,只有用户名靠右”这样的布局,一行就能写出来。
标题和按钮那一行也是 flex。在 HTML 里加上 div class="page-head" 外框,用 justify-content: space-between 分到两端。这一行在手机上也保持左右并排,因为 h1 和按钮都很短,窄屏也放得下(放不下时会用 flex-wrap 折行)。
grid:排进格子
上一讲,汇总小盒子是用 inline-block 和 width: 220px 排列的。宽度写死了,所以在电脑上右边空着一大块,在手机上一个一个纵向排列,右边也空出一块。我们把它换成 grid。
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
第 2 行像咒语,但从里往外读就很简单:
- minmax(200px, 1fr) ── 每列宽度“最小 200px,最大 1fr”。fr 是 grid 专用的单位,意思是“剩余宽度中的份额”,都是 1fr 就平均分
- repeat(auto-fit, …) ── 这样的列“能放几列就放几列”。200px 的列放得下几列,由浏览器根据宽度来计算
改变屏幕宽度,实际测量列宽:
auto-fit 的列数与列宽(实测)
手机上 1 列,600px 时 2 列,768px 时 3 列。一行媒体查询都没写,列数就自己变了。1024px 时有放 4 列的空间,但盒子只有 3 个,auto-fit 会把空着的第 4 列折叠成 0px,让 3 个盒子铺满整行。改成 auto-fill 的话,空列会保留下来,右边空出一列的位置。这次我们选了“盒子铺满整行”的 auto-fit。
grid 只需要记住“最小宽度”,分几列交给浏览器。比起按设备类型分别写“手机 1 列、平板 2 列、电脑 3 列”,CSS 短得多,遇到意想不到的宽度也更稳。
表格,只让表格滚动
表格的意义在于行和列对得整整齐齐。硬压缩就会像②那样没法看;可如果让整个页面横向超出,整页都会左右晃。所以我们用 div 把表格包起来,只让这个外框里面横向滚动。
<div class="table-wrap" tabindex="0" role="region" aria-label="申请一览"> <table> … </table> </div>
.table-wrap {
overflow-x: auto; /* 超出时,在这个盒子里横向滚动 */
}
table {
width: 100%;
min-width: 640px; /* 不再继续压缩 */
}
.amount, td time, .status {
white-space: nowrap; /* 金额・日期・状态不折行(实际是在各条规则里各加一行) */
}
- min-width: 640px ── 规定表格的最小宽度。比这更窄的屏幕上,表格不再压缩,而是在外框里超出,于是在外框里横向滚动
- white-space: nowrap ── 让日期、金额和徽章不在中间折行,防止“2026-10-”和“16”被拆散
- tabindex="0" ── 让外框能获得键盘焦点。获得焦点后按方向键,不用鼠标也能横向滚动
- role="region" 和 aria-label ── 能获得焦点的地方需要名字。告诉读屏软件这是一个叫“申请一览”的区域
媒体查询:按宽度切换
页头只想在电脑这样的宽屏上排成一行。手机上排成一行的话,logo、导航和用户名会挤成一团。像这样“按屏幕条件切换 CSS”的,就是媒体查询。
/* 到这里为止,对所有屏幕都生效(按手机来写) */
body > header { background: var(--brand); padding: 12px 24px; }
@media (min-width: 768px) {
/* 只有屏幕宽度在 768px 以上时,才额外生效 */
body > header { display: flex; … }
}
这次采用的写法是先写手机用的样式,再用 min-width 为宽屏追加设置(移动优先)。屏幕越窄,设置越少,越容易保持简单。我把屏幕宽度每次改 1px,查了外观切换的位置:
# 把屏幕宽度从 300px 到 1300px 每次改 1px,发生变化的位置 300px〜 汇总 1 列・表格横向滚动・页头纵向堆叠 444px〜 汇总 2 列 656px〜 汇总 3 列 672px〜 表格收进外框(不再横向滚动) 768px〜 媒体查询生效 → 页头排成一行(display: flex)
屏幕宽度与切换的位置(实测)
有意思的是,用媒体查询切换的只有页头相关的设置和 main 的留白。汇总的列数(grid)和表格的滚动(overflow),都会随内容宽度自然变化。把媒体查询当作最后的手段,先选择“随宽度自然伸缩”的写法,这是诀窍。
新井768px 有什么讲究吗?是 iPad 的宽度?
古井按设备型号来定,每出一款新机型就得追一次。决定它的是内容。页头的 logo、导航和名字舒舒服服排成一行的宽度,这次大约是 768px,仅此而已。
完成后的 CSS
HTML 的改动(粗体是新加的行):
<main>
<div class="page-head">
<h1>申请列表</h1>
<p><a class="button" href="form.html">+ 新建申请</a></p>
</div>
<section>
<h2>本月汇总</h2>
<ul class="summary">
<li>本月申请:4 笔</li>
<li>待审批金额:<strong>32,400 日元</strong></li>
<li>被退回:1 笔</li>
</ul>
</section>
<section>
<h2>申请一览</h2>
<div class="table-wrap" tabindex="0" role="region" aria-label="申请一览">
<table>
<caption>2026 年 10 月的申请(按时间倒序)</caption>
<thead>
…
</table>
</div>
</section>
然后是 css/style.css 的全部内容。粗体是相对第4讲修改或新增的行。
/* ===== 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: 16px;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
/* 标题和按钮分在左右两边 */
.page-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-head p {
margin: 0;
}
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;
}
/* 汇总:用网格,能放几个就横排几个(最小 200px) */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
}
.summary li {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.summary strong {
display: block;
color: var(--accent);
font-size: 1.25rem;
}
/* ===== 4. 表格 ===== */
/* 表格:在窄屏上,只让表格横向滚动 */
.table-wrap {
overflow-x: auto;
}
.table-wrap:focus-visible {
outline: 3px solid #f5b041;
}
table {
width: 100%;
min-width: 640px;
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;
white-space: nowrap;
}
td time {
white-space: nowrap;
}
/* 状态徽章(不只靠颜色,文字也要保留) */
.status {
display: inline-block;
padding: 2px 10px;
border: 1px solid;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 700;
white-space: nowrap;
}
.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;
}
/* ===== 6. 768px 以上的屏幕(平板・电脑) ===== */
@media (min-width: 768px) {
/* 页头排成横向一行 */
body > header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 32px;
}
nav ul {
margin: 0;
}
/* 把用户名推到最右边 */
.user {
margin: 0 0 0 auto;
}
main {
padding: 24px;
}
}
- main 的 padding ── 手机上 16px,768px 以上 24px。窄屏上留白也稍微收紧
- 从 .summary li 删掉 width 和 inline-block ── 盒子的宽度由 grid 的列决定,子元素不写宽度。写了就会和 grid 打架
- h1 的 margin 改成 0 ── 标题和按钮的纵向位置,交给 .page-head 的 align-items: center 来对齐
新井AI 的“手机适配版”有 5 个媒体查询,同一个设置被覆盖了好几次。
古井这次的媒体查询只有 1 个,因为伸缩交给了 grid 和 overflow。CSS 短,修改时就不会迷路。
检查方法
不用每次都传到真机上,也有办法检查手机上的显示,那就是 Chrome 开发者工具的设备模式。
- 打开方法 ── 打开开发者工具,点左上角手机和平板形状的图标(设备工具栏)。快捷键 Windows 是 Ctrl+Shift+M,Mac 是 Cmd+Shift+M
- 拖动宽度 ── 拖动屏幕边缘改变宽度,找出在哪里乱掉。也要试试 320px 左右这样的窄宽度
- 用 Styles 确认 ── 看媒体查询里的设置在这个宽度下有没有生效(有没有被画上删除线)
设备模式是方便的近似,和真正的手机并不完全一样。字体以及用手指操作的手感,要在真机上确认。让 AI “适配手机”时也一样,从 320px 到 1280px 拖动宽度,亲眼看看有没有乱掉的地方,这就是古法的验货。
结 — 一页总结与下讲预告
- 手机适配的第一步是 meta viewport。没有它,页面会被当作 980px 的屏幕来排版再缩小。
- 排列盒子的方式有 普通流・flex・grid 3 种。排一列用 flex,排格子用 grid。
- flex 有主轴和交叉轴。用 justify-content 和 align-items 排列,用 margin-left: auto 推到最右边。
- grid 的 repeat(auto-fit, minmax(200px, 1fr)) 不用媒体查询也能改变列数。表格只在外框里横向滚动。
- 媒体查询先写手机用的,再用 min-width 追加。断点按内容来定,而不是按设备来定。
新井自己写的 CSS 只有 AI 版的一半。可是按钮啊表格啊,每次都要手写到这个程度吗……?
古井问得好。下一讲是 Bootstrap,我们来打开前辈们“再也不想每次都写一遍”而做出来的工具箱。现在你懂了里面的原理,就不会被工具牵着走。
参考
- MDN — <meta name="viewport">
- MDN — Viewport concepts
- MDN — Basic concepts of flexbox
- MDN — Basic concepts of grid layout
- MDN — Relationship of flexbox to other layout methods
- MDN — Using media queries
- MDN — Responsive web design
- Chrome for Developers — Simulate mobile devices with device mode
页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。手机和平板是用 Playwright 的手机模式(isMobile)再现的,并非真机画面。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。排版宽度与倍率、表格与列的宽度、徽章的高度、flex 子元素的位置、grid 的列宽、切换时的屏幕宽度,都是在同一个 Chromium 中测得的实际值(会因文字宽度略有不同)。index.html 和 style.css 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第5讲 — CSS ②:布局与手机 / 2026.10