Skip to content

响应式布局

结论先行

响应式布局的核心不是"在不同屏幕宽度下切换样式",而是让布局自适应可用空间,最小化硬编码的断点依赖。好的响应式实现是——窗口从 320px 拖到 2560px,断点切换只发生在少数几个关键宽度,中间的过渡靠弹性布局自身完成。

从这个角度看,flexbox 和 grid 不是"另一种布局方式",而是响应式的核心基础设施。它们天生感知可用空间,会在容器尺寸变化时自动重排子元素——这才是响应式应该有的样子:布局规则描述空间分配关系,浏览器根据实际空间计算最终位置。


响应式的三个层次

text
层 1: 弹性布局(flexbox / grid)
  → 同一套规则,自适应任何宽度
  → 没有断点切换
  → 实例:grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))

层 2: 媒体查询(@media)
  → 在关键宽度改变布局规则
  → 断点数量应控制在 3-5 个
  → 实例:@media (min-width: 768px) { ... }

层 3: 容器查询(@container)
  → 基于父容器宽度而非 viewport 宽度
  → 组件级响应式
  → 实例:@container (min-width: 400px) { ... }

层次越深,粒度越细。理想状态下,层 1 覆盖 80% 的响应式需求,层 2 覆盖 15%,层 3 覆盖剩下的 5%。如果媒体查询多达 15 个断点——说明弹性布局用得不够。


层 1:flexbox 和 grid 的弹性机制

flexbox 的空间分配

flexbox 的核心是主轴上的剩余空间分配flex-growflex-shrink 不是简单的"比例",它们的计算规则:

text
剩余空间 = 容器主轴尺寸 - 所有子元素基础尺寸之和

flex-grow 分配:
  每个子项获得 = 剩余空间 × (该子项 flex-grow / 所有子项 flex-grow 之和)

flex-shrink 收缩:
  溢出空间 = 所有子元素基础尺寸之和 - 容器主轴尺寸
  每个子项收缩 = 溢出空间 × (该子项 flex-shrink × 该子项基础尺寸 / Σ(各子项 flex-shrink × 基础尺寸))

flex-shrink 的收缩量是加权的——基础尺寸越大的元素收缩越多(同样 shrink 值下)。这不是 bug,是设计:大元素有更多空间可以释放,小元素收缩空间有限。

css
/* 弹性卡片:最小 280px,剩余空间均分 */
.card-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}
.card {
  flex: 1 1 280px; /* grow shrink basis */
  /* 等价于:flex-grow: 1; flex-shrink: 1; flex-basis: 280px; */
}
/* 容器宽度 1200px → 每行 4 张卡片,每个 ~284px */
/* 容器宽度 800px  → 每行 2 张卡片,每个 ~384px */
/* 容器宽度 320px  → 每行 1 张卡片,铺满 */

一条规则,三种显示效果——这就是弹性布局的价值。

grid 的自适应轨道

grid 在自适应上比 flexbox 更强的地方是 auto-fill / auto-fit + minmax()

css
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
}

auto-fill 的含义:根据容器宽度动态决定列数——每列最小 280px,剩余空间均分。容器 1200px → 4 列;600px → 2 列。全程没有媒体查询,没有 breakpoint。

auto-fill vs auto-fit 的差异:

text
auto-fill:
  容器 1200px,minmax(280px, 1fr),可以放 4 列
  → 创建 4 个列轨道,每列约 300px
  → 即使只有 2 个 grid item,仍然保留 4 个空列轨道

auto-fit:
  同样条件
  → 创建 4 个初始列轨道
  → 如果只有 2 个 item,空轨道宽度折叠为 0
  → 2 个 item 各自获得剩余空间的 1fr

简单选择:需要固定列宽、有空位就留白的列表 → auto-fill;希望内容铺满容器 → auto-fit


层 2:媒体查询的断点策略

移动优先 vs 桌面优先

