🚀 从 JavaScript 到 TypeScript:深入探索 React 中的类型革命 🧪

104 阅读6分钟

🌱 引言:为何要从 JS 迁移到 TS?

在现代前端开发中,React 已经成为构建用户界面的首选框架之一。而随着项目规模的增长,维护一个大型的 JavaScript 项目变得越来越困难。变量类型不明、函数参数不清晰、接口定义模糊等问题接踵而至,导致“运行时错误”频发,调试成本飙升。

这时,TypeScript(TS) 应运而生!它为 JavaScript 添加了静态类型系统,让我们能在编码阶段就发现潜在错误,提升代码可读性、可维护性和开发效率。

💡 TypeScript = JavaScript + 类型系统 + 编译时检查

今天,我们将一起踏上从 React + JavaScript 迁移到 React + TypeScript 的旅程,并深入剖析 TS 在 React 中的常用写法,让你真正掌握“类型的力量”!🔥


🛠️ 第一步:环境搭建与迁移准备

1. 创建一个 TypeScript React 项目

使用 create-react-app 快速创建一个 TS 项目:

npx create-react-app my-ts-react-app --template typescript
cd my-ts-react-app
npm start

上面那种是基于Webpack进行创建的,当然还可以选择下面这种方式基于Vite创建

// 创建项目
npm init vite
// 然后输入项目名(最好是全小写)
// 选择React
// 选择TypeScript

// 进入项目
cd 项目名
// 安装依赖,用pnpm更快
pnpm i

// 启动项目
npm run dev

如果你创建的项目名里面有大写,那么就会出现下面这种情况,让你输入项目名和包名

image.png

这是因为npm 包名推荐使用小写字母,避免因操作系统大小写敏感导致的问题。create-vite 或其他脚手架工具会自动将输入的包名转换为小写,即使你输入的是 dodoList,最终包名也会变成 dodolist。当然你如果输入的都是小写字母那么就会有下面完整的创建过程。

image.png

然后你会发现,项目中的文件扩展名变成了 .tsx(支持 JSX 的 TypeScript 文件)。

2. 现有 JS 项目如何迁移到 TS?

如果你有一个现有的 React + JS 项目,可以这样做:

  1. 安装依赖:

    npm install --save-dev typescript @types/react @types/react-dom @types/node
    
  2. 添加 tsconfig.json 配置文件(可由 tsc --init 生成并修改)。

  3. .js.jsx 文件重命名为 .ts.tsx

  4. 逐步为变量、函数、组件添加类型注解。

📌 建议: 采用渐进式迁移,先让项目能编译通过,再逐步完善类型。


🧱 第二步:TypeScript 基础回顾(React 场景下)

在深入 React 之前,先复习几个 TS 的核心概念:

✅ 1. 基本类型

let str: string = "Hello";
let num: number = 42;
let bool: boolean = true;
let nul: null = null;
let undef: undefined = undefined;
let sym: symbol = Symbol("key");
let big: bigint = 100n;

✅ 2. 数组与元组

// 数组
let arr1: number[] = [1, 2, 3];
let mixed: (string | number)[] = ["a", 1]; // 联合类型数组
let arr2: Array<string> = ["a", "b"]; // 泛型语法

// 元组(固定长度和类型)
let tuple: [string, number] = ["Alice", 25];
tuple[0].toUpperCase(); // 类型安全访问

✅ 3. 对象类型

// 接口申明
interface User {
  id: number;
  name: string;
  email?: string;  // 可选属性
  readonly createdAt: Date; // 只读属性
}

// 添加接口进行类型检测
const user: User = {
  id: 1,
  name: "Alice",
  createdAt: new Date()
};

✅ 4. 接口(Interface) vs 类型别名(Type)

// 使用 interface 定义对象结构(推荐用于对象)
interface User {
  id: number;
  name: string;
  email?: string; // 可选属性
  readonly registerDate: Date; // 只读属性
}

// 使用 type 定义联合类型、元组等
type ID = string | number;
type Point = [number, number];

🤔 何时用 interface? 当你需要定义对象的形状,且可能被扩展(extends)时。 🤔 何时用 type? 当你需要联合类型、映射类型或更复杂的类型操作时。

✅ 5. 函数类型

// 函数声明
function add(a: number, b: number): number {
  return a + b;
}

// 箭头函数
const greet = (name: string): string => `Hello, ${name}!`;

