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

古法前端入门

第5讲 CSS ② ── 布局与手机:用 flex 和 grid 排列,适配屏幕宽度

发布

同一个申请列表,在电脑、平板、手机 3 种屏幕宽度下并排显示。电脑上页头排成一行,3 个汇总小盒子横向排列。手机上汇总小盒子纵向排列,标题和新建申请按钮仍然左右并排
本讲的目标。同一份 HTML 和 CSS,排列方式会随电脑、平板、手机而变化(笔者的演示页面,在云端的 Chromium 中截图。手机是 Playwright 的手机模式(390×844)。2026年10月17日。设备和浏览器的边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第5讲讲的是布局。
上一讲穿好衣服的申请列表,用手机一打开……惨不忍睹。
这一讲我们用 flex(排成一列)、grid(排进格子)和媒体查询(按屏幕宽度切换),
让同一份 HTML 和 CSS,在电脑和手机上都能看得清楚。

一

本讲的目标

新井前辈……我用手机给科长看,他说“状态这一栏,怎么跟对联似的”。

古井所以我说了,用手机打开之前先读第5讲。

新井我让 AI “适配一下手机”,结果 CSS 变成了两倍长,我都不知道该改哪儿了……

古井排列的工具其实只有 3 种。弄懂了它们,手机适配就不可怕。

当前位置:第5讲

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

先看看现状。我用手机(宽 390px)打开了申请列表。

并排的 3 台手机画面。1 没有 meta viewport 时,整个页面被缩小,文字无法阅读。2 保持第4讲的 CSS,表格被挤扁,事由一栏细细地折行,状态徽章变成竖排。3 第5讲中,汇总小盒子纵向排列,表格可以横向滚动
①删掉 meta viewport 的情况。②保持第4讲的 CSS。③本讲的 CSS。②的状态徽章简直像竖着写的对联(笔者的演示页面,在云端的 Chromium 中截图。手机是 Playwright 的手机模式(390×844)。2026年10月17日。设备和浏览器的边框是为了说明而补画的)

②是保持上一讲 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 width=device-width 980px × 0.398 390px × 1 16px 的文字变成约 6.4px 16px 的文字还是 16px

没有 meta 时,页面被缩小到 0.398 倍,16px 的文字只有约 6.4px,不用手指放大根本看不清。width=device-width 的意思是“按设备屏幕的宽度排版”,initial-scale=1 是“一开始按原尺寸显示”。手机适配的第一步,就是这一行。

不过,即使有这一行,也会像②那样乱掉。meta viewport 只负责“按手机的宽度排版”,让内容收进这个宽度,是 CSS 的工作。

三

排列盒子的 3 种方式

第4讲我们学到“所有元素都是盒子”。排列这些盒子的方式,大致分为 3 种。

排列盒子的 3 种方式

普通流 纵向堆叠(默认) 例:正文段落 flex 沿一个方向排列 例:页头、标题行 grid 放进纵横的格子 例:汇总小盒子 block block a block 主轴(排列方向) 先定好列和行
  • 普通流 ── 什么都不指定时的排列方式。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; } .site nav .user gap margin-left: auto 主轴(横向) justify-content 交叉轴(纵向) align-items: center 给父元素写 display: flex,子元素就沿主轴排成一列 margin-left: auto 意思是“左边剩下的空间全给我”,靠右的经典写法
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 与子元素的位置(实测)

justify-content: 子元素左边缘的位置(实测 px,间距 8px) flex-start 0 88 176 flex-end 224 312 400 center 112 200 288 space-between 0 200 400 最后一个加 margin-left: auto 0 88 400 space-between 贴住两端,剩余空间平分。auto 只把一个推到右端

页头用到的 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 的列数与列宽(实测)

repeat(auto-fit, minmax(200px, 1fr)) 屏幕 390px(内容 358px)→ 1 列 358 屏幕 600px(内容 568px)→ 2 列 278 278 屏幕 768px(内容 720px)→ 3 列 232 232 232 屏幕 1024px(内容 912px)→ 3 列 296 296 296 0px 同样 1024px,改成 auto-fill → 第 4 列空着留下 219 219 219 219 能放几列就放几列(最小 200px),剩余用 1fr 平分 auto-fit 把空列折叠成 0,auto-fill 则保留

手机上 1 列,600px 时 2 列,768px 时 3 列。一行媒体查询都没写,列数就自己变了。1024px 时有放 4 列的空间,但盒子只有 3 个,auto-fit 会把空着的第 4 列折叠成 0px,让 3 个盒子铺满整行。改成 auto-fill 的话,空列会保留下来,右边空出一列的位置。这次我们选了“盒子铺满整行”的 auto-fit。

POINT

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;   /* 金额・日期・状态不折行(实际是在各条规则里各加一行) */
}
手机上表格部分的两张截图并排。1 滚动之前,看得到编号、申请日期和事由。2 把表格向左滑,出现金额和状态两列。整个页面不动
①滚动之前。②只把表格横向滑动。页面保持 390px,只有表格在动(笔者的演示页面,在云端的 Chromium 中截图。手机是 Playwright 的手机模式(390×844)。2026年10月17日。设备和浏览器的边框是为了说明而补画的)
  • 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)

屏幕宽度与切换的位置(实测)

320 444 656 768 1024 1280 屏幕宽度(px) 共通样式(先按手机来写) @media (min-width: 768px) 从这里追加 汇总 1 列 汇总 2 列 汇总 3 列 表格横向滚动 672 蓝色和虚线部分,不写媒体查询也会自然变化

有意思的是,用媒体查询切换的只有页头相关的设置和 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;
  }
}
在电脑宽度的申请列表上加红框和编号。1 页头(flex),2 用户名(margin-left: auto),3 标题与按钮那一行(flex),4 汇总小盒子(grid),5 表格的外框(横向滚动)
本讲加上布局的地方。①body > header,②.user,③.page-head,④.summary,⑤.table-wrap(笔者的演示页面,在云端的 Chromium 中截图。手机是 Playwright 的手机模式(390×844)。2026年10月17日。设备和浏览器的边框是为了说明而补画的)
  • 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 确认 ── 看媒体查询里的设置在这个宽度下有没有生效(有没有被画上删除线)
NOTE

设备模式是方便的近似,和真正的手机并不完全一样。字体以及用手指操作的手感,要在真机上确认。让 AI “适配手机”时也一样,从 320px 到 1280px 拖动宽度,亲眼看看有没有乱掉的地方,这就是古法的验货。

结 — 一页总结与下讲预告

  1. 手机适配的第一步是 meta viewport。没有它,页面会被当作 980px 的屏幕来排版再缩小。
  2. 排列盒子的方式有 普通流・flex・grid 3 种。排一列用 flex,排格子用 grid。
  3. flex 有主轴和交叉轴。用 justify-content 和 align-items 排列,用 margin-left: auto 推到最右边。
  4. grid 的 repeat(auto-fit, minmax(200px, 1fr)) 不用媒体查询也能改变列数。表格只在外框里横向滚动。
  5. 媒体查询先写手机用的,再用 min-width 追加。断点按内容来定,而不是按设备来定。

新井自己写的 CSS 只有 AI 版的一半。可是按钮啊表格啊,每次都要手写到这个程度吗……?

古井问得好。下一讲是 Bootstrap,我们来打开前辈们“再也不想每次都写一遍”而做出来的工具箱。现在你懂了里面的原理,就不会被工具牵着走。

参考

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

← 返回文章列表