Skip to content

从 propTypes 到 TypeScript:React 19 之后的组件契约 ​

propTypes 曾经是 React 里唯一的组件契约。React 19 把它移除之后,契约这件事从「运行时警告」彻底搬到了「编译期检查」,顺带还多出一条新的边界要管:服务端组件和客户端组件之间能传什么。

一、先把废弃的东西说清楚 ​

React 19 移除了两样东西,很多老项目升级时会直接踩到:

被移除影响范围替代方案
propTypes全部组件,运行时不再做任何校验TypeScript 类型
defaultProps仅函数组件(class 组件仍然支持)ES6 默认参数

defaultProps 只对函数组件失效这点值得强调:class 组件没有 ES6 默认参数这种等价写法,所以官方保留了它。也就是说升级后你会遇到一种割裂状态——同一个代码库里 class 组件的 defaultProps 照常工作,函数组件的静默失效。静默失效比报错更难查。

写法对照:

jsx
// 旧写法: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' };
tsx
// 新写法:契约由类型系统承担,默认值由参数默认值承担
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', ...]) 在运行时报警,类型系统能在你敲错的那一刻就报错。

升级前先扫一遍 ​

bash
# 找出所有还在用这两个 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),不该两个都传:

tsx
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:

tsx
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 接收:

tsx
// 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' 才是客户端组件。

服务端组件默认,无指令可以直接查库代码不进浏览器'use client' 边界可以传基本类型、数组、纯对象Date、Map、Set、Promise、JSX不能传普通函数、class 实例未经 Symbol.for 注册的 Symbol客户端组件useState、事件处理需要调用服务端逻辑时使用 'use server' 标记的Server Function
图 1 · 跨过 'use client' 这条边界的 props 必须能被序列化;需要「服务端逻辑、客户端触发」时,传 Server Function 的引用而不是普通函数

跨边界传递的 props 必须是可序列化的:

tsx
// 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>
    </>
  );
}
tsx
// 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 而不是传函数:

tsx
// app/orders/actions.ts
'use server';

export async function cancelOrder(orderId: string) {
  await db.order.update({ where: { id: orderId }, data: { status: 'CANCELLED' } });
}
tsx
'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,还要额外记住一条:序列化边界也是契约的一部分,而这条约束目前只能靠框架在运行时报错来发现,类型系统还覆盖不到。


参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权