第 5 章 · 案例实战
从零设计一个博客首页
前四章的方法在这一章收束成一份可运行的成品。四个阶段、四份交付物:需求与线框 → 骨架选型 → 完整实现 → 逐块精修。结尾附一份可以直接拿去用的验收清单。
先回答「谁来读、来干什么」
布局的每一个取舍都应该能追溯到一条需求。没有需求约束的「好看」只是碰运气。
讲解
案例背景
「远山手记」是一份个人技术博客,长期更新设计与前端话题。作者自己写、自己排版,没有运营团队,也没有广告位。改造前的首页是「一栏长文一路铺到底」,问题不是不好看,而是没法用:读者找不到入口,老读者也看不到「有没有更新」。
两类读者,两种任务
| 读者 | 他们来干什么 | 布局必须提供 |
|---|---|---|
| 第一次来的访客 | 30 秒内判断「这里有没有我要的东西」,决定要不要留下来。 | 一个明确的精选区 + 一眼可扫的文章标题列表 + 清晰的文章主题标签。 |
| 回访的老读者 | 快速确认「有没有新文章」,然后直接点进去。 | 稳定的最新文章位置(不要每次改版都换地方)+ 归档入口 + 订阅入口。 |
约束条件
- 技术约束:纯静态、无构建、无图片素材 —— 所以缩略图用 CSS 渐变生成,页面必须脱网可用。
- 内容约束:文章数量会持续增长,因此列表必须能「长得下去」,不能是一个写死高度的大区块。
- 性能与无障碍约束:移动端优先、正文可读、键盘可达、尊重「减少动态效果」的系统设置。
把需求翻译成布局决定,是这一节的核心动作。「老读者要快速确认有没有更新」→ 最新文章列表必须位于 DOM 与视觉的双重起点;「缩略图没有素材」→ 用 aspect-ratio 固定的渐变色块,反而让版面更耐看、更不挑内容。
关键代码
/* 线框阶段的产出:一张能表达优先级与跨列关系的图 */
.blog {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); /* 正文 : 侧栏 = 2 : 1 */
gap: 24px;
}
/* 通栏区:用 1 / -1 跨满所有列,不依赖具体列数 */
.blog > .hero,
.blog > .footer { grid-column: 1 / -1; }
/* 侧栏吸顶:读者滚动正文时次级入口始终可见 */
.blog > .sidebar {
align-self: start;
position: sticky;
top: 24px;
}
/* 侧栏里的卡片等距堆叠,用 Flex 而不是 Grid —— 一维排布 */
.sidebar { display: flex; flex-direction: column; gap: 16px; }
2fr : 1fr的比例来自需求:正文是主任务,侧栏是辅助 —— 不是随手写的。- 侧栏
sticky在长列表页面里价值很高,但要配align-self: start,否则 Grid 项目默认拉伸会让 sticky 失效(本站的章节目录就是同一个坑的实例)。 - 线框阶段就写得出这几行 CSS —— 这正是 Grid 的价值:版式可以先用代码描述,再填内容。
动态演示
试试关掉侧栏:正文立刻获得满宽 —— 但请注意读数里那句取舍说明。线框阶段的价值就在于,你可以用三秒钟否定一个方案,而不必先把它写出来。
三个骨架方案,一次说清取舍
把候选方案并排画出来、把代价写在旁边,决策就不再依赖直觉。
讲解
选型的三个维度
- 阅读体验:正文行宽是否落在可读区间(2.1)?这是硬指标。
- 信息容量:一屏能承载多少个「入口」?这决定了新访客的留存。
- 降级成本:窄屏时要拆掉几层结构?每拆一层就多一个断点、多一处可能出错的地方。
把三个方案放到这三个维度上量一遍,结论其实很明确:方案 B(两栏)在三个维度上的总和最优。本文这个博客的内容量还没到需要三栏的程度,而纯单栏又装不下「归档 + 订阅 + 标签」这三个入口。
最终决策:两栏为主,宽屏升级到三栏。默认(窄屏)单栏 → 600px 起两栏 → 1200px 起加上左侧归档栏变成三栏。选择三栏而不是「两栏 + 把归档塞进侧栏」,是因为归档在宽屏上需要常驻可见(老读者的高频入口),而侧栏已经有订阅与标签两个模块了。
关键代码
/* 默认:手机单栏 */
.f-grid {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "stream" "side";
}
/* 600px:平板两栏 —— 正文为主,侧栏 220px */
@media (min-width: 600px) {
.f-grid {
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-areas: "stream side";
gap: 32px;
}
}
/* 1200px:桌面三栏 —— 归档栏登场 */
@media (min-width: 1200px) {
.f-grid {
grid-template-columns: 180px minmax(0, 1fr) 240px;
grid-template-areas: "rail stream side";
}
.f-rail { display: grid; } /* 归档栏只在最宽档出现 */
}
/* 三段式:DOM 顺序 = 内容优先级,视觉顺序由 areas 决定 */
/* 归档栏放在 DOM 的第二位?不行 —— 它应当是 <aside>,
因此 HTML 里写在 main 之后,靠 grid-area 放到左侧 */
- 三个断点对应三种结构,而不是三套样式 —— 每档只做「加一栏」,没有任何撤销操作。
.f-rail默认display: none:它在窄屏下不存在(内容是归档列表,属于低频信息),宽屏才出现。用显示/隐藏而不是重排,可以让窄屏的阅读顺序保持干净。- 对照第 4 章:这里的结构与 4.1 演示里跑的正是同一份代码。
动态演示
方案 A · 单栏
- 阅读体验最好,行宽容易控制
- 实现与降级成本最低
- 但归档 / 订阅 / 标签三处入口无处安放
方案 B · 两栏(推荐)
- 正文行宽仍可控制在可读区间
- 侧栏承载订阅 / 关于 / 标签
- 窄屏只需一次「侧栏下移」的降级
方案 C · 三栏 + 通栏头尾
- 信息容量最大,导航可常驻
- 900px 以下会挤,需要两处断点降级
- 对当前内容量来说,主内容被压得过窄
切换三个方案,被选中的面板会加一圈高亮,下方给出选择它的完整理由。注意:最终成品其实同时包含了 A 和 B —— 窄屏是 A,宽屏是 B(再加一栏),这正是响应式布局的常态。
完整实现:三份文件,一个成品
成品只有 HTML、CSS、一小段 JS。下面是可以直接复制运行的代码,以及它在四种宽度下的真实表现。
讲解
文件结构
case/
├─ index.html 结构与文案(语义化标签:header / main / aside / footer)
├─ frame.css 全部样式:令牌 → 骨架 → 组件 → 断点
└─ lang.js 一小段脚本:读取 ?lang 参数做双语,与本站主站的配置对象同源
样式表内部严格按四层顺序排列,这个顺序本身就是一份可读的文档:
- 令牌层:颜色、间距刻度、字体、圆角 —— 所有值只在这里出现一次;
- 骨架层:页面级的 Grid(
.f-shell/.f-grid),决定区域的相对位置; - 组件层:卡片、页头、侧栏模块;内部用 Flex,尺寸用令牌;
- 断点层:三个
min-width媒体查询,全部集中在文件末尾。
读这份样式表的顺序:只想改版式,先看断点层(三档结构);只想换配色或间距,只看令牌层。中间两层通常不用动 —— 这正是四层顺序带来的好处。
几处值得单独说明的决定
- 缩略图用 CSS 渐变而不是图片:
aspect-ratio固定比例 → 加载零等待、不产生布局跳动(CLS),而且不用维护素材。缺点是同质化,但博客列表页的缩略图本来就是「节奏点」而非内容。 - 用边框分组,而不是阴影:列表里十几张卡片,如果每张都带阴影,页面会变得很吵;1px 边框在密集列表里安静得多。
- 用
grid-auto-rows与1fr保证同一行的卡片等高,按钮靠margin-top: auto吸到卡片底部 —— 一行里所有按钮因此落在同一条线上(回到 3.4 的第三条判据)。 - 主题只提供了浅色,但所有颜色都走令牌,因此加一套
[data-theme=dark]覆盖即可 —— 主站正是这么做的。
关键代码
<div class="f-shell">
<header class="f-head">
<div class="f-brand">远山<span>手记</span></div>
<nav class="f-nav">
<a href="#" aria-current="page">文章</a>
<a href="#">笔记</a>
<a href="#">关于</a>
</nav>
<button class="f-search" type="button">搜索</button>
</header>
<div class="f-grid">
<!-- 归档栏:DOM 里在正文之后,靠 grid-area 放到左侧 -->
<aside class="f-rail">
<h4>归档</h4>
<a href="#" aria-current="page">2026 · 秋</a>
<a href="#">2026 · 夏</a>
</aside>
<!-- 正文:DOM 顺序上的第一块内容 -->
<main class="f-stream">
<section class="f-hero">
<p class="f-kicker">精选</p>
<h1>用栅格与留白,把首页安顿好</h1>
<p class="f-lead">先确定信息优先级,再用栅格落位……</p>
</section>
<h2 class="f-sec-title">最新文章</h2>
<div class="f-posts">
<a class="f-post" href="#">
<span class="f-thumb" aria-hidden="true"></span>
<span>
<h3>圣杯布局为什么还在被使用</h3>
<p>三栏 + 通栏头尾 + 正文优先加载……</p>
</span>
</a>
</div>
</main>
<aside class="f-side">…订阅 / 关于 / 标签…</aside>
</div>
<footer class="f-foot">© 2026 远山手记</footer>
</div>
- 用语义化标签(
header/main/aside/nav/footer)而不是一堆div:屏幕阅读器据此提供地标导航,键盘用户可以一键跳到main。 - 页面里只有一个
h1,标题层级不跳级(h1 → h2 → h3)—— 这是无障碍的基本要求,也顺手保证了视觉层级。 aria-hidden用在纯装饰的缩略图上,避免屏幕阅读器读出无意义的空元素。
/* ① 令牌层 */
:root {
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--accent: #1f6b64;
--line: #e6e2da;
}
/* ② 骨架层:Grid 决定区域位置 */
.f-shell {
display: grid;
gap: var(--space-5);
max-width: 1180px;
margin-inline: auto;
padding: var(--space-4);
}
.f-grid {
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "stream" "side";
}
.f-rail { display: none; }
.f-stream { grid-area: stream; min-width: 0; }
.f-side { grid-area: side; min-width: 0; }
/* ③ 组件层:Flex 处理组件内部 */
.f-post {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: var(--space-3);
padding: var(--space-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.f-thumb {
width: 64px;
aspect-ratio: 1; /* 固定比例:加载不跳动 */
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #7fb8ae);
}
.f-hero h1 {
font-size: clamp(22px, 3.4vw + 8px, 40px); /* 流体字号:少一个断点 */
}
/* ④ 断点层:集中在文件末尾,只做增强 */
@media (min-width: 600px) {
.f-grid {
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-areas: "stream side";
gap: var(--space-6);
}
}
@media (min-width: 900px) {
.f-posts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.f-post:first-child { grid-column: 1 / -1; } /* 首篇通栏,形成节奏 */
}
@media (min-width: 1200px) {
.f-grid {
grid-template-columns: 180px minmax(0, 1fr) 240px;
grid-template-areas: "rail stream side";
}
.f-rail { display: grid; align-content: start; gap: 4px; }
}
- 四层的顺序不能乱:令牌在最上(所有值来自这里),断点在最下(只覆盖位置与尺寸)。中间两层是「结构」与「零件」的分工。
- 注意组件层里
.f-post用的是 Grid(二维:图与文),而不是 Flex —— 图文并排且需要对齐时 Grid 更省事;.f-thumb用aspect-ratio: 1保证永远是正方形。 - 断点层里没有任何颜色或字体声明 —— 那是令牌层的职责。这条纪律让改版变得很安全。
// 文案集中在一个配置对象里,切换语言只做一件事:遍历带 data-k 的元素
const DICT = {
en: {
nav1: "Writing", nav2: "Notes", nav3: "About",
h1: "Settling a homepage with grids and whitespace",
streamTitle: "Latest writing"
}
};
const params = new URLSearchParams(location.search);
const lang = params.get("lang") === "en" ? "en" : "zh";
const state = params.get("state") === "before" ? "before" : "after";
// state=before 用于「改造前 / 改造后」对比,只切一个 class
if (state === "before") document.body.classList.add("is-before");
if (lang === "en") {
document.querySelectorAll("[data-k]").forEach(function (el) {
const value = DICT.en[el.dataset.k];
if (value != null) el.textContent = value; // 中文原文写在 HTML 里,缺译文时自然退回中文
});
}
- 中文直接写在 HTML 里:没有 JS 也能完整阅读;英文放在配置对象里,只需维护一份对照,不会出现两份 HTML 逐渐「漂移」。
- 缺译文时优雅退回原文,而不是显示空白或
undefined—— 双语站点最容易出的问题就是漏译,这一点必须兜住。 - 本站主站的 i18n 用的是同一套思路,只是规模更大(区分「页面文案」与「JS 动态文案」两张表)。
动态演示
这就是成品本身:可以滚动、可以点链接、可以在四种设备宽度之间来回切。注意 900px 那一档 —— 文章流变成两列且首篇通栏,这是一处「为了节奏而加」的断点,不是需求要求的结构变化。
逐块精修:把「能用」变成「好读」
最后一轮改造不动结构,只修正七处具体的版面问题 —— 而效果差别相当大。
讲解
改造前到底哪里不对
最初的实现可以运行、内容也对,但每一处都是「随手写的」:没有版心(max-width: none)、三栏写死(任何宽度都是三栏)、标题和正文同一字号、间距来自 3px / 4px 这类随手值、缩略图写死 96px。
这一节的要点:坏版面往往不是「设计能力不足」,而是每一个决定都缺少依据。逐条对照前面四章的原则,问题会自己浮出来 —— 这也是为什么验收清单比审美直觉更可靠。
七处修正,以及它们各自对应的原则
| 改动 | 从 → 到 | 对应原则 |
|---|---|---|
| 加版心 | max-width: none → 1180px + margin-inline: auto |
2.1 行宽 |
| 三栏写死 → 四档断点 | 固定三栏 → 单栏 / 两栏 / 三栏 | 4.1 移动优先 |
| 建立字号层级 | 标题 15px → clamp(22px, 3.4vw + 8px, 40px) |
1.3 层级 + 4.2 流体 |
| 间距归入刻度 | 3px / 4px 随手值 → --space-1 … --space-7 |
1.2 留白 |
| 缩略图改为弹性 | width: 96px → width: 64px; aspect-ratio: 1 |
4.3 响应式陷阱 |
| 卡片内部对齐 | 文字随内容浮动 → 图与文分列,按钮吸底 | 1.1 对齐 + 3.4 跨卡片对齐 |
| 分组关系可见 | 组内 = 组间 → 组间 = 组内 × 1.9 | 1.2 接近性原则 |
关键代码
/* 演示用的「改造前」:全部是常见错误,逐条对应上面的修正表 */
body.is-before .f-shell {
max-width: none; /* ✗ 没有版心 → 行宽随窗口无限拉长 */
padding: 4px; /* ✗ 间距随手给 */
gap: 4px;
}
body.is-before .f-grid {
grid-template-columns: 70px minmax(0, 1fr) 70px;
grid-template-areas: "rail stream side";
/* ✗ 不管多窄都硬撑三栏,窄屏挤成一团 */
}
body.is-before .f-hero h1 {
font-size: 15px; /* ✗ 标题与正文同级 → 层次消失 */
font-weight: 400;
}
body.is-before .f-thumb {
width: 96px; /* ✗ 写死宽度,容器再窄也不缩 */
height: 96px;
}
body.is-before .f-posts { gap: 3px; } /* ✗ 组内组间一样大 → 分组关系消失 */
- 把旧样式写成一层覆盖(而不是维护两份 HTML),使 A/B 对比只需切换一个 class —— 演示、回归测试、向同事解释改了什么,都用同一份代码。
- 注意每一行都标了「✗ + 原因」。代码注释写清楚「为什么这样是错的」,比只写「这是旧版本」有用得多。
- 这个
is-before类在真实项目里通常只存在于演示环境;它的价值是让「改版收益」可见、可测。
动态演示
- 加版心:max-width 1180px + 居中
- 三栏写死 → 四档断点(移动优先)
- 建立字号层级(标题用流体 clamp)
- 间距全部归入统一刻度
- 缩略图改用比例而非固定宽高
- 卡片内部对齐,按钮吸底
- 组间间距放大到组内的 1.9 倍
先看「改造前」并在下方把视口拖窄 —— 你会看到行宽失控、三栏挤成一团、标题与正文一样大。再切回「改造后」:同样的内容、同样的 HTML 结构,只是每一个决定都有了依据。
验收清单
把下面这份清单用在你自己的页面上。八条全部打勾,版面质量就有了基本保障(勾选状态会记在这台设备上)。
- 把浏览器从最窄拖到最宽,没有任何一处横向滚动条、没有内容重叠或溢出。
- 正文行宽在 45–75 西文字符(或 25–40 汉字)之间,超宽屏上也不会继续拉长。
- 眯眼看页面(或缩到 25%),还能看出几条清晰的竖直对齐线。
- 页面里的层级不超过三级,且用比例尺而不是随手取的字号。
- 所有间距都来自同一套刻度;组内间距小于组间间距。
- 关闭 CSS 的高亮,只用键盘 Tab 走一遍:焦点顺序与视觉顺序一致,且没有卡在不可见元素上。
- 缩放到 200% 或把默认字号调大,版面仍然成立(说明用了 rem / 相对单位)。
- 支持「减少动态效果」的系统设置;图片有宽高或比例,加载时不产生跳动。
案例到此完成。回看这五章:需求决定了骨架,骨架决定了实现方式,实现方式决定了断点怎么加 —— 每一步都可以追溯到上一步。页面布局的「好」,最终来自这种可追溯,而不是来自某一个漂亮的像素值。