React + TypeScript 深度实战:泛型组件、Hook 类型、API 数据契约、设计模式与严格配置

React 与 TypeScript 结合的生产级实践:组件 Props 泛型模式、Hooks 类型推断、Context 类型安全、API 数据契约(Zod)、常见错误与严格配置(strictMode、noImplicitAny、exactOptionalPropertyTypes)。附模式速查表和组件库类型设计。

TypeScript 已成为 React 项目的标配。本文不是基础教程,而是覆盖生产环境中最常用的类型模式、设计规范和常见陷阱的深度指南。


一、基础但关键的类型模式

1.1 Props 类型设计原则

// ✅ 使用 interface(可扩展,merge 友好)
interface ButtonProps {
  children: React.ReactNode;        // 通用子元素
  variant?: 'primary' | 'secondary' | 'ghost';  // 字面量联合(有限选项)
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  className?: string;               // 允许外部 className 覆盖
}

// ✅ HTML 原生属性继承(避免重复声明)
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

function Input({ label, error, className, ...rest }: InputProps) {
  return (
    <div className={className}>
      <label>{label}</label>
      <input {...rest} aria-invalid={!!error} />
      {error && <span role="alert">{error}</span>}
    </div>
  );
}

Props 命名规范

  • Props 后缀:ButtonPropsCardProps
  • 不要内联类型:function Button(props: { label: string })
  • 不要把组件名放进接口:interface Button { label: string } ❌(会和 DOM Button 冲突)

1.2 组件返回类型

// ✅ 推荐:不声明返回类型,让 TypeScript 推断
function Button({ label }: ButtonProps) {
  return <button>{label}</button>;
}

// ✅ 如果需要显式声明(如 forwardRef、HOC)
const Button: React.FC<ButtonProps> = ({ label }) => {
  return <button>{label}</button>;
};

// ✅ forwardRef 类型
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...rest }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...rest} />
      </div>
    );
  }
);
Input.displayName = 'Input';

React.FC 争议:社区推荐不使用 React.FC(不自动包含 children、无法正确支持泛型组件)。上面的示例仅为演示。推荐直接声明函数,infer 返回类型。


二、泛型组件:组件库的核心能力

2.1 List/Table 通用组件

interface ListProps<T> {
  items: T[];
  keyExtractor: (item: T) => string | number;
  renderItem: (item: T, index: number) => React.ReactNode;
  emptyComponent?: React.ReactNode;
  className?: string;
}

function List<T>({ items, keyExtractor, renderItem, emptyComponent, className }: ListProps<T>) {
  if (items.length === 0) {
    return <>{emptyComponent ?? null}</>;
  }

  return (
    <ul className={className}>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// 使用:类型自动推断
interface User {
  id: string;
  name: string;
}

<List
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>}
  emptyComponent={<p>No users found</p>}
/>

2.2 Select/Dropdown 泛型组件

interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  labelKey: keyof T;        // { label: string, value: string } 中取哪个字段当 label
  valueKey: keyof T;        // 取哪个字段当 value
  placeholder?: string;
}

