🌱 引言:为何要从 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
如果你创建的项目名里面有大写,那么就会出现下面这种情况,让你输入项目名和包名
这是因为npm 包名推荐使用小写字母,避免因操作系统大小写敏感导致的问题。create-vite 或其他脚手架工具会自动将输入的包名转换为小写,即使你输入的是 dodoList,最终包名也会变成 dodolist。当然你如果输入的都是小写字母那么就会有下面完整的创建过程。
然后你会发现,项目中的文件扩展名变成了 .tsx(支持 JSX 的 TypeScript 文件)。
2. 现有 JS 项目如何迁移到 TS?
如果你有一个现有的 React + JS 项目,可以这样做:
-
安装依赖:
npm install --save-dev typescript @types/react @types/react-dom @types/node -
添加
tsconfig.json配置文件(可由tsc --init生成并修改)。 -
将
.js和.jsx文件重命名为.ts或.tsx。 -
逐步为变量、函数、组件添加类型注解。
📌 建议: 采用渐进式迁移,先让项目能编译通过,再逐步完善类型。
🧱 第二步: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 能自动推断类型。- 当初始值为
null或undefined时,必须使用联合类型(如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。useThemeHook 中进行存在性检查,确保安全使用。- 在
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函数参数的类型一致性。
🧪 第五步:最佳实践与常见陷阱
✅ 最佳实践
- 优先使用
interface定义对象结构。 - 为
Props和State明确定义类型。 - 避免使用
any,尽可能使用更具体的类型。 - 利用
Readonly和Partial等工具类型。 - 为自定义 Hook 提供清晰的返回类型。
- 使用
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[]
❌ 常见陷阱
- 过度使用
any: 这会完全关闭类型检查,违背了使用 TS 的初衷。 - 忽略
undefined和null: TS 的严格模式下,必须处理这些情况。 - 滥用类型断言
as: 应尽可能通过类型守卫或更精确的类型定义来避免。 React.FC的children陷阱: 如前所述,可能导致意外行为。- 事件处理函数类型错误: 记住使用
React.ChangeEvent,React.MouseEvent等。
🏁 结语:拥抱类型,提升代码质量
从 JavaScript 过渡到 TypeScript 可能需要一些学习成本,但其带来的类型安全、代码可读性提升、开发效率提高是巨大的。尤其是在 React 项目中,TS 能有效管理组件的 Props、State、事件处理和异步逻辑,让大型应用的维护变得更加轻松。
💬 记住: TypeScript 不是银弹,但它是一把强大的工具。合理使用它,让你的代码更加健壮、可维护!
希望这篇全面的技术博客能帮助你顺利踏上 React + TypeScript 的旅程!🚀
📚 参考资料
喜欢这篇文章吗? ❤️
欢迎点赞、收藏、分享!
有任何问题或建议,欢迎在评论区留言讨论! 👇