自定义组件状态 CSS 读不到我折腾了三年——今天 :state() 终于把这件事说清楚了

以前给自定义组件写样式,外界只知道它「存在」,不知道它内部在干什么——现在 CSS 终于能直接读到组件自己暴露的状态了。

这件事的转折点是 :state() 伪类和 CustomStateSet。简单说,自定义组件内部可以往自己的 CustomStateSet 里添加或删除状态字符串,CSS 选择器就能用 :state(状态名) 直接匹配到它。

先看一个最基础的例子——一个抽屉组件,对外暴露 open 和 closed 两个状态:

class DrawerPanel extends HTMLElement {
  constructor() {
    super();
    this._states = new CustomStateSet(this);
  }

  open() {
    this._states.add("open");
    this._states.delete("closed");
  }

  close() {
    this._states.add("closed");
    this._states.delete("open");
  }

  set loading(val) {
    if (val) this._states.add("loading");
    else this._states.delete("loading");
  }
}

customElements.define("drawer-panel", DrawerPanel);

CSS 这边直接就能用了,不需要任何 class 绑定:

/* 匹配 open 状态 */
drawer-panel:state(open) {
  transform: translateX(0);
  box-shadow: 0 0 20px rgba(0,0,0,0.2);
}

/* 匹配 loading 状态 */
drawer-panel:state(loading) {
  opacity: 0.7;
  pointer-events: none;
}

/* 组合多个状态 */
drawer-panel:state(open):state(loading) {
  /* 打开中的加载状态 */
}

这就是关键区别——以前你得自己在组件上 toggle class,JS 和 CSS 各管各的,现在 CSS 直接订阅组件内部状态,JS 只需要负责往 CustomStateSet 里写数据。

再来看一个实际场景:表单组件对外暴露 valid、invalid、dirty 三个状态。

class SmartInput extends HTMLElement {
  constructor() {
    super();
    this._states = new CustomStateSet(this);
  }

  connectedCallback() {
    this._input = this.querySelector("input");
    this._input.addEventListener("blur", () => this._validate());
    this._input.addEventListener("input", () => {
      this._states.add("dirty");
      this._validate();
    });
  }

  _validate() {
    if (this._input.validity.valid) {
      this._states.add("valid");
      this._states.delete("invalid");
    } else {
      this._states.add("invalid");
      this._states.delete("valid");
    }
  }
}

CSS 写法:

smart-input:state(invalid) {
  border-color: red;
}

smart-input:state(invalid):state(focused) {
  border-color: darkred;
  box-shadow: 0 0 0 2px rgba(255,0,0,0.2);
}

smart-input:state(valid) {
  border-color: green;
}

smart-input:state(dirty):state(invalid) {
  /* 用户改过了且当前无效 */
  animation: shake 0.3s ease-in-out;
}

注意 :state() 接收的是一个自定义标识符,不是 :has() 那种复杂选择器。它适合的场景是:组件自己定义一组有限状态,CSS 按状态渲染样式。

如果你的组件是 popover 或 details 这种浏览器内置组件,也有内置状态可以用:

/* 浏览器内置状态 */
popover:state(open) {
  opacity: 1;
  transform: scale(1);
}

浏览器兼容性方面:Chrome 129+ 和 Safari 18+ 已经支持,Firefox 还没上。实际项目里可以照常写,配合 @supports 做渐进增强:

drawer-panel {
  /* 默认降级样式 */
  display: none;
}

@supports selector(drawer-panel:state(open)) {
  drawer-panel:state(open) {
    display: block;
  }
  drawer-panel:not(:state(open)) {
    display: none;
  }
}

最后说一个很多人会踩的坑::state() 里的状态名是大小写敏感的,state(“Open”) 和 state(“open”) 是两个完全不同的状态。所以组件内部定义状态常量的时候,统一用小写加连字符的写法,比如 state(“loading-skeleton”) 而不是 state(“LoadingSkeleton”)。

落地步骤

  1. 盘点项目里有哪些自定义组件目前靠 class toggle 来控制样式
  2. 把这些组件的内部状态抽象成有限集合(比如 open/closed、loading/idle、selected/unselected)
  3. 用 CustomStateSet 替换手动 class 操作,CSS 用 :state() 直接匹配
  4. 加上 @supports 降级,确保 Firefox 用户体验不降级

评论区

0 条评论

登录后可评论。

阿柯·前端架构 1085 阅读