前几天做可视化大屏重构,遇到一个听起来特别适合“上 JavaScript”的交互:页面左侧是按月份排布的趋势柱状图,右侧是对应月份的明细列表,产品要求鼠标悬停柱状图的某一根柱子时,右侧表格里同月份的整行高亮;反过来,鼠标放进表格某一行时,左侧的对应柱子也要高亮。更麻烦的是,这种模块在页面上不止一个,有折线图配列表、有地图点位配排行,规则几乎一致,都是“多组兄弟元素之间做悬停高亮联动”。如果按老办法一个个注册 mouseenter、逐个维护 class,代码量不小,改起来也容易漏。后来我把整个交互收敛成一套纯 CSS 的自动化方案,没有写一行事件逻辑,靠 SCSS 编译期循环加 CSS :has() 选择器,把所有联动规则批量生成出来。这篇文章就完整记录这套思路、踩坑过程以及可直接抄走的实现模板,适合正在做 Dashboard、数据报表、可视化大屏,或者经常被“列表和图表相互高亮”需求折磨的前端同学。
1. 需求拆解:多组兄弟元素的联动到底在联什么
1.1 我先复述一遍最初的业务场景
页面里有一个“运营趋势总览”模块,布局是左右结构。左边画了六根柱子,每根表示一个月的销售达成率;右边是一张六行的表格,每行放着对应月份的明细,比如目标值、完成值、同比、环比。需求交互很明确:
- 鼠标移到左边第 3 根柱子上,右边表格第 3 行背景变蓝,同时这一行可以稍微把文字加粗,用户视线能瞬间沿横向扫过去;
- 鼠标移到右边第 5 行时,左边第 5 根柱子也要变色,甚至可以“拔高”一点,用一个放大效果让用户看出右侧这条数据在左侧对应哪个点。
这样的联动其实在做一件事:让两个互相独立、但又共享同一维度顺序的区域,在某一维度项被 hover 时,都高亮“当前正在看的这一项”。如果用数据结构的话说,两组兄弟元素之间天然存在一个同序索引映射:第一组第 N 项,对应第二组第 N 项。
页面上一共有三个大模块要这么联,有的模块里是三组对应,有的甚至是四组对应。如果每个模块都写一套 JS,先 querySelectorAll 拿两组节点,再循环绑定事件,接着还要在事件里用 index 去找另一组的兄弟节点,逻辑大同小异,但每个模块都要写一遍,每个都要考虑离开时清样式、连击时会不会闪烁、插入新数据后索引是否还正确。
这也是我思考“自动化方案”的起点:能不能让交互状态根本不去操作 DOM,而是让 CSS 自己知道“当前 hover 的是哪个索引”,然后把这个信息广播给同容器下的所有兄弟分组?
1.2 把问题抽象成“悬停源”和“联动目标”
为了后面写方案的清晰,我给这套机制定义两个角色:
- 悬停源:用户鼠标真正停留在上面的那一组元素;
- 联动目标:因为悬停源进入 hover 状态而需要同步高亮的那一组元素。
比如左边柱状图是悬停源时,右边表格就是联动目标;反过来,右边表格是悬停源,左边柱状图就变成联动目标。实际开发里,一个模块中常常是两组互为悬停源、互为联动目标,也就是双向联动。
这个抽象非常重要,因为它决定了我后续写 SCSS 混入时的参数。悬停源允许有多个,联动目标也允许有多个。举例来说,一个区块顶部是图例,中间是折线,下面是明细表,你希望悬停图例某一项时,折线上的对应点和对应表格行全部高亮,这时候“悬停源”是图例组,“联动目标”是剩下的折线和表格。
把这些角色理清楚后,整个方案要解决的问题就剩下一个:对每一对“悬停源组元素 + 联动目标组元素”,是否有一种纯 CSS 的方式知道当前 hover 的源元素序号,并把序号对应到目标组的同序号元素上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 纯 CSS 可行的三个底层机制,以及为什么 :has() 是关键
2.1 传统兄弟选择器为什么做不到跨区域联动
以前想写纯 CSS 的 hover 联动,最常见的手段是利用相邻兄弟选择器 + 和通用兄弟选择器 ~。例如:
css复制.title:hover + .content {
border-color: red;
}
这个模式要求悬停源和联动目标在结构上是紧密相邻的兄弟。但我实际业务里的柱状图容器和表格容器并不是直接相邻,它们之间隔着布局元素,而且左侧柱子那一组和右侧表格那一组,可能在 DOM 上各自处在不同的深层容器中。如果只用兄弟选择器,你要么把结构强行改成“hover 元素后面直接跟着一个联动目标”,要么只能在同一组内部的兄弟之间做高亮。
还有另一个更反直觉的限制:~ 只能选择目标位于 hover 源之后且在同一个父容器内的元素。如果联动目标在 hover 源前面,那 CSS 常规写法完全没有办法在 hover 源状态下去选中它。元素选择只能单向流动,纯旧 CSS 在跨组、反向、多深度的场景上会马上碰壁。
那真正的解法不是去补“兄弟之间的直接选择”,而是把 hover 状态提升到它们共同的父容器上,让父容器成为状态广播中心,再从这个中心向下派发样式。
2.2 :has() 好比给 CSS 装上了事件委托
2022 年底开始,主流浏览器逐步实现了 :has() 选择器。它的能力通俗说就是:我不仅可以根据某个元素自身状态选中它,还可以根据它的后代或兄弟状态反过来选中它自己。
例如:
css复制.parent:has(.child:hover) {
background: #f5f7fa;
}
意思是:如果某个 .parent 内部存在一个正处于 hover 状态的 .child,那么这个 .parent 就匹配这条规则。这跟我们平时说的 CSS 事件委托非常像。JavaScript 里有个事件委托策略,是让父节点统一监听子节点冒泡上来的事件;CSS 的 :has() 虽然不处理事件,但它把“某个后代正处于某种状态”变成了父节点的匹配条件,本质上就是把状态上浮了一层。
有了这一层上浮,跨组联动就顺了。我们不需要从悬停源去选联动目标,而是让公共父容器监听“我的某个子分组里,第 N 项被 hover 了”,一旦成立,再去选择另一个子分组里的第 N 项。父容器作为中转站,绕开了“元素只能向后选择兄弟”的单向限制,也绕开了“不能跨容器选择”的层级限制。
2.3 用 nth-child 做同序索引映射是最省事的约定
在 HTML 里,如果两组兄弟元素是一一对应的,那么它们天然满足一个约定:悬停源组的第 N 个孩子,对应联动目标组的第 N 个孩子。也就是说,源组和目标组内部顺序一致时,不需要在 DOM 上额外写 data-id、data-index 之类的标记,直接利用 :nth-child() 就能完成映射。
这种“依靠同序索引而不是自定义属性”的做法,是降低维护成本的关键。很多团队以前会在 HTML 里写:
html复制<li data-index="0">一月</li>
<li data-index="1">二月</li>
但 index 其实是一个冗余字段,只要列表顺序没有和另一组错位,直接用 nth-child 自动定位就足够。真正推动我实现自动化的原因就在这里::has() 加上 nth-child 的映射虽然能写,但如果元素有 12 个,难道要手写 12 条重复规则?这时候就该把循环生成交给 SCSS 了。
3. SCSS 循环生成:一套混入搞定任意数量的联动组
3.1 目录结构先说清楚:必须把联动模块包进同一个父容器
要让 :has() 状态广播生效,第一前提是“悬停源组”和“联动目标组”必须处于同一个公共父容器内部。以我代码里的实际结构为例:
html复制<section class="linked-visual">
<div class="visual-panel">
<div class="bars">
<span style="--h: 42%"></span>
<span style="--h: 55%"></span>
<span style="--h: 48%"></span>
<span style="--h: 63%"></span>
<span style="--h: 58%"></span>
<span style="--h: 76%"></span>
</div>
</div>
<ul class="data-list">
<li><b>一月</b><em>目标 120 万</em></li>
<li><b>二月</b><em>目标 140 万</em></li>
<li><b>三月</b><em>目标 128 万</em></li>
<li><b>四月</b><em>目标 130 万</em></li>
<li><b>五月</b><em>目标 150 万</em></li>
<li><b>六月</b><em>目标 144 万</em></li>
</ul>
</section>
外层 .linked-visual 就是公共父容器,.bars 下面的 span 和 .data-list 下面的 li 就是两组需要联动的兄弟元素。这里要特别强调一个结构纪律:如果两组元素不在同一个父容器里,:has() 没法跨到另一个独立父容器去判断,那不是 CSS 做不到,而是结构上根本不具备联动上下文。所以写这个功能时,我宁愿在布局上多包一层,也不要把联动模块拆散到页面不同角落。
柱子的高度我用 CSS 自定义属性 --h 控制,这样能保持 DOM 结构精简:
css复制.visual-panel .bars {
display: flex;
align-items: flex-end;
gap: 12px;
height: 220px;
padding: 20px;
background: #fbfcfe;
border-radius: 12px;
}
.visual-panel .bars span {
height: var(--h);
flex: 1;
border-radius: 6px 6px 0 0;
background: #cdd8e4;
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
transform-origin: bottom;
}
3.2 最简版:SCSS 循环生成“悬停一组,亮起另一组”
先把逻辑限定在单向:当鼠标 hover .bars 里的第 N 根柱子时,.data-list 里的第 N 行高亮。
如果用原生 CSS 写 6 条,大概是这种感觉:
css复制.linked-visual:has(.bars > span:nth-child(1):hover) .data-list > li:nth-child(1) {}
.linked-visual:has(.bars > span:nth-child(2):hover) .data-list > li:nth-child(2) {}
写 6 个可能还能忍,但如果月份换成年份粒度、表格变成 30 行,这条规则就会爆炸。我实际用的是 SCSS 的 @for 循环,循环次数只取决于“一组的元素数量”:
scss复制$linked-count: 6;
@for $i from 1 through $linked-count {
.linked-visual:has(.bars > span:nth-child(#{$i}):hover) {
.data-list > li:nth-child(#{$i}) {
background: rgba(24, 119, 242, 0.08);
box-shadow: inset 3px 0 0 rgba(24, 119, 242, 0.9);
}
}
}
编译结果是 6 条选择器,但你在 SCSS 源码里永远只维护一个循环次数。以后数据从 6 个月变成 12 个月,把 $linked-count 改成 12 就行。这个循环写起来非常直白,不过它离“一套方案到处复用”还差一点,因为类名写死了。
3.3 封装成混入:参数化之后才能叫自动化
为了让多个模块复用同一套逻辑,我把上面逻辑封装成 SCSS 混入。参数包含联动容器、悬停源组、联动目标组和元素数量:
scss复制@mixin linked-highlight(
$count: 6,
$scope: '.linked-visual',
$source: '.bars',
$target: '.data-list'
) {
@for $i from 1 through $count {
#{$scope}:has(#{$source} > :nth-child(#{$i}):hover) {
#{$target} > :nth-child(#{$i}) {
@content;
}
}
}
}
调用方式特别像使用一个“方案模板”:
scss复制.linked-visual {
@include linked-highlight(
$count: 6,
$source: '.bars',
$target: '.data-list'
) {
background: rgba(24, 119, 242, 0.08);
box-shadow: inset 3px 0 0 rgba(24, 119, 242, 0.9);
color: #1877f2;
}
}
这样就完成了第一个通用能力:凡是符合“同一个容器下两组同序元素”的结构,套上这段混入,悬停源 hover 第 N 个元素,联动目标的第 N 个元素就会应用你写在 @content 里的样式。注意我在混入参数里把 source 和 target 都做成了变量,所以一个模块可以自定义谁是悬停源,谁是联动目标。
3.4 双向联动怎么做?一个方案反向调用两次就行
业务需求往往不是单向的,右边表格 hover 时,左边柱子也要高亮。这不需要新增任何复杂机制,只需要再反向调用一次同一个混入:
scss复制.linked-visual {
// 柱子 hover,亮起表格行
@include linked-highlight(
$count: 6,
$source: '.bars',
$target: '.data-list'
) {
background: rgba(24, 119, 242, 0.08);
box-shadow: inset 3px 0 0 rgba(24, 119, 242, 0.9);
color: #1877f2;
}
// 表格行 hover,亮起柱子
@include linked-highlight(
$count: 6,
$source: '.data-list',
$target: '.bars'
) {
transform: scaleY(1.06);
background: #1877f2;
opacity: 1;
}
}
这里我先给了表格行的样式,只加了浅蓝背景和左侧强调条;反向给柱子做高亮时,用了 transform: scaleY 让柱子轻微“长高”,同时柱子背景色变实。两轮混入会把同一个写法覆盖到两个方向,但实际触发的上下文是互斥的:hover 柱子时第一条生效,第二条的 :has() 条件不成立,所以不会互相干扰。
如果你希望鼠标 hover 某一组时,自己这一项和其他对应的组一起高亮,比如 hover 柱子时要求柱子自身也加深、表格行也加深,那么只需要在外层再补充一条“目标同时包含 source 和 target”的规则,或者在 HTML 结构上再约定一层样式类。实际项目中我通常会把高亮效果分为“当前项效果”和“远距离对应项效果”两套。
3.5 扩展到三组、四组联动:遍历目标组列表
页面里还有一个模块是季度汇报看板,上面一行是四个季度的色块图例,中间是拆成四段的横向条形图,下面是一个四行表格。需求是悬停任意一组内某个季度,其余所有组里同一季度都要高亮。
这种情况只要对“联动目标组”稍加扩展。可以改成接收一个目标组列表:
scss复制@mixin multi-linked(
$count: 4,
$scope: '.linked-annual',
$source: '.legend-list',
$targets: '.bar-chart', '.detail-table'
) {
@for $i from 1 through $count {
#{$scope}:has(#{$source} > :nth-child(#{$i}):hover) {
@each $target in $targets {
#{$target} > :nth-child(#{$i}) {
@content;
}
}
}
}
}
理论上它可以继续扩成“多个悬停源组”,让任意一组都能成为悬停源。写成一个嵌套的 @each 循环即可,规则数量会等于“悬停源数 × 目标数 × 条目数”,在几十条的范围内对 CSS 文件体积影响微乎其微。但这里我很想说一句经验之谈:自动化建立在“多组成员使用同一顺序”的约定之上,一旦某组数据排序不一致,就应该立刻放弃纯 CSS,不要硬撑。
4. 完整可运行示例:月份柱状图和明细表的双向悬停高亮
4.1 把散装代码整理成一个可以直接粘贴的版本
下面这个片段包含了 HTML、SCSS 和必要的基础布局,如果你想快速验证效果,可以在 CodePen 或本地 Vite 项目里直接拷过去试。SCSS 部分我用上方定义的混入完成双向联动:
html复制<section class="linked-visual">
<div class="visual-panel">
<div class="bars">
<span style="--h: 42%"></span>
<span style="--h: 55%"></span>
<span style="--h: 48%"></span>
<span style="--h: 63%"></span>
<span style="--h: 58%"></span>
<span style="--h: 76%"></span>
</div>
</div>
<ul class="data-list">
<li><b>一月</b><em>42 万</em></li>
<li><b>二月</b><em>55 万</em></li>
<li><b>三月</b><em>48 万</em></li>
<li><b>四月</b><em>63 万</em></li>
<li><b>五月</b><em>58 万</em></li>
<li><b>六月</b><em>76 万</em></li>
</ul>
</section>
布局的重点是 .linked-visual 用 flex 把图表和列表横向排开,左右两个区域内部保持等宽的业务语义,不要让某一列窄到塞不下文字:
scss复制.linked-visual {
display: flex;
gap: 24px;
align-items: stretch;
max-width: 860px;
margin: 0 auto;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.visual-panel {
flex: 3;
display: flex;
align-items: flex-end;
background: #f3f6fc;
border-radius: 16px;
padding: 20px;
}
.data-list {
flex: 2;
list-style: none;
margin: 0;
padding: 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
}
.data-list li {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-radius: 10px;
transition: background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
}
柱子区域也需要补充基础样式。这里使用了每根柱子通过 CSS 变量 --h 控制高度,这个写法适合动态数据,如果数据是后端返回,直接在 HTML 模板里把变量值拼进去就行:
scss复制.visual-panel .bars {
display: flex;
align-items: flex-end;
gap: 10px;
height: 100%;
min-height: 220px;
width: 100%;
}
.visual-panel .bars span {
flex: 1;
height: var(--h);
border-radius: 8px 8px 0 0;
background: #c6d4e2;
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
transform-origin: bottom;
}
// 默认给柱子底部留一个透明阴影,避免放大时溢出
.visual-panel .bars span {
box-shadow: 0 0 0 4px transparent;
}
然后调用封装好的自动化混入。为了让柱子 hover 时表格行高亮、表格行 hover 时柱子高亮,我把同一个混入调用两次:
scss复制@mixin linked-highlight(
$count: 6,
$scope: '.linked-visual',
$source: '.bars',
$target: '.data-list'
) {
@for $i from 1 through $count {
#{$scope}:has(#{$source} > :nth-child(#{$i}):hover) {
#{$target} > :nth-child(#{$i}) {
@content;
}
}
}
}
.linked-visual {
// 柱子 hover,右侧表格行高亮
@include linked-highlight(
$count: 6,
$source: '.bars',
$target: '.data-list'
) {
background: rgba(24, 119, 242, 0.08);
box-shadow: inset 3px 0 0 rgba(24, 119, 242, 0.9);
color: #1877f2;
}
// 表格行 hover,左侧柱子高亮
@include linked-highlight(
$count: 6,
$source: '.data-list',
$target: '.bars'
) {
background: #1877f2;
transform: scaleY(1.07);
box-shadow: 0 6px 16px rgba(24, 119, 242, 0.25);
}
}
编译后你会看到类似这样的 CSS 规则,每一条都按 nth-child 编号对应好:
css复制.linked-visual:has(.bars > span:nth-child(1):hover) .data-list > li:nth-child(1) {
background: rgba(24, 119, 242, 0.08);
box-shadow: inset 3px 0 0 rgba(24, 119, 242, 0.9);
color: #1877f2;
}
.linked-visual:has(.data-list > li:nth-child(2):hover) .bars > span:nth-child(2) {
background: #1877f2;
transform: scaleY(1.07);
}
我建议自己手写混入时,尽量先编译一次,看一眼生成的 CSS,确认选择器没有重复冲突。尤其是多方向联动时,肉眼检查生成的 CSS 能避免很多难以排查的问题。
4.2 再加一个“其它项变淡,只有对应项醒目”的效果
有些可视化场景下,单纯高亮目标项还不够,还要把同一组里的其它项压暗,否则视线仍然会被大量颜色干扰。实现思路是:当容器内第 N 项 hover 时,除了第 N 项之外的其他项全部降低透明度。这个规则也可以循环生成:
scss复制@mixin linked-dim(
$count: 6,
$scope: '.linked-visual',
$source: '.data-list',
$target: '.bars'
) {
@for $i from 1 through $count {
#{$scope}:has(#{$source} > :nth-child(#{$i}):hover) {
#{$target} > :not(:nth-child(#{$i})) {
opacity: 0.35;
}
}
}
}
但这里有个细节要注意:如果目标组容器里还混入了一些说明性节点,比如首行标题、空状态占位等,:not(:nth-child(#{$i})) 会把它们也选中。所以我在实际结构里会把需要参与联动的条目单独包裹成一个 group 容器。例如:
html复制<div class="bars-group">
<span></span>
<span></span>
</div>
然后 :has() 和 :not() 都作用于 .bars-group 内部,避免误伤。这个“误伤”问题我在早期版本踩过,后面会再展开讲。
5. 使用边界、浏览器兼容与性能注意事项
5.1 :has() 的支持情况和我的兼容判断
:has() 从 2022 年底开始在 Chrome 105、Edge 105、Firefox 121、Safari 15.4 之后的版本陆续可用。到 2024 年后,面向内部运营系统、后台管理、数据可视化展示这类场景,基本可以放心用,因为它不是渐进增强样式,而是核心交互机制。
但如果你的项目还需要支持很老的企业浏览器内核,比如某个银行客户的内网 Chromium 72 内核,那这套方案就不能作为唯一交互,需要退回 JS。这里给一个直接的判断标准:上生产环境前,先在所有必须支持的浏览器里跑一次 :has() 烟雾测试,再决定核心交互是否完全依赖它。
5.2 注意 :has() 选择器的性能热点
:has() 本身不会造成严重性能问题,但用的时候要控制“判断范围”。
