第 28 章

React 19 新特性全解:Actions、use()、编译器与并发改进

第28章:React 19 新特性全解:Actions、use()、编译器与并发改进

React 19 围绕一个核心主题展开:让代码更接近'描述意图'而不是'描述实现'。

本章核心问题:React 19 的 Actions 模型、use() Hook 和 Compiler 分别解决了什么问题? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Actions:异步操作的声明式模型

问题:表单提交的状态管理噩梦

在 React 18 时代,处理一个异步表单提交需要手动管理大量状态:

// React 18 中的典型异步表单处理
function UpdateNameForm() {
  const [name, setName] = useState('');
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);
  const [optimisticName, setOptimisticName] = useState('');
  
  async function handleSubmit(e) {
    e.preventDefault();
    setIsPending(true);
    setError(null);
    setOptimisticName(name); // 乐观更新
    
    try {
      await updateName(name);
    } catch (err) {
      setError(err.message);
      setOptimisticName(''); // 回滚乐观更新
    } finally {
      setIsPending(false);
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      {isPending && <Spinner />}
      {error && <p>{error}</p>}
    </form>
  );
}

这段代码并不复杂,但每个表单都要重复这个模式,不仅繁琐,而且容易遗漏错误处理。

Actions:用异步函数替代事件处理器

React 19 引入了 Actions 的概念:可以将异步函数直接传给表单的 action 属性,React 会自动管理 pending 状态、错误状态和乐观更新:

// React 19 的 Actions 模型
function UpdateNameForm() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      const name = formData.get('name');
      const error = await updateName(name);
      if (error) return error;
      return null;
    },
    null // 初始状态
  );
  
  return (
    <form action={submitAction}>
      <input name="name" />
      {isPending && <Spinner />}
      {error && <p>{error}</p>}
      <button type="submit" disabled={isPending}>Update</button>
    </form>
  );
}

useActionState 接收一个异步函数,返回 [state, action, isPending] 元组。当表单提交时,React 自动将 isPending 设为 true,等待异步函数完成后更新 state,并将 isPending 重置为 false

useFormStatus:读取父表单状态

useFormStatus 允许子组件读取其所属表单的提交状态,无需通过 props 传递:

// packages/react-dom/src/client/ReactDOMFormElement.js 提供支持
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  // 自动读取最近的父 <form> 的状态
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

function Form() {
  return (
    <form action={someAction}>
      <input name="data" />
      <SubmitButton /> {/* 自动获取表单 pending 状态 */}
    </form>
  );
}

useFormStatus 底层使用 Context 机制,React 在处理 <form action={fn}> 时会创建一个 FormStatus Context,所有子组件可以通过 useFormStatus 订阅它。

useOptimistic:乐观更新的声明式实现

import { useOptimistic } from 'react';

