1. 为什么我会关注><button class="submit-btn">.submit-btn[data-status="idle"] { background-color: #1677ff; }
只有当按钮的><div class="card is-selected is-expanded is-highlighted theme-dark">内容</div>
这种情况下,用户看 HTML 可能会困惑:“这几个 class 到底谁控制颜色?谁控制边框?谁控制加粗?”。如果你想在 JavaScript 里切换某一个状态,最土的方式是classList.remove一大串,再classList.add另一大串。一旦某个状态对应好几个 class,漏一个就麻烦了。
><div class="card">.card[data-status="selected"] { border-color: #1677ff; box-shadow: ...; } .card[data-status="expanded"] { height: auto; } .card[data-status="selected"][data-status="expanded"] { ... }
等等,这里有一个很坑的细节:同一个元素上,同一个属性不能同时存在两个值。><div class="upload-box" id="uploadBox">.upload-box[data-status="waiting"] .upload-text { color: #666; } .upload-box[data-status="uploading"] .upload-icon { animation: spin 1s linear infinite; } .upload-box[data-status="uploading"] .upload-text { color: #1677ff; } .upload-box[data-status="success"] .upload-text { color: #52c41a; } .upload-box[data-status="error"] .upload-text { color: #ff4d4f; }
这样写的好处是整个 CSS 区块像一张状态表,标签一眼就能扫完:什么时候文字变蓝,什么时候图标转圈,什么时候变绿变红,清清楚楚。不需要纠结是否要给元素加一个.is-uploading的 class,再考虑移除其他 class。我们只需要维护一个>.btn[data-status="loading"], .btn[data-status="disabled"] { pointer-events: none; opacity: 0.6; } .btn[data-status="loading"] .icon { display: inline-block; animation: spin 1s linear infinite; } .btn[data-status="disabled"] { background: #f5f5f5; color: #999; }
看到没有,公共的禁交互行为可以合并写,视觉差异可以单独再补。状态越多,这种拆分方式越有价值。如果你用 class 来做,那就要写.is-loading、.is-disabled,然后为了合并公共样式还得额外写一个.is-inactive之类的类名,不仅麻烦,而且在别人读代码的时候会怀疑“什么时候要加双类名?”。
2.3 结合 CSS 优先级与特异性
使用属性选择器需要留个心眼,就是优先级(specificity)。[data-status="waiting"]和.class在优先级上是同一级别的,都属于“类选择器”这个等级。所以如果某个元素同时有 class 设置颜色和>.text { color: red; } [data-status="error"] .text { color: red; }
两行代码都没问题,但如果某天我把.text的默认颜色改成黑色,却忘了调整后面的状态覆盖,那页面上错误状态下的文字仍然是黑色,因为.text这行写在后面。所以在组织 CSS 时,我的习惯是把基于状态的样式统一放在组件样式之后,形成一个明确的“状态层”。这样调试的时候会非常舒服。
如果你担心优先级太乱,也可以写得再精确一点:
.error-text[data-status="error"] { color: red; }但这种写法会让每个元素都要挂上><button id="submitBtn" class="btn">const btn = document.getElementById('submitBtn'); btn.addEventListener('click', () => { // 先置为 loading 状态 btn.dataset.status = 'loading'; // 模拟请求 setTimeout(() => { btn.dataset.status = 'success'; }, 1500); });
这里有一个经常出现的误解:有人说“用setAttribute('data-status', 'loading')和dataset.status效果一样”。答案是最终效果一致,但有一定细节差别。dataset是 DOM 对象上读取 data 属性的便捷接口,底层也是操作属性。如果你用setAttribute同样可以:
btn.setAttribute('data-status', 'loading');两者写出来的代码语义差不多。我推荐统一用dataset,因为更短更清晰。但要记住dataset的命名映射规则:HTML 里>const UploadStatus = { IDLE: 'idle', UPLOADING: 'uploading', SUCCESS: 'success', ERROR: 'error', };
然后业务逻辑里这么写:
box.dataset.status = UploadStatus.UPLOADING;这样写还有一个额外的好处:当你需要调整状态值时,只用改常量定义,不用满项目翻字符串。如果配合 TypeScript,还能用字符串联合类型进一步约束状态范围:
type Status = 'idle' | 'uploading' | 'success' | 'error';这种强约束能让你在写组件时少踩不少低级错误,尤其是团队协作时。虽然本篇文章主要讲 CSS 里的><button class="submit-btn">.submit-btn .btn-loading { display: none; } .submit-btn[data-status="loading"] .btn-loading { display: inline-block; animation: spin 1s linear infinite; } .submit-btn[data-status="loading"] .btn-text::after { content: "提交中…"; }
文字这里我用伪元素::after覆盖显示“提交中”,这样不需要每次在 JS 里改innerText。当然这个技巧要看场景,如果按钮文字是动态的,那可以改成用 CSS 变量或者 JS 直接改文本。不管怎样,核心思路是同一个:状态驱动视图,js 只需要负责改状态值,不用管那个部件该显示什么样子。
失败态也一样,比如:
.submit-btn[data-status="error"] { border-color: #ff4d4f; background: #fff1f0; } .submit-btn[data-status="error"] .btn-text::after { content: "提交失败,点击重试"; }JS 那边在请求 catch 里写一句btn.dataset.status = 'error',再把错误信息显示到某个><form id="registerForm" class="register-form">.register-form .btn-spinner { display: none; } .register-form .form-alert { display: none; } /* 表单处于 loading 状态:隐藏普通文本,显示 spinner,并禁用按钮 */ .register-form[data-status="loading"] .btn-spinner { display: inline-block; animation: spin 1s linear infinite; } .register-form[data-status="loading"] .btn-text { display: none; } .register-form[data-status="loading"] .submit-btn { pointer-events: none; opacity: 0.7; } /* 表单处于 success 状态:直接隐藏按钮里原有文字,换成功文案 */ .register-form[data-status="success"] .btn-text { visibility: hidden; } .register-form[data-status="success"] .btn-text::after { content: "注册成功 ✓"; visibility: visible; } .register-form[data-status="success"] .submit-btn { pointer-events: none; border-color: #52c41a; } /* 表单处于 error 状态:红色边框和错误横幅 */ .register-form[data-status="error"] .form-alert { display: block; color: #ff4d4f; background: #fff1f0; border-color: #ffa39e; } .register-form[data-status="error"] .submit-btn { border-color: #ff4d4f; }
这里有个小技巧:为了让按钮文字可以在不修改 JS 的情况下切换,我把原文本通过visibility: hidden隐藏掉,然后在伪元素里写新内容。伪元素中的文本是content里的内容,不会受visibility: hidden影响,前提是伪元素本身visibility: visible。这个做法不算新鲜,但很实用。
再写 JavaScript:
const form = document.getElementById('registerForm'); form.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('username').value.trim(); const email = document.getElementById('email').value.trim(); // 前端校验,不通过让表单进入 error 状态 if (!username || !email) { form.dataset.status = 'error'; return; } // 进入 loading 状态 form.dataset.status = 'loading'; try { const resp = await fetch('/api/register', { method: 'POST', body: JSON.stringify({ username, email }), }); if (!resp.ok) throw new Error('http error'); form.dataset.status = 'success'; } catch (err) { form.dataset.status = 'error'; } });这样整个提交流程的视图切换,几乎全部集中在form.dataset.status = 'xxx'这一行。如果以后要支持更多状态,比如限流提示、已注册过,我只需要再扩展状态常量、增加一个 CSS 规则块,业务逻辑上再加一个分支就能完成。
4.3 效果与扩展
我觉得这个方案最迷人的地方在于它的可扩展性。想象一下,后续产品想在“注册成功”状态下额外显示一个“去登录”按钮,我不需要改 CSS,只需要在 HTML 里加一个按钮并按><a class="go-login" href="/login" hidden>去登录</a>
然后在 CSS 里加:
.register-form[data-status="success"] .go-login { display: inline-block; }原先注册按钮的文字会在success状态被伪元素改掉,新增的按钮也同时出现。整个过程对原有代码零侵入。这就是状态驱动样式的好处:状态是唯一事实来源,任何 UI 部分都可以基于状态去响应。
如果你是在 Vue 项目里,其实完全可以直接绑定:
<form :data-status="status">在 React 里也有类似思路,不过 React 通常直接操作 style 或者 className,用>btn.dataset.status = ` loading `;
这样值就变成了"\n loading \n",当然无法匹配[data-status="loading"]。应对方法比较土但也有效:把所有状态常量集中初始化,并且对从外部读取的 data 值做 normalization:
function normalizeStatus(value) { return String(value || '').trim(); }如果你团队里有后端返回的状态值参与驱动,那就更要小心。比如后端返回"Loading",带大写,CSS 里写成小写就会失败。我的建议是永远在入口处把后端状态映射成项目内约定的一套小写常量,然后再赋给>box.setAttribute('data-status', 'idle'); void box.offsetWidth; // 强制 reflow box.setAttribute('data-status', 'error');
这个技巧叫做“类名重触发动画”的经典套路,同样适用于><button class="submit-btn">