Skip to content

第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' });

新版转换的优势:

  1. 无需导入React:编译器自动导入jsx函数
  2. 性能优化jsx函数比createElement更高效
  3. 更好的调试体验:生成的代码更简洁

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.createElementjsx/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) {
  // 实现细节...
}

三个参数的含义:

  1. type:元素类型

    • 字符串:表示原生DOM元素,如'div''span'
    • 函数:表示函数组件
    • 类:表示类组件
    • Symbol:表示特殊类型,如React.Fragment
  2. config:配置对象,包含所有JSX属性

    • key:列表渲染时的唯一标识
    • ref:引用DOM节点或组件实例
    • 其他props:如classNameonClick
  3. 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进行规范化处理:

  1. 原始值:字符串、数字会被保留
  2. null/undefined/boolean:会被忽略(不渲染)
  3. 数组:会被展平处理
  4. 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');

为什么需要这个标识?主要有两个原因:

  1. 安全性:防止XSS攻击

假设服务端返回了一个JSON对象:

javascript
const userInput = {
  type: 'div',
  props: {
    dangerouslySetInnerHTML: {
      __html: '<img src=x onerror="alert(1)">'
    }
  }
};

如果React直接渲染这个对象,就会执行恶意代码。但由于JSON无法序列化Symbol,攻击者无法伪造$typeof字段,React会拒绝渲染这个对象。

  1. 类型识别:快速判断是否为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元素都有唯一的key

key的作用将在第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 = function

React提供了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和构建工具:

  1. 这个模块及其依赖需要打包到客户端bundle中
  2. 这个组件可以使用客户端特性(useState、useEffect、事件处理等)
  3. 这个组件会在客户端执行

指令的处理时机

"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"的文件时,会:

  1. 将该文件标记为客户端模块
  2. 生成一个客户端引用(Client Reference)
  3. 在服务端代码中,用引用替换实际的组件代码

客户端引用的结构

在服务端,客户端组件会被替换为一个特殊的引用对象:

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"的两种用法

  1. 文件级别:整个文件的所有导出都是Server Actions
jsx
"use server";

export async function action1() { /* ... */ }
export async function action2() { /* ... */ }
  1. 函数级别:只标记特定函数
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是服务端组件,只负责展示数据

这种架构的优势:

  1. 更小的客户端bundle:只有SearchBar的代码会发送到客户端
  2. 更快的数据获取:数据库查询在服务端完成,无需API调用
  3. 更好的安全性:数据库凭证不会暴露给客户端

本章小结

本章深入探讨了JSX和ReactElement的实现原理:

  1. JSX的本质:JSX是语法糖,会被编译成createElementjsx函数调用
  2. 新旧JSX转换:React 17+引入了新的JSX转换,无需导入React,性能更好
  3. createElement实现:处理props、children、defaultProps,最终创建ReactElement对象
  4. ReactElement结构:包含$typeof、type、key、ref、props等核心属性
  5. 组件边界标记:通过"use client"和"use server"指令区分服务端和客户端代码

理解这些基础知识后,我们就可以深入React的核心实现了。下一章,我们将学习Fiber架构,这是React协调算法的基础。


思考题

  1. 为什么React 19将ref改为普通prop?这样做有什么好处?
  2. 在什么情况下应该使用"use client"指令?如何判断一个组件应该是服务端组件还是客户端组件?
  3. 如果在客户端组件中需要使用服务端数据,应该如何设计组件结构?