css
/* 移动优先:基础样式是小屏,min-width 逐步增强 */
.card { flex: 1 1 100%; }                  /* 手机:单列 */
@media (min-width: 768px) {
  .card { flex: 1 1 calc(50% - 16px); }   /* 平板:双列 */
}
@media (min-width: 1024px) {
  .card { flex: 1 1 calc(33.33% - 16px); } /* 桌面:三列 */
}
css
/* 桌面优先:基础样式是大屏,max-width 逐步降级 */
.card { flex: 1 1 calc(33.33% - 16px); }   /* 桌面:三列 */
@media (max-width: 1023px) {
  .card { flex: 1 1 calc(50% - 16px); }   /* 平板:双列 */
}
@media (max-width: 767px) {
  .card { flex: 1 1 100%; }               /* 手机:单列 */
}

移动优先的理由:

  • 移动端 CSS 更简单(单列布局,少嵌套),以此为基础,在大屏上增强——代码量更少
  • 移动端网络条件更差,优先加载关键样式
  • 浏览器解析 CSS 时,移动优先的 min-width 在大屏上需要应用的覆盖样式更少(如果没有匹配则完全不加载)

但实际上,选择哪种取决于主要用户群体。如果 90% 的用户在桌面端访问(如后台管理系统),桌面优先完全合理——基础样式覆盖大多数用户,移动端只需少量降级。

em vs px 作为断点单位

css
/* px 断点 */
@media (min-width: 768px) { ... }

/* em 断点 */
@media (min-width: 48em) { ... }  /* 768px / 16px = 48em */

em 断点的优势:如果用户改变了浏览器的默认字体大小(从 16px 改为 20px),em 断点会随着缩放。一个 768px 的断点在 20px 基准下变成 960px——内容和布局按比例缩放,而不是内容变大但布局不变导致文字溢出。

em 断点的代价:不直观。48em 需要心算换算。团队中需要约定一个基准(通常是 16px)并统一使用。或者在 CSS 变量中定义:

css
:root {
  --bp-sm: 30em;   /* ~480px */
  --bp-md: 48em;   /* ~768px */
  --bp-lg: 64em;   /* ~1024px */
  --bp-xl: 80em;   /* ~1280px */
}

@media (min-width: var(--bp-md)) { ... }
/* ⚠️ 但这在 @media 中不生效——媒体查询不支持 CSS 变量 */
/* postcss-custom-media 插件可以编译时解决这个问题 */

现实选择:大多数项目用 px 就够了,成本低。但如果产品面向可访问性要求高的群体(如政务、医疗),em 断点是必备的。

实际断点选择

不是所有项目都需要 5 个断点。通常 3 个足矣:

css
/* 经典的 3 断点策略 */
@media (min-width: 640px)  { ... }  /* 手机横屏 / 小平板 */
@media (min-width: 1024px) { ... }  /* 平板横屏 / 小桌面 */
@media (min-width: 1280px) { ... }  /* 标准桌面 */

断点不应该基于"常见设备宽度",应该基于内容在哪个宽度开始变得不舒适。一个方法:把浏览器窗口从 320px 拖到 2560px,观察内容在哪些宽度点出现布局问题(文字过长一行难以阅读、卡片过窄信息被压扁、侧栏太窄或太宽),在这些点设置断点。而不是对着设备尺寸表机械地设 375/414/768/1024/1440。


层 3:容器查询(container query)

媒体查询的问题:组件的响应式行为取决于 viewport 宽度,而不是自身容器的宽度。

一个卡片组件放在主内容区(宽 800px)和放在侧栏(宽 200px)时,它的内部布局应该不同。但媒体查询只能检查 viewport 宽度,不知道自己在 200px 的侧栏里还是在 800px 的主区域里。

容器查询解决了这个问题:

css
/* 定义容器 */
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

/* 根据容器宽度改变内部布局 */
@container sidebar (min-width: 300px) {
  .card {
    display: flex;
    flex-direction: row;
  }
}

