学完这篇你能把 data-* 属性用对:知道它适合存什么、JS 和 CSS 怎么正确读它,以及哪些常见写法会在上线后才暴露成 bug。
第一步:先确认命名规则,别用大写字母
data-* 的属性名只能是小写字母、数字和连字符,必须以字母开头,且不能包含大写字母。
<!-- 正确 -->
<div data-user-id="42" data-state="open"></div>
<!-- 错误:大写会被浏览器静默转成小写 -->
<div data-userId="42"></div>
上面第二种写法在 HTML 里不会报错,但浏览器会把属性名统一小写,最终变成 data-userid。你后面写 el.dataset.userId 就会拿到 undefined,而且这种错误在源码里看着完全正常。
注意:属性名中的连字符在 dataset 里会被转成驼峰。data-user-id 对应 dataset.userId。规则是「连字符 + 后面一个字母」变成大写,所以 data-a-b-c 对应 dataset.aBC,念起来别扭但就是这样。
第二步:JS 读取,两种方式按场景选
const el = document.querySelector('#card');
// 方式一:dataset,写法短,名字自动转驼峰
el.dataset.userId; // "42"
el.dataset.state; // "open"
// 方式二:getAttribute,属性名原样写
el.getAttribute('data-user-id'); // "42"
什么时候用哪个?
- 属性名是固定的简单单词(
data-state、data-index),用 dataset 更短。
- 属性名带连字符或者要动态拼(
'data-' + key),用 getAttribute 更稳,不会因为驼峰转换出错。
- 需要区分「属性不存在」和「属性为空字符串」时,用
hasAttribute / getAttribute。dataset.missing 两种情况都返回 undefined。
el.hasAttribute('data-state'); // true / false
el.getAttribute('data-state') === ''; // 空值判断
注意:data-* 读出来的值永远是字符串。data-count="3" 拿到的是 "3" 不是 3,data-open="false" 拿到的是字符串 "false",if (el.dataset.open) 恒为真。要么 Number() 转一下,要么用 hasAttribute 判断存在性。
第三步:写入和删除
el.dataset.state = 'closed'; // 写入 → data-state="closed"
el.dataset.userId = 42; // 自动转成字符串 "42"
delete el.dataset.state; // 删除属性
// 用 getAttribute 系的方式删除
el.removeAttribute('data-state');
写入时如果键名有驼峰,dataset.userId = x 会自动生成 data-user-id。反过来的坑是:你写 el.setAttribute('data-userId', 1),属性名会被保留成 data-userid,之后 dataset.userId 读不到——所以别混着来。
第四步:CSS 里用属性选择器控制样式
这是 data-* 最有价值的用法之一:把「状态」和「样式类」解耦。
.panel { display: none; }
.panel[data-state="open"] { display: block; }
tab.addEventListener('click', () => {
panel.dataset.state = panel.dataset.state === 'open' ? 'closed' : 'open';
});
比来回 classList.add/remove 更清楚,也不会和纯样式类混在一起。
注意:HTML 里属性值是区分大小写的。[data-state="open"] 匹配不到 data-state="Open"。统一小写就完事。另外属性选择器的特异性等同于类选择器,别指望它能压过 #id 的样式。
第五步:批量数据和事件委托
列表渲染时最常见:把行的标识塞进元素,让父级监听。
<ul id="list">
<li data-id="101" data-type="post">帖子 A</li>
<li data-id="102" data-type="post">帖子 B</li>
</ul>
document.getElementById('list').addEventListener('click', (e) => {
const item = e.target.closest('[data-id]');
if (!item) return;
console.log(item.dataset.id, item.dataset.type); // "101" "post"
});
一个监听器管整张列表,增删行不用重新绑事件。
注意:复杂结构别硬拆成十几个 data-*。需要传对象时,用 JSON.stringify 塞进一个属性再 JSON.parse 读回来,但属性值里的引号要转义,且 HTML 体积会变大。数据量大时更推荐用 Map 存 id → 数据,DOM 上只留 id。
三个必须避开的坑
- 不要把
data-* 当存储用。 单条几百字节还好,一个列表几千行塞长字符串会明显撑大 HTML,首屏变慢。
- 不要存敏感信息。 用户 ID、价格这些无所谓,但 token、权限判定结果、未公开字段都写在 HTML 里等于直接公开。
- 不要用它替代
aria-*。 辅助技术不读 data-*,data-tooltip="删除" 对读屏软件毫无意义,该用 aria-label 还是得用。
注意:data-* 的值不会被执行。如果你把用户输入塞进 data-html 再 innerHTML 回填,XSS 就来了——这是自己造的洞,不是 data-* 的锅。
小结
- 属性名全小写、用连字符;
data-user-id ↔ dataset.userId,不要混用 setAttribute 和 dataset。
- 读出来永远是字符串,数字用
Number(),布尔用 hasAttribute 判断存在性。
- CSS 用
[data-state="open"] 做状态切换,值统一小写。
- 列表交互用
closest('[data-id]') + 父级事件委托。
- 大对象别塞 DOM,敏感数据别写 HTML,无障碍语义用
aria-* 而不是 data-*。