外观
从 2KB JS 到零脚本::has 表单联动实战避坑
为什么表单联动不一定需要 JavaScript
过去做表单交互,常见思路是监听 input、change 事件,再通过 JS 切换 class,最后由 CSS 控制样式。比如选择「企业用户」后显示税号输入框,勾选「自定义金额」后启用数字输入框,或者点击卡片型 radio 后高亮整张卡片。逻辑本身不复杂,但当页面中存在大量类似交互时,会带来三类成本:脚本体积增加、状态与 DOM 不同步、首屏 hydration 前短暂失效。
CSS 选择器 Level 4 中的 :has() 给了另一条路:父元素可以根据后代或兄弟元素的状态改变样式。它常被称为「父选择器」,但能力不止于选择父级,还能实现祖先条件匹配、前面兄弟选择、表单状态联动等效果。现代浏览器从 Chrome 105、Safari 15.4、Firefox 121 开始支持,已适合在中后台、文档站、营销页等可控环境中使用。
本文通过一个可运行的订单配置表单,演示如何用 :has 完成显示隐藏、卡片选中、按钮禁用态和错误提示,并说明在旧浏览器中的零 JS 降级方式。
:has 实现表单联动的核心思路
:has 的本质是「当某个元素内部或后面存在满足条件的元素时,匹配当前元素」。例如:
css
/* 当卡片内部 radio 被选中时,高亮整张卡片 */
.plan-card:has(input:checked) {
border-color: #6366f1;
box-shadow: 0 12px 30px rgba(99, 102, 241, 0.18);
}
/* 当选择企业用户时,显示税号字段 */
.form:has(#user-type-company:checked) .tax-field {
display: grid;
}
/* 当输入框聚焦时,让外层容器改变背景 */
.field:has(input:focus-within) {
background: #eef2ff;
}与传统 JS 方案相比,关键差异在于状态源仍然是原生表单控件,CSS 只负责表现层。radio 是否选中、checkbox 是否勾选、输入框是否 focus,都由浏览器维护,不需要额外的数据状态。
方案对比:JS、兄弟选择器与 :has
在 :has 出现之前,并不是所有联动都必须使用 JS。利用 checkbox 或 radio 的 checked 状态,配合相邻兄弟选择器 ~,也能实现部分效果。但它对 DOM 结构要求很高:触发控件必须位于目标元素之前,而且二者需要拥有共同父元素。一旦目标是整张卡片、表单容器或更靠后的区域,CSS 就很难表达。
| 方案 | 适用场景 | DOM 限制 | 可维护性 | 旧浏览器支持 |
|---|---|---|---|---|
| JavaScript 监听 | 复杂校验、异步请求、动态数据 | 无限制 | 状态多时成本高 | 最好 |
| input:checked ~ target | 简单开关、纯 CSS 手风琴 | 目标必须在控件后方 | 结构耦合明显 | 很好 |
| :has() | 父级高亮、跨区域联动、条件样式 | 结构要求较低 | 语义清晰、样式内聚 | 新版浏览器良好 |
需要强调的是,:has 适合「样式联动」,不适合承载业务规则。如果选中某个选项后要请求价格、修改提交数据、做跨字段校验,仍然应该交给 JS。把所有交互都塞进 CSS,会让代码从「零 JS」变成「难调试」。
可运行示例:订单配置表单零 JS 联动
下面示例包含三个常见需求:个人或企业用户切换、套餐卡片选中、自定义金额勾选后启用输入框。将代码保存为 HTML 文件即可直接运行。
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>:has 表单联动示例</title>
<style>
body {
margin: 0;
padding: 40px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: #f8fafc;
color: #0f172a;
}
.form {
max-width: 720px;
margin: 0 auto;
padding: 24px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
}
.user-type,
.plans {
display: flex;
gap: 12px;
margin: 12px 0 20px;
}
/* 隐藏原生控件,但保持可聚焦和可选中 */
.choice input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.choice span,
.plan-card {
display: block;
padding: 14px 18px;
border: 1px solid #cbd5e1;
border-radius: 12px;
cursor: pointer;
transition: all 0.18s ease;
}
/* 键盘聚焦时保留可见焦点样式 */
.choice input:focus-visible + span,
.plan-card:has(input:focus-visible) {
outline: 3px solid rgba(99, 102, 241, 0.28);
outline-offset: 2px;
}
.choice:has(input:checked) span,
.plan-card:has(input:checked) {
border-color: #6366f1;
background: #eef2ff;
color: #3730a3;
box-shadow: 0 10px 24px rgba(99, 102, 241, 0.14);
}
.plan-card {
flex: 1;
}
.plan-card strong,
.plan-card small {
display: block;
}
.field {
display: grid;
gap: 8px;
margin-top: 16px;
}
.field input[type="text"],
.field input[type="number"] {
height: 40px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font: inherit;
}
/* 企业用户税号字段默认隐藏 */
.tax-field {
display: none;
}
.form:has(#company:checked) .tax-field {
display: grid;
}
/* 未勾选自定义金额时,禁用并降低数字框存在感 */
.amount-field input[type="number"] {
background: #f1f5f9;
}
.form:not(:has(#custom-amount:checked)) .amount-field input[type="number"] {
pointer-events: none;
opacity: 0.55;
}
.form:has(#custom-amount:checked) .amount-field input[type="number"] {
background: #fff;
}
/* 自定义金额未勾选时,提示使用套餐价 */
.amount-tip-custom,
.amount-tip-default {
font-size: 13px;
color: #64748b;
}
.amount-tip-custom {
display: none;
}
.form:has(#custom-amount:checked) .amount-tip-custom {
display: block;
}
.form:has(#custom-amount:checked) .amount-tip-default {
display: none;
}
button {
margin-top: 24px;
width: 100%;
height: 44px;
border: 0;
border-radius: 12px;
background: #6366f1;
color: #fff;
font-size: 16px;
cursor: pointer;
}
/* 企业用户必须填写税号:未填写时弱化提交按钮,仅作视觉提示 */
.form:has(#company:checked) .tax-field input:placeholder-shown ~ button {
opacity: 0.6;
}
</style>
</head>
<body>
<form class="form">
<h2>订单配置</h2>
<fieldset>
<legend>用户类型</legend>
<div class="user-type">
<label class="choice">
<input type="radio" name="userType" id="personal" checked />
<span>个人用户</span>
</label>
<label class="choice">
<input type="radio" name="userType" id="company" />
<span>企业用户</span>
</label>
</div>
</fieldset>
<fieldset>
<legend>选择套餐</legend>
<div class="plans">
<label class="plan-card">
<input type="radio" name="plan" value="basic" checked />
<strong>基础版</strong>
<small>¥29 / 月</small>
</label>
<label class="plan-card">
<input type="radio" name="plan" value="pro" />
<strong>专业版</strong>
<small>¥99 / 月</small>
</label>
<label class="plan-card">
<input type="radio" name="plan" value="team" />
<strong>团队版</strong>
<small>¥299 / 月</small>
</label>
</div>
</fieldset>
<label class="field tax-field">
<span>企业税号</span>
<input type="text" name="taxNo" placeholder="请输入企业税号" autocomplete="organization" />
</label>
<label>
<input type="checkbox" id="custom-amount" />
使用自定义金额
</label>
<label class="field amount-field">
<span>金额</span>
<input type="number" name="amount" min="1" value="100" />
</label>
<p class="amount-tip-default">当前按所选套餐价格结算。</p>
<p class="amount-tip-custom">请填写自定义金额,提交前仍建议进行业务校验。</p>
<button type="submit">提交订单</button>
</form>
</body>
</html>这个例子没有一行 JavaScript,却能完成大多数视觉层面的联动。重点不是炫技,而是把「选中状态」和「样式结果」放在同一段 CSS 中,后续修改主题或布局时不需要在脚本和样式文件之间来回跳转。
在 Vue3 组件中如何组织 :has 样式
在 Vue3 或 VitePress 文档站中,建议把 :has 写成组件内部的表现逻辑,而不是用它替代 v-model。也就是说,数据仍然可以通过 v-model 提交给后端,但展开、高亮、提示等纯视觉状态不必进入响应式系统。
vue
<script setup lang="ts">
import { reactive } from 'vue'
// 只保存真正需要提交或参与业务计算的数据
const form = reactive({
userType: 'personal',
plan: 'basic',
customAmount: false,
amount: 100,
taxNo: ''
})
function submit() {
// 跨字段校验、请求提交仍由 JS 负责
if (form.userType === 'company' && !form.taxNo) {
alert('请填写企业税号')
return
}
console.log('submit', { ...form })
}
</script>
<template>
<form class="order-form" @submit.prevent="submit">
<label v-for="item in [
{ value: 'personal', label: '个人用户' },
{ value: 'company', label: '企业用户' }
]" :key="item.value" class="choice">
<input v-model="form.userType" type="radio" name="userType" :value="item.value" />
<span>{{ item.label }}</span>
</label>
<label class="field tax-field">
<span>企业税号</span>
<input v-model="form.taxNo" type="text" placeholder="请输入企业税号" />
</label>
<label>
<input v-model="form.customAmount" type="checkbox" />
使用自定义金额
</label>
<button type="submit">提交订单</button>
</form>
</template>
<style scoped>
.order-form:has(input[value="company"]:checked) .tax-field {
display: grid;
}
.choice:has(input:checked) span {
border-color: #6366f1;
background: #eef2ff;
}
</style>这里要注意 scoped 的行为:Vue 会给选择器加上 data-v 属性,但不会改变 :has 的匹配逻辑。只要触发控件和目标组件处于同一个 scoped 样式边界内,写法通常没有问题。若目标是子组件内部节点,需要使用 :deep()。
旧浏览器降级:优先保证内容可用
:has 的降级不应简单理解为「不支持就再写一套 JS」。更稳妥的原则是:默认内容可见,增强体验再隐藏或折叠;关键流程不依赖 CSS 新特性。
第一种方式是特性查询。默认展示所有字段,在支持 :has 的浏览器中再根据条件隐藏。这样旧浏览器虽然少了联动效果,但不会丢失税号输入框。
css
/* 降级优先:旧浏览器中字段始终可见 */
.tax-field {
display: grid;
}
@supports selector(:has(*)) {
.tax-field {
display: none;
}
.form:has(#company:checked) .tax-field {
display: grid;
}
}第二种方式是渐进增强。卡片默认就是普通 label,支持 :has 后增加选中背景和阴影;旧浏览器中通过原生 radio 或 accented-color 仍能看出选中状态。
css
.plan-card input[type="radio"] {
accent-color: #6366f1;
}
@supports selector(:has(*)) {
.plan-card:has(input:checked) {
border-color: #6366f1;
background: #eef2ff;
}
}第三种才是轻量 JS 兜底。只有当业务明确要求旧浏览器也必须折叠字段时,才增加监听脚本。否则为了少量旧环境维护两套状态,往往得不偿失。
typescript
function supportsHas(): boolean {
return CSS.supports('selector(:has(*))')
}
function setupLegacyToggle(form: HTMLFormElement): void {
if (supportsHas()) return
const company = form.querySelector<HTMLInputElement>('#company')
const taxField = form.querySelector<HTMLElement>('.tax-field')
if (!company || !taxField) return
const sync = () => {
taxField.hidden = !company.checked
}
company.addEventListener('change', sync)
sync()
}
document.querySelectorAll<HTMLFormElement>('.form').forEach(setupLegacyToggle)避坑总结:这些问题在真实项目中最常见
1. 用 display: none 的 input 导致无法操作
常见错误是把 radio 或 checkbox 设置为 display: none,再把 label 当作卡片点击。部分场景虽然能通过点 label 切换,但元素会从键盘 tab 序列和可访问性树中异常消失,屏幕阅读器体验也不稳定。更安全的写法是使用 position: absolute; opacity: 0; 视觉隐藏,并补上 :focus-visible 样式。
错误现象:鼠标能点选卡片,但键盘用户按 Tab 找不到选项,焦点环消失。修正方法是不要使用 display: none 或 visibility: hidden,同时为 .card:has(input:focus-visible) 设置清晰 outline。
2. 误以为 :has 能替代表单禁用和校验
CSS 把输入框设置为透明、禁止 pointer-events,并不等于字段真的 disabled。用户仍可能通过键盘、自动填充、开发者工具或默认提交带入值。错误现象是页面看起来不能编辑,提交后接口却收到了 amount 或 taxNo。修正方法是:视觉联动交给 :has,真正需要阻止提交的值必须在 JS 或模板中绑定 :disabled,服务端也要做最终校验。
3. 选择器写得过宽,造成意外高亮
.card:has(input:checked) 在卡片内只有一个 radio 时没问题,但如果卡片中又嵌套了「自动续费」checkbox,就可能因为任意 input 选中而高亮整张卡片。修正方式是提高条件精度,例如 .plan-card:has(input[name="plan"]:checked),避免把无关控件纳入判断。
4. 在循环列表里依赖重复 id
很多人会写 .form:has(#company:checked),但同一页面渲染多个订单卡片时,id 重复会导致查询和 label 关联混乱。Vue 列表中应优先使用稳定的 name、value、data 属性或唯一 id。修正示例:.form:has(input[value="company"]:checked),或通过 :id="\company-${formId}`` 生成唯一标识。
5. 忽略特异性导致样式怎么调都不生效
:has() 本身不会神奇提高优先级,整条选择器仍按正常规则计算。.form:has(#company:checked) .tax-field 中包含 id,特异性会高于普通 class。后续写 .tax-field { display: none; } 很可能无法覆盖。建议把联动样式集中管理,不要在多个层级反复声明 display,并避免滥用 !important。
6. 默认隐藏内容,旧浏览器直接不可见
如果一开始就写 .tax-field { display: none; },而旧浏览器不支持 :has,企业税号字段可能永远无法显示。修正原则是「关键内容默认可见,增强样式再折叠」,并使用 @supports selector(:has(*)) 包裹增强规则。对于筛选面板、详情展开等非关键内容,也要确认旧环境下信息是否仍可访问。
结语
:has 不是要消灭 JavaScript,而是把大量原本属于 CSS 的视觉状态还回 CSS。表单中的卡片选中、条件区域、焦点高亮、提示文案切换,都可以借助它减少事件监听和响应式状态;而提交数据、异步价格、复杂校验仍应保留在 JS 中。
实际落地时,建议从三个问题判断是否使用:它是否只影响样式、状态是否来自原生控件、旧浏览器降级是否不影响核心流程。满足这些条件,:has 通常能让代码更少、首屏更稳、交互更自然。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。