最近一年,React 生态里其实发生了几件“五年级别”的大事情:React 19 稳定版正式发布,React Compiler 开始真的能用,Form Actions 成为官方推荐的写法。很多朋友还在观望,觉得“新东西又是一轮学习成本”,但如果你实际动手把小项目跑一遍,会发现这次改变的不是某个 API 的用法,而是写 React 的思维模式。
这篇文章不打算做版本号的罗列。我主要从自己的实际项目改造经历出发,聊一聊 React 19 里最值得在业务里落地的几个特性:form action 和状态管理 API、React Compiler 的自动记忆化,以及 Server Components / Server Actions 带来的前后端边界变化。文章里也会穿插一些升级过程中踩过的坑和排查经验,希望对正在评估或者已经准备升级的团队有点帮助。
1. React 19 到底改了什么:一次“默认值”的升级
1.1 为什么这次迭代值得关心
说 React 19 是一次“默认值”的升级,是因为它把过去需要你自己反复决策的东西变成了框架的默认行为。以前我们写列表、表单、异步数据,总会纠结要不要用 memo、要不要手动优化重渲染、要不要引入第三方状态管理库。React 19 给出的答案很简单:把大部分能自动的部分交给框架,你只需要把“规则”遵守好。
我自己最近的体会特别明显。一个持续迭代了两三年的模拟项目 X 里,有大量列表页和筛选表单,代码里布满了 React.memo、useMemo、useCallback。升级到 React 19 之前,我本以为自己要花大量精力去清理这些手动优化代码,实际跑下来,React Compiler 确实能把很大一部分优化接管过去,代码体积和可读性都有改善。
当然,“默认值”升级不等于零成本。React 19 对 class 组件、ref 转发、StrictMode 行为都有调整,具体要不要跟、什么时候跟,取决于你的项目有没有历史包袱。但方向很明确:如果你还在用一个两三年前创建的 React 项目,越早规划升级路径,后续踩坑的成本越低。
1.2 React 19 新能力地图:Actions、编译器、元数据
React 19 的新能力,可以大致分成三条主线,这也是我评估一个框架版本时习惯用的坐标:
- Actions 与表单能力:表单可以天然地调用异步函数,配合 useActionState、useFormStatus、useOptimistic 这些官方 Hooks,提交中状态、错误处理、乐观更新都可以在框架内部完成,不再需要自己封装复杂的前端状态机。
- React Compiler:自动记忆化缓存。以前用 useMemo / useCallback 手动标记的地方,编译器会自动分析组件,在保持 UI 纯函数语义的前提下做缓存,减少不必要的渲染。
- 服务端能力与文档元数据:Server Components 继续完善,同时内置了资源预加载(preload / preinit)和文档元数据提升,能把 title、meta、link 这些标签直接写在组件树里,框架负责把它们提升到正确位置。
这三条主线并不是彼此独立的。表单 actions 往往配合 Server Actions 使用,编译器优化的是客户端组件的渲染路径,文档元数据则直接关系 SSR 和 SEO 场景。所以我更建议你把 React 19 当成一整套思路来看,而不是单独学某一个 API。
2. 表单 Actions 与状态管理:从“受控组件”到“数据流指令”
2.1 用 form action 替代受控组件:最直观的变化
如果你写过几年 React,一定对“受控组件”这个词不陌生。把每个 input 的 value 都挂在 state 上,再为每个输入框写 onChange,提交前还要把数据整合一遍。React 19 的 form action 从另一个维度解决了这个问题:它允许你把<form>当成真正的数据入口,提交时通过 FormData 拿到所有字段值,直接传给一个异步函数。
最简单的客户端写法是这样的:
async function handleSubmit(formData) { const title = formData.get('title'); const content = formData.get('content'); await savePost({ title, content }); } function PostForm() { return ( <form action={handleSubmit}> <input name="title" /> <textarea name="content" /> <button type="submit">保存</button> </form> ); }不用再写十几个 useState,表单数据直接从浏览器原生 FormData 读取。这个改动看着很小,但对表单密集型业务来说,代码量的缩减非常明显。
如果表单里需要本地交互状态,比如某个开关切换某个区域显隐,依然可以用 useState。不需要把“全部输入值”都变成受控状态,这是我很喜欢的一点,逻辑上整个表单重新变成了“读数据”和“写数据”两个阶段,而不是无休止的同步 state。
2.2 useActionState 处理提交状态:加载、错误、成功一气呵成
以前做表单提交状态,常见做法是维护 isLoading、isError、isSuccess 三个布尔值,提交前后手动翻转。useActionState 把这个流程收拢成一个状态机。
const [state, formAction, isPending] = useActionState( async (prevState, formData) => { const res = await savePost(formData); if (res.error) return { status: 'error', message: res.error }; return { status: 'success', message: '已保存' }; }, { status: 'idle', message: '' } ); function PostForm() { return ( <form action={formAction}> {/* 其他字段 */} <button disabled={isPending}>{isPending ? '提交中...' : '提交'}</button> {state.status === 'error' && <p style={{ color: 'red' }}>{state.message}</p>} {state.status === 'success' && <p>{state.message}</p>} </form> ); }第三个返回值isPending替代了原来手写的 isLoading。更关键的是,useActionState的第一个参数是一个接收prevState的函数,所以你可以把“上一次的提交结果”作为基础,做更精细的 UI 反馈,比如保留上次填错字段的提示。
这里要提醒一点:useActionState的 action 在提交完成后,表单会自动被重置。这是 React 19 的默认行为,如果你在编辑场景下不希望表单被清空,需要额外处理,我在升级时就在这块踩过坑。
2.3 useOptimistic 与 useFormStatus:乐观更新不再需要额外库
乐观更新是很多 C 端产品非常在意的体验,比如点赞、评论、发送消息。以前用第三方库做乐观更新,除了学习成本,还要考虑回滚逻辑,稍不注意就会产生缓存穿透。React 19 的useOptimistic把这种模式内置到了框架里。
const [optimisticList, addOptimisticItem] = useOptimistic(list, (current, newItem) => [ ...current, newItem, ]); async function handleAdd(formData) { const title = formData.get('title'); addOptimisticItem({ id: 'temp', title }); await api.create(title); }用户点击发送后,界面立刻出现临时数据,异步请求完成后再用服务端数据替换。如果请求失败,你可以基于上一次状态回滚。
useFormStatus则用来解决父组件和子组件之间的 pending 状态传递。表单内某个子组件如果想要读取“这个表单是否正在提交”,不必层层传递 props,直接调用useFormStatus()就能拿到pending值。这个 hook 在把表单拆成多个子组件时特别有用,省掉了大量属性透传。
3. React Compiler 自动记忆化:手写 memo 的时代结束
3.1 编译器在解决什么问题:我为什么讨厌手动 memo
说到 React 性能优化,最让人头疼的就是 memo 的粒度问题。组件写得太细,到处是 memo 和 useCallback,最后发现组件之间 props 引用一直在变,memo 根本没起作用;组件写得太粗,一个列表项更新导致整屏重渲染,又得回头找瓶颈。
React Compiler 的核心思路,是在“UI 是纯函数”这个大前提下,自动判断组件的输入是否变化,如果没变就跳过重新渲染。你不需要手动告诉它“这里依赖哪些值”,它通过静态分析你的代码,生成自动缓存。
我在模拟项目里试了一下,启用编译器后,热重载的开发体验也没有明显变差。按照我的观察,在列表渲染密集、状态更新频繁的页面,渲染耗时确实有可以感知的改善。不过我也要泼一盆冷水:收益的上限取决于你的业务代码是否“规则友好”,并不是无脑开启就能翻倍。
3.2 编译器规则是关键:尽量保持纯函数与不可变
React Compiler 能自动优化的底层依据,是你在组件里“没有破坏 React 的约束”。核心约束其实还是那两条:不能在渲染阶段做副作用,不要对 props 和 state 进行可变修改。
所谓副作用,并不是说不能用 useEffect,而是指组件的渲染函数本身应该是纯的:同样输入,同样的输出。如果你在渲染过程中直接修改了某个外部变量,编译器在分析时就很难推断缓存是否安全,甚至可能跳过对该组件的优化。
具体操作上,有几个非常现实的要求:
- props 和 state 不要直接变更,要用展开或不可变操作。
- 不要再依赖那种“每次渲染重新生成一个外部函数”的模式,编译器会自己处理函数引用。
- 注意闭包陷阱:不要在渲染过程中修改 ref 之外的共享对象。
- 第三方库如果内部不是纯函数,编译器没法优化它内部的逻辑,但你自己的调用层仍然可以优化。
官方推荐配合 eslint-plugin-react-compiler 来检查代码是否满足规则。我建议把这个规则接入 CI,它能在代码合并前就发现“这个写法编译器不优化”,避免上线后才发现某块性能没达到预期。
3.3 编译器的边界:哪些场景别抱太大期望
实际用下来,有几个场景编译器并不能带来明显收益。
第一是依赖非常复杂的全局可变状态的组件,比如使用了大量外部单例或者运行时配置对象。这种情况下,编译器无法追踪所有外部依赖,只能保守处理。
第二是 class 组件。React Compiler 目前对函数组件为主,class 组件基本不在自动优化的范围里。旧项目如果还有不少 class 组件,需要先把它们迁移成函数组件,或者接受这部分缺失优化。
第三是已经被你手动 memo 得很好的代码。编译器接手后,手动 memo 其实可以逐步删掉,让代码回到更自然的状态。这个过程在大型项目里比较费劲,建议先从新功能开始,再逐步清理旧代码。
我在实际改造中采取的策略是:先让编译器只作用于个别高流量页面,跑一段时间性能回归和线上监控,确认稳定后再扩大到全站。另外要提醒的是,React Compiler 是 babel-plugin 还是其他插件方式,不同构建环境下配置方式会不一样,升级前先在测试分支验证你的构建链路。
4. Server Components 与 Server Actions:前后端边界重新划分
4.1 Server Components:让数据请求更靠近数据库
如果你只是做纯客户端应用,Server Components 并不是必需品。但只要你有服务端渲染、有数据接口、有数据库查询,就值得认真看一下这条线。
以前我们获取数据,常见路径是:前端组件挂载 → useEffect 发请求 → 拿到数据 → setState → 渲染。Server Components 改变了这个顺序:页面可以直接在服务端查询数据,然后把数据序列化发给客户端。也就是说,组件可以做成“默认服务端组件”,只有需要交互时才用'use client'标记为客户端组件。
这个模式带来的直接好处是:首屏可以减少一次 round-trip 的请求等待,敏感数据不会下发到浏览器,服务端数据请求也可以做更细粒度的缓存。我改造过一个内容社区项目,原本首屏需要先请求一个聚合接口,再根据结果渲染导航和正文。切到 Server Components 后,正文数据直接由服务端查询插入 HTML,这个首屏的“请求→渲染”链条明显变短。
4.2 Server Actions:服务端逻辑与数据重新验证的缝合点
Server Actions 是 React 19 中另一个和表单强绑定的能力。它允许你定义一个“运行在服务端”的函数,客户端可以直接像调用普通函数一样调用它。配合<form action>使用,就能实现“提交表单 → 在服务端更新数据 → 自动刷新页面数据”的完整闭环。
在实践中我看到最常见的用法,是搭配数据缓存库(比如各种数据请求库)的 revalidate 功能。通常的写法是:在 Server Action 里执行数据库写入,然后调用数据重新验证 API,让相关页面的缓存失效并重新渲染。整个过程对前端调用方是透明的,你只感知到“调了个函数,页面数据自动更新了”。
Server Actions 在客户端也不是只能从表单触发,你可以在事件处理器里直接 import 并调用,只是要注意它不能用于客户端渲染中的普通业务请求。本质还是“以 RPC 模式执行服务端逻辑”,不是替代所有数据请求方案。
4.3 生产实践注意点:权限校验、依赖协议与运行环境
把逻辑放到服务端,最大的安全感提升来自“关键操作不在客户端可见”,但权限校验依然不能省。Server Actions 本质是一个个可调用的远程接口,前端约束不了真正的调用方,你必须像写传统后端接口一样做身份校验、参数校验、幂等处理。
另一个问题是运行环境差异。Server Components 跑在服务端 Node 环境,可能无法使用浏览器专用 API。你在组件里用window.localStorage或者document相关逻辑时,要区分是服务端组件还是客户端组件。我的建议是:默认把涉及浏览器 API 的部分放进客户端组件,Server Components 里只保留纯数据读取和渲染。
依赖协议也是个容易被忽略的问题。团队协作时,服务端组件、客户端组件、Server Actions 往往分布在不同的文件里,如果边界不清晰,很容易出现“明明标了 'use client',却 import 了只适用于服务端的函数”之类的错误。建议在项目文档里明确组件边界的约定,并且用 lint 规则配合检查。
5. 资源预加载与文档元数据:容易被忽视的体验细节
5.1 preload 与 preinit:关键资源加载时机前置
React 19 更新了react-dom的预加载 API,你可以在应用早期预取字体、图片、脚本等资源。常见用法是这样:
import { preload, preinit } from 'react-dom'; // 预加载字体,但不立即执行 preload('/fonts/body.woff2', { as: 'font', type: 'font/woff2' }); // 预初始化一段脚本,优先级更高 preinit('/analytics.js', { as: 'script' });这两个 API 可以放在组件顶层调用,React 会把资源请求提前到浏览器空闲时或关键渲染前。和传统写在 HTML 里的<link rel="preload">相比,它的好处在于可以由组件逻辑按需触发,比如某个路由可能用到某张大图,你可以在路由加载阶段预取图片,而不是在 HTML 里预加载所有页面都可能不用的资源。
这里要特别注意:preload和preinit不应该放在useEffect里面调用。因为在 effect 里调用已经太晚了,资源加载会阻塞在渲染之后。正确的位置是组件函数体的顶层、模块作用域,或者路由切换的加载函数里。
5.2 文档元数据内置:title 不再是组件边界问题
React 19 直接把<title>、<meta>、<link>等标签变成了 React 组件的一部分。你可以在任意层级写:
function ArticlePage() { return ( <> <title>我的文章标题</title> <meta name="description" content="这是一篇讲 React 19 的文章" /> <ArticleContent /> </> ); }框架会在渲染后把这些标签自动移动到<head>里,并且处理多个组件同时设置 title 时后者覆盖前者的逻辑。以前我们做 SEO 或者动态管理页面标题,往往要引入专门库,或者在路由层手动拼接字符串。现在这些逻辑内建了,少了一层依赖。
需要注意的是,元数据内置功能在纯 client side 渲染下依然可用,动态切换时 title 和 meta 也会跟着更新。对于 SSR 场景,这些标签会直接写入服务端输出的 HTML 里,对 SEO 而言是实打实有利的改进。
5.3 这些细节对性能和质量指标的实际影响
从业务价值来看,资源预加载直接影响的是用户体验指标,比如 LCP(最大内容渲染时间)。我之前的项目里,首屏有一个品牌字体和一张 Hero 图,以前字体是在 CSS 加载后才被发现,Hero 图是组件渲染后才请求,延迟明显。改成preload提前预取后,LCP 在本地压测和线上监控里都有肉眼可查的改善。
元数据内置对 SEO 影响更直接,但不一定立竿见影,需要配合搜索平台的抓取观察。比较重要的是:如果你的项目之前依赖专门库来管理元数据,升级时需要把库的逻辑和 React 19 内置逻辑区分开,避免重复设置同名的 meta 导致冲突。
6. 从 React 18 升级到 19:我亲测的迁移经验
6.1 升级前准备:依赖审计和弃用清单
老项目升级最怕的就是“看起来能用,跑起来报错”。所以我的建议是,不要直接改 package.json 收工。先做三件事:
- 阅读官方升级文档,把废弃 API 清单拉出来,对照代码库搜索。
- 检查当前使用的第三方库,是否已经兼容 React 19。这一步重点关注 UI 组件库和状态管理类库。
- 本地建一个 Git 分支,用 codemod 脚本批量识别代码中废弃的用法。
对于 React 版本跨度比较大的项目,比如还在 16 或者 17,我建议先升级到 18 稳定,再考虑 19。一次跨太多版本,编译错误和运行错误的归因会非常困难。
6.2 升级中遇到的兼容性坑:测试用例是最好的先导
我这次升级遇到的几个主要问题,集中在三块:
第一是 ref 相关的 API 变化。React 19 里函数组件可以直接把 ref 作为普通 prop 接收,以前需要用 forwardRef 的写法虽然仍然兼容,但新代码没有必要继续用。
第二是StrictMode的双执行行为。严格模式在生产环境没有影响,但开发环境里 useEffect 会执行两次,如果你的代码里存在副作用不幂等的情况,升级后更容易暴露问题。这部分不是 React 19 的新增行为,但在升级过程中配合测试更容易看清问题。
第三是旧的ReactDOM.render不能用了,必须迁移到createRoot。如果你还在维护一个老项目,这个改动几乎是必须的。
这里我特别建议项目里有足够的基础交互测试。我这次能把问题快速收敛,依赖的是一套覆盖登录、列表、表单提交、切换路由冒烟用例的端到端测试。没有测试的话,光靠人工点页面,很多边界问题很难暴露。
6.3 升级后收益评估:用性能面板检验是否值得
升级完成后,我做过一轮量化和体验评估。量化主要是性能面板的数据:组件渲染次数、渲染耗时、首屏请求数。体验评估则是让核心使用者在测试环境走一遍完整业务路径,感受表单提交反馈和页面切换是否更跟手。
结论是:对模拟项目 X 这种以表单和列表为核心的项目,React 19 配合 Compiler 带来的收益是比较明显的,尤其是搜索列表频繁切换筛选条件时的响应速度。这次升级也让我把项目里的旧写法统一了一遍,从技术债清理的角度看,也是值得的。
如果你所在的项目业务逻辑非常复杂,旧代码里坑很多,也不用急于一次切完。可以先引入 React 19 的新特性到新页面,比如新表单用 form action,新列表页试用编译器,逐步验证,能让团队更有掌控感。
7. 常见问题与排查技巧实录
7.1 启用编译器后,组件数据不更新
有朋友在社区里反馈,React Compiler 开启后,某个组件明明状态变了,界面却不刷新。这个现象我在测试时也遇到过,一个原因是你破坏了“不可变数据”的约定:也许是某个 reducer 里直接改了 state 的某个字段,或者对 props 引用的对象做了原地修改。编译器认为输入没变,所以跳过了重新渲染。
排查手段很简单:先临时把编译器关闭,如果问题消失,基本就是你的代码不满足编译器的不可变假设。再打开编译器的 lint 插件,它会直接标出不安全的位置。解决方式不是绕开编译器,而是改数据更新的方式,确保用新的引用来触发更新。
7.2 表单 action 提交成功后,内容被清空了
React 19 的 form action 在成功后会重置表单。如果你在编辑场景里不希望清空表单,需要显式地恢复初始值。我个人建议的做法是把“当前表单值”和“初始表单值”分开管理:action 成功后,把服务端最新数据重新设为defaultValue或者 key 值,通过重新挂载来重置表单。
如果你确实需要保留用户填写的部分内容,可以把这些值放进一个 ref 或者状态里,在 action 结束后再放回表单。总之要记住,自动重置是 framework 的默认行为,不是 bug,不要试图用 preventDefault 去强行阻止它。
7.3 Suspense 一直转圈或直接报错
Suspense 配合 use() 或者 React Server Components 场景下,偶尔会出现“一直 loading”或者“内部错误”的情况。最常见原因是悬停边界放在了一个同步渲染的组件内部,而异步数据源始终没有 resolve。
排查时先确认异步资源是不是真的会结束,比如 Promise 是否因为异常而被忽略,或者请求参数是否让服务端永远不返回。然后再看 Suspense 边界层级是不是正确,异步组件如果被缓存层拦截,可能并不会触发新的加载状态。
7.4 旧库在 React 19 下警告或行为异常
第三方库兼容性是升级里绕不开的问题。遇到警告后,第一件事不是换库,而是去它的 issues 里搜 React 19 相关的讨论。很多知名库半年内一定会发布适配版本,你只需要确认版本兼容范围。
有一种情况是库内部用了废弃 API,外观上无大碍,但控制台会持续输出 warning。如果库本身维护不活跃,我的建议是评估替代方案,不要长期背着这个 warning 上线,否则未来再升级可能更难收敛。
我个人在实际操作中的重要体感是:任何新框架能力,都不要为了炫技而引入业务。React 19 的 Actions、编译器、Server Components,哪一条对你手上的业务场景有直接价值,就先用哪条。先在小范围验证,再逐步铺开,往往能绕开大多数坑。最后分享一个小技巧:升级到 React 19 后,顺手把项目里那些“看起来严谨但实际没人维护”的手动 memo 和 useCallback 留下一处不删,把它当作基准点,观察编译器在同样场景下的运行时表现,你会比看文档更直观地理解 React Compiler 到底替你做了什么。