前言
曾经有一份真挚的 JavaScript 代码摆在我面前,我没有珍惜。直到 Uncaught TypeError: Cannot read property of undefined 这种红色报错占满屏幕,我才后悔莫及。如果上天能够给我一个再来一次的机会,我会对那个变量说三个字:“定类型!”。
如果非要在这份类型上加一个期限,我希望是——TypeScript。
今天我们不聊高大上的架构,只聊怎么让你写代码时“心里有底”。我们要从最基础的弱类型痛点讲起,一路杀到 TypeScript 的核心——泛型,最后用 React 撸一个 实战小组件 TodoList。
系好安全带,我们要发车了!
第一章:JS 的温柔陷阱与 TS 的铁血秩序
1.1 弱类型的“二义性”之痛
JavaScript 是一个非常“随和”的语言,随和到什么程度?它允许你胡作非为。
看看这段代码
function add(a, b) {
// js 是弱类型的优势:好学,易上手
// 也就是这种“随和”,让你在大型项目中痛不欲生
return a + b; // 二义性:是加法?还是字符串拼接?
}
const res = add("1", "2"); // 结果是 "12",而不是 3
console.log(res);
在你写下 add 的那一刻,你心里想的是数学加法。但 JS 运行时心想:“嘿,大哥给我俩字符串,那我给你拼起来呗。”
这就是 动态语言 的特点:Bug 只有在运行的时候才会发生。在大型项目中,这就像在排雷,你永远不知道哪一行代码会在用户点击按钮时爆炸。要保证 99.999% 不出问题,靠人脑去记 a 是数字还是字符串,简直是天方夜谭。
1.2 TypeScript:给JS穿上外骨骼
TypeScript 是什么?官方说它是 JS 的超集。
在集合论中,如果集合 A 包含了集合 B 的所有元素,并且集合 A 还有 B 没有的东西,那么 A 就是 B 的超集。通俗点说,它是 JS 的亲爹,专门负责管教这个熊孩子。
TS 是 强类型、静态语言。它在代码编译阶段(运行前)就对其进行检查。
// 强类型可以杜绝 90% 的低级错误
// 其中,前两个:number规定的是参数的数据类型,最后一个:number规定的是函数返回值的数据类型
function addTs(a: number, b: number): number {
return a + b;
}
// const result = addTs("1", "2"); // 报错!编译都不让你过!
const result = addTs(1, 2);
console.log(result);
这就是 TS 的核心价值:把错误扼杀在摇篮里。它不仅是类型约束,更是你免费的“结对编程伙伴”,时刻提醒你:“兄弟,这里不能传字符串。”
1. 安装编译器 (TSC)
打开终端,运行:
npm install -g typescript
- 验证是否安装成功:输入 tsc -v,看到版本号即成功。
2. 编译(翻译)
在终端运行:
tsc index.ts
这时你会发现文件夹里多了一个 index.js 文件。这就是“翻译”后的结果。
3. 运行
运行生成的 JS 文件:
node index.js
第二章:TS 基础武器库 —— 不仅仅是加个冒号
在进入实战前,我们需要清点一下武器库。很多新手把 TS 写成了 AnyScript,遇见报错就加 any,这不仅违背了初衷,甚至让代码比原生 JS 更难维护。
TypeScript 的类型系统其实非常庞大,为了方便记忆,我们把它们分为五大类:基本底座、特殊兵种、对象建模、集合容器、以及逻辑运算。
2.1 基本底座:JS 的老朋友与新面孔
基本数据类型: boolean, number, string, null, undefined, symbol, bigint
这部分大家最熟悉,它们直接对应 JavaScript 的原始类型。但在 TS 中,它们变得更加“铁面无私”。
// 1. 老三样:一板一眼
let isDone: boolean = false;
let age: number = 18; // 支持十进制、十六进制等
let name: string = "Tom";
// 2. 只有在 ES2020+ 才有的新贵
// bigint: 处理超大整数,记得在 tsconfig 中开启 ES2020
let bigNumber: bigint = 100n;
// symbol: 独一无二的标识
let sym: symbol = Symbol("key");
// 3. 让人头疼的空值:null 和 undefined
// 在 strictNullChecks: true (严格模式) 下,它们不能赋值给 number 等其他类型
let u: undefined = undefined;
let n: null = null;
// let num: number = undefined; // ❌ 报错!别想蒙混过关
2.2 特殊兵种:虚空与黑洞
这是 TS 特有的概念,理解它们是脱离新手村的标志。
1. Any vs Unknown:放纵与克制
新手最爱用 any,但资深工程师偏爱 unknown。
// any: 放弃治疗,跳过检查 (逃生舱)
let aa: any = 1;
aa = "111";
aa.hello(); // ✅ 编译通过,但运行爆炸!这是 JS 的原罪
// unknown: 未知类型 (更安全的 Any)
let bb: unknown = 1;
bb = "hello";
// bb.hello(); // ❌ 报错!TS 说:我不确定它是啥,你不许乱动
// 必须先“验身” (类型收窄) 才能用
if (typeof bb === 'string') {
console.log(bb.toUpperCase()); // ✅ 现在安全了
}
2. Void vs Never:空无一物与万劫不复
// void: 空。通常用于函数没有返回值
function logMessage(): void {
console.log("只是打印一下,不返回东西");
}
// never: 绝不。表示永远不会有结果的类型 (黑洞)
// 场景1: 抛出错误,函数提前终结,执行不到结尾
function error(message: string): never {
throw new Error(message);
}
// 场景2: 死循环
function loop(): never {
while (true) {}
}
2.3 对象建模:描述世界的形状
在 TS 中,我们主要用两种方式描述对象:接口 (interface) 和 类型别名 (type)。
// 1. 字面量类型 (Literal Types)
// 只有 "male" 或 "female" 才是合法值,其他字符串不行
type Gender = "male" | "female";
// 2. 接口 (Interface):就像签订契约,适合定义对象形状
interface User {
name: string;
age: number;
gender: Gender;
readonly id: number; // 只读属性,不可篡改
hobby?: string; // 可选属性,有了更好,没有也行
[key: string]: any; // 索引签名:允许有额外的任意属性
}
const u: User = {
name: "李四",
age: 18,
gender: "female",
id: 1,
school: "Qinghua" // ✅ 匹配索引签名
};
// 3. 小写的 object
// 代表非原始类型 (即不是 number/string/boolean...)
// 很少直接用,因为它太宽泛了,你无法访问里面的属性
function create(o: object | null): void {}
create({ prop: 0 }); // OK
// create(42); // Error
2.4 集合容器:数组与元组
// 1. 数组:两种写法
let list1: number[] = [1, 2, 3]; // 写法一:简洁(推荐)
let list2: Array<string> = ["a", "b"]; // 写法二:泛型写法(逼格高,且 foreshadow 了后面的泛型章节)
// 2. 元组 (Tuple):一种特殊的数组
// 它是定长、定类型的。React 的 useState 就是返回一个元组
let x: [string, number];
x = ["hello", 10]; // OK
// x = [10, "hello"]; // Error,顺序不对
2.5 高级逻辑:组合与枚举
最后,我们需要一些工具来处理复杂的类型关系。
1. 枚举 (Enum):让魔法数字滚出代码
不要在代码里写 if (status === 2),鬼知道 2 是什么。
enum Status {
Pending = 0,
Success = 1,
Failed = 2,
}
let s: Status = Status.Pending;
// 可读性爆炸:Status.Success 比 s = 1 强一万倍
2. 联合 (Union) 与 交叉 (Intersection)
这是类型的“逻辑或”与“逻辑与”。
// 联合类型 (|):是 A 或者 B
// 就像 ID,可能是数字 ID,也可能是字符串 UUID
type ID = string | number;
function printId(id: ID) {
// 这里需要注意,只能访问 string 和 number 共有的方法
// 或者通过 typeof 判断类型
}
// 交叉类型 (&):是 A 并且也是 B
// 常用于对象合并
interface A { name: string }
interface B { age: number }
type C = A & B; // C 必须同时拥有 name 和 age
const person: C = {
name: "Tony",
age: 35
};
老司机总结:
- 能用 unknown 别用 any。
- 能用 interface 描述对象就先用 interface。
- 看到 | 竖线是“或者”,看到 & 符号是“合体”。
- 基础打牢,后面讲泛型才不会晕车。
第三章:TS 的核武器 —— 泛型 (Generics)
好,前面的都是开胃菜。接下来我们要讲 TS 中最难理解但也最强大的特性:泛型。
很多同学看泛型就像看天书,看到 就头大。其实,泛型就是类型的“传参” 。
3.1 为什么需要泛型?
想象一下,你要写一个函数,把传入的内容原样返回。
如果不这用泛型:
function echo(arg: number): number { return arg; } // 只能处理数字
function echoString(arg: string): string { return arg; } // 只能处理字符串
function echoAny(arg: any): any { return arg; } // 丧失了类型信息,传入 string 返回 any
我们希望:我传入什么类型,你就自动识别为什么类型,并保证返回值也是那个类型。
3.2 泛型实战:能够“变形”的容器
让我们看看项目中的 storages.ts,这是泛型最经典的应用场景:
// T 是一个占位符,就像函数的参数一样
// 当你调用 getStorage<User> 时,所有的 T 都会变成 User
export function getStorage<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : defaultValue;
}
代码解析:
-
getStorage:告诉 TS,这个函数有一个“类型变量”叫 T。
-
defaultValue: T:默认值的类型必须是 T。
-
: T (返回值):函数返回的类型也是 T。
优势:
当我们存储 Todo 列表时,我们可以这样用:
// 哪怕 localStorage 本质存储的是字符串
// 通过泛型,res 自动获得了 Todo[] 的类型提示!
const res = getStorage<Todo[]>("todos", []);
// res.map... // 这里的 map 里面会自动提示 Todo 的属性!
如果你不用泛型,JSON.parse 返回的是 any,你后续对数据的操作将失去所有类型保护。泛型,让你的通用工具函数不仅通用,而且安全。
第四章:React + TS 全栈实战 —— TodoList 架构解析
现在,我们把 TS 的知识应用到 React 项目中。不要小看一个 TodoList,麻雀虽小,五脏俱全。我们会按照企业级的代码规范来组织结构。
4.1 项目结构:井井有条
观察我们的文件树,这是一个非常标准的分层结构:
src
├── components // 纯展示组件 (UI)
├── hooks // 自定义 Hooks (逻辑核心)
├── types // 类型定义 (契约)
├── utils // 工具函数 (泛型的高发地)
├── App.tsx // 根组件
└── assets
为什么要这样分?
-
关注点分离 :UI 归 UI,逻辑归逻辑,类型归类型。
-
可维护性 :当你想修改数据结构时,去 types;当你想修改业务逻辑时,去 hooks。
4.2 Step 1: 定义灵魂 —— Model (types/Todo.ts)
一切开发,先定数据结构。这是 TS 开发者的直觉。
// types/Todo.ts
// 接口用来约定对象必须实现的属性和方法
// export 导出,供全项目使用
export interface Todo {
id: number;
title: string;
completed: boolean;
}
有了这个 Todo 接口,全项目凡是涉及到 todo item 的地方,都有了标准。
4.3 Step 2: 逻辑抽离 —— Custom Hook (hooks/useTodos.ts)
在 App.tsx 里写一堆 useState 和 useEffect 是新手的做法。资深工程师会把业务逻辑抽离。
这里我们用到了刚才讲的 泛型 和 接口:
import { useState, useEffect } from "react";
import type { Todo } from "../types/Todo"; // 显式引入 type
import { getStorage, setStorage } from "../utils/storages";
export default function useTodos() {
// 泛型应用:useState<Todo[]>
// 告诉 React,这个状态是一个 Todo 类型的数组
const [todos, setTodos] = useState<Todo[]>(() =>
// 泛型应用:getStorage<Todo[]>
// 从本地存储取出来的一定是 Todo[]
getStorage<Todo[]>("todos", [])
);
useEffect(() => {
// 泛型应用:setStorage<Todo[]>
setStorage<Todo[]>("todos", todos);
}, [todos]);
const addTodo = (title: string) => {
const newTodo: Todo = {
id: +new Date(),
title,
completed: false,
};
// 这里如果写 newTodo.xxx = 123,TS 马上会报错,因为 Todo 接口里没定义 xxx
setTodos([...todos, newTodo]);
};
// ... toggleTodo, removeTodo 省略
return { todos, addTodo, toggleTodo, removeTodo };
}
亮点分析:
- useState<Todo[]>:这保证了 todos 变量在使用数组方法(如 .map, .filter)时,回调函数里的参数自动推断为 Todo 类型。
- 逻辑复用:如果以后要把 TodoList 移植到别的页面,直接引入这个 Hook 即可。
4.4 Step 3: 组件开发与 Props 约束 (components/*.tsx)
在 React + TS 中,组件最重要的就是定义 Props 的接口。
TodoInput.tsx:
import * as React from "react";
// 定义 Props 接口
// 清晰地告诉调用者:你要用我,必须给我一个 onAdd 函数,参数是 string,没返回值
interface Props {
onAdd: (title: string) => void;
}
// React.FC<Props>:
// FC = Function Component。泛型 P = Props。
// 这让 TS 知道 TodoInput 是一个组件,且接受符合 Props 接口的参数
const TodoInput: React.FC<Props> = ({ onAdd }) => {
const [value, setValue] = React.useState<string>("");
// ... 逻辑
};
TodoList.tsx:
import type { Todo } from "../types/Todo";
import TodoItem from "./TodoItem";
import * as React from "react";
interface Props {
todos: Todo[]; // 核心数据
onToggle: (id: number) => void; // 回调
onRemove: (id: number) => void;
}
const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
return (
<ul>
{/* 因为 todos 被定义为 Todo[],这里的 map 里 todo 自动识别为 Todo 类型 */}
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
);
};
注意看:
在 TodoList 组件中,当我们在 map 里面渲染 TodoItem 时,如果忘记传 onRemove,IDE 会立刻划红线报
错。这就叫编译时检查。这比在浏览器里跑半天发现按钮没反应要强一万倍。
4.5 Step 4: 拼装 (App.tsx)
最后,我们在 App.tsx 里把所有积木搭起来。
import useTodos from "./hooks/useTodos";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";
export default function App() {
// 就像去超市买东西,直接拿想要的逻辑
const { todos, addTodo, toggleTodo, removeTodo } = useTodos();
return (
<div>
<h1>TodoList</h1>
{/* TS 检查:addTodo 的类型匹配 TodoInput 的 props 要求吗?匹配! */}
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
</div>
);
}
第五章:细节拆分
一、 FC
在 React + TypeScript 的开发语境下,FC 是 Function Component(函数式组件)的缩写。
它是 React 官方类型库(@types/react)提供的一个泛型接口,用来专门定义“函数组件”的类型。
简单来说,它的作用就是告诉 TypeScript:“嘿,这个变量不仅仅是一个普通的函数,它是一个 React 组件。 ”
1. 它的全貌
在代码中,你通常这样看到它:
// React.FC<Props>
// ^^ ^^
// || ||
// 接口名称 泛型(传入组件的Props类型)
const TodoInput: React.FC<Props> = ({ onAdd }) => { ... }
2. FC 到底帮我们做了什么?
当你把一个组件标注为 React.FC 时,TypeScript 会自动帮你做这几件事:
A. 约定返回值
它强制要求这个函数的返回值必须是 JSX 元素(或者 null)。如果你不小心返回了一个对象或者 undefined,TS 会立刻报错。
B. 泛型传参 (最重要的功能)
它接受一个泛型参数(就是尖括号 <> 里的东西)。
比如 React.FC< Props>,这意味着:
- 这个组件接收的 props 参数,必须符合 Props 接口的定义。
- 你在组件内部使用 props 时,会有自动补全提示。
- 父组件在使用这个组件时,必须传递 Props 里规定的属性,少传或错传都会报错。
C. 提供静态属性类型 (相对少用)
它还包含了组件的一些静态属性定义,比如 displayName、propTypes、defaultProps(注:defaultProps 在函数组件中已不推荐使用)。
3. 一个需要注意的“坑”:Children (React 18 的变化)
这是面试或实战中常遇到的知识点。
-
在 React 17 及以前:
React.FC 实际上自带了一个隐含的属性 children。也就是说,即使你的 Props 接口里是空的,你也可以在组件里写 {props.children}。
但这被认为是不安全的,因为有些组件本来就不该包含子元素。
-
在 React 18 (现在) :
React.FC 移除了 隐式的 children。
如果你的组件需要包含子元素(比如一个 ... 组件),你需要显式地在接口里定义它:
// React 18+ 的正确姿势
interface Props {
title: string;
children?: React.ReactNode; // 必须手动加上这一行,否则报错
}
const Layout: React.FC<Props> = ({ title, children }) => {
return (
<div>
<h1>{title}</h1>
{children}
</div>
);
}
二、 storage.js中的 T 是什么?
在 storages.ts 那个文件中,T 代表 Type (类型) 。
它是 TypeScript 中 泛型 (Generics) 的标准占位符。
你可以把 T 看作是一个 “类型的变量” 或者 “类型的占位符” 。就像你在数学函数
f(x)=x+1f(x)=x+1
中,x 代表任意数字一样;在 TS 中,T 代表任意类型。
我们来深入剖析一下 getStorage 这个函数:
// 1. 定义泛型变量 <T>
export function getStorage<T>(key: string, defaultValue: T): T {
// ...
}
1. 把它拆解来看
这里的 T 出现了三次,分别代表不同的含义:
-
getStorage (声明):
这是在告诉 TypeScript:“嘿,老兄,我现在定义一个函数。我不确定用户将来要存取什么类型的数据,可能是数字,可能是字符串,也可能是 Todo 对象。所以我先用 T 占个坑。” 在这里的T就相当于一个声明,方便后续读取使用
-
defaultValue: T (参数约束):
这表示:“传入的默认值,必须和 T 是同一种类型。” 你不能一边说 T 是数字,一边传个字符串做默认值。
-
: T (返回值约束):
这表示:“这个函数运行结束吐出来的数据,一定也是 T 类型。”
2. 它是如何“变身”的?
泛型的神奇之处在于,当你调用函数的时候,T 才会确定它到底是什么。
让我们看看在 useTodos.ts 是怎么用的:
// 场景一:获取 Todo 列表
getStorage<Todo[]>("todos", []);
当你写下 <Todo[]> 的那一瞬间,TypeScript 会在后台自动把所有的 T 替换掉:
如果换个场景:
// 场景二:获取一个计数器
getStorage<number>("count", 0);
此时,所有的 T 瞬间变成了 number。
3. 为什么要用 T?(不用行不行?)
如果你不用泛型,你只能面临两个糟糕的选择:
糟糕选择 A:写死类型
function getStorage(key: string, val: number): number { ... }
这样这个函数就废了,只能存取数字,存取 Todo 列表还得再写一个函数。
糟糕选择 B:使用 any
function getStorage(key: string, val: any): any { ... }
这是最常见的错误。虽然函数通用了,但当你拿到返回值时,它是 any。你敲代码时,IDE 无法提示你有 todo.title 还是 todo.name。你失去了 TS 所有的保护。
第六章:总结与思考
6.1 为什么这一套流程是“高质量”的?
-
类型即文档:你看一眼 interface Props 或 interface Todo,就知道数据长什么样,不用去猜后端返回的 JSON 到底有没有 id 字段。
-
泛型的妙用:在 utils/storages.ts 和 hooks/useTodos.ts 中,泛型极大地提高了代码的复用性和安全性。它让我们可以写出既通用又类型严格的代码。
-
开发体验 (DX) :智能提示(IntelliSense)让你敲代码如飞,重构代码时也不用担心漏改了哪个文件。
6.2 给初学者的建议
-
不要害怕报错:TS 的红色波浪线不是在骂你,而是在救你。
-
多用 Interface:养成先定义数据结构,再写业务逻辑的习惯。
-
理解泛型:把泛型想象成一个“类型插槽”,它是 TS 进阶的分水岭。
-
拒绝 Any:如果实在不知道写什么类型,先写 unknown,或者去查文档,不要轻易妥协用 any。
6.3 结语
从 JavaScript 到 TypeScript,是一次思维的升级。它让你从“大概也许可能是这样”变成了“肯定是这样”。在 AI 全栈的时代,代码的健壮性尤为重要。
希望这篇文章能帮你推开 TypeScript 的大门。记住,类型不是枷锁,而是你的铠甲。
现在,打开你的 IDE,把那个 .js 后缀改成 .ts,开始你的重构之旅吧!