应使用 data-read 属性控制消息读写状态样式,配合 aria-label 显式声明未读状态、datetime 带时区、伪元素红点及接口获取未读数,确保可访问性、同步性与一致性。

用 data-read 属性控制样式,别碰 class
直接写 data-read="0" 或 data-read="1" 到每条消息的容器上,CSS 用属性选择器匹配,比如:[data-read="0"] { opacity: 1; font-weight: bold; }[data-read="1"] { opacity: 0.7; font-weight: normal; }
这样做的核心原因是:Vue/React 的 diff 机制可能在重渲染时丢掉 class,但不会丢掉 data 属性(只要值是字符串)。常见翻车点是后端返回 is_read: false,前端模板直接写 data-read="{{ is_read }}",结果属性被移除——false 和 0 在 HTML 中会被解析为“空属性”,CSS 选择器 [data-read="0"] 就完全失效。必须强制转成字符串:data-read="{{ is_read ? '1' : '0' }}"。
aria-label 必须显式声明未读状态,不能只靠视觉
屏幕阅读器不会读 class="unread" 或红点图标,也不会感知 opacity 变化。每条未读消息的容器(<article></article>)必须带 aria-label="未读:系统更新已发布",已读则可省略或写 aria-label="已读:系统更新已发布"。这个字符串要包含“未读”二字,且内容需和标题一致,不能只写“未读”。否则刷新后状态丢失、多端不同步时,视障用户完全无法判断哪条没看过。同时,<time></time> 标签的 datetime 属性也得带时区,比如 datetime="2026-06-15T10:30:00+08:00",否则本地解析时间会偏移。
红点用 ::after + data-unread 控制,位置靠 transform 定锚
红点不是真实 DOM 节点,是伪元素:.notification-bell { position: relative; }.notification-bell::after { content: ""; display: block; width: 8px; height: 8px; background: #f00; border-radius: 50%; position: absolute; top: 0; right: 0; transform: translate(50%, -50%); }[data-unread="0"] .notification-bell::after { display: none; }
关键细节:父容器必须是 position: relative;transform: translate(50%, -50%) 才能真正把圆心对准右上角;移动端加 @media (max-width: 768px) { width: 6px; height: 6px; } 防止太突兀。别用字符 ●,缩放不一致;也别用 display: none 切换整个按钮,那会破坏焦点流和动画连贯性。
未读数永远调接口,别数 data-read="0" 的 DOM 个数
右上角铃铛里的数字,每次进页面或下拉刷新都得发一次请求,比如 GET /api/messages/unread-count。前端缓存的 localStorage 只能当 fallback,比如网络失败时显示上次缓存值,但不能作为决策依据。原因很实在:用户可能在手机 App 里已读了某条,网页还没同步;后端可能有撤回逻辑;甚至消息本身已被删除。DOM 上 data-read="0" 的数量,只反映当前页面加载那一刻的快照,不是真相。最容易被忽略的是:这个接口返回的不仅是数字,还应带 last_updated 时间戳,用于判断是否需要强制刷新列表。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











