# 响应式网页设计

Source: https://codewiki.com/zh/frontend/responsive-design/

> - **what**: 响应式网页设计（responsive web design）让同一份内容根据可用空间、用户偏好和输入能力改变呈现方式。
> - **trap**: 只给几个设备宽度添加媒体查询，会漏掉组件容器、缩放、长文本和非鼠标输入造成的约束。
> - **fix**: 先写可收缩、可换行的基础布局，再用内容触发的媒体查询或容器查询增强，并在边界两侧测试真实内容。

## 是什么，为什么存在

响应式设计不是为手机、平板和桌面各写一套页面，而是让一个文档在约束变化时仍然可读、可操作。约束不只有视口宽度，还包括容器宽度、文字缩放、屏幕方向、指针能力以及用户对动态效果的偏好。

它解决的是未知显示环境问题。开发时无法穷举窗口大小、分屏比例、嵌入位置、翻译长度和系统设置；如果布局依赖几个假定设备尺寸，中间状态就容易溢出或遮挡内容。响应式规则把这些假设改写成浏览器可以求值的条件。

常见实现由流式尺寸、能够换行或重排的布局、弹性媒体和条件规则组成。CSS Grid、Flexbox、`min()`、`max()` 与 `clamp()` 可以在没有断点时连续适应；媒体查询（media query）处理视口和用户环境；容器查询（container query）处理可复用组件所在空间。

响应式也不等于把所有内容缩小。导航可能从横排变为展开按钮，数据表可能需要改变信息组织方式，图片可以选择不同候选资源，但文档顺序、名称和任务路径仍应保持一致。

## 工作原理

浏览器先根据正常层叠计算基础样式，再判断条件规则是否匹配。条件变化后，例如窗口变窄或组件进入较窄栏位，浏览器重新计算受影响元素的样式和布局；JavaScript 通常不需要复制这套判断。

基础规则应该覆盖最受限的可用空间，并保留自然流。随后使用 `min-width` 或范围语法增加布局能力，这是一种渐进增强（progressive enhancement）：条件不匹配或某项增强不可用时，核心内容仍然存在。

流式布局先减少断点数量。`max-width` 限制舒适行长，`minmax()` 为网格轨道设置可用下限，`flex-wrap` 允许项目换行，`clamp()` 则为字号或间距设置下限、流式首选值和上限。固定像素仍可表达边框或经过验证的最小尺寸，但不应成为整页宽度的假定。

```css
.page {
  width: min(100% - 2rem, 70rem);
  margin-inline: auto;
}

.cards {
  display: grid;
  gap: clamp(0.75rem, 2vw, 1.5rem);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}

.cards > * {
  min-width: 0;
}

.hero-title {
  font-size: clamp(2rem, 1.4rem + 3vw, 4rem);
}

img,
video {
  display: block;
  max-width: 100%;
  height: auto;
}
```

这组基础规则没有猜测设备类型。页面宽度有上限和边距，卡片按自身下限自动成列，标题在明确上下限之间连续缩放，媒体不会超过内容框。

断点（breakpoint）是布局约束需要离散变化的位置，不是某款设备的代号。把视口缓慢拉宽，在内容开始拥挤、行长失控或控件相撞的位置设断点；再用 `em` 或 `rem` 表达与文字尺度相关的条件，避免根字号变化后布局仍锁在原像素假设上。

媒体查询观察视口或用户环境。尺寸查询可以改页面级结构，`prefers-reduced-motion` 可以选择减少动态效果（reduced motion）方案，`hover` 与 `pointer` 描述输入能力。后两者不能可靠识别“手机”或“桌面”，混合输入设备也可能同时具有多种能力。

容器查询观察祖先查询容器，而不是视口。设置 `container-type: inline-size` 后，后代可以通过 `@container (width >= 30rem)` 按容器行内尺寸切换布局。同一张卡片因此能在主栏横排、在侧栏竖排，无需知道页面模板。

