首页 / 文章

从JSX到普通对象:React元素、组件和属性的工作原理

了解 JSX 编译后的结果,为何组件本质上只是函数,以及属性、子元素、默认值、展开运算和插槽是如何基于这一核心概念衍生出来的。

发布者:REACTAPP.TOP阅读约 9 分钟1983 词

来源: JSX, Components & Props — The Mental Model Every React Developer Actually Needs

许多 React 错误源于对 JSX、组件和属性本质的模糊认识。一旦明白 JSX 实际上就是函数调用,且该调用会生成一个普通对象,同时组件也是一个接收单个参数对象的函数,那些看似随意的规则就会变得合理起来。本文从编译器输出出发构建这一模型,然后将其应用到你日常会用到的属性、children、默认值、展开运算、不可变性和组合模式上。

JSX 是函数调用,而非 HTML

JSX 常被称作“JavaScript 中的 HTML”,但这并不准确。更确切地说,JSX 是一种语法糖,由编译器转换成普通的函数调用。

以一个简单的标题为例:

const element = <h1 className="title">Hello World</h1>;

像 Babel 这样的编译器会将其转换为对 React.createElement 的调用,传入标签名、属性对象以及内容:

const element = React.createElement("h1", { className: "title" }, "Hello World");

这是传统的转换方式。从 React 17 开始,大多数工具链开始使用自动 JSX 运行时,它会调用从 react/jsx-runtime 导入的 jsx() 函数,因此不再需要在作用域中引入 React。其原理依然相同:标记会被转换为函数调用。

该调用会返回一个名为 React 元素的普通 JavaScript 对象:

{
  type: "h1",
  props: {
    className: "title",
    children: "Hello World"
  }
}

React 元素只不过是对屏幕上应显示内容的描述。React 会遍历这些对象的树结构,从而确定如何让真实的 DOM 与之保持一致;人们通常所说的虚拟 DOM 就是指这种描述结构。

该模型所解释的内容

牢记“JSX 是一个返回对象的函数调用”这一概念,就能解决几个常见疑问:

  • JSX 中不能有 if 语句。 无法在函数参数中放置语句,因此需使用三元运算符或 && 表达式,或将逻辑放在 return 之前。
  • 应使用 className 而非 class。 JSX 是 JavaScript,而 class 在其中是保留字。
  • 只能有一个根元素。 函数只会返回一个值,因此组件也必须返回一个元素(该元素可包含多个子元素)。

组件是返回元素的函数

组件是指那些返回 React 元素或 null 以表示不渲染任何内容的函数。

function Greeting() {
  return <h1>Hello, Devendra!</h1>;
}

不存在任何隐藏的机制,在现代 React 中也无需使用类。每当需要决定渲染什么内容时,React 会调用相应的函数。当数据发生变化时,React 会再次调用该函数,获取新的元素树,将其与之前的元素树进行比较,仅更新 DOM 中有差异的部分。这种比较过程被称为协调。

每个组件都必须遵循的两条规则

名称必须以大写字母开头

JSX 通过大小写来判断标签的含义。以大写字母开头的标签表示一个组件,因此 React 会调用你的函数:

// ✅ React treats this as a component
<Greeting />

以小写字母开头的标签会被视为内置的 DOM 元素,因此 React 会尝试创建一个字面量的 <greeting> 元素,而不会调用任何函数:

// ❌ React treats this as a native DOM element (like <div>)
<greeting />

必须返回一个根元素

尝试将两个兄弟元素并排返回会失败,因为这相当于从一个函数中返回两个值:

// ❌ This will throw an error
function Card() {
  return (
    <h2>Title</h2>
    <p>Description</p>
  );
}

有两种解决方法:要么将这两个兄弟元素包裹在父元素中,要么使用以 <>...</> 形式写的 Fragment,这样可以在不向 DOM 添加额外节点的情况下将它们组合在一起。下面的代码片段展示了这两种方法的对比。