function Select<T extends Record<string, unknown>>({
  options,
  value,
  onChange,
  labelKey,
  valueKey,
  placeholder,
}: SelectProps<T>) {
  return (
    <select
      value={value ? String(value[valueKey]) : ''}
      onChange={(e) => {
        const selected = options.find((o) => String(o[valueKey]) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {placeholder && <option value="">{placeholder}</option>}
      {options.map((option) => (
        <option key={String(option[valueKey])} value={String(option[valueKey])}>
          {String(option[labelKey])}
        </option>
      ))}
    </select>
  );
}

2.3 as 属性:多态组件(Polymorphic)

// ✅ Button 组件可以渲染为 <button>、<a>、<Link> 等不同标签
type PolymorphicProps<E extends React.ElementType> = {
  as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, 'as'>;

function Button<C extends React.ElementType = 'button'>({
  as,
  children,
  className,
  ...rest
}: PolymorphicProps<C>) {
  const Component = as || 'button';
  return (
    <Component className={cn('btn', className)} {...rest}>
      {children}
    </Component>
  );
}

// 使用
<Button>Default Button</Button>                          // <button>
<Button as="a" href="/login">Link Button</Button>       // <a>
<Button as={Link} to="/home">React Router</Button>      // <Link>

多态组件(Polymorphic)是 Radix UI、Headless UI 等组件库的核心模式,允许同一组件适配不同的底层 HTML 标签。


三、Hooks 的类型化

3.1 常用 Hook 类型速查

// useState:泛型显式声明
const [user, setUser] = useState<User | null>(null);
const [count, setCount] = useState<number>(0);           // 可省略,自动推断为 number

// useRef:DOM 引用必须初始化 null
const inputRef = useRef<HTMLInputElement>(null);
// 若不加 null,HTMLElement 类型不匹配

// useMemo:返回值自动推断
const sortedUsers = useMemo(() =>
  [...users].sort((a, b) => a.name.localeCompare(b.name)),
  [users]
);

// useCallback:函数签名类型
const handleClick = useCallback<(id: string) => void>((id) => {
  navigate(`/user/${id}`);
}, [navigate]);

// useReducer:状态与 Action 类型
interface State {
  loading: boolean;
  data: Post[] | null;
  error: Error | null;
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: Post[] }
  | { type: 'FETCH_ERROR'; payload: Error };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'FETCH_START': return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS': return { ...state, loading: false, data: action.payload };
    case 'FETCH_ERROR': return { ...state, loading: false, error: action.payload };
    default: return state;
  }
};

3.2 自定义 Hook 的类型设计

// ✅ 自定义 Hook 返回对象时,使用接口而不是元组
interface UseFetchResult<T> {
  data: T | undefined;
  isLoading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T>();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const refetch = useCallback(async () => {
    setIsLoading(true);
    try {
      const res = await fetch(url);
      const json = await res.json();
      setData(json);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    } finally {
      setIsLoading(false);
    }
  }, [url]);

  useEffect(() => { refetch(); }, [refetch]);

  return { data, isLoading, error, refetch };
}

// 使用
const { data: user, isLoading } = useFetch<User>('/api/user/123');

3.3 Context 类型安全

// ✅ 创建 Context 时提供完备的类型
interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

// ✅ 自定义 hook 处理 null check,避免每个组件都判断
export function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (context === null) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

// 组件中使用
function Profile() {
  const { user, logout } = useAuth();  // user 类型自动推断为 User | null
  // 不需要再写 if (!user) return null;
}

四、API 数据契约:Zod 运行时验证

TypeScript 的编译时类型检查在运行时无效——API 返回的数据结构可能与定义不符,导致运行时错误。

4.1 Zod Schema 定义与推断

import { z } from 'zod';

// 定义 Schema
export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
});

// 类型推断(编译时)
export type User = z.infer<typeof UserSchema>;

// 运行时验证
export function parseUser(data: unknown): User {
  return UserSchema.parse(data);  // 验证失败时抛出 ZodError
}

// 安全解析(不抛出,返回结果对象)
export function safeParseUser(data: unknown): { success: true; data: User } | { success: false; error: z.ZodError } {
  const result = UserSchema.safeParse(data);
  if (result.success) {
    return { success: true, data: result.data };
  }
  return { success: false, error: result.error };
}

4.2 API 层统一封装

// utils/api.ts
import { z } from 'zod';

interface ApiResponse<T> {
  data: T;
  status: number;
}

async function apiFetch<T>(
  url: string,
  schema: z.ZodSchema<T>,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  const res = await fetch(url, options);
  if (!res.ok) {
    throw new ApiError(res.status, await res.text());
  }

  const json = await res.json();
  const parsed = schema.safeParse(json);

  if (!parsed.success) {
    console.error('API response validation failed:', parsed.error.format());
    throw new ApiError(500, 'Invalid response format');
  }

  return { data: parsed.data, status: res.status };
}

// 使用
const { data: user } = await apiFetch('/api/user/123', UserSchema);
// data 类型自动推断为 User

4.3 API 路由 Zod 验证(Next.js App Router)

// app/api/users/route.ts
import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().optional(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();

  const result = CreateUserSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  // result.data 类型为 { name: string; email: string; age?: number }
  const user = await db.user.create({ data: result.data });
  return NextResponse.json(user, { status: 201 });
}

