Files
2026-05-24 11:42:38 +08:00

16 KiB
Raw Permalink Blame History

tags, create time
tags create time
React
TypeScript
Frontend
2026-04-29 22:09

TS + React

概述

TypeScript 为 React 提供编译时类型检查和智能提示,将运行时错误提前到编码阶段。本文档从 Props、State、Hook、Ref 等核心场景切入,逐步深入到泛型推导、区分联合类型和 Schema 校验等进阶模式。

[!question] 为什么 React + TS 值得投入?

React 本身是纯 JS 库,但大型项目中「谁在改什么数据」「这个函数接收什么参数」往往成为协作瓶颈。TS 让你在写代码时就能获得精确的类型反馈,而不是等到测试环节才发现 undefined is not a function。

Props 类型定义

基础方式

// ✅ 方式1:interface(推荐,可 extend)
interface ButtonProps {
  label: string;
  onClick?: () => void;
}
const Button = ({ label, onClick }: ButtonProps) => (
  <button onClick={onClick}>{label}</button>
);

// ✅ 方式2:type alias(适合联合类型)
type ButtonProps = { label: string; onClick?: () => void };

// ❌ 避免:解构后不标注类型
function BadComponent({ a, b }) { ... }  // a 和 b 都是 any!

[!tip] interface vs type —— 何时用哪个?

  • 优先 interface:它支持 extends / implements,更适合组件 Props 的层级扩展
  • 选 type:当你需要联合类型 (A | B)、交叉类型 (A & B) 或映射类型 (Record<K,V>) 时

合成事件类型

// ❌ 不要用 HTML 原生的 Event
const handleChange = (e: Event) => {};

// ✅ 使用 React 的合成事件类型
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  e.target.value;  // string | number | string[](取决于 input type)
};

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();  // 阻止表单默认提交
};

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.button);  // 0=左键, 1=中键, 2=右键
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === "Enter") submit();
};

[!warning] 常见误区:syntheticEvent 的池化问题

React 17 之前合成事件会被复用(池化),异步访问时可能已被清空。在 setTimeout 或 Promise 回调中,请先读取到局部变量:

const value = e.target.value;  // 先取值
setTimeout(() => console.log(value), 100);

Children 类型

// 通用 children —— 接受任意合法 React 节点
function Card({ children }: { children: React.ReactNode }) {}

// 严格类型 children —— 限制允许的子节点类型
interface TabsProps {
  children: React.ReactElement<TabProps>;  // 只能是 Tab 组件
}

// 泛型 children —— 子节点携带的数据类型可参数化
interface ListProps<T> {
  children: React.ReactElement<{ item: T }> [];
}

State 类型推导

useState

interface User { name: string; role: "admin" | "user"; age: number }

// 完整泛型
const [user, setUser] = useState<User>({ name: "", role: "user", age: 0 });

// 可选初始值 —— 必须显式声明联合类型
const [user, setUser] = useState<User | null>(null);
// 使用时需判空
user?.name;           // ✅ 可选链,安全
(user as User).name;  // or: 类型断言
user!?.name;          // non-null assertion(慎用)

[!tip] 利用构造函数推断

当 initialState 是个对象字面量时,可以用类构造函数的模式让 TS 自动推导出 State 类型,减少重复书写:

class UserStore {
  name = "";
  role: "admin" | "user" = "user";
  age = 0;
}
const [user, setUser] = useState(new UserStore());
// 注意:这里 user 类型就是 new UserStore() 的实例类型

useReducer

interface State { items: Item[]; filter: string }
type Action =
  | { type: "SET_FILTER"; payload: string }
  | { type: "ADD_ITEM";   payload: Item };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "SET_FILTER": return { ...state, filter: action.payload };
    case "ADD_ITEM":   return { ...state, items: [...state.items, action.payload] };
  }
}
const [state, dispatch] = useReducer(reducer, initialState);

Ref 类型定义

useRef

// 元素 ref —— 最常用
const inputRef = useRef<HTMLInputElement>(null);

// mutable value ref(不触发 re-render)
const timerIdRef = useRef<ReturnType<typeof setInterval>>(undefined);
timerIdRef.current = setInterval(() => {}, 1000);

// class component style ref object(用于挂载子组件引用)
const childRef = useRef<ComponentInstance>(null);
// 需要 useImperativeHandle 暴露方法给父级

[!example] 为什么 ref.current 改变不会触发渲染?

React 的更新机制只响应 setState 调用。ref 的设计初衷就是绕过 React 的响应式系统——比如在 useEffect 中保存上一次 props 的值、存储定时器 ID、或者调用子组件的原生 DOM API。如果你发现修改 ref 后需要 UI 同步更新,说明你可能应该用 state。

forwardRef + useImperativeHandle

