CSS容器查询实战:从小白到进阶
发布日期: 2026/08/09 阅读总量: 0

场景:300 张卡片的适配噩梦

上个月第三周,我接手了一个 B 端后台管理系统的前端改造。登录后的生产看板页里有 300 多张卡片组件,散落在三个区域:左侧导航抽屉(固定 320px)、中间内容区(1280px)、右侧详情浮层(480px)。产品经理提了一个需求:同一个 <ProductCard> 组件,在三个区域里要展示成窄版、标版、宽版三种形态,并且容器宽度变化时要平滑过渡。

第一版我按老习惯用 media query 写,写完就翻车。左侧抽屉的宽度是固定的 320px,不管视口怎么变它都不变,media query 感知不到「这个卡片在 320px 的容器里」。我用的还是最朴素的条件:

/* 错误做法:用视口媒体查询控制容器内的组件 */
@media (max-width: 768px) {
  .product-card {
    display: flex;
    flex-direction: column;
  }
}

@media (min-width: 1200px) {
  .product-card {
    flex-direction: row;
  }
}

结果主内容区 1280px 宽的卡片,因为视口宽度在手机上小于 768px,被强制压成了竖排。而侧边栏 320px 的卡片,在桌面视口下永远命中不了窄版样式。

这不是个案。老系统后端是 PHP 模板渲染,300 张卡片的数据来自这样一条 SQL:

SELECT product_id, product_name, price, category
FROM products
WHERE is_active = 1
ORDER BY created_at DESC
LIMIT 300;

SQL 返回 300 条数据,前端渲染 300 个卡片,分布在上面说的三个容器里。任何一个容器宽度变化,我都得重新考虑这些卡片怎么排。这个问题的根源,是响应式判断的维度错了。

问题根因:视口和容器是两套维度

Media Query 的设计初衷,是让页面针对不同设备的视口宽度做整体布局调整。但组件化开发之后,真正需要判断的是「这个组件被塞进了一个多宽的容器」。两者经常不一致:

  • 同一个页面,左侧栏容器 320px,中间容器 1280px
  • 同一个视口 1440px,里面可以同时存在 320px、480px、1280px 的容器
  • 同一个组件,被复用到不同页面后,容器宽度完全不同

用视口维度去管容器维度,就像用整栋楼的电梯按钮控制某一间办公室的灯光:不是不能用,是牵线搭桥成本太高。

方案 A:ResizeObserver + JS 切换类名

遇到这个需求,我第一反应是上 ResizeObserver。监听每个卡片的宽度,宽度变化时切换 CSS 类名。

// 方案 A:ResizeObserver + 类名切换
const CARD_SELECTOR = '.product-card';
const cards = document.querySelectorAll(CARD_SELECTOR);

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentRect.width;
    entry.target.classList.toggle('is-narrow', width < 420);
    entry.target.classList.toggle('is-wide', width >= 840);
  }
});

cards.forEach((card) => observer.observe(card));

这套代码能跑,但问题很现实:

  • 300 个卡片全部要 observe,初始化时 ResizeObserver 会同步触发一次回调,强制读取 layout。实测初始化 JS 执行 126ms(M1 Pro Chrome 125,中位数)。
  • 宽度变化时连续触发回调,需要自己写防抖,否则拖动窗口的一瞬间能触发几十次样式重算。
  • JS 逻辑和 CSS 逻辑分离。CSS 文件里躺着 146 行类名样式,JS 文件里躺着 266 行监听逻辑。改一个断点要同时改两处。
  • 页面初次渲染时 JS 还没跑,卡片样式会闪一下,出现 FOUC。

改到一半我就知道这条路走不通。组件宽度切换是纯布局行为,不应该让 JS 介入。

方案 B:CSS Container Queries

Container Queries 让组件自己声明依赖的容器,容器宽度变了,内部样式自动切换。纯 CSS,零 JS。

第一步:声明容器

.product-card-wrapper {
  container-type: inline-size;
  container-name: product-card;
}

container-type 有两个常用值:

  • inline-size:容器只对内联轴(水平方向)产生尺寸约束,不会影响高度。推荐默认用这个。
  • size:水平和垂直都约束。代价是强制元素尺寸由容器决定,内容溢出会被裁剪,轻易别用。

container-name 给容器起名字,方便多个容器共存时精确匹配。