```css
.card-host {
  container: card / inline-size;
}

@media (width >= 64rem) {
  .page-shell {
    grid-template-columns: minmax(0, 1fr) 18rem;
  }
}

@container card (width >= 30rem) {
  .card {
    grid-template-columns: 10rem minmax(0, 1fr);
  }
}

@media (hover: hover) and (pointer: fine) {
  .menu-button:hover {
    text-decoration: underline;
  }
}

@media (prefers-reduced-motion: reduce) {
  .panel {
    scroll-behavior: auto;
  }
}
```

这些条件分别读取视口、查询容器、主输入能力和用户偏好。它们回答的是不同问题，不应该折叠成一个“移动端”布尔值。

响应式图片处理的是资源选择和艺术指导。使用宽度描述符的 `srcset` 时，`sizes` 告诉浏览器图片在各条件下预计占用的 CSS 宽度，浏览器再结合设备像素比选择候选资源。`picture` 则允许不同条件使用不同裁切或格式；它不能代替准确的 `alt` 文本和明确的固有尺寸。

## 示例

### 无断点的内在网格

先让内容和可用空间共同决定列数。`min()` 防止最小轨道在容器本身比 `14rem` 更窄时造成溢出，长标题也可以在卡片内部断行。

<!-- quick -->

```html
<section class="gallery" aria-label="Plans">
  <article class="card"><h2>Starter</h2><p>For personal projects.</p></article>
  <article class="card"><h2>Collaboration</h2><p>For growing teams.</p></article>
  <article class="card"><h2>EnterpriseSecurityControls</h2><p>For regulated work.</p></article>
</section>

<style>
  .gallery {
    display: grid;
    width: 680px;
    gap: 16px;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
  }

  .card {
    min-width: 0;
    padding: 16px;
    border: 1px solid #888;
  }

  .card h2 {
    overflow-wrap: anywhere;
  }
</style>

<script>
  const gallery = document.querySelector('.gallery');
  const tracks = getComputedStyle(gallery).gridTemplateColumns.split(' ').length;
  console.log(`${gallery.clientWidth}px: ${tracks} columns`);
</script>
```

```text
680px: 2 columns
```

<!-- /quick -->

这个输出来自宽度为 `680px` 的真实 Chromium 布局。三条 `14rem` 轨道加两段间距放不下，因此 Grid 建立两列并把剩余空间分配给它们；这里不需要设备断点。

### 由内容触发的页面切换

页面结构确实需要离散变化时，再添加媒体查询。这个示例在窄视口保持单列，到正文和补充栏都能容纳时才切换为两列。

```html
<meta name="viewport" content="width=device-width">
<main class="layout">
  <article>Main report</article>
  <aside>Filters</aside>
</main>

<style>
  .layout {
    display: grid;
    gap: 1rem;
    grid-template-columns: minmax(0, 1fr);
  }

  .layout > * {
    min-width: 0;
    padding: 1rem;
    border: 1px solid #888;
  }

  @media (width >= 48rem) {
    .layout {
      grid-template-columns: minmax(0, 1fr) 16rem;
    }
  }
</style>

<script>
  const columns = getComputedStyle(document.querySelector('.layout'))
    .gridTemplateColumns.split(' ').length;
  console.log(`640px viewport: ${columns === 1 ? 'stacked' : 'split'}`);
</script>
```

```text
640px viewport: stacked
```

输出在 `640px` CSS 视口中执行得到。`48rem` 在默认根字号下为 `768px`，所以增强规则未匹配；DOM 中的正文仍在补充栏之前。

### 依据容器复用组件

卡片不知道自己位于整页、主栏还是侧栏，只读取最近的具名查询容器。断点属于组件的内容约束，因此组件移到另一页面模板时不必同步全局视口断点。

