首页 / 文章 / JSX并非HTML:组件标记背后的真实权衡

JSX并非HTML:组件标记背后的真实权衡

了解当标记语言转变为JSX时需要放弃什么,包括工具与解析、表单功能、无障碍性及可移植性等方面,以及能够挽回大部分优势的解决方案。

3076 词

几乎所有的 React 教程都会向新手保证,JSX “本质上是 JavaScript 中的 HTML”。二者确实有相似之处,但这种说法掩盖了后续在构建流程、代码包大小、输入延迟以及无障碍性检测中会出现的一系列问题。下文将揭示 JSX 真正的本质,说明当标记从浏览器的解析器转移到 JavaScript 运行时后哪些功能会发生变化,以及有哪些具体模式能够在不放弃组件优势的前提下弥补大部分损失。

同一套规则在不同环境下的应用

JSX 几乎完全沿用了 HTML 的结构形式。元素以 <button> 开头,以 </button> 结尾,并且像 20 世纪 90 年代以来的标记语言那样支持嵌套。正是这种熟悉感,让整整一代开发者更容易过渡到单页应用程序的开发:

// It looks like HTML...
function UserCard({ name, role }) {
  return (
    <div className="card">
      <h3>{name}</h3>
      <p>{role}</p>
    </div>
  );
}

但实际上,这两种技术并无关联。HTML是一种声明式标记语言,浏览器引擎会使用高度优化的原生代码直接对其进行解析。而JSX则是一种语法,编译器会将其重写为嵌套的JavaScript函数调用:在传统的转换方式中是React.createElement,在现代的自动运行时系统中则是类似_jsx()这样的辅助函数。组件中的<div>根本不是标记,而是一个参数列表。

使用 JSX 能带来诸多好处:由数据驱动的组件树、状态与 DOM 之间的自动同步,以及模板类型的检查功能。不过,每一种抽象都有其代价。用 JavaScript 层替代原生浏览器格式意味着要依赖构建工具,运行时占用更多内存,还会失去平台免费提供的若干容错功能。这些代价并非避免使用 JSX 的理由,但在设计应用程序时了解它们都是很有必要的。

工具带来的额外成本

双击操作流程的缺失

首先消失的是 Web 平台那种无需任何依赖的简单性。一个普通的 HTML 页面只需要编辑器和浏览器即可。你可以在离线设备上创建 index.html 文件,双击它,浏览器就会立即从 file:// URL 加载并渲染该页面。

无论是 V8、JavaScriptCore 还是 SpiderMonkey,没有任何 JavaScript 引擎会将 <div className="box"> 理解为代码。在内容显示到屏幕上之前,JSX 需要经过一系列编译步骤:

  • 如 Babel、SWC 或 esbuild 这样的编译器
  • 如 Vite、Webpack、Rollup 或 Turbopack 这样的打包工具
  • 用于安装依赖的 npm、pnpm 或 yarn
  • 用于运行这些工具的 Node.js 或 Bun
  • 通常包含数百兆字节的预设、解析器、插件和填充代码的 node_modules 文件夹

(虽然有用于快速实验的浏览器内 Babel 编译方式,但并不适合正式发布。)从源文件到最终显示在屏幕上的内容,其路径转换过程如下:

HTML Workflow:
[index.html] ----------> Directly parsed by Browser Engine (Instant)

JSX Workflow:
[Component.jsx]
   └─> AST Parsing
        └─> Transpilation (_jsx() calls)
             └─> Bundling & Minification
                  └─> Network Download
                       └─> JS Parse & Compile
                            └─> Runtime Virtual DOM
                                 └─> DOM Mutation

维护负担

将标记语言与编译器绑定在一起会带来持续的维护成本:

  • 工具链过时问题。那些三年无人维护的项目常常无法构建,因为上游包、所需的Node.js版本或打包器API都已经更新。
  • 源码映射的脆弱性。在生产环境中调试时需要将压缩后的输出反推回原始代码。如果源码映射缺失或错误,堆栈跟踪显示的将是匿名运行时调用信息,而非实际代码。
  • 构建反馈的延迟。用Rust或Go编写的现代打包器速度极快,但在庞大的代码库中,持续重新编译仍然会带来延迟,而编辑原始标记文件时则不存在这种问题。