const FancyInput = forwardRef<HTMLInputElement, { label: string }>(
  function FancyInput(props, ref) {
    const innerRef = useRef<HTMLInputElement>(null);

    // 只暴露 focus 和 blur 给父组件,隐藏其他原生方法
    useImperativeHandle(ref, () => ({
      focus: () => innerRef.current?.focus(),
      blur: () => innerRef.current?.blur(),
    }));

    return <input ref={innerRef} label={props.label} />;
  }
);

Hook 类型推导

自定义 Hook 返回值

interface UseCountReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
}

function useCount(initial = 0): UseCountReturn {
  const [count, setCount] = useState(initial);
  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
  };
}

[!tip] 让返回值类型自动推导

如果不想手动编写返回类型接口,可以借助 TypeScript 4.7+ 的 as const 技巧或使用 ReturnType 工具类型:

function useMouse() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => { /* ... */ }, []);
  return pos;  // TS 会自动推导出 { readonly x: number; readonly y: number }
}

泛型 Hook —— 最强大的类型推导场景

function useAsync<T, Args extends any[]>(
  asyncFn: (...args: Args) => Promise<T>,
  deps: DependencyList
): { data: T | null; loading: boolean; error: Error | null; invoke: (...args: Args) => void } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const invoke = useCallback(async (...args: Args) => {
    setLoading(true);
    setError(null);
    try {
      const result = await asyncFn(...args);
      setData(result);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  }, [asyncFn, ...deps]);

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

  return { data, loading, error, invoke };
}

// T 自动推导!无需手动指定
const { data: users }     = useAsync(fetchUsers, []);       // data: User[] | null
const { data: user }      = useAsync(fetchUserById, [id]);  // data: User | null
const { data: config }    = useAsync(fetchConfig, [env]);   // data: Config | null

[!question] 为什么 Args extends any[]?

这让我们可以同时参数化「异步函数的返回值类型」和「异步函数的入参」。例如 fetchUserById(id: string) 的 T = User,Args = [string],类型信息从内到外全自动推导,不需要在调用处再次声明。

discriminated Union(区分联合类型)

interface SuccessAction { type: "success"; data: User[] }
interface ErrorAction   { type: "error";   error: string }
interface LoadingAction { type: "loading" }

type Action = SuccessAction | ErrorAction | LoadingAction;

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "success":
      return { ...state, data: action.data, status: "loaded" };
      // ✅ TS 自动推断 action.data 是 User[]
    case "error":
      return { ...state, error: action.error };
      // ✅ TS 自动推断 action.error 是 string
    case "loading":
      return { ...state, status: "loading" };
  }
}

[!important] exhaustive check 防漏分支

添加一个 default 分支并用 never 类型确保所有联合成员都被覆盖:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "success": return { ...state, status: "loaded" };
    case "error":   return { ...state, status: "failed" };
    case "loading": return { ...state, status: "pending" };
    default:
      const _exhaustiveCheck: never = action;
      throw new Error(`Unhandled action type: ${_exhaustiveCheck}`);
  }
}

一旦新增了一个 Action 类型但没有在 switch 中处理,TS 编译器会立刻报错。

graph LR
    A["Union Type"] -->|"switch on type field"| B["Type Narrowing"]
    B --> C["discriminated union — 最优解"]
    B --> D["typeof check"]
    B --> E["in operator"]
    B --> F["instanceof check"]

    style C fill:#61DAFB,color:#000
    style A fill:#fff,color:#000

Context 类型定义

interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

// 创建 context 时传入默认值(可为 null,配合非空断言使用)
const ThemeContext = createContext<ThemeContextType | null>(null);

// 封装类型安全的消费 Hook —— 比直接 useContext 更安全
function useTheme(): ThemeContextType {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
  return ctx;
}

// Provider 类型 —— 将 value 的类型与 Context 绑定
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === "light" ? "dark" : "light") }}>
      {children}
    </ThemeContext.Provider>
  );
}

[!tip] 多 Context 时的组合策略

Context 数量增多后,推荐使用 多个小型 Context 而非一个巨型 Context:每个 Context 负责一小块职责(认证、主题、语言),这样消费者只会在自己关注的 context 变化时重渲染。也可以用一个 Context 包裹另一个,形成嵌套结构。

泛型组件与 Polymorphic Components

泛型列表组件

interface SelectableTableProps<T> {
  data: T[];
  renderRow: (item: T, selected: boolean) => React.ReactNode;
  selectedIds: Set<string>;
  onSelect: (id: string) => void;
  getId: (item: T) => string;
}

function SelectableTable<T>({
  data, renderRow, selectedIds, onSelect, getId,
}: SelectableTableProps<T>) {
  return (
    <table>
      <tbody>
        {data.map(item => {
          const id = getId(item);
          const selected = selectedIds.has(id);
          return <tr key={id} onClick={() => onSelect(id)}>
            <td>{renderRow(item, selected)}</td>
          </tr>;
        })}
      </tbody>
    </table>
  );
}