// 可选参数和默认值
function log(message: string, prefix?: string = "LOG"): void {
  console.log(`[${prefix}] ${message}`);
}

🧩 第三步:React 组件中的 TypeScript 实战

这才是重头戏!我们来看在 React 组件中如何使用 TypeScript。

🧩 1. 函数组件(Function Components)与 Props

基础写法

import React from 'react';

// 定义 Props 接口
interface UserCardProps {
  user: {
    name: string;
    avatarUrl: string;
    bio?: string;
  };
  // 是否高亮显示
  isHighlighted?: boolean;
  // 点击事件处理函数
  onClick: (id: number) => void;
}

// 使用泛型 React.FC(FunctionComponent)—— 已不推荐用于新项目
// const UserCard: React.FC<UserCardProps> = ({ user, isHighlighted = false, onClick }) => {
//   return (
//     <div style={{ border: isHighlighted ? '2px solid gold' : '1px solid #ccc', padding: '1rem' }}>
//       <img src={user.avatarUrl} alt={user.name} width="50" />
//       <h3>{user.name}</h3>
//       {user.bio && <p>{user.bio}</p>}
//       <button onClick={() => onClick(1)}>Click Me</button>
//     </div>
//   );
// };

// ✅ 推荐写法:直接定义函数参数类型(更清晰,避免 React.FC 的隐式 children 问题)
const UserCard = ({ user, isHighlighted = false, onClick }: UserCardProps) => {
  return (
    <div style={{ border: isHighlighted ? '2px solid gold' : '1px solid #ccc', padding: '1rem' }}>
      <img src={user.avatarUrl} alt={user.name} width="50" />
      <h3>{user.name}</h3>
      {user.bio && <p>{user.bio}</p>}
      <button onClick={() => onClick(1)}>Click Me</button>
    </div>
  );
};

export default UserCard;

🔍 解析:

  • 我们使用 interface 定义了 UserCardProps,清晰地描述了组件需要的属性。
  • isHighlighted? 表示该属性可选,我们在解构时提供了默认值 false
  • onClick 是一个函数类型,接收一个 number 参数,无返回值(void)。
  • 为什么推荐直接定义参数类型而不是 React.FC 因为 React.FC 会隐式地将 children 作为可选属性包含在 Props 中,这可能导致类型错误或意外行为。直接定义更精确、更可控。👍

🧩 2. 处理事件(Event Handling)

事件处理是 React 中的常见场景,TS 能帮助我们精准获取事件对象的类型。

import React, { useState } from 'react';

interface LoginFormProps {
  onSubmit: (credentials: { username: string; password: string }) => void;
}

const LoginForm = ({ onSubmit }: LoginFormProps) => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  // 🔥 事件处理器的类型推断
  const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUsername(e.target.value);
  };

  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  // 表单提交事件
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit({ username, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Username:</label>
        <input 
          type="text" 
          value={username} 
          onChange={handleUsernameChange} 
        />
      </div>
      <div>
        <label>Password:</label>
        <input 
          type="password" 
          value={password} 
          onChange={handlePasswordChange} 
        />
      </div>
      <button type="submit">Login</button>
    </form>
  );
};

export default LoginForm;

🔍 解析:

  • React.ChangeEvent<HTMLInputElement>:这是输入框 onChange 事件的标准类型。ChangeEvent 是一个泛型,HTMLInputElement 指定了目标元素类型。
  • React.FormEvent<HTMLFormElement>:表单提交事件,指定了目标是 form 元素。
  • 使用 e.preventDefault() 阻止默认提交行为。
  • e.target.value 能被 TS 正确推断为字符串。

🚨 常见错误: 如果写成 (e) => setUsername(e.target.value),TS 会报错,因为 e 的类型是 any。必须显式声明事件类型!


🧩 3. 使用 useState 的类型推断

TS 通常能根据初始值推断 useState 的类型,但有时需要手动指定。

import React, { useState } from 'react';

const Counter = () => {
  // ✅ 类型推断:count 的类型是 number
  const [count, setCount] = useState(0);

  // ❓ 当初始值为 null 或 undefined 时,需要手动指定类型
  const [user, setUser] = useState<User | null>(null); // 初始为 null

  // ✅ 或者使用泛型明确指定
  const [items, setItems] = useState<string[]>([]);

  // ✅ 处理复杂对象状态
  const [formState, setFormState] = useState({
    name: '',
    email: '',
    age: 0,
  });

  // 如果后续需要修改 formState 的某个字段,可以这样:
  const updateName = (name: string) => {
    setFormState(prev => ({ ...prev, name }));
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>

      {user && <p>Welcome, {user.name}!</p>}
    </div>
  );
};