第二步:写容器查询

.product-card {
  display: flex;
  gap: 16px;
}

/* 容器宽度 ≤ 419px:窄版 */
@container product-card (max-width: 419px) {
  .product-card {
    flex-direction: column;
  }

  .product-cover {
    height: 120px;
  }

  .product-actions {
    flex-direction: column;
  }
}

/* 容器宽度 ≥ 840px:宽版 */
@container product-card (min-width: 840px) {
  .product-card {
    flex-direction: row;
  }

  .product-cover {
    width: 200px;
    height: 150px;
  }
}

注意:@container 查询的是容器宽度,不是视口宽度。同一个卡片,在 320px 的侧边栏走窄版,在 1280px 的内容区走宽版,互不干扰。

Chrome 105+ 还支持范围语法,更直观:

@container product-card (400px < width < 840px) {
  .product-title {
    font-size: 18px;
  }
}

第三步:容器查询单位

Container Queries 配套了 6 个单位,和视口单位一一对应:

单位 对应视口单位 含义
cqwvw容器宽度的 1%
cqhvh容器高度的 1%
cqivi容器内联尺寸的 1%
cqbvb容器块向尺寸的 1%
cqminvmin容器宽高中较小值的 1%
cqmaxvmax容器宽高中较大值的 1%

实际项目里我用 cqi 做流体排版,一行代码替代三个断点:

.product-title {
  font-size: clamp(14px, 4cqi, 24px);
  line-height: 1.4;
}

容器越宽,字号平滑变大;容器越窄,字号平滑变小。没有断点跳变。

第四步:样式容器(Style Queries)

除了尺寸,Container Queries 还能查容器上的自定义属性。我们在主题切换场景里直接用上了:

<div class="dashboard-card" style="--accent: #f97316">
  <h3 class="card-title">今日订单</h3>
  <p class="card-value">1,286</p>
</div>
.dashboard-card {
  container-name: theme;
}

/* 当容器上 --accent 是 #f97316 时,标题走橙色 */
@container theme style(--accent: #f97316) {
  .card-title {
    color: #f97316;
  }
}

/* 暗色主题 */
@container theme style(--theme: dark) {
  .card-value {
    color: #cdd6f4;
    background: #1e1e2e;
  }
}

这解决了我们项目里 React Context + 600 行样式切换代码的难题。主题状态不再需要 JS 跨组件传递,CSS 自己就能判断。

第五步:嵌套容器

容器可以嵌套。嵌套时 @container 会匹配最近的同名单个祖先容器。如果你有页面级容器和卡片级容器,给它们起不同的名字即可精确控制:

.page-grid {
  container-type: inline-size;
  container-name: page;
}

.product-card-wrapper {
  container-type: inline-size;
  container-name: card;
}

/* 只受最近的 card 容器影响 */
@container card (max-width: 419px) {
  .product-cover {
    border-radius: 0;
  }
}

/* 只受 page 容器影响 */
@container page (min-width: 1200px) {
  .product-card-wrapper {
    padding: 24px;
  }
}

项目改造全流程

我直接拿之前 ResizeObserver 的项目做改造。React 18.3.1 组件结构如下:

// React 组件:ProductCard.jsx
export function ProductCard({ data }) {
  return (
    <div className="product-card-wrapper">
      <article className="product-card">
        <img className="product-cover" src={data.cover} alt="" />
        <div className="product-info">
          <h3 className="product-title">{data.name}</h3>
          <p className="product-desc">{data.desc}</p>
          <button className="product-btn">查看详情</button>
        </div>
      </article>
    </div>
  );
}

去掉 JS 监听,CSS 里加上容器声明和查询规则,改造完成。代码量从 412 行降到 86 行。

指标 ResizeObserver 方案 Container Queries 方案 变化
初始化 JS 执行126ms0ms(纯 CSS)-100%
首次可交互时间(TBT)184ms32ms-82.6%
容器监听器数量3000
容器宽度变化时样式重算45ms(防抖后)12ms-73.3%
代码量412 行(JS+CSS)86 行(纯 CSS)-79.1%
LCP(看板页)1.8s1.2s-33.3%

测试环境:MacBook Pro 14-inch 2021(M1 Pro 16GB)、Chrome 125.0.6422.112、React 18.3.1,DevTools Performance 面板 5 次取中位数。