// 使用:T 自动推导为 User
<SelectableTable
  data={users}
  getId={(u: User) => u.id}
  renderRow={(u, sel) => <span style={{ background: sel ? "#eee" : "" }}>{u.name}</span>}
  selectedIds={selected}
  onSelect={setId}
/>

HTML 标签聚合组件(Polymorphic)

// 基于 JSX.IntrinsicElements 实现 polymorphic component
type PolymorphicComponent<P, D extends keyof JSX.IntrinsicElements> = {
  <As extends keyof JSX.IntrinsicElements = D>(
    props: P & { as?: As } & JSX.IntrinsicElements[As]
  ): ReactNode;
};

// Button 可以是 button/div/a 等任何 HTML 元素
const Button: PolymorphicComponent<
  { variant?: "primary" | "secondary" },
  "button"
> = ({ variant = "primary", as: Tag = "button", ...props }) => (
  <Tag className={`btn btn-${variant}`} {...props} />
);

[!warning] Polymorphic Component 的类型难点

上述写法是简化版。生产环境通常借助第三方库如 @radix-ui/react-slot 来处理复杂的泛型推导,因为要让 as="a" 时同时合并 <a> 的属性(href, target 等)而不产生冲突,泛型约束较为复杂。

Form 校验与 Zod Schema

现代 React + TS 项目中,推荐使用 Zod 等 Schema 库将校验逻辑和类型推导合二为一:

import { z } from "zod";

// 1. 定义 Schema —— 类型从 schema 自动推导
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
type LoginFormValue = z.infer<typeof LoginSchema>;

// 2. React Hook Form + ZodResolver 无缝对接
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormValue>({
  resolver: zodResolver(LoginSchema),
});

// 3. 模板中使用 —— errors 和 register 都有完整类型提示
<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("email")} placeholder="Email" />
  {/* errors.email?.message 有类型提示 */}
  {errors.email && <span>{errors.email.message}</span>}

  <input {...register("password")} type="password" />
  <button type="submit">登录</button>
</form>

[!tip] 为什么选择 Zod 而非 Yup?

  • Zod 用 TypeScript 原生语法定义,无需额外 import 类型(Yup 需要 yupToZooooootTypes 等桥接方案)
  • Zod 零依赖、性能更优,且支持 .parse() 运行时校验与 .infer() 类型推导一体化
  • Zod 的错误消息更可定制化

常见坑点与避坑指南

[!failure] 坑1:any 的隐式传播

Array<any>、Record<string, any> 会让整个链条失去类型保护。替代方案:

  • 用 unknown 替代顶层 any(必须先类型守卫才能使用)
  • 用泛型 <T> 传递具体的数据结构
// ❌ 危险
const data: Record<string, any> = {};
data.foo.bar.baz;  // 编译通过,运行崩溃

// ✅ 安全
const data: Record<string, unknown> = {};
if (typeof data.foo === "object" && data.foo !== null) {
  data.foo.bar;  // 需要额外的守卫
}

[!failure] 坑2:事件处理器中的类型收窄失效

箭头函数无法正确收窄:

// ❌ TS 无法推断 e 具体是哪个事件
<button onClick={(e) => handleClick(e)}>Click</button>

// ✅ 用命名函数保持类型信息
<button onClick={(e) => handleClick(e)}>Click</button>
// 但最好直接在属性上写,不包箭头
<button onClick={handleClick}>Click</button>

[!failure] 坑3:useState 初始值的类型陷阱

// ❌ 类型变成 never[] —— TS 推断了最窄的空数组类型
const [items, setItems] = useState([]);
items.push("hello");  // error: Property 'push' does not exist on type 'never[]'

// ✅ 显式声明泛型
const [items, setItems] = useState<string[]>([]);

// ✅ 或者用 non-empty array 初始化
const [items, setItems] = useState(["default"]);

[!failure] 坑4:React.FC 的副作用

React.FC 在早期被广泛推荐,但现在社区趋于弃用原因如下:

  • 它会隐式包含 children prop(即使你的组件不需要)
  • 不支持泛型
  • 使得 ReturnType 无法正确推导
  • 使 HOC 的 WrappedComponent 类型丢失
// ❌ 不推荐
const MyComponent: React.FC<MyProps> = ({ children }) => <div>{children}</div>;

// ✅ 推荐
function MyComponent({ children }: MyProps) {
  return <div>{children}</div>;
}

关联笔记

  • hhs/REACT/README.md
  • hhs/REACT/1. 基础篇/03-组件与 Props.md
  • hhs/REACT/1. 基础篇/04-State 与不可变性.md
  • hhs/REACT/3. 生态工具篇/08-路由管理.md
  • hhs/REACT/3. 生态工具篇/09-状态管理.md