跳到主内容

从 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,都由浏览器维护,不需要额外的数据状态。

原生控件状态checked / focus:has 条件匹配祖先或兄弟联动目标区域样式显示、高亮、禁用无需事件监听、无需框架渲染、无需维护 class状态由浏览器原生表单系统维护

方案对比: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 通常能让代码更少、首屏更稳、交互更自然。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。