🔍 解析:

  • 当初始值明确时(如 0, [], {}),TS 能自动推断类型。
  • 当初始值为 nullundefined 时,必须使用联合类型(如 User | null)或泛型(useState<User | null>(null))来明确可能的类型。
  • 对于对象状态,使用 ...prev 展开语法确保其他字段不变。

🧩 4. 使用 useRef 的类型

useRef 用于访问 DOM 元素或存储可变值。

import React, { useRef, useEffect } from 'react';

const TextInputWithFocusButton = () => {
  // 🔥 指向 DOM 元素
  const inputEl = useRef<HTMLInputElement>(null);

  const onButtonClick = () => {
    // ✅ TS 知道 inputEl.current 是 HTMLInputElement | null
    if (inputEl.current) {
      inputEl.current.focus();
    }
  };

  // 🔥 存储可变值(不会触发重新渲染)
  const intervalRef = useRef<number | null>(null);

  useEffect(() => {
    intervalRef.current = window.setInterval(() => {
      console.log('Tick');
    }, 1000);

    return () => {
      if (intervalRef.current) {
        window.clearInterval(intervalRef.current);
      }
    };
  }, []);

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
};

🔍 解析:

  • useRef<HTMLInputElement>(null):泛型参数指定了 current 属性的类型。
  • 访问 current 时,TS 知道它可能是 null,因此需要进行空值检查。
  • intervalRef 用于存储 setInterval 的返回值(一个数字 ID)。

🧩 5. 自定义 Hook 的类型

自定义 Hook 也应该有清晰的类型定义。

// useLocalStorage.ts
import { useState, useEffect } from 'react';

// 定义 Hook 的返回类型
type UseLocalStorage<T> = [T, (value: T | ((val: T) => T)) => void];

function useLocalStorage<T>(key: string, initialValue: T): UseLocalStorage<T> {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;
// 使用自定义 Hook
import useLocalStorage from './useLocalStorage';

const ThemeToggle = () => {
  // ✅ 泛型 T 被推断为 'light' | 'dark'
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
};

🔍 解析:

  • useLocalStorage 是一个泛型函数,T 代表存储值的类型。
  • 返回类型 UseLocalStorage<T> 是一个元组类型,包含当前值和设置函数。
  • ThemeToggle 中,我们指定了泛型为 'light' | 'dark'(字符串字面量联合类型),实现了类型安全的主题切换。

🧩 6. 处理 Context API

使用 TS 为 React.createContext 提供类型。

// ThemeContext.ts
import { createContext, useContext } from 'react';

// 定义 Context 的值类型
type Theme = 'light' | 'dark';
interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

// 创建 Context,提供默认值和类型
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// 自定义 Hook 使用 Context
const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};

export { ThemeContext, useTheme, type Theme };
// ThemeProvider.tsx
import React, { useState, useCallback } from 'react';
import { ThemeContext, ThemeContextType } from './ThemeContext';

const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = useCallback(() => {
    setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
  }, []);

  const value: ThemeContextType = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};

export default ThemeProvider;
// 使用 Context 的组件
import { useTheme } from './ThemeContext';

