Tim小站
CSS 容器查询:一次不声张的组件重构

CSS 容器查询:一次不声张的组件重构

卡片在侧栏里挤成一团

公司的后台有个卡片组件,ProductCard。图、标题、两行描述、一个按钮,用媒体查询写的:

@media (max-width: 1024px) {
  .product-card { padding: 12px; }
  .product-card__desc { display: none; }
}

问题出在侧栏。侧栏 280px,主栏 700px,在同一个断点上行为却一样:窗口拉大,侧栏的卡片也展开成宽屏的样子,描述露出来,挤成四行;窗口缩小,主栏的卡片反倒把描述藏了,明明它还有富余。

断点描述的是屏幕,不是组件。

这个 bug 是运营先发现的。她在侧栏里截了张图问我,为什么同样一张卡片,在这里只剩两行字。我打开页面量了半天,才意识到媒体查询根本不知道侧栏有多宽,它只知道窗口有多宽。

先量,再改

动手之前我做了一件很土的事:在浏览器里把后台每个容器量了一遍。主栏内容区 704px,右侧栏 288px,中间还有一个 380px 宽的抽屉。三个宽度,同一个卡片,而媒体查询按 1024 和 768 两个断点切的,正好把它们切得乱七八糟。

我把这些数字写在一张便签上贴在显示器边:卡片要在 280、380、700 这三种宽度下都说得过去。这三个数后来变成了容器查询的阈值。

改之前我先写了一个只有三张卡片的 demo 页,把侧栏、主栏、抽屉三种宽度并排摆着,改一段就缩一次窗口,省得在真实页面里来回点。

让组件问自己的父级

容器查询把参照物从视口换成最近的有尺寸的祖先。

.card-list {
  container-type: inline-size;
  container-name: products;
}

@container products (min-width: 420px) {
  .product-card {
    display: grid;
    grid-template-columns: 112px 1fr;
    gap: 16px;
  }
}

@container products (max-width: 419px) {
  .product-card__desc {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
}

inline-size 只关心宽度,不会因为高度触发查询,也不用给容器固定高度。我一开始写成 size,列表在块方向上的尺寸一下塌成 0,才明白两者的区别。

两个坑

第一个是命名。查询只认最近一个声明了 container-type 的祖先,而我们外层那个 .panel 恰好也写了 container-type: inline-size。@container products 匹配不到,样式静默失效,控制台一句话都不给。把名字认真写上,就是为了让这种失效有迹可循。

第二个是 containment 的副作用。容器自己的布局不再受内容影响,项目里一个靠 height: 100% 撑满的内层忽然归零;卡片里那个 position: fixed 的下拉菜单,也被容器约束着跑进了裁剪区。前者改成 min-height,后者换成 absolute 加一个 relative 的定位层。

改完不算优雅,但每一处都能说出理由。顺手把标题的字号也交给容器:font-size: clamp(15px, 2.6cqw, 18px),侧栏里是十五像素,主栏里是十八,中间连续过渡,不用再写断点。

兜底与代价

  • 删掉四个媒体查询断点,六十多行 CSS;
  • 卡片在什么容器里都能自己决定长相:侧栏里竖排紧凑,主栏里横排舒展;
  • 代价是浏览器基线。Safari 16 之后才稳,我们最低支持 Safari 17,刚好够用;再老一点的浏览器,就得留着媒体查询当兜底。

老浏览器留了一段兜底,只在页面级布局里保留一个断点:

@supports not (container-type: inline-size) {
  @media (max-width: 1024px) {
    .product-card__desc { display: none; }
  }
}

这套写法后来被别的项目抄了过去。有人把 container-type 直接写在卡片自己身上,然后疑惑为什么 @container 一直不生效——查询的是父级,不是它自己。这个坑我该早点写进文档。

上线那天没有人注意到差别。这大概是组件重构能拿到的最好的评价。