ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React 19 + TypeScript 升级实战:从编译报错到类型收敛

React 19 + TypeScript 升级实战:从编译报错到类型收敛 React 19 正式发布之后我做的第一件事就是把手上一个中后台项目从 React 18 升到 React 19。升级本身不算难但 TypeScript 这边的新规则让我在编译错误里泡了整整两天useRef 必须传初始值了、forwardRef 突然显得多余、函数组件返回类型变宽还有一堆 deprecated 类型警告。如果你正准备让 React19 和 Typescript 这对组合跑起来又不想像我一样在编译错误里反复横跳这篇文章应该能帮你把这个过程理清楚。内容以实战为主会覆盖工程配置、组件类型设计、新 hooks 的类型用法以及迁移期最常见的报错无论你是刚要起步的新手还是维护老项目的老手都能找到可以直接抄作业的部分。1. React 19 到底变了什么先搞清楚这次升级的重心1.1 从 18 到 19核心变化速览React 19 这次升级不是换了个版本号而已。以我实际使用的感受来说最大的变化集中在三块Actions 体系、ref 的传递方式、以及对原生表单和异步操作更好的类型友好度。Actions 把 form action、useActionState、useOptimistic、useFormStatus 这些能力和异步状态串在了一起TS 推断在中间明显更顺畅了。ref 从“必须 forwardRef 转发”变成“直接作为 props 声明”这让普通函数组件也能干净地暴露 DOM 引用。还有 use() 这个新 API能在组件里直接读 Context 或 Promise类型上收敛了不少以前用条件判断才能绕开的死角。对于 TypeScript 用户来说最需要先接受的是类型定义的变化节奏。React 自身的 runtime 和 types/react 仍然是分开发布的升级 react 之后如果 types/react 还停在 18编译时大概率会出现大面积的“旧 API 不存在”或行为不一致的报错。我建议先统一把 react、react-dom、types/react、types/react-dom 都升到 19然后在一个干净分支上跑一遍 tsc --noEmit底数先摸清楚。为什么这么建议因为类型报错往往会把真正的隐患放大如果版本错位你会看到一堆和业务无关的噪音。从 18 到 19几个最影响类型写法的变化可以收成一张表变化点React 18 时期常见写法React 19 的新规则函数组件返回类型常用 JSX.Element 或 ReactElement放宽为 ReactNode字符串、数字、数组都合法ref 转发forwardRef 包装ref 作为普通 prop 声明useRef 初始化可以省略初始值必须传初始值React.FC children隐式可用不再自动提供需要显式声明新增异步 hooks无useOptimistic / useActionState / useFormStatus1.2 为什么要重点看 TypeScript 类型定义的变化很多 React 开发者习惯“业务代码写起来不报错就行”对类型定义调整不敏感。但 React 19 这次不一样它有几个类型层面的破坏性提升比如 useRef 的无参重载被删了React.FC 默认 children 被去掉了函数组件返回类型变得更贴近 ReactNode 而不再是 ReactElement。这些都不是运行时行为变化而是编译层面的收紧。升级之后旧写法会直接变成红波浪线逼着你把类型重新想清楚。从工程角度看这是好事。比如 useRef 以前可以用 useRef () 得到 undefined 初始值但类型上同时可能被当成 ref 对象和可变容器导致 null 判断经常失效。React 19 逼着你写 useRef (null)等于把“这个 ref 到底是 DOM 引用还是可变值容器”在声明时就说清楚。我在实际项目里把十几处 useRef 全改掉之后原本一到线上就出现的 null 引用问题排查起来明显变快了。这就是类型定义变化的价值所在。如果你以前重度依赖 React.FC 的隐式 children升级后第一个编译报错大概率就是Property children does not exist。这个变化也有正面意义隐式 children 其实掩盖了很多 props 层面的错误。一个组件到底要不要允许嵌套内容应该由组件自己的类型定义决定而不是靠 FC 顺手塞进来。把 children 显式写出来之后调用方传错或者漏传在 IDE 里立刻就能看到。2. 项目初始化与工程配置搭一个能跑的 React19TS 环境2.1 官方脚手架和手动配置怎么选新项目推荐直接用 Vite 的 react-ts 模板npm create vitelatest react19-demo -- --template react-ts。这一串会生成 Vite React TS 的基础工程react 默认会是 19 的最新版。不要再用 CRACreate React App 已经被官方标记为不推荐用于新项目维护也基本停了。Vite 的好处是启停快、TS 编译交给 esbuild 做转译、tsc 做类型检查配置直观生态也干净。如果是从老项目升级别急着大改配置文件。我通常的路径是先把 package.json 里 react、react-dom 改成^19.0.0types/react、types/react-dom 也改成^19然后重新安装依赖。装完之后跑pnpm exec tsc --noEmit看一波真实的报错再决定改哪些。这里有一个容易忽略的点node_modules 里可能存在嵌套的旧 types/react 副本比如某些依赖自己的同名依赖还是 18。如果报错里的来源不是你的 src 而是 node_modules大多可以用 pnpm 的 overrides 或删除 node_modules 重新安装解决。2.2 tsconfig 关键配置与路径别名下面这份 tsconfig.json 是我基于 Vite react-ts 模板整理出来的适合 React 19 TS 5.x 项目。可以直接复制再根据项目目录微调{ compilerOptions: { target: ES2020, useDefineForClassFields: true, lib: [ES2020, DOM, DOM.Iterable], module: ESNext, skipLibCheck: true, moduleResolution: bundler, allowImportingTsExtensions: true, resolveJsonModule: true, isolatedModules: true, noEmit: true, jsx: react-jsx, strict: true, noUnusedLocals: true, noUnusedParameters: true, noFallthroughCasesInSwitch: true, baseUrl: ., paths: { /*: [./src/*] } }, include: [src] }几个重点说明。jsx必须设成react-jsx这样不再需要每个文件手动import React from react。moduleResolution用bundler是为了让 Vite 的解析规则和 TS 对齐不然 import 后缀、CSS module 等会闹脾气。strict一定要开不开的话 React 19 新类型大概率会反过来给你制造一堆隐忧。allowImportingTsExtensions只在 noEmit 时可用因为我们用 Vite 打包tsc 只做类型检查所以没问题。路径别名 很好用但要记得同步在 vite.config.ts 里配置 resolve.alias不然 Vite 打包时找不到模块。2.3 依赖版本与包管理器的坑这个阶段最容易踩的坑是 react 和 types/react 版本不匹配。如果你看到Cannot find namespace React或者某个 JSX 类型失效先检查这两个包的版本。React 19 的类型发布在types/react19下理论上和 React runtime 是同步的但实际升级时可能因为缓存、overrides 导致版本漂移。用 pnpm 的话可以在 package.json 里加一个pnpm.overrides强制统一版本避免间接依赖引入旧类型pnpm: { overrides: { types/react: ^19.0.0, types/react-dom: ^19.0.0 } }顺带提一句TS 版本建议至少 5.0。React 19 的类型用到了不少新版语法低版本 TS 会出现语法层面不支持的问题。装依赖时如果报 peerDependencies 冲突多半是某个组件库还没适配 React 19可以先用--legacy-peer-deps临时绕过但解决期限别拖太久。我的判断标准是如果第三方库在 React 19 下运行时表现正常只是 types 版本没跟上那还可以等一等如果运行时都出问题就要考虑换替代方案了。3. 核心类型的正确打开方式组件、Props、事件与 ref3.1 用类型标注组件 Props 的正确姿势写组件 Props 时我现在的习惯是不再依赖 React.FC至少不会依赖它的隐式 children。React 19 的类型里 FC 依旧存在但默认不再包含 children 属性所以以前那种写法直接会编译报错。想继续用 FC就显式加 PropsWithChildren但我更推荐直接用普通函数组件的写法type CardProps { title: string description?: string children?: React.ReactNode } export function Card({ title, description, children }: CardProps) { return ( div h2{title}/h2 {description ? p{description}/p : null} {children} /div ) }为什么不推荐靠 FC 自动带 children因为一个组件是否会渲染 children应该由组件自己的 Props 定义决定而不是由类型系统偷偷塞给你一个可选字段。显式声明之后谁调用组件、能不能传 children读代码一眼就知道。另一个理由来自返回类型函数组件现在允许返回 string、number、数组这些 ReactNode而 FC 的历史类型常常把它限制在 JSX.Element 或 ReactElement新版本虽然放宽了却容易让老项目出现类型打架。直接用普通函数加显式返回类型标注反而最省心。3.2 事件处理器的类型推导与校验绝大多数事件处理不需要手动写类型因为 JSX 属性本身已经给出了参数类型。比如 onChange 里的 e 会自动推导为 React.ChangeEvent 你只需要用 e.target.value 就行。但在自定义事件处理函数抽出去的时候一定要明确 e 的类型最常见的坑是把 e.target 和 e.currentTarget 搞混。function handleChange(e: React.ChangeEventHTMLInputElement) { // e.target.value 是输入框当前值 setValue(e.target.value) } input value{value} onChange{handleChange} /如果处理的是表单提交用 React.FormEvent 键盘事件用 React.KeyboardEvent 按钮点击用 React.MouseEvent 。没必要把所有事件类型背下来IDE 会自动带入你只需要关心 target 和 currentTarget 的区别target 可能指向事件实际发生的后代节点而 currentTarget 永远是绑定事件监听的元素。在 React 合成事件系统里异步回调中访问 currentTarget 会取不到值这也是很多人排查 value 丢失时忽略的细节。写类型时尽量用 currentTarget尤其是需要写入 ref 的场景这个习惯能避免不少隐性 bug。3.3 useRef 必须传初始值如何应对React 19 的 types/react 里useRef 的无参重载已经被删除了。以前可以写const timerRef useRefnumber()来声明一个 undefined 初值的 ref现在必须写const timerRef useRefnumber | undefined(undefined)或干脆给它一个真实初始值。这个改动看似强行其实理清了 ref 的两种语义作为 DOM 引用useRefHTMLDivElement(null)类型是 RefObjectHTMLDivElement | null作为可变值容器useRef(count)类型是 MutableRefObject 初始值就是当前值。如果你在迁移时发现Argument for initialValue is required不用慌。第一步看变量到底存什么如果只是 setTimeout 返回的 iduseRefnumber | null(null)就行需要判断时再收窄如果是给子组件传递 DOM refuseRefHTMLInputElement(null)是标配。还有一个进阶技巧想避免 null 判断可以先定义 interface 表示“实例确定存在”的状态再用useRefInstanceType(null!)非空断言初始化。这个写法有争议但在类组件时代很常见我一般不推荐入门者用除非你清楚知道自己为什么敢跳过 null 检查。4. 升级迁移中的类型报错你大概率会撞上的 6 个问题4.1 ReactNode 与 ReactElement 的严格化在旧类型里函数组件的返回类型比较容易被理解成“只能返回 ReactElement”于是你会发现这样写的代码是合法又别扭的function Loading(): JSX.Element { return null }实际上 React 允许组件返回 null、字符串、数字React 19 的类型终于把这一点体现出来了。新类型里函数组件的返回类型被放宽到 React.ReactNodeReactElement 的子集关系让很多旧写法变得不适用。如果你自己标注了返回类型改用 ReactNode 是最平滑的迁移方式。如果只是靠 TS 推导那一般不会报错真正的坑是第三方库或组件库内部类型还停在旧约束上导致某些高阶组件包装后返回类型不兼容。这里没有万能解法只能通过类型断言在边界处处理然后记录到 TODO等依赖升级后再回来看。4.2 React.FC 和 children 不再自动注入升级后最普遍的编译错误之一就是Property children does not exist on type Props。原因就是上面说的新版 FC 不再自动加 children。处理方式很简单把 Props 改成React.PropsWithChildrenCardProps或者直接在类型里显式加children?: React.ReactNode。我建议选显式声明因为 PropsWithChildren 隐藏了 children 的存在对长期维护并不友好。如果你在升级过程中看到FC被标记 deprecated 之类的警告也别立刻全部删掉先把编译跑绿再逐步替换成普通函数组件。React 19 里 FC 仍然可用只是不再替你兜底真正麻烦的是那些代码里到处React.FCProps、但从来不传 children 的组件它们需要确认 props 签名是否要补。4.3 forwardRef 不是必须了但类型要跟着改React 19 最大的体验提升之一就是 ref 可以作为普通 prop 透传。以前我们不得不写const Input forwardRefHTMLInputElement, { label: string }( ({ label }, ref) input ref{ref} aria-label{label} / )现在可以直接type InputProps { label: string ref?: React.RefHTMLInputElement } function Input({ label, ref }: InputProps) { return input ref{ref} aria-label{label} / }注意这里 ref 在函数组件 props 里是保留字段React 19 运行时专门做了处理所以解构出来传给原生元素就行。类型上React.RefHTMLInputElement既可以是 RefObject也可以是 callback ref 或 null方便使用者自由选择。迁移到这种写法后最直观的好处是自定义组件的调用方不必再怀疑 ref 指向的是组件实例还是 DOM 节点因为它和普通 prop 一样可见。唯一的额外工作是如果你的组件库里有大量 forwardRef 封装需要逐个改成新写法并同步更新类型导出。4.4 升级期间高频编译错误速查表报错信息常见原因处理办法Property children does not existReact.FC 不再隐式包含 children显式声明 children 或使用 PropsWithChildrenExpected 1 arguments, but got 0useRef 必须传初始值补上 null / undefined / 业务初值ref is declared but its value is never read旧式组件未用 ref prop改为新式 ref prop 或继续用 forwardRefJSX element type ReactNode is not a constructor function返回类型和实际 JSX 不匹配组件返回类型改为 ReactNode或去掉显式类型Cannot find namespace Reacttypes/react 版本或类型加载问题升级并统一 types/react检查 tsconfig typesModule react has no exported member useActionStatereact 版本未升级到 19升级 react 与 types/react这张表不一定覆盖所有场景但能覆盖升级迁移中大概 80% 的报错。每次遇到新报错先看它是不是来自 node_modules 的类型声明再决定是升级依赖还是改业务代码。很多时候是同一个错误在多个文件里反复出现清理掉根因之后关联报错会自动消失。5. 状态管理与异步数据处理中的类型实践5.1 useReducer 与 discriminated union 的经典配合React 19 并没有改 useReducer 的用法但 TypeScript 在复杂状态机场景下能力更强了。我特别喜欢用 discriminated union 来定义 Action因为它能让 reducer 里的每一个 case 都有精准的类型收窄写错字段时 TS 会第一时间跳出来。type State { status: idle | loading | success | error data: string | null error: string | null } type Action | { type: start } | { type: success; data: string } | { type: error; error: string } function reducer(state: State, action: Action): State { switch (action.type) { case start: return { status: loading, data: null, error: null } case success: return { status: success, data: action.data, error: null } case error: return { status: error, data: null, error: action.error } } }在 useReducer 里dispatch 的签名会从 Action 类型自动推导业务代码里dispatch({ type: success, data: ok })是合法的但dispatch({ type: success, data: 123 })立刻报错。这个模式在 React 19 TS 项目里几乎是标配比 useState 加一堆 setter 更可控。唯一要留意的是reducer 的 default 分支就算穷尽了所有 caseTS 也未必能推导出 never需要在 default 里 return state 或 throw否则函数可能被认为没有完整返回类型。5.2 封装 fetch 数据请求 Hook 时的泛型设计自定义 Hook 最常用到泛型的地方就是请求封装。一个简单的 useRequest 可以写成function useRequestT(fetcher: () PromiseT) { const [data, setData] useStateT | null(null) const [loading, setLoading] useState(false) const [error, setError] useStateunknown(null) const run useCallback(async () { setLoading(true) setError(null) try { const result await fetcher() setData(result) } catch (err) { setError(err) } finally { setLoading(false) } }, [fetcher]) return { data, loading, error, run } }调用时只需要告诉它返回什么类型具体业务字段就能被完整保留interface User { id: number; name: string } const { data, loading } useRequestUser(() fetchUserById(1)) // data?.name 有类型提示不会莫名变成 any这里有几个细节error 字段我刻意用了unknown而不是Error | null因为 catch 到的内容未必是 Error 实例。后续处理时再通过error instanceof Error收窄类型上更安全。还有 fetcher 需要稳定引用否则 useCallback 依赖会一直变触发无限请求如果你在外面定义获取函数记得用 useCallback 包一层。React 19 里 useState 的 dispatch 类型没变所以这种封装照常使用。5.3 useOptimistic / useActionState 的类型推断React 19 给异步 UI 加了两个新 hook类型设计得相当统一。useActionState 用来封装 form action 的状态签名大概是useActionState(action, initialState)TS 会根据 action 的入参和返回类型自动推导 state 和 pending。interface FormState { success: boolean; message: string } async function submitAction(_prevState: FormState, formData: FormData): PromiseFormState { const name formData.get(name) as string return { success: true, message: hello ${name} } } const [state, formAction, pending] useActionState(submitAction, { success: false, message: })注意 action 的第一个参数必须是上一个 state第二个才是 formData返回值必须是完整的 FormState。如果类型对不上TS 会在 hook 调用处直接报错这就是新类型的贴心之处。实际使用时pending 可以用来控制按钮 loadingstate 则用来渲染服务端返回的消息。useOptimistic 的用法则是给现有 state 叠加一个“乐观值”const [optimisticLikes, addOptimisticLike] useOptimisticnumber, void( likes, (current, _action) current 1 )使用的时候调用 addOptimisticLike()UI 立即刷新请求失败后你再用真正状态覆盖。类型上 useOptimistic 会根据传入的 state 类型推断出第一个返回值不需要额外泛型但如果你需要在 action 里传参数第二个泛型可以写成对象类型比如useOptimisticTodo[], Todo让 action 接收一个待办项作为参数。新手上手时容易忘记的是乐观量是临时的真正状态变更后它会被自动覆盖所以不要在 optimisticLikes 基础上再叠加业务逻辑否则会出现数值跳动。6. 从编译到运行tsconfig 审查与工程化建议6.1 严格模式到底要不要开我的答案是直接开strict: true。React 19 的类型本身就比 18 严格如果再把 strict 关掉等于自己给自己埋雷。strict 打开后null/undefined 的判断会被强制检查很多运行时才会爆的问题提前暴露在编译期。可能你会觉得“报错太多”但这恰恰说明老代码里有太多隐式依赖。如果项目代码量很大可以分两步走先开 strict 但不开启noUncheckedIndexedAccess把基础 null 检查补上等数组和对象访问的报错清理得差不多再补上exactOptionalPropertyTypes或noUncheckedIndexedAccess。这两个选项属于进阶配置exactOptionalPropertyTypes会要求可选属性只有显式传入 undefined 时才允许值为 undefinednoUncheckedIndexedAccess会让所有下标访问返回T | undefined。后者对读数组写法的代码能憋出不少严格性但也会让 map、find 之后的类型判断变得啰嗦需要团队统一认知后再开。6.2 用好 satisfies 与 const 断言TS 5 引入的 satisfies 操作符非常适合 React 19 项目里的配置场景。比如定义路由表时既想校验每一项都符合 Route 类型又想保留 as const 带来的字面量类型type Route { path: string; element: React.ReactNode; children?: Route[] } const routes [ { path: /, element: Home / }, { path: /about, element: About / }, { path: /users, element: Users /, children: [{ path: :id, element: UserDetail / }] }, ] as const satisfies Route[]这里as const让路径字符串变成精确的字面量类型satisfies Route[]则校验结构两者的结合让路由跳转时的类型提示非常舒服。同样思路可以用在 Tab 配置、图标映射、权限表里。React 19 项目里组件库、API 类型都相对完整satisfies 能让你在“校验”和“推断”之间拿到平衡不会出现为了类型安全被迫写一堆宽类型定义的局面。6.3 类型测试与 dts 验证应用项目不需要过于复杂的类型测试但至少要让tsc --noEmit成为 CI 的一环。我一般会在 package.json 里加一个typecheck脚本然后在 CI 里先跑类型检查再跑单测避免把类型错误带到发布阶段。如果你是开发公共组件库还需要对导出的类型做保障可以用 vitest 的 expectTypeOf 配合 type-test 文件或者简单的export type断言方式。对于纯应用项目更重要的是把 .d.ts 文件的“脏类型”清理干净比如别到处用any、别在第三方类型不全时用ts-ignore糊过去。看到eslint-disable typescript-eslint/no-explicit-any的频率升高就该考虑把公共边界类型定义清楚了。6.4 推荐的工具链配置ESLint 我用typescript-eslint的 recommended 配置再叠加eslint-plugin-react-hooks。React 19 新增了几个 hooks旧版本插件可能不认识需要升级到支持 React 19 的版本否则会出现“unknown hook”误报。Prettier 负责格式统一简单不解释。如果你想在提交前拦截编译错误可以用 husky lint-staged 跑tsc --noEmit和 eslint实测下来能拦截掉不少低级问题。不过千万别把全量 tsc 放到每次保存都执行否则大型项目会等得人崩溃CI 才是它的主场。7. 实战心得迁移和日常开发的几个建议7.1 我的 React 19 组件类型约定经过一段时间的实战我给自己定了三条约定分享给你参考。第一所有业务组件 Props 用 type 而不是 interface这样在条件类型推断和交叉类型组合时没有坑。除非你要做 declaration merging 或者继承否则 interface 的优势在这里并不明显。第二children 显式写children?: React.ReactNode不依赖 FC 或 PropsWithChildren。第三对外暴露的 DOM 引用类型用React.RefObjectT | null或者React.RefT不要写死成MutableRefObjectT否则调用方换成 callback ref 时类型对不上。这三条约定让我在重构页面时几乎不用改类型。你也完全可以按团队习惯微调但核心是让类型表达意图而不是让类型仅仅为了消灭报错。7.2 值得尝试但不要急着用的特性React 19 新特性很多我建议按顺序引入。先上 Form Actions useFormStatus因为这类功能对表单场景帮助很大而且类型友好门槛低。再用 useOptimistic 处理点赞、收藏类交互注意它需要 Suspense/startTransition 配合实际落地前最好给后端接口补一个稳定的失败回退。use() 读 Context 或 Promise 的写法我很喜欢但它和 Suspense 绑定较深项目如果没有可靠的错误边界暂时可以缓一缓。Server Components 和相关能力不要在这个阶段硬上它牵扯到部署和框架生态等官方工具链再稳一点再说。7.3 升级后的一小段收尾技巧最后分享一个我自己很受用的小技巧升级完成后不要急着删掉所有 deprecated 类型。先把tsc --noEmit跑绿再开一个strict加强分支给团队一周时间逐步清理。因为 React 19 的类型变化是面向未来的直接把所有旧写法一次性推翻会让同事在 diff 里看不清业务改名和类型改动的边界。我在项目里就是用这种“先跑通、再收紧”的节奏最后类型检查从上千个报错降到 0整个过程没有回滚过一次。这一套 React19 TypeScript 的配合值得你花点心思去适应。
返回列表