从 propTypes 到 TypeScript:React 19 之后的组件契约
propTypes曾经是 React 里唯一的组件契约。React 19 把它移除之后,契约这件事从「运行时警告」彻底搬到了「编译期检查」,顺带还多出一条新的边界要管:服务端组件和客户端组件之间能传什么。
一、先把废弃的东西说清楚
React 19 移除了两样东西,很多老项目升级时会直接踩到:
| 被移除 | 影响范围 | 替代方案 |
|---|---|---|
propTypes | 全部组件,运行时不再做任何校验 | TypeScript 类型 |
defaultProps | 仅函数组件(class 组件仍然支持) | ES6 默认参数 |
defaultProps 只对函数组件失效这点值得强调:class 组件没有 ES6 默认参数这种等价写法,所以官方保留了它。也就是说升级后你会遇到一种割裂状态——同一个代码库里 class 组件的 defaultProps 照常工作,函数组件的静默失效。静默失效比报错更难查。
写法对照:
// 旧写法:React 19 中 defaultProps 被忽略,propTypes 完全不生效
function Badge({ label, tone }) {
return <span className={`badge badge--${tone}`}>{label}</span>;
}
Badge.propTypes = { label: PropTypes.string.isRequired, tone: PropTypes.string };
Badge.defaultProps = { tone: 'neutral' };// 新写法:契约由类型系统承担,默认值由参数默认值承担
type BadgeProps = {
label: string;
tone?: 'neutral' | 'success' | 'danger';
};
function Badge({ label, tone = 'neutral' }: BadgeProps) {
return <span className={`badge badge--${tone}`}>{label}</span>;
}顺手还赚了一笔:tone 从 string 变成了三个字面量的联合类型。propTypes 只能用 PropTypes.oneOf(['neutral', ...]) 在运行时报警,类型系统能在你敲错的那一刻就报错。
升级前先扫一遍
# 找出所有还在用这两个 API 的地方
grep -rn "propTypes\|defaultProps" src/ --include="*.jsx" --include="*.tsx" --include="*.js"
# 官方 codemod 可以批量处理一部分
npx codemod@latest react/19/migration-recipe要注意第三方库也可能内部使用了 defaultProps(图表库尤其常见),升级时如果控制台刷出一堆来自 node_modules 的告警,那是等库作者更新,不是你的代码问题。
二、契约不只是「字段是什么类型」
从 propTypes 迁到 TypeScript 之后,最容易停在「把类型抄一遍」这一步。真正的收益在于表达 propTypes 表达不了的约束。
2.1 互斥的 props:判别联合
一个按钮要么是链接(有 href)、要么是按钮(有 onClick),不该两个都传:
type BaseProps = {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
};
type LinkButton = BaseProps & {
as: 'a';
href: string;
onClick?: never; // 明确禁止
};
type ActionButton = BaseProps & {
as?: 'button';
onClick: () => void;
href?: never;
};
type ButtonProps = LinkButton | ActionButton;
function Button(props: ButtonProps) {
if (props.as === 'a') {
// 这个分支里 props.href 一定存在,TS 能收窄
return <a href={props.href} className={`btn btn--${props.size ?? 'md'}`}>{props.children}</a>;
}
return (
<button type="button" onClick={props.onClick} className={`btn btn--${props.size ?? 'md'}`}>
{props.children}
</button>
);
}
// <Button as="a" href="/docs">文档</Button> ✅
// <Button onClick={save}>保存</Button> ✅
// <Button as="a" href="/docs" onClick={save} /> ❌ 编译期就拦住这类约束以前只能写在注释里靠人自觉,现在能被工具保证。
2.2 继承原生元素属性
很多组件是对原生元素的包装,别手写一遍 className、aria-*、onFocus:
type InputProps = React.ComponentPropsWithoutRef<'input'> & {
label: string;
error?: string;
};
function TextField({ label, error, id, ...rest }: InputProps) {
const inputId = id ?? React.useId();
const errorId = `${inputId}-error`;
return (
<div className="field">
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
{...rest}
/>
{error && <p id={errorId} role="alert" className="field__error">{error}</p>}
</div>
);
}这里顺带把无障碍做对了:label 与 input 通过 htmlFor/id 关联,错误信息用 aria-describedby 挂上并带 role="alert",屏幕阅读器才能读到。React.useId() 保证服务端渲染和客户端 hydration 的 id 一致。
2.3 ref 不再需要 forwardRef
React 19 起,函数组件可以直接把 ref 当普通 prop 接收:
// React 18 的写法
const Input18 = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// React 19:ref 就是一个普通 prop
function Input19({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}forwardRef 还能用,但新代码没必要再套一层。
三、新增的一条边界:Server Components
在使用 RSC 的框架里(Next.js App Router 等),组件契约多了一个维度:这个 prop 能不能跨越服务端到客户端的边界。
默认所有组件都是服务端组件,加了 'use client' 才是客户端组件。
跨边界传递的 props 必须是可序列化的:
// app/orders/page.tsx —— 服务端组件,没有 'use client'
import { OrderFilter } from './order-filter';
export default async function OrdersPage() {
const orders = await db.order.findMany({ take: 50 }); // 直接查库,代码不会进浏览器
return (
<>
{/* ✅ 纯数据可以传 */}
<OrderFilter options={['全部', '待付款', '已发货']} />
{/* ❌ 函数不能传:Functions cannot be passed directly to Client Components */}
{/* <OrderFilter onChange={(v) => console.log(v)} /> */}
<ul>{orders.map(o => <li key={o.id}>{o.no}</li>)}</ul>
</>
);
}// app/orders/order-filter.tsx
'use client';
import { useState } from 'react';
export function OrderFilter({ options }: { options: string[] }) {
const [active, setActive] = useState(options[0]);
return (
<div role="tablist">
{options.map(o => (
<button key={o} role="tab" aria-selected={o === active} onClick={() => setActive(o)}>
{o}
</button>
))}
</div>
);
}能跨边界的:基本类型、数组、纯对象、Date、Map/Set、Promise、JSX 元素,以及用 'use server' 标记的 Server Action。 不能跨边界的:普通函数、class 实例、未经 Symbol.for 全局注册的 Symbol、闭包捕获了不可序列化内容的对象。
需要「服务端逻辑 + 客户端触发」时,用 Server Action 而不是传函数:
// app/orders/actions.ts
'use server';
export async function cancelOrder(orderId: string) {
await db.order.update({ where: { id: orderId }, data: { status: 'CANCELLED' } });
}'use client';
import { useActionState } from 'react';
import { cancelOrder } from './actions';
export function CancelButton({ orderId }: { orderId: string }) {
const [state, submit, pending] = useActionState(
async () => { await cancelOrder(orderId); return 'done'; },
null
);
return (
<button onClick={() => submit()} disabled={pending} aria-busy={pending}>
{pending ? '取消中…' : '取消订单'}
</button>
);
}从契约角度看,Server Action 是一个「可以安全跨边界传递的函数引用」——React 在底层把它变成了一次带类型的 RPC 调用。
四、迁移检查清单
- [ ]
grep出全部propTypes/defaultProps,函数组件的defaultProps改成参数默认值 - [ ]
tsconfig.json打开strict,否则大部分收益拿不到 - [ ] 把
string、object、any这类宽泛类型收窄成字面量联合或具体接口 - [ ] 互斥 props 用判别联合表达,而不是写在注释里
- [ ] 包装原生元素的组件改用
ComponentPropsWithoutRef - [ ] 新组件不再用
forwardRef - [ ] 用 RSC 的项目:审计跨
'use client'边界的 props 是否都可序列化 - [ ] 第三方库的
defaultProps告警——确认是库的问题,不要在自己代码里绕
小结
propTypes 的退场不是简单换个校验工具,而是契约的执行时机从运行时前移到了编译期。这带来两个实际变化:错误在写代码时就暴露,以及能表达比「字段类型」复杂得多的约束。
如果项目在用 RSC,还要额外记住一条:序列化边界也是契约的一部分,而这条约束目前只能靠框架在运行时报错来发现,类型系统还覆盖不到。
参考资料