5件不需要React的事
五种浏览器原生 API 可替代常见 React 组件:dialog、Popover、Custom Elements、container queries 和 View Transitions。
浏览器平台已经为若干曾经需要React组件或第三方库才能实现的UI原语提供了原生的、基线可用的替代方案:模态对话框、弹出层与工具提示、框架无关的可复用组件、容器感知的响应式布局,以及带动画效果的视图过渡。本文并非反对使用React——对于复杂的共享状态管理、大型表单工作流,以及Next.js和Remix这类生态系统而言,React依然是正确的选择。本文更像是一份维护者的审计清单:列出你的React代码库中可能已有的五类组件,而浏览器现在已能原生处理它们,无需增加任何JavaScript bundle体积。
本文的目标读者是那些对”浏览器能做什么”的认知停留在2020年前后的React开发者。React 19是当前的稳定版本,其生态系统曾经解决的若干模式现已成为平台的一部分。以下各节分别指出了React的惯用做法、可替代它的原生API,以及在删除旧代码之前必须了解的无障碍注意事项。
核心要点
- 配合
showModal()使用的HTML<dialog>元素提供了原生的焦点捕获、Escape键关闭和::backdrop伪元素,消除了依赖React模态框库的绝大多数理由。 - Popover API将元素渲染在浏览器的顶层(top layer),从根本上消除了手写React工具提示和下拉菜单时常见的z-index冲突和
overflow: hidden裁剪问题。 - 结合Shadow DOM的Custom Elements让你可以交付一个在任何框架或纯HTML中都能使用的组件,无需针对每个技术栈重新实现。
- CSS容器查询(
@container)让组件能够响应其父元素的宽度,替代了纯粹用于布局决策的ResizeObserverhook和React状态。 - View Transitions API(
document.startViewTransition())以原生方式为DOM状态变化添加动画,覆盖了以往由Framer Motion或react-transition-group处理的许多场景。
模态框:使用<dialog>元素替代模态框库
对于模态对话框,通过showModal()调用的原生HTML <dialog>元素提供了焦点捕获、背景内容惰性化(inert)、Escape键关闭和背景遮罩样式——这些行为在自定义React模态框中必须手动实现,且往往容易出错。<dialog>元素已是Baseline的一部分;在发布内部文档之前,请在MDN上确认其具体可用日期。
React的惯用做法。 团队通常会使用react-modal、Radix Dialog,或基于portal的自定义useModal hook。这种hook模式通常结合了createPortal、用于切换document.body.style.overflow的useEffect,以及手写的焦点捕获逻辑。对这类实现的生产环境会话回放中,经常能发现用户通过Tab键跳出模态框、进入背景内容的问题——这正是焦点捕获逻辑不完整的典型症状。
原生API。
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete project?</h2>
<p>This action cannot be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
<script>
document.getElementById('confirm').showModal();
</script>
showModal()将对话框置于顶层,在其内部捕获焦点,使文档其余部分进入惰性状态,并渲染可用CSS样式化的::backdrop伪元素。<form method="dialog">会关闭对话框,并通过dialog.returnValue返回被点击按钮的value值——无需任何事件监听器。
注意事项。 无障碍方面的关键问题在于:<dialog>不会自动播报标签内容。你需要通过aria-labelledby指向一个可见的标题元素(或使用aria-label),以便屏幕阅读器识别它。如果对话框是非模态的——即通过show()而非showModal()打开——则不会捕获焦点,此时可以考虑使用Popover API。当你需要与其他组件紧密耦合的声明式状态驱动开关逻辑,或需要在对话框卸载前执行动画时,React或库仍然是更好的选择。
弹出层、工具提示与下拉菜单:使用Popover API
Popover API将元素渲染在浏览器的顶层,这意味着弹出层始终显示在其他内容之上,不受层叠上下文或祖先元素overflow: hidden的影响。这从根本上消除了手写工具提示和下拉菜单实现中常见的z-index冲突和裁剪问题。
React的惯用做法。 Floating UI、Radix Popover和React-Aria的overlay原语是常见的依赖项。它们处理定位、点击外部关闭以及portal渲染。对于一个简单的工具提示而言,引入这些代码的代价相当高昂。
原生API。
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<a href="/account">Account</a>
<a href="/logout">Log out</a>
</div>
仅凭popover属性——无需任何JavaScript——就能让元素通过popovertarget按钮进行切换,支持点击外部关闭和Escape键关闭,并在顶层渲染。默认值popover="auto"启用轻触关闭(light-dismiss);popover="manual"则禁用此功能,适用于需要显式控制的场景。Popover API属于Baseline新近可用(Newly Available);请查阅MDN兼容性表格了解当前状态。
注意事项。 无障碍方面的关键问题在于:与<dialog>的showModal()不同,Popover API不会自动管理焦点。如果你的弹出层在功能上是一个菜单,你仍然需要添加role="menu"、管理漫游tabindex(roving tabindex),并在弹出层打开时将焦点移入其中。对于相对于触发元素的定位,你还需要CSS锚点定位(CSS Anchor Positioning),其Baseline状态更为有限——在跨浏览器使用前请在MDN上确认。对于包含子菜单、键盘导航模式和预输入(typeahead)功能的复杂菜单,Radix或React-Aria这类库仍能节省大量工作。
Discover how at OpenReplay.com.
可复用组件:使用Custom Elements和Shadow DOM
通过customElements.define()注册的Custom Element可在任何HTML环境中使用——React、Vue、Angular、Svelte或纯HTML文件——无需针对每个框架重新实现。结合Shadow DOM,它无需CSS Modules、CSS-in-JS或构建步骤即可提供样式封装。Custom Elements和Shadow DOM属于Baseline广泛可用(Widely Available);请在MDN上确认具体年份。
Web Components并未在主流应用开发中取代React。它们真正替代的,是当你维护一套设计系统或分发第三方嵌入组件时,不得不为每个框架重复实现同一个组件的需求。
React的惯用做法。 将可复用的按钮、徽章或图表封装为React组件,发布到npm,然后为使用不同框架的团队重新实现(或重新封装)。
原生API。
class CopyButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>button { padding: 6px 12px; }</style>
<button><slot>Copy</slot></button>
`;
this.shadowRoot.querySelector('button')
.addEventListener('click', () => {
navigator.clipboard.writeText(this.dataset.value ?? '');
});
}
}
customElements.define('copy-button', CopyButton);
可在任何HTML中(包括JSX内部)以<copy-button data-value="hello">Copy</copy-button>的形式使用。React 19直接支持custom elements,包括传递对象类型的props和监听自定义事件。
注意事项。 无障碍方面的关键问题在于:无障碍树(accessibility tree)默认不会穿透shadow边界——light DOM中的aria-labelledby和aria-describedby引用无法指向shadow root内部的ID,反之亦然。ARIA in HTML规范和正在推进中的reference target提案正在解决这一问题,但目前的实践模式要求在宿主元素上显式添加ARIA属性,或使用带有ElementInternals的attachInternals()。当组件需要与应用状态紧密集成、共享React Context或使用Suspense时,React仍然是更好的选择。
组件级响应式布局:使用CSS容器查询
CSS容器查询(@container)让组件能够根据其父元素的宽度(而非视口宽度)来调整布局。这消除了useResizeObserver hook模式——即用React状态追踪容器尺寸、仅仅为了驱动className切换的做法。容器查询属于Baseline广泛可用——请在MDN上确认具体年份。
React的惯用做法。 使用useResizeObserver hook(通常来自@react-hook/resize-observer或手写实现),与组件状态绑定,通过切换layout="compact" prop或className来改变布局。每次resize都会触发React重渲染,即便唯一的消费方只是CSS。
原生API。
.card-container {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
}
@container (min-width: 400px) {
.card {
grid-template-columns: 120px 1fr;
}
}
在父元素上声明container-type: inline-size,然后针对子元素编写@container规则。浏览器原生处理resize监听,无需JavaScript,无需重渲染,无需hydration不匹配问题。
:has()选择器是状态感知样式的有力补充。像form:has(input:invalid) button[type="submit"] { opacity: 0.5 }这样的规则,表达了以往需要useState和受控输入模式才能实现的逻辑。:has()属于Baseline广泛可用——请查阅MDN。
注意事项。 无障碍方面的考量虽然微妙但确实存在:容器查询可能在不改变DOM顺序的情况下大幅改变布局——这对屏幕阅读器来说是好事,但意味着你仍需在每个断点验证阅读顺序与视觉顺序是否一致。容器查询还会引入containment行为,可能影响后代元素的布局和定位,因此需要测试依赖视口相对定位或其他布局假设的组件。当布局决策不仅影响样式,还需要渲染不同的组件树时,React状态仍然是必要的。
动画过渡:使用View Transitions API
View Transitions API默认以交叉淡入淡出动画包裹DOM更新,并通过::view-transition-*伪元素提供对过渡效果的完整CSS控制。对于同文档过渡,它覆盖了以往需要动画库处理的大多数路由和状态过渡动画场景。
React的惯用做法。 在路由组件外层使用Framer Motion、react-transition-group或AnimatePresence包裹器。这些方案可以正常工作,但要求动画能够在React的渲染模型中表达,而对于跨越一棵树卸载和另一棵树挂载的过渡,这种表达方式相当别扭。
原生API。
function navigate(url) {
if (!document.startViewTransition) {
updateDOM(url);
return;
}
document.startViewTransition(() => updateDOM(url));
}
document.startViewTransition()接受一个执行DOM更新的回调函数。浏览器捕获过渡前的状态,执行回调,捕获过渡后的状态,然后在两者之间进行交叉淡入淡出。若要为特定元素添加跨过渡动画——例如缩略图展开为详情视图——只需在CSS中为匹配的元素赋予相同的view-transition-name即可。同文档View Transitions属于Baseline新近可用;跨文档View Transitions(用于MPA导航)的支持范围更为有限——在依赖跨文档模式之前,请查阅MDN兼容性表格和WebKit博客了解Safari的当前状态。
注意事项。 无障碍方面的关键问题是动效:通过媒体查询包裹过渡效果,或对选择退出动效的用户完全跳过该调用,以遵循prefers-reduced-motion设置。默认的交叉淡入淡出虽然短暂,但仍属于动画。当你需要弹簧物理效果、手势驱动的过渡,或能够在执行过程中中断并反向播放的动画时,React库仍然是更好的选择——view transitions是原子性的,并非为此类场景设计。
React仍然占优的场景
上述五项替换方案针对的是特定类别的组件。对于以下场景,React仍然是正确的工具,用平台特性替换它的代价将远大于收益。
- 跨远距离组件的复杂共享状态。 当UI中多个互不相关的部分需要通过派生选择器订阅同一个不断变化的状态时,Zustand、Jotai或Redux Toolkit所做的工作是平台无法替代的。Web Components上的自定义事件可以携带数据,但无法对派生状态进行建模。
- 包含跨字段验证和动态渲染的大型表单工作流。 原生
<form>、约束验证API(Constraint Validation API)和FormData能够干净地处理单表单提交。但多步骤向导、依赖表单其他字段值的条件字段、与客户端验证合并的服务端驱动验证,以及字段数组,仍然受益于React Hook Form或TanStack Form。 - 服务端驱动的渲染与数据获取。 React Server Components、用于异步数据的
use()hook,以及Next.js和Remix中的流式SSR模型,解决了平台层面无法直接处理的hydration、代码分割和数据获取协调问题。 - 路由和数据层的生态成熟度。 TanStack Router、TanStack Query和成熟的React Router生态提供了缓存失效、乐观更新和路由加载器模式,若要基于原生API重新实现,需要付出相当大的工作量。
- 团队规范与既有投入。 围绕React构建的代码库、招聘渠道、设计系统和CI本身就是一种资产。本文的审计立场是:在平台已能胜任的地方移除特定组件——而非迁移整个技术栈。
实际行动建议: 打开你最大的React组件目录,搜索Modal、Popover、Tooltip、Dropdown以及任何useResizeObserver的导入。每一个都是上述原生替换方案的候选对象。在MDN上针对你所支持的浏览器范围验证相应API的Baseline状态,在feature flag保护下上线替换方案,并测量bundle体积的变化。浏览器已经追上来了——剩下的工作是审计哪些依赖你不再需要。
常见问题
我能将原生dialog元素与React的状态模型结合使用吗?
可以。将ref附加到dialog元素上,并在由React状态驱动的effect中调用ref.current.showModal()或ref.current.close()。dialog保留在React树中,可以正常接受JSX子元素,但你绕过了渲染输出上由useState驱动的open prop。主要的摩擦点在于:React不会为dialog的内部cancel事件重新运行effect,因此需要通过useEffect附加原生close监听器来将状态同步回来。
Custom Elements如何与React进行复杂数据的传递?
React 19会将非字符串类型的props直接传递给custom element的属性(property),而非将其序列化为HTML属性(attribute),因此对象和数组无需JSON编码即可正常传递。Custom elements通过CustomEvent将数据传回,React 19使用标准的on-前缀处理器props来监听这些事件(例如onMyEvent)。在React 18及更早版本中,由于合成事件不处理自定义事件名称,你必须通过ref以命令式方式附加事件监听器。
容器查询和:has()选择器会影响渲染性能吗?
两者都有可测量的性能开销,但通常比它们所替代的JavaScript方案更轻量。容器查询需要浏览器维护一个containment上下文,并在尺寸变化时重新评估匹配规则,但这仍然比ResizeObserver回调触发React重渲染更快。:has()选择器在对大型DOM树使用宽泛的主体选择器时可能开销较大;建议将其作用域限定在特定父元素上,而非应用于body或根级元素。
View Transitions API能与React Router等客户端路由器配合使用吗?
可以,适用于同文档过渡。将路由器的导航回调包裹在document.startViewTransition()中,使React在路由切换期间执行的DOM更新在过渡内部运行。React Router v6和TanStack Router均通过导航拦截支持此模式。跨文档view transitions(为完整页面加载添加动画)需要通过@view-transition CSS规则额外选择启用,且浏览器支持范围更窄——在依赖此功能之前请在MDN上确认。