从JavaScript继承的语法限制

由于JSX会被解析为JavaScript,它因此继承了JavaScript的保留字和更严格的语法规则,同时也失去了HTML的宽容性。

保留字会被重命名为属性

HTML属性是附加在DOM节点上的字符串,而JSX属性则是传递给函数的对象中的键。由于classfor是JavaScript中的保留字,JSX会使用其他名称来替代它们。标准的HTML写法为:

<!-- Native, standards-compliant HTML -->
<label for="username">Username</label>
<div class="profile-card" tabindex="0"></div>

在JSX中则变为如下形式。另外请注意,tabIndex接收的是数字表达式而非字符串:

// JSX equivalents forced by JavaScript engine constraints
<label htmlFor="username">Username</label>
<div className="profile-card" tabIndex={0}></div>

大小写敏感性与样式对象

HTML属性名不区分大小写,而JSX属性则区分大小写,且大多采用驼峰命名法:onClickstrokeWidthautoCompletetabIndex。需要纠正一个常见误解:aria-*data-*属性属于例外,它们在JSX中仍保持带连字符的名称,因此aria-label的写法与HTML完全一致。

内联样式的变化更为根本。在HTML中,样式只是浏览器需要解析的普通字符串:

<!-- HTML: Zero runtime allocation -->
<div style="background-color: red; margin-top: 10px;"></div>

而在JSX中,样式是JavaScript对象字面量,且每次组件渲染时,渲染输出中的字面量都会被重新创建:

// JSX: Allocates a new JavaScript object on every single render pass
<div style={{ backgroundColor: 'red', marginTop: '10px' }} />

对于大多数组件而言,这种影响可以忽略不计。但在那些需要频繁重新渲染的组件中,比如大型数据网格或基于指针的画布叠加层,成千上万个生命周期短暂的样式对象会带来垃圾回收压力,进而导致渲染暂停。将静态样式对象移出组件外部,或使用类名,即可避免这一问题。

严格的闭合规则

HTML5故意允许不使用闭合斜杠的空元素:<input><img><br><hr>这样的写法都是有效的。而JSX则遵循XML的规则——一旦忘记使用自闭合斜杠或闭合标签,编译器就会因语法错误而停止,从而导致构建失败,而无法平滑降级处理。

运行时成本:原生解析与虚拟DOM

当浏览器接收到HTML时,它会边接收数据边对字节进行解析,并利用经过数十年优化的原生代码逐步构建DOM节点,同时还会借助一种预加载扫描器提前发现所需资源。而当应用程序完全通过JSX进行渲染时,这一原生路径大多会被绕过,转而由JavaScript来执行:

Standard HTML Processing:
Network Bytes ──> Tokenizer ──> DOM Tree ──> Render Tree ──> Paint

JSX / Virtual DOM Processing:
Network Bytes ──> JS Parse/Compile ──> JS Execution ──> Component Tree
              ──> VDOM Allocation ──> VDOM Diffing (Reconciliation)
              ──> DOM Patching ──> Render Tree ──> Paint

内存与垃圾回收