原始数据记录在项目文档里:

{
  "testEnv": {
    "machine": "MacBook Pro 2021 M1 Pro 16GB",
    "browser": "Chrome 125.0.6422.112",
    "framework": "React 18.3.1"
  },
  "resizeObserver": {
    "initJsMs": 126,
    "tbtMs": 184,
    "listeners": 300,
    "recalcMs": 45,
    "linesOfCode": 412,
    "lcpSec": 1.8
  },
  "containerQueries": {
    "initJsMs": 0,
    "tbtMs": 32,
    "listeners": 0,
    "recalcMs": 12,
    "linesOfCode": 86,
    "lcpSec": 1.2
  }
}

另一个隐藏收益:组件拖拽排序时,ResizeObserver 会因为尺寸抖动反复触发回调,Container Queries 完全没有这个问题。

避坑指南

下面 5 个坑是我实际踩过的,每个都有代价。

坑 1:container-type: size 会让内容溢出

/* 错误写法 */
.product-card-wrapper {
  container-type: size; /* 不要这么写 */
}

size 会创建尺寸约束,容器高度由查询结果决定,而不是内容决定。结果就是内容一旦超出就被裁剪。除非你明确知道容器宽高都是固定的,否则一律用 inline-size

坑 2:@container 查不到容器自身

/* 错误写法 */
.product-card {
  container-type: inline-size;
  container-name: card;
}

@container card (max-width: 400px) {
  .product-card { /* 永远不会生效 */
    background: red;
  }
}

@container 的样式只能作用于容器内部的子孙元素,不能作用于容器本身。所以项目中必须有一个外层 wrapper 当容器,内部元素做查询目标。

坑 3:混用 @media 和 @container 控制同一属性

如果你用 @media 控制页面布局,用 @container 控制组件布局,同一个属性可能被两套规则命中,层叠优先级很难追。我的建议:页面级布局用 @media,组件内部一律用 @container,各管各的,不要交叉。

坑 4:height: 100cqh 会走进死循环

/* 错误写法 */
.product-cover {
  height: 100cqh; /* 容器高度还没定稿,这里无法计算 */
}

容器高度通常由内容撑开,内容又依赖容器高度,浏览器只能放弃计算。实际项目里,封面高度用固定值或 aspect-ratio,宽度用 cqi/cqw。

坑 5:container-type 会让 position: fixed 失效

这是最隐蔽的一个。container-type: inline-size 会带来 layout containment,元素会变成绝对定位和固定定位后代的包含块。也就是说,容器内的 Tooltip 如果用 position: fixed,定位基准不再是视口,而是最近的容器。弹窗整体错位。

解法:把弹层/浮层节点移到容器外面,或者用 Portal 渲染到 body 下。

坑 6:样式查询的浏览器支持有差异

@container style() 语法在 Chrome 111、Safari 16+ 可用,但 Firefox 的完整支持相对滞后。生产环境里做一层特性检测再降级:

const supportsContainerQueries = CSS.supports('container-type', 'inline-size');
const supportsStyleQueries = CSS.supports('container-type', 'style');

if (!supportsContainerQueries) {
  // 降级:加载 container-query-polyfill,或者退回 ResizeObserver
  import('container-query-polyfill');
}

浏览器兼容性与技术选型建议

Container Queries 在 2023 年 3 月成为 Baseline 新可用特性。支持情况:

  • Chrome 105+(2022 年 8 月)
  • Edge 105+
  • Firefox 110+(2023 年 2 月)
  • Safari 16+(2022 年 9 月)

如果目标用户是国内普通 PC 用户,2024 年年中以后更新的浏览器基本都覆盖。如果你的产品还要兼容 Chrome 90 左右的旧内核,建议加 polyfill 并做特性检测。不要拿它做核心交互的依赖,但纯展示卡片的布局可以大胆用。

总结

Container Queries 不是来替代 Media Query 的。它补上了 Media Query 缺失的另一半:组件级响应式。把判断维度从视口拉回到容器,组件内部的事情组件自己解决。

如果你的项目里有卡片、弹窗、侧边栏这类复用的组件,直接上 Container Queries。记住:容器用 inline-size,查询目标写在容器内部,浮层需要 Portal,字体排印用 cqi。避开这几个坑,剩下的都是顺滑。