const Header = () => {
  const { theme, toggleTheme } = useTheme(); // ✅ 类型自动推断!

  return (
    <header style={{ background: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#333' }}>
      <h1>My App</h1>
      <button onClick={toggleTheme}>Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode</button>
    </header>
  );
};

🔍 解析:

  • createContext<ThemeContextType | undefined>(undefined):明确指定了 Context 的值类型,并允许初始为 undefined
  • useTheme Hook 中进行存在性检查,确保安全使用。
  • ThemeProvider 中,value 对象的类型与 ThemeContextType 一致。

🧩 7. 处理异步操作(useEffect + fetch

import React, { useState, useEffect } from 'react';

interface Post {
  id: number;
  title: string;
  body: string;
}

const PostList = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        
        // ✅ 类型断言(确保数据结构正确)
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        
        const data: Post[] = await response.json(); // 🔥 显式类型断言
        setPosts(data);
      } catch (err) {
        // ✅ err 的类型是 unknown,需要类型守卫
        if (err instanceof Error) {
          setError(err.message);
        } else {
          setError('An unknown error occurred');
        }
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
};

🔍 解析:

  • useState<Post[]>([]):明确状态类型为 Post 数组。
  • await response.json() 的返回类型是 any,我们使用类型断言 as Post[] 或直接赋值给 : Post[] 变量来确保类型安全。
  • catch (err) 中,err 的类型是 unknown,必须使用类型守卫(instanceof Error)来安全地访问 err.message

🧠 第四步:TypeScript 高级技巧在 React 中的应用

🧠 1. 映射类型(Mapped Types)

用于从现有类型派生新类型。

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// 将所有属性变为可选
type PartialTodo = Partial<Todo>;

// 将所有属性变为只读
type ReadonlyTodo = Readonly<Todo>;

// 仅选取某些属性
type TodoPreview = Pick<Todo, 'id' | 'title'>;

// 排除某些属性
type TodoInfo = Omit<Todo, 'completed'>;

// 自定义映射类型
type Stringify<T> = {
  [K in keyof T]: string; // 将所有属性的值类型变为 string
};

type StringifiedTodo = Stringify<Todo>; 
// 等价于 { id: string; title: string; completed: string; }

🧠 2. 条件类型(Conditional Types)

根据条件选择类型。

// 如果 T 是 string 类型,则返回 number,否则返回 boolean
type ToNumberOrBoolean<T> = T extends string ? number : boolean;

type Result1 = ToNumberOrBoolean<string>;  // number
type Result2 = ToNumberOrBoolean<number>;  // boolean

// Extract 和 Exclude
type OnlyStrings = Extract<'a' | 'b' | 'c' | 1 | 2, string>; // 'a' | 'b' | 'c'
type NoStrings = Exclude<'a' | 'b' | 'c' | 1 | 2, string>;   // 1 | 2

🧠 3. 泛型组件(Generic Components)

创建可重用的、类型安全的组件。

// 一个通用的列表组件
function List<T>({ items, renderItem }: { 
  items: T[]; 
  renderItem: (item: T) => React.ReactNode 
}) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 使用
const numbers = [1, 2, 3];
const users: User[] = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];

<List 
  items={numbers} 
  renderItem={(num) => <span>Number: {num}</span>} 
/>

<List 
  items={users} 
  renderItem={(user) => <div>{user.name}</div>} 
/>

🔍 解析: List 组件通过泛型 T 保证了 items 数组和 renderItem 函数参数的类型一致性。


🧪 第五步:最佳实践与常见陷阱

✅ 最佳实践

  1. 优先使用 interface 定义对象结构。
  2. PropsState 明确定义类型。
  3. 避免使用 any,尽可能使用更具体的类型。
  4. 利用 ReadonlyPartial 等工具类型。
  5. 为自定义 Hook 提供清晰的返回类型。
  6. 使用 satisfies 操作符(TS 4.9+)验证值的结构而不改变其推断类型。
// 使用 satisfies
const palette = {
  red: [255, 0, 0],
  green: [0, 255, 0],
  blue: [0, 0, 255],
} satisfies Record<string, [number, number, number]>;

// red 的类型仍然是 [number, number, number],而不是 number[]

❌ 常见陷阱

  1. 过度使用 any 这会完全关闭类型检查,违背了使用 TS 的初衷。
  2. 忽略 undefinednull TS 的严格模式下,必须处理这些情况。
  3. 滥用类型断言 as 应尽可能通过类型守卫或更精确的类型定义来避免。
  4. React.FCchildren 陷阱: 如前所述,可能导致意外行为。
  5. 事件处理函数类型错误: 记住使用 React.ChangeEvent, React.MouseEvent 等。

🏁 结语:拥抱类型,提升代码质量

从 JavaScript 过渡到 TypeScript 可能需要一些学习成本,但其带来的类型安全、代码可读性提升、开发效率提高是巨大的。尤其是在 React 项目中,TS 能有效管理组件的 PropsState、事件处理和异步逻辑,让大型应用的维护变得更加轻松。

💬 记住: TypeScript 不是银弹,但它是一把强大的工具。合理使用它,让你的代码更加健壮、可维护!

希望这篇全面的技术博客能帮助你顺利踏上 React + TypeScript 的旅程!🚀


📚 参考资料


喜欢这篇文章吗? ❤️
欢迎点赞、收藏、分享!
有任何问题或建议,欢迎在评论区留言讨论! 👇