@container sidebar (max-width: 299px) {
  .card {
    display: flex;
    flex-direction: column;
  }
}

container-type 的三个值:

  • inline-size:只监听内联轴(通常指宽度)的变化,layout 层面的性能代价较小
  • size:同时监听内联轴和块轴,但会创建新的包含块(containment),可能影响子元素的百分比高度计算
  • normal:不创建容器查询上下文

容器查询的 containment 副作用:

当一个元素设置 container-type: inline-size 时,浏览器会对其施加 containment——该元素的尺寸计算不依赖子元素内容,布局被隔离。这意味着子元素的 height: 100% 可能不再生效(因为父元素的高度现在是基于自身尺寸而非内容计算)。这是一个常见的踩坑点——打开容器查询后,需要显式给容器设置 height


响应式中的数学函数

clamp():一个值覆盖三种状态

css
/* 字体在 16px ~ 4vw ~ 24px 之间,自动缩放 */
.heading {
  font-size: clamp(16px, 4vw, 24px);
}

/* 等价于 */
.heading {
  font-size: max(16px, min(4vw, 24px));
}
/* 含义:取 4vw,但不小于 16px,不大于 24px */

clamp() 在没有媒体查询的情况下实现了流畅缩放:viewport 400px → 字体 16px(触底);viewport 400-600px → 字体在 16-24px 之间线性变化;viewport 600px+ → 字体 24px(封顶)。

clamp() + calc() 可以实现复杂的流畅布局:

css
.section {
  /* 内边距从 16px 线性增长到 64px,viewport 320px → 1200px */
  padding: clamp(16px, 16px + (64 - 16) * (100vw - 320px) / (1200 - 320), 64px);
}

成本:100vw 的频繁使用会触发布局计算。如果多个元素依赖 vw,每次 resize 都会触发多个节点的 layout。在低端设备上,流畅缩放的性能代价可能 > 断点切换。

min() / max():简单的二选一

css
/* 容器宽度不超过 1200px,且在窄屏上不溢出 */
.container {
  width: min(100% - 32px, 1200px);
  margin-inline: auto;
}

/* 图片高度至少占 viewport 的 50% */
.hero img {
  height: max(50vh, 300px);
}

响应式图片

图片是响应式中最容易被忽略但体积影响最大的部分——一张 2000px 宽的图片在 375px 的手机上完全浪费。

srcset + sizes:分辨率切换

html
<img
  src="hero-800.jpg"
  srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
  sizes="(max-width: 600px) 100vw, 50vw"
  alt="..."
/>

sizes 告诉浏览器这张图片在页面上占据的视口宽度比例。浏览器根据 sizes 计算图片实际显示尺寸,再从 srcset 中选择不小于实际尺寸的最小资源。

text
viewport 375px,sizes = 100vw → 需要 375px 宽的图片 → 选择 hero-400.jpg
viewport 1440px,sizes = 50vw → 需要 720px 宽的图片 → 选择 hero-800.jpg

sizes 默认值是 100vw——如果不写 sizes,浏览器假设图片总是占满整个视口宽度,会选择偏大的资源。

picture:艺术方向(art direction)

html
<picture>
  <source media="(min-width: 768px)" srcset="hero-wide.jpg" />
  <source media="(min-width: 400px)" srcset="hero-medium.jpg" />
  <img src="hero-narrow.jpg" alt="..." />
</picture>

picture 适用于不同断点需要不同裁剪/构图的图片(宽屏用横图,手机用竖图)。如果只是同一图片的不同分辨率,用 srcset + sizes 就够了。

一个常见的坑:<picture> 内的媒体查询顺序必须是从大到小从小到大,因为浏览器会选择第一个匹配的 <source>。如果 <source media="(min-width: 400px)"> 放在 <source media="(min-width: 768px)"> 前面,viewport 800px 会匹配第一个(400px 条件为真),不会继续检查后面的。

响应式图片的性能陷阱