```html
<section class="card-host">
  <article class="profile">
    <div class="avatar" aria-hidden="true">AL</div>
    <div><h2>Ada Lovelace</h2><p>Computing notes and correspondence</p></div>
  </article>
</section>

<style>
  .card-host {
    container: profile / inline-size;
    width: 520px;
  }

  .profile {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1rem;
    padding: 1rem;
    border: 1px solid #888;
  }

  .avatar {
    display: grid;
    place-items: center;
    min-block-size: 8rem;
    background: #ddd;
  }

  @container profile (width >= 30rem) {
    .profile { grid-template-columns: 8rem minmax(0, 1fr); }
  }
</style>

<script>
  const columns = getComputedStyle(document.querySelector('.profile'))
    .gridTemplateColumns.split(' ').length;
  console.log(`520px container: ${columns} columns`);
</script>
```

```text
520px container: 2 columns
```

查询阈值 `30rem` 在默认根字号下为 `480px`，所以宽度为 `520px` 的容器匹配横排布局。查询的是 `.card-host`，不能查询并同时依赖 `.profile` 自己因查询而改变的尺寸，否则可能形成不稳定反馈。

### 把用户偏好与设备尺寸分开

动态效果偏好与视口宽度无关。下面的页面在模拟减少动态效果时关闭过渡，但精确指针仍可保留悬停提示；核心按钮在所有组合下都存在。

```html
<button class="save-button" type="button">
  Save <span class="hint">(Ctrl+S)</span>
</button>

<style>
  .save-button {
    padding: 0.75rem 1rem;
  }

  .hint {
    display: none;
  }

  @media (hover: hover) and (pointer: fine) {
    .save-button:hover .hint {
      display: inline;
    }
  }

  @media (prefers-reduced-motion: no-preference) {
    .save-button {
      transition: transform 160ms ease-out;
    }

    .save-button:active {
      transform: scale(0.98);
    }
  }
</style>

<script>
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const precise = matchMedia('(hover: hover) and (pointer: fine)').matches;
  console.log(`reduced motion: ${reduced}; precise pointer: ${precise}`);
</script>
```

```text
reduced motion: true; precise pointer: false
```

输出来自启用减少动态效果、且没有精确主指针的无头 Chromium。两项查询分别求值，说明代码不能根据屏幕宽度替用户推断偏好或输入方式。

## 陷阱

> **陷阱:** 把 `768px` 标成“平板断点”，并假设这个宽度两侧分别对应触屏和鼠标，会把设备类别当成布局约束。分屏桌面窗口可能很窄，平板也可能连接精确指针。
>
> **修复：**断点应来自真实内容开始失效的位置。输入相关交互单独使用 `hover`、`pointer` 与键盘测试，不要从视口宽度推断能力。

> **陷阱:** `width: 100vw` 会忽略元素所在容器，还可能把垂直滚动条宽度计入盒子；`height: 100vh` 在移动浏览器界面伸缩时也可能遮住内容。
>
> **修复：**普通块宽度优先使用 `width: auto` 或 `100%`。必须占满视口高度时，根据需求选择稳定的小视口单位 `svh` 或会随界面变化的 `dvh`，并保留内容可滚动性。

> **陷阱:** Flex 或 Grid 项目即使使用 `1fr`，也可能被长 URL、表格或预格式化文本撑出容器。默认的自动最小尺寸（automatic minimum size）可能阻止项目缩小到分配轨道以内。
>
> **修复：**在应该收缩的轨道上使用 `minmax(0, 1fr)`，必要时给项目设置 `min-width: 0`，同时为真实长内容选择换行或局部滚动策略。

> **陷阱:** 使用 `order`、Grid 坐标或 `grid-auto-flow: dense` 得到好看的窄屏排列，可能让视觉顺序偏离 DOM 阅读顺序和键盘焦点顺序。
>
> **修复：**先按有意义的阅读顺序编写 DOM。布局切换只改变几何位置；如果任务顺序真的不同，应重新审视文档结构，而不是用 CSS 掩盖它。