function MessageList({ messages, sendMessage }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMessage) => [
      ...currentMessages,
      { text: newMessage, status: 'sending' }
    ]
  );
  
  async function formAction(formData) {
    const message = formData.get('message');
    // 立即显示乐观消息(不等待服务器响应)
    addOptimisticMessage(message);
    // 发送到服务器
    await sendMessage(message);
    // 服务器响应后,optimisticMessages 自动回退到 messages(真实数据)
  }
  
  return (
    <div>
      {optimisticMessages.map((msg, i) => (
        <p key={i} style={{ opacity: msg.status === 'sending' ? 0.7 : 1 }}>
          {msg.text}
        </p>
      ))}
      <form action={formAction}>
        <input name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

useOptimistic 的实现原理是:在 action 执行期间,它使用"乐观状态"(通过 addOptimisticMessage 应用的中间状态)替代真实状态进行渲染。当 action 完成时(无论成功还是失败),React 自动切换回真实状态,丢弃乐观状态。这个切换发生在一个 transition 中,所以不会引起页面闪烁。

use():在渲染中读取资源

use() 是 React 19 中最具革命性的 Hook——它打破了以往"只能在 React 组件函数顶层调用 Hook"的惯例,可以在条件语句、循环,乃至普通函数中调用。

读取 Promise

import { use, Suspense } from 'react';

// 在组件外创建 Promise(不能在组件内创建,避免每次渲染重新创建)
const userPromise = fetchUser(userId);

function UserProfile() {
  // use() 读取 Promise — 如果 Promise 还未 resolve,组件会 suspend
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
}

use(promise) 的工作机制与 throw promise(Suspense 的传统触发方式)类似,但更加简洁:当 Promise 还未 resolve 时,use() 会抛出 Promise,触发最近的 Suspense 边界显示 fallback;当 Promise resolve 后,React 重新渲染组件,use() 直接返回 resolved 的值。

// use() 可以在条件语句中使用
function DataDisplay({ condition, promise1, promise2 }) {
  const data = use(condition ? promise1 : promise2);
  return <div>{data}</div>;
}

读取 Context

use() 也可以读取 Context,与 useContext 等价,但可以在条件语句中使用:

function Button({ show }) {
  if (!show) return null; // 可以在这之后调用 use()
  
  const theme = use(ThemeContext); // 等价于 useContext(ThemeContext)
  return <button style={{ color: theme.color }}>Click</button>;
}

这个特性让之前许多需要重构组件结构的场景变得简单——你不再需要为了"有条件地使用 Context"而把组件拆成两个。

use() 的内部实现

// packages/react/src/ReactHooks.js(简化)
export function use(usable) {
  return ReactCurrentDispatcher.current.use(usable);
}

// packages/react-reconciler/src/ReactFiberHooks.js
function useThenable(thenable) {
  // 追踪当前 thenable 的状态
  const index = thenableIndexCounter++;
  const thenableState = getThenableStateAfterSuspending();
  
  // 检查这个 Promise 是否已经 resolve
  switch (thenable.status) {
    case 'fulfilled': {
      const fulfilledValue = thenable.value;
      return fulfilledValue;
    }
    case 'rejected': {
      const rejectedReason = thenable.reason;
      throw rejectedReason;
    }
    default: {
      const prevThenableAtIndex = thenableState !== null
        ? thenableState.get(index)
        : null;
      
      if (prevThenableAtIndex === thenable) {
        // 同一个 Promise,检查状态
        switch (thenable.status) {
          case 'fulfilled': return thenable.value;
          case 'rejected': throw thenable.reason;
        }
      }
      
      // 订阅 Promise 状态变化
      switch (thenable.status) {
        case 'pending':
          thenable.then(
            (fulfilledValue) => {
              if (thenable.status === 'pending') {
                thenable.status = 'fulfilled';
                thenable.value = fulfilledValue;
              }
            },
            (error) => {
              if (thenable.status === 'pending') {
                thenable.status = 'rejected';
                thenable.reason = error;
              }
            }
          );
          break;
      }
      
      // 抛出 Promise,触发 Suspense
      throw thenable;
    }
  }
}

React Compiler:自动记忆化

React Compiler(前身是 React Forget 项目)是 React 19 生态中最具野心的工程:一个将 React 组件的手动 useMemo/useCallback 优化自动化的编译器

工作原理

React Compiler 在构建时(而非运行时)分析组件代码,判断哪些值和函数在什么条件下保持稳定,并自动插入记忆化逻辑。

// 你写的代码
function ProductList({ category, onSelect }) {
  const filteredProducts = products.filter(p => p.category === category);
  
  return filteredProducts.map(product => (
    <ProductCard
      key={product.id}
      product={product}
      onSelect={onSelect}
    />
  ));
}

// React Compiler 编译后的输出(概念性展示)
function ProductList({ category, onSelect }) {
  const $ = useMemoCache(3);
  
  let filteredProducts;
  if ($[0] !== category) {
    filteredProducts = products.filter(p => p.category === category);
    $[0] = category;
    $[1] = filteredProducts;
  } else {
    filteredProducts = $[1];
  }
  
  let result;
  if ($[2] !== filteredProducts || $[3] !== onSelect) {
    result = filteredProducts.map(product => (
      <ProductCard
        key={product.id}
        product={product}
        onSelect={onSelect}
      />
    ));
    $[2] = filteredProducts;
    $[3] = onSelect;
    $[4] = result;
  } else {
    result = $[4];
  }
  
  return result;
}

编译器使用了一个名为 useMemoCache 的内部 Hook(不对外公开)来存储缓存值。它分析组件的数据流图,识别哪些计算依赖于哪些 props/state,然后只在依赖变化时重新计算。

组件纯度假设

React Compiler 的工作前提是组件和 Hook 必须是纯的(相同输入,相同输出,无副作用)。编译器会分析代码并检测潜在的不纯操作。如果检测到不纯,编译器会跳过对该组件的优化,而不是生成错误的代码。

// 编译器会跳过优化:直接修改 props 或外部变量
function BadComponent({ items }) {
  items.push('new item'); // 违反纯度,编译器会放弃优化
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}

Level 2 · 它是怎么运行的(3-5年经验)

ref 作为 prop:告别 forwardRef

React 19 中,函数组件可以直接接收 ref 作为 prop,不再需要 forwardRef 包装:

// React 18:需要 forwardRef
const MyInput = forwardRef(function MyInput({ placeholder }, ref) {
  return <input ref={ref} placeholder={placeholder} />;
});

// React 19:直接接收 ref prop
function MyInput({ placeholder, ref }) {
  return <input ref={ref} placeholder={placeholder} />;
}

// 使用方式相同
function Parent() {
  const ref = useRef(null);
  return <MyInput ref={ref} placeholder="Enter text" />;
}

在 React 19 内部,当渲染函数组件时,如果组件的 props 中有 ref 字段,React 会直接将其传入,不再需要中间的包装层。forwardRef 在 React 19 中仍然有效(向后兼容),但官方建议逐步迁移到直接 prop 模式。

文档元数据:在组件中声明 title 和 meta

React 19 原生支持在组件中声明文档元数据,无需第三方库:

function BlogPost({ post }) {
  return (
    <article>
      {/* 这些标签会被自动提升到 <head> */}
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <link rel="canonical" href={post.url} />
      
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

React 19 的渲染器会识别 <title><meta><link> 等特殊标签,并将它们"提升"(hoist)到文档的 <head> 部分。在服务端渲染(SSR)场景下,这些标签会在 HTML 输出时被正确放置在 <head> 中;在客户端渲染场景下,React 会使用 DOM API 将它们插入 <head>

当同一个元数据标签出现在多个组件中时,React 会进行去重——例如,多个组件都声明了 <title>,页面上只会呈现最后一个(或优先级最高的那个)。

资源预加载 API

React 19 新增了一组资源加载 API,可以在组件中声明式地预加载关键资源:

import { preload, preinit, prefetchDNS, preconnect } from 'react-dom';

function VideoPlayer({ src }) {
  // 预加载视频资源(以更高优先级获取,但不立即执行)
  preload(src, { as: 'video' });
  
  // preinit: 预加载并立即执行(适用于关键脚本和样式)
  preinit('https://cdn.example.com/player.js', { as: 'script' });
  
  return <video src={src} controls />;
}

function App() {
  // DNS 预解析和预连接
  prefetchDNS('https://api.example.com');
  preconnect('https://api.example.com', { crossOrigin: 'anonymous' });
  
  return <VideoPlayer src="/video.mp4" />;
}

这些 API 最终生成 <link rel="preload"><link rel="prefetch"> 等 HTML 标签,帮助浏览器更早地获取关键资源,改善页面加载性能。在 SSR 场景下,这些提示会被写入初始 HTML,让浏览器在解析 HTML 时就开始并行加载资源。


Level 3 · 规范怎么定义的(资深)

React 18 → 19 迁移指南

破坏性变更

1. 移除的遗留根 API

// 已移除(React 18 已废弃)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, container);        // ❌
ReactDOM.hydrate(<App />, container);       // ❌
ReactDOM.unmountComponentAtNode(container); // ❌

// 使用新 API
import { createRoot, hydrateRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<App />);                       // ✅

2. 移除 propTypes 运行时检查

// React 19 完全移除了运行时 propTypes 验证
MyComponent.propTypes = { ... }; // 不再有任何效果,建议改用 TypeScript

3. 移除 defaultProps(函数组件)

// React 19 中,函数组件的 defaultProps 被移除
// 改为使用 ES 默认参数
function Button({ color = 'blue', size = 'medium' }) {
  return <button className={`btn-${color} btn-${size}`}>Click</button>;
}
// 注意:类组件的 defaultProps 仍然保留

4. ref 回调的清理函数

// React 19 中,ref 回调可以返回清理函数
function Component() {
  return (
    <div
      ref={(node) => {
        // setup
        if (node) {
          node.addEventListener('click', handler);
        }
        // cleanup(React 19 新增)
        return () => {
          node.removeEventListener('click', handler);
        };
      }}
    />
  );
}

5. Context 提供方写法简化

// React 18
<ThemeContext.Provider value={theme}>
  {children}
</ThemeContext.Provider>

// React 19 可以直接使用 Context 本身作为 Provider
<ThemeContext value={theme}>
  {children}
</ThemeContext>

迁移建议优先级

优先级 变更 工作量
必须 替换 ReactDOM.rendercreateRoot
必须 删除 propTypes 中(建议迁移到 TypeScript)
建议 forwardRef 替换为直接 prop
建议 Context.Provider 替换为 Context
可选 使用 use() 替换 useContext
可选 采用 Actions 模式替换手动表单状态管理 高(收益大)

React 19 的这些改进不是孤立的功能点,而是围绕一个核心主题展开的:让 React 代码更接近"描述意图"而不是"描述实现"。Actions 让你描述"提交这个表单"而不是管理 pending/error 状态;Compiler 让你描述"这是组件的渲染逻辑"而不是手动优化哪些值应该记忆化;文档元数据让你在组件中声明"这个页面的标题是什么"而不是调用外部库的命令式 API。这是 React 设计哲学的持续演进。


Level 4 · 边界与陷阱(所有人)

改进的错误报告与 hydration 错误提示

更好的错误分组

React 19 改进了错误捕获机制。在之前的版本中,一个错误可能在控制台触发多次输出(react 内部处理一次,window.onerror 再捕获一次)。React 19 统一了错误报告路径:

// React 19 中,新增了两个 root 选项
const root = createRoot(container, {
  // 可恢复的错误(有 ErrorBoundary 捕获)
  onRecoverableError: (error, errorInfo) => {
    console.error('Caught by ErrorBoundary:', error, errorInfo.componentStack);
  },
  // 不可恢复的错误(没有 ErrorBoundary 捕获)
  onUncaughtError: (error, errorInfo) => {
    console.error('Uncaught error:', error, errorInfo.componentStack);
  },
});

Hydration 错误的差异展示

React 19 在 hydration 错误时会显示服务端与客户端渲染结果的具体差异,而不仅仅是"hydration 失败"这样的模糊提示:

Hydration failed because the server rendered HTML didn't match the client.
  Server: <div class="container">Hello</div>
  Client: <div class="container">World</div>

这让定位和修复 SSR 不一致问题变得直接得多。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论