srcset 中定义的资源越多,浏览器的选择计算代价越大。不要在 srcset 中列出 10 个不同分辨率的图片——3-4 个足够(如 400w / 800w / 1200w)。分辨率再细分,视觉差异用户感知不到,性价比极低。

sizes 的百分比必须精确反映图片在页面上的布局——如果 sizes 声明图片占 50vw,但实际上占 80vw,浏览器会选择偏小的图片,导致在高分辨率屏幕上模糊。


性能考量

布局抖动的预防

css
/* 图片加载前没有高度 → 加载后撑开布局 → 页面内容跳动 → CLS */
img {
  max-width: 100%;
  height: auto;
  /* ⚠️ 没有设置 aspect-ratio 或显式高度 → 图片高度 = 0 直到加载完成 */
}

预防方法:

css
/* 方案 1:显式宽高 */
img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* 或从 HTML 属性 width/height 继承 */
}

/* 方案 2:占位容器 */
.image-wrapper {
  position: relative;
  padding-top: 56.25%; /* 16:9 的百分百 padding = 高度 */
  overflow: hidden;
}
.image-wrapper img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

resize 的节流

响应式布局在 resize 事件上最敏感——窗口拖拽时每秒可能触发 60 次。如果 resize 触发的不是纯 CSS 计算而是 JS:

js
// ❌ 每次 resize 都计算,60fps 下主线程被占满
window.addEventListener('resize', () => {
  recalculateLayout()
})

// ✅ 只在 resize 结束后执行一次
let timer
window.addEventListener('resize', () => {
  clearTimeout(timer)
  timer = setTimeout(recalculateLayout, 150)
})

// ✅ 更好:matchMedia 代替 resize 监听,只在断点切换时触发
const mql = window.matchMedia('(min-width: 768px)')
mql.addEventListener('change', (e) => {
  if (e.matches) { /* 进入桌面布局 */ }
  else { /* 进入移动布局 */ }
})

matchMediaresize 更精确——只在特定条件切换时回调,不是在每次 resize 像素变化时触发。


浏览器对响应式的内部处理

当 viewport 尺寸改变时,浏览器的处理不是"整个页面重新渲染":

text
viewport resize
  → 检查受影响的 @media / @container 规则
    → 仅对匹配状态改变的规则执行 Style Recalc
      → 仅对受影响的元素执行 Layout
        → 如果 resize 不改变布局(如仅颜色变化),Layout 被跳过
          → Paint → Composite

这就是为什么尽量在最小作用域上应用媒体查询:如果一个媒体查询只影响一个组件,放在该组件的样式中,而不是全局样式表中。浏览器可以更精确地确定受影响元素范围,减少 Style Recalc 的波及面。


一些实际判断

响应式不是"手机 + 平板 + 桌面"三个独立设计。 如果设计稿只出了手机和桌面两版,中间状态由弹性布局自动补全。不需要为每一个中间宽度出设计稿——这是用"固定宽度的思维"做响应式。

能用弹性布局就不要用媒体查询,能用媒体查询就不要用 JS resize。 每一层都引入额外的复杂度和性能代价。JS resize 是最昂贵的方案——它在主线程上执行,阻塞渲染。

容器查询的浏览器支持已经到了可以生产使用的阶段(Chrome/Edge 105+, Safari 16+, Firefox 110+)。但对于组件库,在提供容器查询版本的同时保留 fallback(使用 CSS 层叠的优先规则:不支持 @container 的浏览器会忽略该规则块,回退到默认样式)。

vw / vh 在移动端上要加上动态视口的后备。 移动浏览器(特别是 Safari iOS)的地址栏展开/收起会改变 vh 的实际值。100vh 在地址栏可见时可能比屏幕实际高度大,导致底部内容被遮挡。使用 dvh(dynamic viewport height)或 svh(small viewport height):

css
.hero {
  height: 100vh;       /* fallback */
  height: 100dvh;      /* Chrome 108+, Safari 15.4+ */
}