组件自适应,为什么不能只看视口?
很多团队在做组件化时都会遇到一个问题:组件在demo里看着挺好,一放到真实的布局里就变形了。原因很简单,组件不是根据自己身处的容器自适应,而是跟着整个视口走。比如一个卡片组件,在宽屏下的布局是横向排列,但同一个组件被放进侧边栏时,宽度只有三百来像素,它依然按照视口宽度计算,结果文案挤压、按钮换行,整个组件变得很狼狈。

过去我们习惯用媒体查询(Media Query)来解决响应式问题,但它本质上是基于视口的,也就是说,不管这个组件被放在页面的哪个位置,它只在乎浏览器窗口的宽度。组件要真正做到“看容器脸色行事”,需要的是容器查询(Container Queries)这类能力,以及当容器尺寸变化时需要同步处理JS逻辑的Resize Observer。这篇文章想聊的就是,两者各自能解决什么,彼此之间如何配合,以及在实际项目中怎么落地。
容器查询:让样式跟着宿主容器走
容器查询的核心思路是:给一个元素声明为容器,然后它的子孙元素就能基于这个容器的尺寸来应用样式。这样,组件样式不再依赖视口,而是依赖它最近的那个被标记为容器的祖先。
CSS写法大致是这样:
/* 把 .card-grid 声明为容器 */
.card-grid {
container-type: inline-size;
container-name: card-grid;
}
/* 子孙组件在容器宽度小于 400px 时启用竖排 */
@container card-grid (max-width: 400px) {
.card {
flex-direction: column;
}
}
注意,这里有一个很多人第一次用时会踩的坑:container-type: inline-size 是只追踪容器的内联尺寸(通常就是宽度),块尺寸(高度)默认不影响。container-type 还有个值为 size,表示同时追踪宽高,但使用 size 时容器自身无法根据内容撑开,需要显式设置尺寸,所以大多数场景下我们用的是 inline-size。
容器查询的浏览器支持现在已经很稳定了,从2023年年初开始,Chrome、Edge、Safari、Firefox 都支持了 @container。对于团队来说,最大的门槛其实不是兼容性,而是“如何把组件依赖的容器边界定义清楚”。因为容器是一层的,如果你的组件套了多层容器,它找的是最近的声明容器,这一点和 position 的包含块有点类似,但又不完全一样。
Resize Observer:容器变化时的JS感知
样式层面的响应可以交给容器查询,但页面上总有一些行为需要JavaScript跟手。比如一个图表组件,容器宽度从800px变成400px时,图表实例不会自动重绘,你需要重新设置尺寸;一个表格组件,窄屏时需要隐藏某些列,或者把单元格拍成上下的结构,这些逻辑往往不是简单改CSS能搞定的。
Resize Observer 就是用来监听某个元素尺寸变化的API。它的用法很简单:
const container = document.querySelector('.chart-box');
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
renderChart(width, height);
}
});
ro.observe(container);
相比window.resize事件,Resize Observer能精确观察到具体容器的变化,而且它只在容器实际尺寸变化时触发,不会像监听window那样,用户滚动时也疯狂触发。这对组件内部的状态管理来说,体验要舒服很多。但它也并非万能:回调里如果修改了容器尺寸,就会导致循环触发,所以一般需要在回调里做防抖,或者通过判断条件中止递归。
真实场景:卡片组件的“变形”
我之前参与过一个数据可视化项目,里面有个指标卡组件,设计稿里提供了三套方案:宽布局、中布局和窄布局。宽布局下,数值和环比标签并排;中布局时数值变大,标签绕到右上角;窄布局时干脆隐藏环比标签,只保留主数值。
一开始我们用的媒体查询+JS判断视口宽度来切换。但问题很快暴露:指标卡可能出现在顶部的大卡片里,也可能出现在侧栏的迷你“运营概览”里。同样一个组件,同一屏下,因为不同parent容器宽度不同,需要显示成不同变体。媒体查询毕竟只管视口,根本没法区分这两个位置的差异。后来我们引入了容器查询,配合Resize Observer去动态更新一些装饰元素的动画开关。这是容器查询最典型的价值:同一个组件在不同容器里,自己长成不同的形态。
真实场景:表格在窄容器中的结构变化
另一个常见的场景是数据表格。一个列表页,表格区域宽度取决于左侧菜单是否折叠,菜单折叠和展开时,表格容器宽度会剧烈变化。过去我们只能监听window resize,然后计算当前表格容器实际宽度,再决定是否切换为“卡片式表格”。这个判断逻辑往往非常脆弱,因为你得自己维护容器的宽度,而且视口变化并不一定等于容器宽度变化,比如侧边栏宽度变化时,视口可能没变。
现在我们会直接用容器查询来做表格的“视觉形态”切换:容器窄时,表头隐藏,每行改为一个卡片;容器中等时,显示部分列;容器宽时,显示全部列。而Resize Observer用来在容器尺寸变化后触发表格的某些内部计算,比如需要重新计算列的省略宽度,或者通知虚拟滚动插件更新视口尺寸。两者配合之后,表格的逻辑比之前清爽多了。
常见的误区和边界
我在团队里普及这些技术时,发现有几个误解特别普遍,这里集中说一下。
- 误区一:容器查询能完全替代媒体查询。实际上它们解决的是不同层面的问题。媒体查询依然要管视口级别的整体布局,比如导航栏在移动端收起,侧边栏从两列变单列;容器查询管的是组件在某个容器里的样子。两者是互补,不是替代。
- 误区二:Resize Observer 只用于看宽度。它也能看高度,但height变化往往由内容撑开,这会导致更复杂的循环问题。通常我们只关心宽度,所以建议优先使用
container-type: inline-size,把样式的响应交给CSS,JS只管必要的副作用。 - 误区三:
container-type: inline-size会同时监听高度变化。这个问题前面提过,inline-size 只监听内联尺寸,高度变化不会触发容器查询。但这不代表它不能用于高度查询,只是需要把容器设置为container-type: size,同时就会牺牲容器的高度自适应内容的能力,使用时要特别注意。
三种方案的定位差异
要理解它们各自的适用边界,一张表格可能比长篇大论更直观。下表对比了媒体查询、容器查询和Resize Observer在实际使用中的差异:
| 能力 | 媒体查询 | 容器查询 | Resize Observer + JS |
|---|---|---|---|
| 关注维度 | 视口尺寸 | 容器尺寸 | 任意元素的尺寸 |
| 触发方式 | CSS响应 | CSS响应 | JS回调 |
| 适用于组件级自适应 | 不适用 | 非常适合 | 适合需要JS计算的场景 |
| 性能开销 | 极低 | 低 | 中,需防抖/去重 |
| 维护复杂度 | 低 | 中,需定义容器边界 | 高,需手动维护监听与状态 |
从这个表也能看出来,纯样式的UI适配优先用容器查询,涉及动画状态或DOM操作时再用Resize Observer补位。直接把所有逻辑堆在JS里是最后的兜底方案,不应该成为默认选择。
实际项目中怎么落地
真正到一个已有项目里引入这些能力,不建议一上来就全量重构。我的建议是分三步走。
- 先盘点组件。找出那些“在不同容器里需要不同表现”的组件,优先改造。通常是卡片、表格、图表、表单分组这类复用率高的组件。
- 渐进增强容器查询。先给组件的父级加上
container-type: inline-size,再写@container规则。如果你的浏览器需要兼容旧版本,可以用@supports (container-type: inline-size)做能力检测,不支持的浏览器走原先的媒体查询方案。 - 用Resize Observer封装自定义Hook。把对容器尺寸的监听封装成React/Vue里的一个hook或mixin,组件内部只调用它拿宽度和高度,避免到处散落
new ResizeObserver和销毁逻辑。顺便在hook里做一个100ms以内的防抖,减少高频回调对性能的影响。
下面是一个轻量的React hook示例,把Resize Observer包装成组件可以随时使用的尺寸值:
import { useState, useEffect, useRef } from 'react';
export function useContainerSize(ref) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setSize({ width, height });
});
ro.observe(ref.current);
return () => ro.disconnect();
}, [ref]);
return size;
}
注意到上面代码里的箭头函数,在编译时必须先转成普通函数,否则旧浏览器会报语法错误。不过现在项目大多有Babel或swc,这个不是问题。真正的坑是:React的赋值更新会触发重新渲染,如果渲染导致容器尺寸再变化,就会陷入“监听-改尺寸-再监听”的循环。所以对于尺寸敏感的组件,建议在setSize前判断一下宽度是否真的发生了超过阈值的改变。
再给一个实践建议:慎用嵌套容器。嵌套容器会让查询目标变得不够稳定,如果子组件内部还声明了容器,那么内部的@container将优先匹配最近的祖先容器,外部容器查询对它可能失效。遇到这种情况,明确容器名称或者干脆拆组件,会比硬靠css规则省事得多。
没有银弹,但这是目前最好的组合
说回主题,Resize Observer和容器查询并不是一个非此即彼的选择。容器查询把样式响应下沉到“容器级”,解决了组件复用时的视觉自适应问题;Resize Observer把尺寸变化变成JS可以感知的事件,解决了样式之外的行为同步问题。两者配合,才算是把“自适应组件”这件事补齐了。
当然,技术选型永远要结合自己的团队和项目阶段。如果只是几个页面偶尔需要,那用媒体查询+window.resize疯狂计算也没啥大不了;但如果你正在做一个需要长期演进的组件库,或者维护一个中后台系统,那么尽早把容器边界梳理清楚,引入容器查询和Resize Observer,会让后面每一位维护者都轻松很多。组件的外观和逻辑都跟着容器走,而不是跟着窗口走,这才是组件“真正自适应”该有的样子。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/856/