> **陷阱:** `srcset` 写了多个图片候选，但 `sizes` 仍声称图片总是 `100vw`，浏览器就可能选择比实际栏宽更大的资源。只用 CSS 缩小图片不会撤销已经做出的资源选择。
>
> **修复：**让 `sizes` 与最终布局条件对应，并在断点两侧检查网络请求和渲染尺寸。需要不同构图时用 `picture`，只需不同分辨率时保留同一图像内容的候选。

<!-- deep -->

## 断点、容器与来源顺序

### 先选择查询坐标系

当条件描述整个浏览环境时，使用媒体查询。页面主导航、视口级双栏结构、打印样式和用户偏好都属于这个范围；组件内部不应通过全局页面宽度猜测自己有多少空间。

当条件描述组件被分配的空间时，使用容器查询。查询容器应由拥有该空间的包装元素建立，后代负责响应；不要仅为了查询而把 `container-type` 散落在每个元素上，因为它会改变尺寸包含行为和可查询祖先。

不需要离散状态时，两者都不用。Grid 的 `auto-fit`、Flexbox 换行、百分比、`minmax()` 和 `clamp()` 能表达连续约束，也会减少边界处规则冲突。查询负责布局模式变化，流式值负责模式内部伸缩。

### 从失败点得到断点

先用最窄的支持宽度和真实内容完成基础布局，再逐渐扩大容器。标题行长过长、控件之间出现多余空洞，或者两组内容终于可以并排时，记录那个约束变化点；断点应放在失败之前，而不是取最接近的设备预设值。

断点附近最容易暴露层叠错误。至少检查阈值前后各一个 CSS 像素，并改变根字号；如果查询用 `rem` 或 `em`，文字尺度变化会有意改变像素阈值。范围语法如 `@media (40rem <= width < 64rem)` 可以直接表达半开区间，避免相邻 `min-width` 与 `max-width` 在同一点重叠。

容器断点同样从组件内容得出。测试卡片时改变实际宿主宽度，而不是只拖动顶层视口；固定侧栏在宽视口中仍可能给卡片很窄的空间，这正是容器查询与媒体查询结果不同的场景。

### 把阅读顺序留在文档中

CSS 可以改变视觉位置，但屏幕阅读器和顺序键盘导航通常仍跟随 DOM。响应式布局应让来源顺序在每种模式下都讲得通，例如标题、正文、主操作、补充信息；这样单列和多列只是同一叙事的不同几何排列。

如果宽屏设计要求侧栏视觉上提前出现，可以把它放到另一列，但不要让键盘用户先穿过视觉上位于页面末端的控件。对交互内容逐项记录 DOM 序号、视觉坐标和 Tab 序号，这比只比较截图更容易发现错位。

### 建立约束测试矩阵

尺寸测试应覆盖断点本身，也要覆盖断点之间。选择能触发真实约束的用例：最短与最长内容、空状态、错误消息、缩放文字、窄容器、横竖方向，以及页面嵌入侧栏或对话框后的宽度。

能力测试与尺寸测试是两条轴。用键盘完成核心任务，分别模拟无悬停粗指针和有悬停精确指针，并启用减少动态效果；不要把某个视口预设当成这些能力的替身。

WCAG 2.2 的重排理解文档给出了一个明确审查目标：除二维布局等例外，内容在相当于 `320` CSS 像素宽的条件下不应要求双向滚动。测试时还要定位具体溢出元素，因为给整个页面加 `overflow-x: hidden` 只会隐藏证据和可能仍需操作的内容。

<!-- /deep -->

[检查点: frontend/responsive-design](https://codewiki.com/zh/frontend/responsive-design/#checkpoint)

## 延伸阅读

- [Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/)
- [CSS Containment Module Level 3](https://www.w3.org/TR/css-contain-3/)
- [HTML Standard：Responsive images](https://html.spec.whatwg.org/multipage/images.html#responsive-images)
- [MDN：Responsive design](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Responsive_Design)
- [WCAG 2.2：Understanding Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)