// ✅ Wrap in a parent
function Card() {
  return (
    <div>
      <h2>Title</h2>
      <p>Description</p>
    </div>
  );
}// ✅ Or use a Fragment (renders nothing extra in the DOM)
function Card() {
  return (
    <>
      <h2>Title</h2>
      <p>Description</p>
    </>
  );
}

除非需要通过包装元素进行样式或布局处理,否则应优先使用 Fragment。

Props 即函数的参数

如果组件是函数,那么 Props 就是它们的参数。此处,组件会从接收到的对象中读取 name 值:

function Greeting(props) {
  return <h1>Hello, {props.name}!</h1>;
}

然后它会带着该属性被渲染出来:

// Usage
<Greeting name="Devendra" />

React会将JSX标签中所有的属性收集到一个名为props的对象中,并将其作为第一个参数传递。这就是整个机制。

在函数签名中进行解构

反复使用props.会让代码显得杂乱。在参数列表中对该对象进行解构,可以为每个使用的属性命名,从而一目了然地展示组件的输入参数:

function Greeting({ name, role }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Role: {role}</p>
    </div>
  );
}

调用方式保持不变:

<Greeting name="Devendra" role="Senior Engineer" />

结合解构使用默认值

当某个属性是可选的时,可以在解构模式中直接为其指定默认值。如果未提供role,则默认使用"Developer":

function Greeting({ name, role = "Developer" }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Role: {role}</p>
    </div>
  );
}

这样使用就会显示默认的role值:

// role will be "Developer" if not provided
<Greeting name="Devendra" />

解构默认值仅在值为 undefined 时适用。显式传入 null 可以绕过默认值,当属性来自 API 数据时需牢记这一点。

children:语法内置的组合方式

组件开头标签与结尾标签之间的内容会自动作为 children 属性传入。一个 Card 组件只需渲染其包装结构内的内容即可:

function Card({ children }) {
  return (
    <div className="card">
      {children}
    </div>
  );
}

调用方决定其中应包含什么内容:

// Usage
<Card>
  <h2>My Title</h2>
  <p>Some description here.</p>
</Card>

通过 children 实现的组合方式使得组件更加灵活且可复用。无需通过额外的属性来应对各种变化,组件负责提供结构,父组件则负责提供内容。shadcn/ui 和 Material UI 等库就是用这种方式构建对话框、卡片和按钮的。

任何 JavaScript 值都可以作为属性

JSX 中的花括号表示“以 JavaScript 方式进行计算”,因此属性可以包含任何类型的值:字符串、数字、布尔值、数组、对象或函数。该组件会同时接收多种类型的值,根据条件渲染徽章,将数组映射为列表项,并为按钮绑定回调函数:

function UserCard({ name, age, isAdmin, hobbies, onDelete }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>Age: {age}</p>
      {isAdmin && <span>Admin</span>}
      <ul>
        {hobbies.map(h => <li key={h}>{h}</li>)}
      </ul>
      <button onClick={onDelete}>Delete</button>
    </div>
  );
}

调用示例展示了每种类型值的传递方式:

<UserCard
  name="Devendra"
  age={28}
  isAdmin={true}
  hobbies={["coding", "teaching", "gaming"]}
  onDelete={() => console.log("deleted")}
/>