五、严格模式配置:tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/types/*": ["./src/types/*"]
    }
  },
  "include": ["src", "tests"],
  "exclude": ["node_modules", "dist"]
}

关键严格选项说明

选项作用推荐值
strict: true开启所有严格类型检查✅ 必须
noUnusedLocals未使用的局部变量报错✅ 启用
exactOptionalPropertyTypesundefined 不能赋值给可选属性✅ 启用
noUncheckedIndexedAccess索引访问返回 T | undefined✅ 启用
noImplicitReturns函数没有默认返回值时报错✅ 启用

六、常见 TypeScript 错误与修复

6.1 “无法赋值给 ‘RefObject’,因为类型 ‘MutableRefObject<…>’ 不兼容”

// ❌ 错误
const ref = useRef<HTMLInputElement>();
// 类型是 MutableRefObject<HTMLInputElement | undefined>
// 传给需要 RefObject<HTMLInputElement | null> 的组件时不兼容

// ✅ 修正
const ref = useRef<HTMLInputElement>(null);
// 类型是 RefObject<HTMLInputElement | null>

6.2 “对象可能为 ’null’"(严格空检查)

// ❌ 错误:在 !strictNullChecks 下不报错,打开严格模式后暴露
useEffect(() => {
  inputRef.current.focus();  // ❌ Object is possibly 'null'
}, []);

// ✅ 修正
useEffect(() => {
  inputRef.current?.focus();   // 可选链
}, []);

// 或 if 守卫
if (inputRef.current) {
  inputRef.current.focus();
}

6.3 “类型 string | undefined 不能赋值给类型 string”

// ❌ 错误
interface User { name?: string; }
function greet(user: User): string {
  return `Hello, ${user.name.toUpperCase()}`;  // ❌ name 可能 undefined
}

// ✅ 修正
greet(user: User): string {
  return `Hello, ${user.name?.toUpperCase() ?? 'Guest'}`;
}
// 或用非空断言(确信有值时)
return `Hello, ${user.name!.toUpperCase()}`;

6.4 “Event handler 类型不匹配”

// ❌ 错误
function handleClick(event: Event) {  // 错误的类型
  console.log(event.target.value);
}
<button onClick={handleClick} />  // ❌ 类型不兼容

// ✅ 修正
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  console.log((event.target as HTMLButtonElement).textContent);
}
// 或用 React 提供的 Handler 类型
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
  console.log(event.currentTarget.textContent);
};

七、类型速查表

场景类型
组件返回类型React.ReactNode(最通用)/ JSX.Element
Props childrenReact.ReactNode
Ref 引用React.RefObject<T> / React.RefCallback<T>
事件处理器React.MouseEventHandler<T> / React.ChangeEventHandler<T>
样式属性React.CSSProperties
组件类React.ComponentType<Props>
上下文默认值MyContextType | null(配合自定义 hook 检查)
函数组件(forwardRef)React.ForwardRefRenderFunction<E, Props>
HTML 属性继承React.HTMLAttributes<T> / React.InputHTMLAttributes<HTMLInputElement>

常见问题(FAQ)

React.FC 为什么不推荐?

React.FC 在 React 18 之前默认包含 children、不自动推断 defaultProps,且无法正确支持泛型组件。推荐直接声明组件函数,让 TypeScript 推断返回类型。

Props 用 interface 还是 type?

推荐 interface:可自动合并声明(Declaration Merging)、扩展用 extends 更直观。type 更适合联合类型和条件类型。对于组件 Props 优先 interface。

第三方库没有类型定义怎么办?

  1. 搜索 @types/库名(DefinitelyTyped)
  2. 自建类型声明: declare module '未定义库' { export function foo(): string; }
  3. 如果库是纯 JS 且没类型,考虑类型友好的替代品

泛型组件怎么限制类型参数?

// ❌ 太宽泛
function List<T>(items: T[]) {  }

// ✅ 限制为可比较类型
function List<T extends { id: string }>(items: T[]) {
  return items.map(item => <li key={item.id}>{item}</li>);
}

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章