Appearance
第3章 JSX与React Element
本章将深入JSX的本质,分析React.createElement的源码实现,理解ReactElement数据结构,并探讨服务端组件与客户端组件的标记机制。
在前两章中,我们了解了React 19的整体架构和核心模块。从本章开始,我们将深入React的具体实现细节。首先要理解的是:我们编写的JSX代码是如何被React处理的?ReactElement是什么?服务端组件和客户端组件在底层是如何区分的?
这些问题的答案将帮助我们理解React组件的本质,为后续学习Fiber架构和协调算法打下基础。
3.1 JSX的本质
当我们编写React组件时,通常会使用JSX语法:
jsx
function Welcome() {
return <h1>Hello, React 19!</h1>;
}这段代码看起来像HTML,但它既不是字符串,也不是真正的HTML。那么,JSX到底是什么?
3.1.1 JSX是语法糖
JSX(JavaScript XML)是JavaScript的语法扩展,它允许我们在JavaScript代码中编写类似HTML的标记。但浏览器并不能直接理解JSX,它需要被编译成普通的JavaScript代码。
JSX的本质是语法糖。上面的JSX代码会被编译成:
javascript
function Welcome() {
return React.createElement('h1', null, 'Hello, React 19!');
}这就是JSX的真面目:每一个JSX标签都会被转换成一个React.createElement函数调用。
让我们看一个更复杂的例子:
jsx
function App() {
return (
<div className="container">
<h1>Welcome</h1>
<p>This is a paragraph.</p>
</div>
);
}编译后的代码:
javascript
function App() {
return React.createElement(
'div',
{ className: 'container' },
React.createElement('h1', null, 'Welcome'),
React.createElement('p', null, 'This is a paragraph.')
);
}可以看到:
- JSX标签名变成了
createElement的第一个参数 - JSX属性变成了第二个参数(一个对象)
- JSX子元素变成了后续的参数
这种转换使得JSX既保持了声明式的简洁性,又能被JavaScript引擎执行。
3.1.2 Babel编译JSX
JSX的编译工作通常由Babel完成。Babel是一个JavaScript编译器,它可以将新版本的JavaScript(包括JSX)转换成向后兼容的版本。
旧版JSX转换(React 17之前)
在React 17之前,JSX会被转换成React.createElement调用:
jsx
// 源代码
const element = <h1>Hello</h1>;
// 编译后
const element = React.createElement('h1', null, 'Hello');这种方式有一个问题:即使你的代码中没有直接使用React,也必须在文件顶部导入它:
jsx
import React from 'react'; // 必须导入,否则编译后的代码会报错
function App() {
return <div>Hello</div>;
}新版JSX转换(React 17+)
React 17引入了新的JSX转换,不再需要导入React:
jsx
// 源代码
const element = <h1>Hello</h1>;
// 编译后(简化版)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { children: 'Hello' });新版转换的优势:
- 无需导入React:编译器自动导入
jsx函数 - 性能优化:
jsx函数比createElement更高效 - 更好的调试体验:生成的代码更简洁
3.1.3 新旧JSX转换对比
让我们通过一个实际例子对比两种转换方式:
jsx
// 源代码
function Button({ onClick, children }) {
return (
<button className="btn" onClick={onClick}>
{children}
</button>
);
}旧版转换(React 16):
javascript
import React from 'react';
function Button({ onClick, children }) {
return React.createElement(
'button',
{ className: 'btn', onClick: onClick },
children
);
}新版转换(React 17+):
javascript
import { jsx as _jsx } from 'react/jsx-runtime';
function Button({ onClick, children }) {
return _jsx('button', {
className: 'btn',
onClick: onClick,
children: children
});
}主要区别:
| 特性 | 旧版转换 | 新版转换 |
|---|---|---|
| 导入方式 | 必须导入React | 自动导入jsx-runtime |
| 函数名 | React.createElement | jsx/jsxs |
| children处理 | 作为额外参数 | 作为props.children |
| 性能 | 较慢 | 更快 |
| 包大小 | 较大 | 较小 |
在React 19中,新版JSX转换已经成为默认选项。如果你使用现代构建工具(如Vite、Next.js 13+),它们会自动配置新版转换。
配置Babel使用新版转换:
json
{
"presets": [
["@babel/preset-react", {
"runtime": "automatic"
}]
]
}3.2 React.createElement源码分析
理解了JSX的编译过程后,我们来深入React.createElement的源码实现。这个函数是React创建元素的核心入口。
3.2.1 函数签名与参数
createElement函数定义在packages/react/src/jsx/ReactJSXElement.js文件中:
javascript
// 文件:packages/react/src/jsx/ReactJSXElement.js
// 行号:610-750(React 19.3.0)
export function createElement(type, config, children) {
// 实现细节...
}三个参数的含义:
type:元素类型
- 字符串:表示原生DOM元素,如
'div'、'span' - 函数:表示函数组件
- 类:表示类组件
- Symbol:表示特殊类型,如
React.Fragment
- 字符串:表示原生DOM元素,如
config:配置对象,包含所有JSX属性
key:列表渲染时的唯一标识ref:引用DOM节点或组件实例- 其他props:如
className、onClick等
children:子元素
- 可以是单个元素
- 可以是多个元素(通过
arguments获取) - 可以是文本、数字等原始值
3.2.2 props处理逻辑
createElement的核心工作是处理props。让我们看看简化后的实现:
javascript
// 简化版 - 省略了开发环境检查
// 文件:packages/react/src/jsx/ReactJSXElement.js
export function createElement(type, config, children) {
let propName;
const props = {};
let key = null;
// 1. 提取key
if (config != null) {
if (hasValidKey(config)) {
key = '' + config.key; // 转换为字符串
}
// 2. 复制props(排除key、__self、__source)
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
propName !== 'key' &&
propName !== '__self' &&
propName !== '__source'
) {
props[propName] = config[propName];
}
}
}
// 3. 处理children
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}
// 4. 处理defaultProps
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 5. 创建ReactElement
return ReactElement(type, key, props, getOwner(), debugStack, debugTask);
}处理流程解析:
步骤1:提取key
key是React用于列表渲染优化的特殊属性。它会被单独提取,不会出现在最终的props中:
javascript
if (hasValidKey(config)) {
key = '' + config.key; // 强制转换为字符串
}为什么要转换为字符串?因为React内部使用字符串比较key,这样可以统一处理数字和字符串类型的key。
步骤2:复制props
遍历config对象,将除了key、__self、__source之外的所有属性复制到props对象:
javascript
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
propName !== 'key' &&
propName !== '__self' && // Babel添加的调试信息
propName !== '__source' // Babel添加的调试信息
) {
props[propName] = config[propName];
}
}__self和__source是Babel在开发环境添加的元数据,用于错误提示和调试,不应该出现在最终的props中。
步骤3:处理children
children的处理比较特殊。如果只有一个子元素,直接赋值;如果有多个,创建数组:
javascript
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}这就是为什么我们可以这样写:
jsx
// 单个子元素
<div>Hello</div>
// props.children = 'Hello'
// 多个子元素
<div>
<span>A</span>
<span>B</span>
</div>
// props.children = [<span>A</span>, <span>B</span>]步骤4:处理defaultProps
如果组件定义了defaultProps,会在这里应用:
javascript
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}示例:
javascript
function Button({ color = 'blue', size = 'medium' }) {
return <button className={`btn-${color} btn-${size}`}>Click</button>;
}
Button.defaultProps = {
color: 'blue',
size: 'medium'
};
// 使用时
<Button /> // color='blue', size='medium'
<Button color="red" /> // color='red', size='medium'3.2.3 children规范化
在实际使用中,children可以有多种形式:
jsx
// 文本节点
<div>Hello</div>
// 单个元素
<div><span>Hello</span></div>
// 多个元素
<div>
<span>A</span>
<span>B</span>
</div>
// 混合类型
<div>
Text
<span>Element</span>
{123}
{null}
{undefined}
</div>
// 数组
<div>{[1, 2, 3].map(n => <span key={n}>{n}</span>)}</div>React会对这些不同形式的children进行规范化处理:
- 原始值:字符串、数字会被保留
- null/undefined/boolean:会被忽略(不渲染)
- 数组:会被展平处理
- React元素:保持不变
这种灵活性使得我们可以方便地进行条件渲染:
jsx
function Greeting({ isLoggedIn, username }) {
return (
<div>
{isLoggedIn && <span>Welcome, {username}!</span>}
{!isLoggedIn && <span>Please log in.</span>}
</div>
);
}3.3 ReactElement数据结构
createElement最终会调用ReactElement函数创建一个元素对象。这个对象就是React元素的数据结构。
3.3.1 $typeof标识
让我们看看ReactElement函数的实现:
javascript
// 文件:packages/react/src/jsx/ReactJSXElement.js
// 行号:170-260(React 19.3.0)
function ReactElement(type, key, props, owner, debugStack, debugTask) {
const refProp = props.ref;
const ref = refProp !== undefined ? refProp : null;
let element;
if (__DEV__) {
// 开发环境:包含调试信息
element = {
$typeof: REACT_ELEMENT_TYPE,
type,
key,
props,
_owner: owner,
};
// ... 添加ref、_store、_debugInfo等属性
} else {
// 生产环境:精简版本
element = {
$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
};
}
return element;
}$typeof的作用
$typeof是一个Symbol类型的标识符,用于标记这是一个React元素:
javascript
// 文件:packages/shared/ReactSymbols.js
export const REACT_ELEMENT_TYPE = Symbol.for('react.transitional.element');为什么需要这个标识?主要有两个原因:
- 安全性:防止XSS攻击
假设服务端返回了一个JSON对象:
javascript
const userInput = {
type: 'div',
props: {
dangerouslySetInnerHTML: {
__html: '<img src=x onerror="alert(1)">'
}
}
};如果React直接渲染这个对象,就会执行恶意代码。但由于JSON无法序列化Symbol,攻击者无法伪造$typeof字段,React会拒绝渲染这个对象。
- 类型识别:快速判断是否为React元素
javascript
function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$typeof === REACT_ELEMENT_TYPE
);
}3.3.2 type、key、ref属性
ReactElement对象的核心属性:
type属性
表示元素的类型,决定了如何渲染这个元素:
javascript
// 原生DOM元素
const div = <div />;
// div.type === 'div'
// 函数组件
function MyComponent() { return <div />; }
const element = <MyComponent />;
// element.type === MyComponent
// 类组件
class MyClass extends React.Component {
render() { return <div />; }
}
const element2 = <MyClass />;
// element2.type === MyClass
// Fragment
const fragment = <><div /></>;
// fragment.type === Symbol.for('react.fragment')key属性
用于列表渲染时的元素识别和优化:
javascript
const list = ['A', 'B', 'C'].map(item => (
<li key={item}>{item}</li>
));
// 每个li元素都有唯一的keykey的作用将在第6章"协调过程"中详细讲解。这里只需要知道:
- key必须在兄弟元素中唯一
- key不会传递给组件(不会出现在props中)
- key应该稳定、可预测(不要使用随机数或索引)
ref属性
在React 19中,ref的处理发生了重大变化。ref不再是特殊属性,而是作为普通prop传递:
javascript
// React 18及之前
<input ref={inputRef} />
// ref不在props中
// React 19
<input ref={inputRef} />
// ref在props中:props.ref = inputRef这个变化简化了ref的处理逻辑,使得ref可以像其他props一样被转发:
javascript
// React 19中,不再需要forwardRef
function MyInput(props) {
return <input {...props} />;
}
// 直接使用
<MyInput ref={inputRef} />3.3.3 props与children
props对象
props包含了所有传递给组件的属性(除了key):
jsx
<Button
color="blue"
size="large"
onClick={handleClick}
>
Click Me
</Button>
// 对应的props对象:
{
color: 'blue',
size: 'large',
onClick: handleClick,
children: 'Click Me'
}children的特殊性
children是一个特殊的prop,它有多种形式:
javascript
// 1. 单个文本
<div>Hello</div>
// props.children = 'Hello'
// 2. 单个元素
<div><span>Hello</span></div>
// props.children = ReactElement
// 3. 多个元素
<div>
<span>A</span>
<span>B</span>
</div>
// props.children = [ReactElement, ReactElement]
// 4. 函数(render props模式)
<DataProvider>
{data => <div>{data}</div>}
</DataProvider>
// props.children = functionReact提供了React.Children工具函数来处理children:
javascript
React.Children.map(children, child => {
// 遍历children,无论是单个还是数组
});
React.Children.count(children); // 计算children数量
React.Children.only(children); // 确保只有一个child
React.Children.toArray(children); // 转换为数组示例:ReactElement的完整结构
让我们看一个完整的例子:
jsx
function App() {
return (
<div className="container" id="app">
<h1>Hello</h1>
<p>World</p>
</div>
);
}对应的ReactElement结构(简化版):
javascript
{
$typeof: Symbol.for('react.transitional.element'),
type: 'div',
key: null,
ref: null,
props: {
className: 'container',
id: 'app',
children: [
{
$typeof: Symbol.for('react.transitional.element'),
type: 'h1',
key: null,
ref: null,
props: {
children: 'Hello'
}
},
{
$typeof: Symbol.for('react.transitional.element'),
type: 'p',
key: null,
ref: null,
props: {
children: 'World'
}
}
]
}
}这个嵌套的对象结构就是React的虚拟DOM。React会根据这个结构创建真实的DOM节点,或者在更新时对比新旧结构来决定如何更新DOM。
3.4 服务端组件与客户端组件的标记
React 19引入了服务端组件(Server Components)和客户端组件(Client Components)的概念。那么,React是如何区分这两种组件的呢?
3.4.1 "use client"指令
在React 19中,默认情况下,所有组件都是服务端组件。如果你想让一个组件在客户端运行,需要在文件顶部添加"use client"指令:
jsx
// ClientButton.jsx
"use client";
import { useState } from 'react';
export default function ClientButton() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}"use client"的作用
这个指令告诉React和构建工具:
- 这个模块及其依赖需要打包到客户端bundle中
- 这个组件可以使用客户端特性(useState、useEffect、事件处理等)
- 这个组件会在客户端执行
指令的处理时机
"use client"不是在运行时处理的,而是在构建时由打包工具(如Webpack、Turbopack)处理:
javascript
// 文件:packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
// 行号:405-425(React 19.3.0)
function hasUseClientDirective(source: string): boolean {
if (source.indexOf('use client') === -1) {
return false;
}
// 解析AST,查找指令
const ast = parse(source);
for (const node of ast.body) {
if (node.type === 'ExpressionStatement' &&
node.directive === 'use client') {
return true;
}
}
return false;
}当Webpack遇到带有"use client"的文件时,会:
- 将该文件标记为客户端模块
- 生成一个客户端引用(Client Reference)
- 在服务端代码中,用引用替换实际的组件代码
客户端引用的结构
在服务端,客户端组件会被替换为一个特殊的引用对象:
javascript
// 服务端看到的ClientButton
{
$typeof: Symbol.for('react.client.reference'),
$$id: 'path/to/ClientButton.jsx#default',
$$async: false
}这个引用对象包含:
$typeof:标识这是一个客户端引用$$id:模块路径和导出名称$$async:是否为异步组件
在客户端,这个引用会被解析为实际的组件代码。
3.4.2 "use server"指令
与"use client"相对的是"use server"指令,它用于标记服务端函数(Server Actions):
jsx
// actions.js
"use server";
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// 直接访问数据库
await db.posts.create({ title, content });
// 重新验证缓存
revalidatePath('/posts');
}"use server"的两种用法
- 文件级别:整个文件的所有导出都是Server Actions
jsx
"use server";
export async function action1() { /* ... */ }
export async function action2() { /* ... */ }- 函数级别:只标记特定函数
jsx
export async function action1() {
"use server";
// 这是一个Server Action
}
export function normalFunction() {
// 这是一个普通函数
}Server Actions的序列化
Server Actions会被序列化为一个特殊的引用,可以从服务端传递到客户端:
javascript
// 服务端
async function ServerComponent() {
async function handleSubmit(formData) {
"use server";
// 处理表单
}
return <form action={handleSubmit}>...</form>;
}在Flight协议中,handleSubmit会被序列化为:
S1:"path/to/ServerComponent.jsx#handleSubmit"客户端收到这个引用后,可以调用它,React会自动发送HTTP请求到服务端执行实际的函数。
3.4.3 组件边界的确定
在一个React 19应用中,服务端组件和客户端组件可以混合使用,但需要遵循一些规则:
规则1:服务端组件可以导入客户端组件
jsx
// ServerComponent.jsx(默认是服务端组件)
import ClientButton from './ClientButton'; // 带有"use client"
export default async function ServerComponent() {
const data = await fetchData();
return (
<div>
<h1>{data.title}</h1>
<ClientButton /> {/* 可以使用客户端组件 */}
</div>
);
}规则2:客户端组件不能导入服务端组件
jsx
// ClientComponent.jsx
"use client";
import ServerComponent from './ServerComponent'; // ❌ 错误!
export default function ClientComponent() {
return <ServerComponent />; // 无法工作
}为什么?因为客户端组件的代码会被打包到客户端bundle中,而服务端组件的代码只能在服务端运行(可能包含数据库访问等服务端逻辑)。
规则3:可以通过props传递服务端组件
虽然不能直接导入,但可以通过props传递:
jsx
// ClientWrapper.jsx
"use client";
export default function ClientWrapper({ children }) {
return (
<div className="wrapper">
{children}
</div>
);
}
// ServerPage.jsx
import ClientWrapper from './ClientWrapper';
import ServerComponent from './ServerComponent';
export default function ServerPage() {
return (
<ClientWrapper>
<ServerComponent /> {/* ✅ 通过children传递 */}
</ClientWrapper>
);
}这种模式被称为"组件组合"(Component Composition),是React 19推荐的架构模式。
组件边界的可视化
┌─────────────────────────────────────────┐
│ ServerPage (Server Component) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ClientWrapper (Client Component) │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ ServerComponent (Server) │ │ │
│ │ │ (通过children传递) │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘边界检测
React在运行时会检测组件边界的违规情况:
javascript
// 文件:packages/react-reconciler/src/ReactFiberHooks.js
// 行号:431-435(React 19.3.0)
if (isAsyncFunction(Component)) {
throw new Error(
`${componentName} is an async Client Component. ` +
'Only Server Components can be async at the moment. ' +
"This error is often caused by accidentally adding `'use client'` " +
'to a module that was originally written for the server.'
);
}这个错误提示会在你尝试在客户端组件中使用async函数时触发。
示例:混合使用服务端和客户端组件
让我们看一个完整的例子:
jsx
// app/page.jsx - 服务端组件
import { db } from '@/lib/db';
import ProductList from './ProductList';
import SearchBar from './SearchBar';
export default async function ProductsPage() {
// 在服务端直接查询数据库
const products = await db.products.findMany();
return (
<div>
<h1>Products</h1>
<SearchBar /> {/* 客户端组件:需要交互 */}
<ProductList products={products} /> {/* 服务端组件:只展示 */}
</div>
);
}
// SearchBar.jsx - 客户端组件
"use client";
import { useState } from 'react';
export default function SearchBar() {
const [query, setQuery] = useState('');
return (
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
);
}
// ProductList.jsx - 服务端组件
export default function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}在这个例子中:
ProductsPage是服务端组件,可以直接访问数据库SearchBar是客户端组件,需要使用useState和事件处理ProductList是服务端组件,只负责展示数据
这种架构的优势:
- 更小的客户端bundle:只有SearchBar的代码会发送到客户端
- 更快的数据获取:数据库查询在服务端完成,无需API调用
- 更好的安全性:数据库凭证不会暴露给客户端
本章小结
本章深入探讨了JSX和ReactElement的实现原理:
- JSX的本质:JSX是语法糖,会被编译成
createElement或jsx函数调用 - 新旧JSX转换:React 17+引入了新的JSX转换,无需导入React,性能更好
- createElement实现:处理props、children、defaultProps,最终创建ReactElement对象
- ReactElement结构:包含$typeof、type、key、ref、props等核心属性
- 组件边界标记:通过"use client"和"use server"指令区分服务端和客户端代码
理解这些基础知识后,我们就可以深入React的核心实现了。下一章,我们将学习Fiber架构,这是React协调算法的基础。
思考题
- 为什么React 19将ref改为普通prop?这样做有什么好处?
- 在什么情况下应该使用"use client"指令?如何判断一个组件应该是服务端组件还是客户端组件?
- 如果在客户端组件中需要使用服务端数据,应该如何设计组件结构?