在 React 中重拾原生 HTML 属性
<p>在现代 React 组件开发中,优先想到 <strong>useState、useEffect、context、props drilling</strong> 这样的框架能力,而容易忽略:<br><strong>浏览器原生 HTML 属性本身,就是一个强大而成熟的状态表达载体。</strong></p>
<p>比如 <strong>data-*</strong> 为代表的自定义属性,在近几年被越来越多的专业组件库采用,如 <strong>Radix UI、Headless UI、Ark UI</strong> 等。</p>
<p>本文将从基础到深入,拆解为什么在 React 组件中大量使用原生属性(尤其是 <code>data-*</code>)是一种更专业、更可维护、更高性能的工程实践。</p>
<h2 id="1-data-语义扩展与原生兼容性">1. data-*:语义扩展与原生兼容性</h2>
<p>HTML 原生属性有一个重要优势:<br>
<strong>它们天生是“被设计来给用户代理(浏览器、辅助工具)理解的”。</strong></p>
<p>而 data-* 作为 HTML5 制定的可扩展机制:</p>
<ul>
<li>保证语法合法</li>
<li>不破坏 HTML 自身语义</li>
<li>与 ARIA 标准兼容</li>
<li>支持 CSS、JS 原生读取</li>
</ul>
<p>这意味着使用 data-* 做状态表达,是天然符合浏览器和工具链的方式。</p>
<h2 id="2-提升可访问性">2. 提升可访问性</h2>
<p>在构建无障碍(a11y)兼容组件时,一种错误做法是:</p>
<p>把组件状态(如 open/closed)全部存储在 React 内部,屏幕阅读器却读不到。</p>
<p>但如果将状态同步到 <strong>data-state、data-disabled</strong>,辅助工具就能更轻松感知 UI 状态。例如:</p>
<pre><code class="language-html"><button data-state="open" aria-expanded="true">Menu</button>
</code></pre>
<p>屏幕阅读器可以根据 ARIA 属性直接宣布状态,而 data-state 也能作为冗余状态标识用于调试和样式。</p>
<h3 id="radix-dropdownmenu-的-trigger">Radix DropdownMenu 的 Trigger</h3>
<pre><code class="language-tsx"><DropdownMenuPrimitive.Trigger
data-state={open ? "open" : "closed"}
aria-expanded={open}
>
{children}
</DropdownMenuPrimitive.Trigger>
</code></pre>
<p>Radix 始终同步 <strong>data-state</strong> 与 <strong>aria-expanded</strong>——<br>
这样即便 React 状态层出故障,ARIA 与 DevTools 都能明确显示组件状态。</p>
<h2 id="3-简化样式化css-直接响应状态避免-js-再渲染">3. 简化样式化:CSS 直接响应状态,避免 JS 再渲染</h2>
<p>传统方式:</p>
<ul>
<li>React 改状态 → 组件重新渲染 → className 改变 → 样式变化</li>
</ul>
<p>而 data-* 提供了更直接、无阻塞的方式:</p>
<pre><code class="language-css"> {
opacity: 1;
transform: scale(1);
}
{
opacity: 0;
transform: scale(0.95);
}
</code></pre>
<p>完全不需要额外 JS 逻辑。</p>
<h3 id="tailwind-示例">Tailwind 示例:</h3>
<pre><code class="language-html"><div data-state="open" class="transition data-:opacity-100 data-:opacity-0">
</div>
</code></pre>
<h3 id="radix-的-tabs-root">Radix 的 Tabs Root</h3>
<p>Radix 的 Tabs Root 会给触发项注入:</p>
<pre><code class="language-tsx"><Tab data-state={selected ? 'active' : 'inactive'} />
</code></pre>
<p>CSS 直接响应:</p>
<pre><code class="language-css"> {
color: var(--accent);
}
</code></pre>
<h3 id="优点总结">优点总结</h3>
<ul>
<li><strong>更少的 JS 参与</strong> 意味着更快</li>
<li><strong>避免 React re-render</strong> 意味着更稳定</li>
<li><strong>样式只靠 CSS cascade</strong> 意味着更干净</li>
</ul>
<h2 id="4-框架无关性">4. 框架无关性</h2>
<p>React 的 className、state、useMemo、useCallback 仅存在于虚拟 DOM 中。</p>
<p>而 data-* 写在真正的 DOM 节点上:</p>
<ul>
<li>测试工具(Playwright、Cypress)可直接选择</li>
<li>浏览器可直接识别</li>
<li>SSR 与 SEO 可直接读取</li>
<li>迁移框架时不受代码结构影响(例如迁移到 Vue/Solid/Svelte)</li>
</ul>
<h3 id="radix-ui-做得最极致的一点">Radix UI 做得最极致的一点:</h3>
<p>它所有组件都输出没有样式的 “primitive DOM 节点”,<br>
而状态全部映射为 data-*:</p>
<pre><code class="language-html"><div data-disabled data-orientation="vertical"></div>
</code></pre>
<p>使之成为一套真正的 <strong>headless 组件协议</strong>,而不是 React 专属 DSL。</p>
<h2 id="5-性能优化减少不必要的-react-re-render">5. 性能优化:减少不必要的 React re-render</h2>
<p>如果用 className 或 props 作为状态传递,当状态变化时,React 必须:</p>
<ol>
<li>重新执行组件函数</li>
<li>diff 虚拟 DOM</li>
<li>再决定是否更新 DOM</li>
</ol>
<p>但若使用 data-*:</p>
<p>React 只需更新一次根节点的属性。<br>
子组件无需 re-render。</p>
<h3 id="radix-accordion">Radix Accordion</h3>
<p>Accordion 内容展开时只更新触发器的 data-state:</p>
<pre><code class="language-tsx"><AccordionTrigger data-state={open ? 'open' : 'closed'} />
</code></pre>
<p><strong>内容本身不会重新渲染</strong>,不会额外执行 useEffect、useLayoutEffect。</p>
<p>这种模式特别适合:</p>
<ul>
<li>大型表格组件</li>
<li>虚拟滚动</li>
<li>菜单、Popover、Tooltip 等频繁开合的复杂交互</li>
</ul>
<h2 id="6-调试友好">6. 调试友好</h2>
<p>React 状态调试有几个问题:</p>
<ul>
<li>useState 值在 DevTools 中需要额外打开 React 面板</li>
<li>className 合成后难以识别状态来源</li>
<li>在复杂组件中状态链路不清晰</li>
</ul>
<p>但 data-* 让调试变得“肉眼直观”:</p>
<pre><code class="language-html"><button data-state="open" data-disabled="true">...</button>
</code></pre>
<p>你不用打开任何插件,就能立刻看到每个节点的状态。</p>
<p>Radix 团队在 RFC 中提到:</p>
<blockquote>
<p>data-state 与 data-disabled 的主要目的之一,就是增强可调试性。</p>
</blockquote>
<h2 id="7-案例剖析radix-的-data--状态模型">7. 案例剖析:Radix 的 data-* 状态模型</h2>
<p>下面根据源码梳理一张类图,逻辑示意,展示 Radix 组件的状态是如何“外溢”到 DOM 属性的:</p>
<div class="mermaid">graph LR
A -->|derives state| C
C -->|expose state| D
</div><p>Radix 的数据流是一种精心设计的“漏斗”:</p>
<ol>
<li>React 层管理逻辑</li>
<li>计算状态</li>
<li>把状态下沉到 DOM 原生属性</li>
<li>CSS / ARIA / 工具链再根据这些属性响应</li>
</ol>
<p>这是一种非常解耦的模型。</p>
<h2 id="8-radix-dropdownmenu">8. Radix DropdownMenu</h2>
<p>以 <code>@radix-ui/react-dropdown-menu</code> 为例。</p>
<h3 id="trigger">Trigger</h3>
<pre><code class="language-tsx">const Trigger = React.forwardRef((props, ref) => {
const open = useDropdownMenuContext();
return (
<Primitive.button
ref={ref}
data-state={open ? 'open' : 'closed'}
aria-expanded={open}
{...props}
/>
);
});
</code></pre>
<p>触发器只负责把状态表达为:</p>
<ul>
<li><code>data-state</code></li>
<li><code>aria-expanded</code></li>
</ul>
<p>完全不关心样式、动画、布局。</p>
<h3 id="菜单内容content">菜单内容(Content)</h3>
<pre><code class="language-tsx"><Content
data-state={open ? 'open' : 'closed'}
data-side={side}
data-align={align}
>
{children}
</Content>
</code></pre>
<p>这些 data-* 使得 CSS 可以精确选择:</p>
<pre><code class="language-css"> {
animation: slideDown 200ms;
}
</code></pre>
<p>从而达到 <strong>交互逻辑与展示逻辑彻底分离</strong>。</p>
<h2 id="9-总结">9. 总结</h2>
<p>工作为求效率使用框架合情合理,但个人学习不能只看框架,有时候学学 HTML 也不错,哈哈,甚至可以帮助我们更好地使用框架。最后总结下各项优势:</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>优势</th>
</tr>
</thead>
<tbody>
<tr>
<td>可访问性</td>
<td>与 ARIA 标准兼容,屏幕阅读器更容易识别状态</td>
</tr>
<tr>
<td>样式化</td>
<td>CSS 可直接响应状态,不需要 JS 驱动 class 切换</td>
</tr>
<tr>
<td>性能</td>
<td>减少不必要 re-render,复杂组件收益巨大</td>
</tr>
<tr>
<td>框架无关性</td>
<td>状态直接存在 DOM,可跨框架复用</td>
</tr>
<tr>
<td>调试</td>
<td>DevTools 可见属性,定位问题更直接</td>
</tr>
<tr>
<td>工程化</td>
<td>支持 Tailwind、设计系统、主题系统等工具</td>
</tr>
</tbody>
</table><br><br>
来源:https://www.cnblogs.com/guangzan/p/19243436
頁:
[1]