从JSX到普通对象:React元素、组件和属性的工作原理
了解 JSX 编译后的结果,为何组件本质上只是函数,以及属性、子元素、默认值、展开运算和插槽是如何基于这一核心概念衍生出来的。
来源: 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可以安全地跳过、重复或重新排序操作,而且你可以单独分析每个组件。严格模式在开发阶段甚至会多次渲染组件,以暴露那些不纯的逻辑。
一旦打破纯度,就会引发一些与实际原因相距甚远的错误。保持纯度,就能让组件在应用不断扩展时依然具备可预测性,同时也更易于测试和维护。