需要注意的几点:

  • 普通的字符串值可以直接用引号括起来。
  • 其他所有类型,包括数字、布尔值、数组、对象和函数,都需要放在花括号中。
  • onDelete 这样的函数属性是子组件向父组件反馈事件的方式。正是这种回调模式使得状态提升机制得以实现:状态存储在共享的父组件中,子组件只能通过调用父组件传递下来的函数来修改状态。
  • 属性为只读

    绝不要修改属性。对属性对象中的字段进行赋值会悄悄改变父组件所拥有的数据:

    // ❌ Never do this
    function Greeting({ user }) {
      user.name = "Modified"; // This will cause silent bugs
      return <h1>{user.name}</h1>;
    }
    

    应生成新值并保持原始值不变:

    // ✅ Create a new value instead
    function Greeting({ user }) {
      const displayName = user.name.toUpperCase();
      return <h1>{displayName}</h1>;
    }
    

    数据流动是单向的,从父组件流向子组件,这正是 React 应用可预测性的来源。当子组件需要父组件修改某些内容时,它会调用父组件提供的函数。

    使用 rest 和 spread 传递属性

    当一个组件包裹某个 DOM 元素时,通常希望接收多个属性而无需逐一列出。参数中的 rest 语法会提取出该组件自行处理的属性,即 label,并将其余属性收集到 inputProps 中,之后再将其应用到 input 元素上:

    function Input({ label, ...inputProps }) {
      return (
        <div>
          <label>{label}</label>
          <input {...inputProps} />
        </div>
      );
    }
    

    所有额外的属性,如 type、placeholder 或 onChange,都会直接传递给底层的元素:

    // All extra props (type, placeholder, onChange, etc.) flow straight to <input>
    <Input
      label="Email"
      type="email"
      placeholder="you@example.com"
      onChange={handleChange}
    />
    

    库中经常使用这种模式,但将任意属性直接添加到 DOM 元素上可能会导致传递它无法理解的值,从而引发未知属性警告。务必谨慎选择要添加的属性及其位置。示例中还渲染了一个 label,但并未将其与输入框关联;在实际代码中,应使用 htmlFor 和 id 将二者连接起来,以便该标签能够被访问。

    组件的三种灵活性层级

    随着需求的变化,组件通常会经历若干可识别的发展阶段。

    层级 1:每种变体对应一个属性

    最简单的按钮会为可能显示的每种内容分别设置一个属性:

    function Button({ text, color, icon }) {
      return <button style={{ color }}>{icon} {text}</button>;
    }
    

    每增加一种新变体就需要额外的属性,导致 API 不断膨胀。这个问题通常被称为“属性爆炸”;它与“属性钻取”不同,后者是通过多层结构向下传递数据。

    第二级:子组件组合

    允许调用者通过children传递内容,可以消除大部分冗余属性。按钮仅保留用于样式的variant属性:

    function Button({ children, variant }) {
      return <button className={`btn btn-${variant}`}>{children}</button>;
    }
    

    现在调用者可以控制内部显示的内容,包括图标:

    <Button variant="primary">
      <Icon name="save" /> Save Changes
    </Button>
    

    第三级:命名插槽

    当组件需要多个独立区域时,可以通过命名属性接收 JSX。页面布局可以包含标题、侧边栏和主要内容:

    function PageLayout({ header, sidebar, children }) {
      return (
        <div className="layout">
          <header>{header}</header>
          <aside>{sidebar}</aside>
          <main>{children}</main>
        </div>
      );
    }
    

    每个区域的内容都在调用处进行设置:

    <PageLayout
      header={<Navbar />}
      sidebar={<FilterPanel />}
    >
      <ProductList />
    </PageLayout>
    

    Slots只是用于存放JSX的普通属性:调用者可以控制每个区域的填充内容,组件则负责管理结构,这样就能应对不断变化的需求。如需更深入地了解这种重构方法,请参阅使用组合模式与Slots解决React属性过载问题。

    模型简述

    • JSX:用于简化如React.createElement()这类创建元素函数调用的语法结构。
    • React元素:用于描述UI的普通JavaScript对象。
    • 组件:返回React元素的函数。
    • 属性:传递给该函数的参数所构成的对象。
    • children:用于存放嵌套JSX内容的特殊属性。
  • 属性修改:绝不允许;应将属性视为只读。
  • 纯度是可扩展的核心原则

    这一切都取决于纯度:相同的属性应始终产生相同的输出。当渲染没有副作用、不会修改属性也不会改变外部状态时,React可以安全地跳过、重复或重新排序操作,而且你可以单独分析每个组件。严格模式在开发阶段甚至会多次渲染组件,以暴露那些不纯的逻辑。

    一旦打破纯度,就会引发一些与实际原因相距甚远的错误。保持纯度,就能让组件在应用不断扩展时依然具备可预测性,同时也更易于测试和维护。