本章目录 / 案例进度
第 4 章 · 响应式布局
一套代码,适配所有宽度
响应式不是「为每种设备写一份样式」,而是「让版面在任意宽度下都成立」。这一章把上一章搭好的骨架改造成移动优先的结构,并补上两个进阶工具:流体排版与容器查询。
案例第 4 阶段:让首页在手机上也能读。只有三个动作:把窄屏样式写成默认值、用 min-width 逐级增强、把字号与标题改成流体值。下面的视口模拟器里跑的正是案例首页的真实代码 —— 拖动宽度,你看到的每一次重排都来自那三条媒体查询。
断点应该加在「版面撑不住」的地方
断点不是设备清单。设备宽度每年都在变,而「两栏挤到 200px 就不可读」这个事实不会变。
讲解
怎么找到你的断点
把浏览器窗口从最窄慢慢拖到最宽,记录第一处「看起来不对」的宽度 —— 通常是某段文字开始贴边、某个按钮被压变形、两栏的内容互相挤压的时候。那就是你的断点。它由你的内容决定,不由 iPhone 的型号决定。这样得到的断点,长时间内都不需要跟着新设备更新。
移动优先:默认样式写给窄屏
「移动优先」不是「先考虑手机用户」,而是一种书写顺序:默认样式是最简单的一栏版本,然后不断用 min-width 往上叠加。它带来四个实际好处:
- 强迫你先做内容优先级判断 —— 窄屏放不下一切,必须排序;
- 覆盖方向单一:后写的规则覆盖先写的,永远只需担心「往上加」,不会互相打架;
- 基线是最简单的版本,因此在老设备、慢网络、媒体查询失效的环境里依然可用;
- 不需要用
max-width把宽屏样式「砍回去」,避免了层层覆盖与!important救火。
反过来,桌面优先(默认宽屏 + max-width 一路下砍)的问题是每加一档都在做减法:断点之间的组合容易漏掉,最后常常要靠 !important 强行压住前面的规则。
断点要几个
两到四个通常就够。本站案例用了三个:600px(正文 + 侧栏两栏)、900px(更宽的版心、文章流两列)、1200px(三栏:归档 / 正文 / 信息)。每多一个断点,就多一组需要测试的宽度组合 —— 断点越多,越容易出现「只在某个宽度下才好看」的脆弱版面。
先试试能不能不用断点。能靠 repeat(auto-fit, minmax(...))、flex-wrap、clamp() 解决的问题,就不要写媒体查询 —— 它们在任何宽度下都成立,不需要你预先猜出「撑不住」的那个点。
关键代码
.blog { /* 默认 = 手机:单栏堆叠 */
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "stream" "side";
gap: 24px;
padding-inline: 16px;
}
/* 600px:正文 + 侧栏。为什么是 600?因为 600 以下两栏都太窄 */
@media (min-width: 600px) {
.blog {
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-areas: "stream side";
gap: 32px;
}
}
/* 900px:版心变宽,文章流两列 */
@media (min-width: 900px) {
.blog { padding-inline: 32px; gap: 48px; }
.posts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 1200px:三栏 —— 左侧归档栏出现 */
@media (min-width: 1200px) {
.blog {
grid-template-columns: 180px minmax(0, 1fr) 240px;
grid-template-areas: "rail stream side";
max-width: 1180px; /* 再宽也不拉长行宽 */
margin-inline: auto;
}
}
- 每个断点都只做「增强」:加列、加间距、加区域。没有任何一档需要撤销上一档的设定 —— 这是移动优先最实际的收益。
max-width: 1180px只出现在最后一档:版面在超宽屏上不再拉伸,行宽保持可读(回到 2.1 的约束)。- 断点值写在 CSS 里,但要配一句说明「这一档解决了什么问题」—— 半年后你只会记得数字,不会记得理由。
动态演示
拖动滑块(或用下面的设备预设),注意三个地方:右侧标尺上当前档位的高亮、iframe 里首页的重排、以及实时输出里被命中的那条媒体查询。当视口比演示区还宽时,模拟器会整体等比缩小 —— 但 iframe 内部的真实宽度不变,媒体查询的判断因此不会失真。
流体排版:让断点之间的过渡不断裂
只有三个断点,就意味着在 599 → 600px 时所有值都会「跳一档」。clamp() 用来抹平这些台阶。
讲解
clamp(最小值, 首选值, 最大值)
clamp() 的三个参数关系很直白:当首选值落在区间内时用它,超出上下限时被夹住。把 vw 放进首选值,字号就随视口连续变化:
font-size: clamp(1.5rem, 4vw + 0.5rem, 3.25rem);
- 4vw 提供缩放:视口每宽 100px,字号涨 4px;
- + 0.5rem 提供底座:纯
vw会等比缩到不可读(视口 320px 时只剩 12.8px),加上一个不随视口缩放的常量,极小屏才有底线; - 上下限用 rem 而不是 px:尊重用户在浏览器里调大的默认字号,这是无障碍要求,也是用
rem做排版单位的核心理由。
一个容易被忽略的取舍:字号流体,间距离散
既然字号能用流体,间距为什么不同样处理?因为两者关心的东西不同:字号需要视觉上的连续(台阶很显眼),而间距需要「可数的节奏」。把间距也做成连续流体值,1.2 节建立的刻度系统就失去意义了 —— 你无法再指着某个值说「这是组内间距」。
实践中更稳的分工是:间距用离散的几档(并在窄屏整体降一档),字号与行内细节用流体值。本站正是这么做的:--s-1 … --s-9 是固定的几档,字号刻度 --step-* 则全部是 clamp()。
别把首选值写成「纯 vw」。clamp(1rem, 5vw, 3rem) 在小屏上会缩到 16px 以下(因为 5vw 在 320px 视口只有 16px),而 min 恰恰是最不该被触发的那个值。写成 clamp(1rem, 2vw + 1rem, 3rem) 更稳:缩放的斜率小一些,底座大一些。
关键代码
:root {
/* 阶梯式:三档之间的过渡是连续的,不再是台阶 */
--step--1: clamp(0.80rem, 0.78rem + 0.10vw, 0.875rem);
--step-0: clamp(0.98rem, 0.95rem + 0.15vw, 1.0625rem);
--step-1: clamp(1.12rem, 1.06rem + 0.30vw, 1.30rem);
--step-2: clamp(1.30rem, 1.18rem + 0.55vw, 1.65rem);
--step-3: clamp(1.55rem, 1.34rem + 0.95vw, 2.15rem);
--step-4: clamp(1.90rem, 1.50rem + 1.80vw, 3.00rem);
}
.page-title {
font-size: var(--step-4);
line-height: 1.1; /* 流体字号要配无单位行高 */
letter-spacing: -0.02em; /* 字号越大,字距越要收紧 */
text-wrap: balance; /* 标题断行自动均衡,避免末行只剩一个字 */
}
/* 间距保持离散的几档:节奏可数,团队可沟通 */
:root {
--s-4: 1rem;
--s-5: 1.5rem;
--s-6: 2rem;
}
@media (max-width: 600px) {
/* 窄屏整体降一档,而不是把每个值都改成流体 */
:root { --s-6: 1.5rem; --s-7: 2rem; }
}
- 注意每一档的
vw系数不同:小字号几乎不缩放(0.1vw),大字号缩放明显(1.8vw)。这样层级差距在大屏上会自然拉开。 - 行高用无单位值:它随字号等比缩放,不需要为每一档单独定义。
text-wrap: balance与pretty是较新的排版属性,能让断行更接近专业排版 —— 不支持时自动退化为默认行为,是安全的增强。
动态演示
这个 iframe 里的 1vw 等于 iframe 宽度 的 1%,所以你拖动宽度滑块时是真实触发 vw 变化,而不是模拟。把缩放系数设为 0,标题就完全固定;调到 8vw,小屏会迅速触到下限。
容器查询:让组件知道「自己被放进了多宽的容器」
媒体查询只知道视口宽度。同一个卡片组件放在主栏和侧栏里,它无法区分 —— 这正是容器查询要解决的问题。
讲解
媒体查询的固有局限
媒体查询查询的是视口。这意味着:同一个卡片组件,放进 700px 宽的主栏时应该「图左文右」,放进 240px 宽的侧栏时应该「上下堆叠」—— 但在视口 1200px 时这两种情况同时存在,媒体查询无法区分它们。过去的解决办法是给组件加变体类(.card--compact),由使用它的页面负责记得加对类名。
容器查询的解法
容器查询把「查询什么」从视口换成了祖先容器的宽度:父级声明 container-type: inline-size,组件内部用 @container 写规则。于是组件的行为由「它被放进的容器」决定,而不是「用户的屏幕」。这正好符合组件化的直觉 —— 组件成为一个真正自适应的独立单元。
container-name给容器命名,便于在多层嵌套时精确指定查询对象(@container card (min-width: 400px));- 除了宽度,还能用容器查询单位:
cqw/cqi等,让字号也相对于容器而不是视口缩放; - 代价是调试时多一层推理:看到组件变形,你要先问「它的容器有多宽」。
一个容易踩的坑:container-type: inline-size 会让容器自身的尺寸不再依赖内容。因此不要给「需要被内容撑开」的元素加它(例如一个自适应宽度的按钮);它应该加在负责提供宽度的布局容器上,再由内部组件去查询。
四类最常见的响应式陷阱
| 陷阱 | 为什么会坏 | 正确做法 |
|---|---|---|
| 写死高度 + 绝对定位 | 内容一旦换行或字号变大就溢出、重叠,且不会把容器撑开。 | 用 min-height 与正常文档流;确实要覆盖时用 Grid 叠放两层。 |
用 100vh 做满屏 |
移动端地址栏收放会改变视口高度,导致首屏内容被裁掉或跳动。 | 用 100dvh(动态)/ 100svh(最小)/ 100lvh(最大)。 |
| 横向滚动条 | 固定宽度、超长单词 / URL、负 margin、或 width: 100% 加 padding 而没设 box-sizing。 |
全局 box-sizing: border-box;长文本 overflow-wrap: anywhere;用 minmax(0, 1fr)。 |
| 图片与布局跳动 | 图片加载完成后高度突变,把下方内容顶走(CLS 差的常见原因)。 | max-width: 100%; height: auto + aspect-ratio 预留空间 + srcset。 |
关键代码
/* 1. 容器查询:父级提供宽度,组件自己适应 */
.card-slot {
container-type: inline-size;
container-name: card;
}
.card { display: grid; gap: 12px; }
@container card (min-width: 400px) {
.card {
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
}
}
/* 2. 满屏用 dvh,别用 vh */
.hero { min-height: 100dvh; }
/* 3. 长内容不再撑破容器 */
.card__title {
overflow-wrap: anywhere; /* 超长单词 / URL 允许断行 */
}
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } /* 注意那个 0 */
/* 4. 图片:预留比例,加载完不跳动 */
.card__cover {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* 兜底:不支持容器查询的浏览器至少拿到可用的堆叠版 */
@supports not (container-type: inline-size) {
.card { grid-template-columns: minmax(0, 1fr); }
}
- 容器查询与媒体查询可以并存:用媒体查询决定页面分几栏,用容器查询决定组件在栏内怎么变 —— 两者层次不同,不冲突。
object-fit: cover配合aspect-ratio:图片永远填满预留区域且不变形。- 本站的「卡片墙」用
auto-fit解决列数,用容器查询解决组件内部形态 —— 两者结合基本可以做到「零媒体查询的自适应」。
动态演示
@container
查询自己的容器宽度,会跟着滑块变。
容器查询版卡片
容器够宽就变成图左文右,窄了就上下堆叠 —— 由它所在容器的宽度决定。
@media
只认视口宽度,容器再窄也不会变。
媒体查询版卡片
同一份标记,同样的容器宽度,但它的形态只由浏览器窗口决定。
把宽度滑块从 220 拖到 760:左侧卡片在 400px 处自动变成「图左文右」,右侧那张纹丝不动。这就是「组件不知道自己在哪」与「组件知道自己在哪」的差别。