为计算更新内容,React会在JavaScript内存中保存UI的描述,这通常被称为虚拟DOM。其工作流程大致如下:

  1. 首次渲染时会创建一个由JavaScript对象构成的树结构,用来描述每个元素、其属性以及子元素。
  2. 当状态发生变化时,受影响的组件会再次运行,并生成该部分树结构的新描述。
  • React会将新描述与之前的描述进行比对(对齐),从而找出最少的更改内容。
  • 只有这些更改会被应用到真实的DOM中。
  • 需要注意的是,React只会重新渲染状态发生变化的组件及其子树,而非整个应用程序;但原理依然相同:浏览器已在自身的内部内存中保存了真实的DOM,而JavaScript堆中也存在一个对应的副本。这种额外的内存分配会增加内存使用量及垃圾回收的频率,这在性能较低的移动设备上表现得尤为明显。

    水合带来的代价

    在 Next.js 或 Remix 等框架中,服务器端渲染会直接发送真实的 HTML,从而实现快速的首次绘制。然而,在页面响应用户输入之前,浏览器必须先下载该页面中组件的 JavaScript 代码并执行它们,重新构建 React 的内部数据结构,同时为现有的 DOM 添加事件处理程序。这一同步过程会占用主线程,而在内容复杂的页面上,就会表现为较高的总阻塞时间(TBT)以及较低的“到下一次绘制的时间”(INP)评分。

    流式传输与容错能力

    HTML 的设计基于两个原则,而以 JSX 为中心的单页应用往往违背了这些原则:渐进式流式传输与错误容错能力。

    当流式传输不再存在时

    浏览器会在文档尚未完全下载时就开始渲染它。假设服务器已经发送了200KB页面中的<head>部分以及50KB内容;此时浏览器就可以立即请求样式表和字体,并绘制页头和导航栏,而剩余内容仍在传输中。

    纯客户端渲染的JSX应用则有所不同:

    • 浏览器首先收到一个几乎为空的框架,其中仅包含<div id="root"></div>
    • 它接着下载JavaScript代码包
    • 解析并执行该代码包
    • 组件开始运行,构建DOM结构,最终显示内容

    在3G网络较慢或手机性能不足的情况下,用户在整个处理过程中都会看到空白页面。这就是浏览器最初所能使用的全部内容:

    <!-- What the browser sees initially in a standard JSX SPA -->
    <!DOCTYPE html>
    <html>
      <head>
        <title>App</title>
      </head>
      <body>
        <div id="root"></div>
        <script src="/static/bundle.8f9b2c.js"></script>
      </body>
    </html>
    

    何时错误不再被容忍

    HTML解析器以其极高的容错性而闻名。即便输入像这样的错误标记:

    <div>
      <p>Unclosed paragraph
      <div>Nested incorrectly</b>
    </div>
    

    它也不会出错。解析算法会依据既定的恢复规则来关闭和重新嵌套元素,依然能够显示内容。

    而React在运行时的容错能力则弱得多。例如,如果因为像{user.profile.name}这样的表达式试图访问undefined的属性而导致渲染出错,且在没有错误边界捕获该错误的情况下,React会卸载整个组件树,最终只显示空白屏幕。React并未提供现成的<ErrorBoundary>组件;需要自己编写类组件(或使用小型库)并将其放置在可能存在风险的区域周围。

    表单与事件

    从早期网页时代起,HTML表单就具备处理输入、验证和提交的功能。而常见的JSX模式往往用JavaScript实现的版本来替代这些原生功能。

    受控输入与原生输入

    普通的<input>元素会保留自己的状态。用户输入时会立即更新浏览器的内部缓冲区,无需任何脚本参与。而React的常规做法则是让输入变为受控状态,这样React的状态就成为了数据的真实来源:

    // Every keystroke triggers a state change, a re-render, and a VDOM diff
    function SearchInput() {
      const [value, setValue] = useState("");
    
      return (
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
      );
    }
    

    现在,每次按键都会经过事件系统、设置状态、再次执行组件函数、进行同步处理,最后再将值推回 DOM。对于小型组件来说这没问题。但当主线程忙于数据处理或复杂的动画操作,或者输入事件位于一个会随之重新渲染的大型组件树中时,用户就会在输入字符后明显看到它们才显示出来。

    合成事件

    React 将浏览器的原生事件封装在自己的合成事件系统中,初衷是为了消除旧版浏览器之间的差异。不过这种抽象层也会带来一些隐藏的弊端:

    • 在 React 的组件树中的事件传播方式可能与原生 DOM 监听器的传播方式不同,这会让同时使用两者的代码变得混乱
  • React将监听器委托给一个根节点(React 17之前为document,之后则为根容器),当集成会添加自身监听器的原生JavaScript库时,这可能会导致顺序上的问题
  • 每个原生事件都被包裹在另一个对象中
  • 无障碍性与语义漂移

    JSX能够生成完全符合无障碍标准且具有良好语义结构的HTML。然而,它所倡导的编码模式往往会导致语义逐渐弱化。

    组件包装层产生的冗余结构

    一个组件必须返回一个根节点。Fragment(<Fragment>或<>)可以在不增加额外DOM节点的情况下解决这个问题,但许多代码库仍出于习惯或布局考虑将子元素包裹在<div>容器中。你期望生成的结构应该是这样的:

    <!-- What you intended to build -->
    <main>
      <article>
        <h1>Article Title</h1>
        <p>Content goes here...</p>
      </article>
    </main>
    

    而多层包装组件通常会渲染出类似这样的结构:

    <!-- What JSX component wrapping often generates in the actual DOM -->
    <div class="AppWrapper">
      <div class="LayoutContainer">
        <main>
          <div class="ArticleWrapper">
            <article>
              <div class="HeadingGroup">
                <h1>Article Title</h1>
              </div>
              <div class="ParagraphContainer">
                <p>Content goes here...</p>
              </div>
            </article>
          </div>
        </main>
      </div>
    </div>
    

    额外的层会增加 DOM 的负载,使 CSS 布局更加复杂,并在各个元素之间引入不必要的干扰。没有特定角色的普通 <div> 元素大多会被辅助技术忽略,但复杂的包装层级仍会让标记结构更难理解,也更容易出错,比如某个包装组件意外破坏了列表或标题的结构。

    原本可免费获得的键盘操作功能,后来却不再如此

    <button><a><select><details> 这样的原生交互元素,其自带的功能往往容易被人们视为理所当然:

    • 它们默认就可以按照 Tab 键顺序获得焦点
    • 按 Enter 或空格键即可自动触发它们的功能
  • 它们会向辅助技术暴露正确的角色和状态
  • 它们会显示焦点指示器,并能被屏幕阅读器正确读取
  • 由于 JSX 使得为任何元素添加点击处理程序变得极为简单,比如 <div onClick={handleClick}>,团队们常常会用非语义元素构建自定义控件,从而忽略那些原生元素自动提供的键盘处理程序、tabIndex 以及 ARIA 角色。我们在关于 React 组件中那些会拖慢现代应用开发速度的隐藏陷阱的文章中进一步探讨了这些问题。

    标准、互操作性与锁定效应

    HTML是由WHATWG维护的开放标准,W3C也曾参与其发展。1997年编写的页面在当前浏览器中依然可以正常显示。相比之下,JSX并非网络标准,它只有非正式的规范,并得到React、Preact和Solid等若干库的支持,但任何使用方式都依赖于编译器以及编译后代码所依赖的运行时环境。这虽然比专有格式的锁定程度轻,但依然存在锁定效应。

    自定义元素与平台自身的组件模型

    浏览器已经提供了组件模型:自定义元素加上阴影DOM。普通HTML可以直接使用它们:

    <user-avatar src="avatar.jpg" size="large"></user-avatar>
    

    由于两个原因,React过去在处理自定义元素方面表现不佳:

    • 它会将所有属性作为字符串属性传递给那些未知的小写标签,因此无法将对象和数组作为元素属性传入
  • 通过new CustomEvent('user-select')发送的此类自定义事件并未映射到如onUserSelect这样的属性,因此不得不使用包装组件或通过refs手动添加监听器。
  • React 19在很大程度上解决了这些问题,它允许在自定义元素定义属性时为其设置相应属性,并支持自定义事件处理程序,因此在认为这些限制仍然存在之前,请先确认所使用的React版本。

    标记语的兼容性

    用标准HTML和CSS编写的设计系统可以在任何地方使用:WordPress、Django、Ruby on Rails、Go模板、Vue、Angular、Svelte或纯静态页面。而用JSX组件编写的设计系统则与JavaScript生态系统紧密相关,若要在非JavaScript后端使用它,则需要Node.js渲染服务或为每个组件单独实现。

    HTML与JSX并行使用

    总结各种权衡:

    • 执行方式:HTML可被浏览器直接解析;JSX则需编译为JavaScript函数调用,然后在运行时执行。
    • 工具需求:HTML需要编辑器和浏览器;JSX则需要编译器、打包工具、包管理器以及构建运行环境。
    • 语法特性:HTML不区分大小写且较为宽容;JSX则区分大小写,使用重命名的属性,并要求采用XML风格的闭合方式。
    • 渲染机制:HTML以流式方式逐步绘制内容;而客户端渲染的JSX则需等待打包文件下载并运行完毕。
    • 错误处理:HTML能够从格式错误的标记中恢复;未捕获的渲染错误会导致React组件树被卸载。
    • 表单处理:原生输入元素可独立维护自身状态;受控输入元素则会在每次按键时重新渲染。
  • 可移植性:HTML 可与任何后端或框架配合使用;而 JSX 组件则需要依赖 JavaScript 生态系统。
  • JSX 的优势:可组合的组件、声明式的数据驱动更新以及类型检查模板。
  • 找回失去的东西

    以上内容并非主张放弃基于组件的开发方式,而是强调应在何处应用抽象化技术以获得相应价值。当前生态系统正朝着这个方向发展,通过特定模式在保持声明式开发模型的同时,恢复原生 HTML 的速度与稳定性。

    选择服务器端优先渲染

    • React Server Components会在服务器端进行渲染,对于非交互性部分不会向客户端发送任何组件 JavaScript 代码。
    • Astro采用岛屿式架构:页面默认为静态HTML,只有独立的交互组件会被动态加载。
    • Qwik则用可恢复性替代动态加载机制,将应用状态序列化到HTML中,这样代码仅在用户实际进行交互时才会执行。

    让浏览器管理表单状态

    无需将每次按键操作都同步到状态中,让原生的<form>元素保存数值,并在提交时通过FormData一次性读取这些数值:

    // Clean, native, performant HTML-first form submission
    function LoginForm() {
      function handleSubmit(event) {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        const email = data.get("email");
        // Send payload...
      }
    
      return (
        <form onSubmit={handleSubmit}>
          <input type="email" name="email" required />
          <button type="submit">Sign In</button>
        </form>
      );
    }
    

    输入内容的更新速度与原生浏览器一致,required属性可提供内置验证功能,而且该组件只需渲染一次,无需在每次按键时都重新渲染。较新的React版本也基于相同理念实现了表单功能,可直接接收FormData

    保持语义的严格性

    应将 JSX 视为生成语义化 HTML 的工具,而非堆叠容器的借口:

    • 在包装元素没有样式作用时,用片段(<></>)替代 <div>
    • 使用原生交互元素,如 <button><dialog><details><summary>,而非自行编写的组件
    • 在持续集成流程中加入 eslint-plugin-jsx-a11y,确保缺少标签、角色和键盘处理函数时构建会失败

    总结

    JSX通过表明界面应作为数据的可预测函数来描述,从而改变了前端开发方式,同时解决了保持大型动态用户界面与状态同步的实际问题。它仍然是JavaScript的一种抽象,而非HTML的新版本。选择使用JSX意味着要放弃原生流式渲染、无需构建工具即可编写代码、错误容错能力以及长期的标准稳定性,以换取组件化架构和响应式设计带来的便利。这通常是一笔值得的交换。关键在于准确了解自己放弃了什么,并尽可能通过服务器端渲染、原生表单以及语义化元素来免费恢复那些功能。

    相关阅读

  • 从普通div到有意义的标记:实用的语义HTML指南 — 了解为何普通的div会破坏可访问性、搜索引擎抓取以及代码的可维护性,应使用哪些语义化元素作为替代,以及如何重构一个实际的卡片组件。