大家好!欢迎回到 AI 全栈项目实战 的第二天。👋
回顾第一天,我们掌握了 React Router,学会了如何在不同的页面“平行宇宙”间自由穿梭。
今天,我们要面对一个更严肃的话题:安全感。
在 JavaScript 的世界里,我们就像在裸奔。你写了一个 add(a, b) 函数,期待传入两个数字,结果队友传了个字符串 '5',代码也不报错,直到运行起来界面上显示了一个奇怪的 105(因为 10 + '5' = '105'),你才在大半夜的报警声中惊醒。😱
TypeScript (简称 TS) 就是来拯救这个局面的。如果说 JavaScript 是自由散漫的“手枪”,那么 TypeScript 就是加装了瞄准镜、防走火装置的“精密步枪”。
今天,我们不仅要学 TS 的语法,还要用 TS 重构我们的 Todo List 项目,感受一下“强类型”带来的极致开发体验!
⏳ 一、 为什么要学 TS?从“裸奔”到“全副武装”
我们先来聊聊背景。
1.1 JavaScript 的“原罪”
我们来看一个真实的代码惨案。请看:
function add(a, b) {
// js 弱类型优势:好学,简单易上手
// js 是弱类型的
// js 是动态语言,不是静态语言,运行时候才发生 bug
// 加法?还是拼接?
return a + b; // 🛑 二义性:这里埋下了一个巨大的雷
}
const result = add(10, '5');
// 结果是 "105",而不是我们预期的 15
痛点分析:
- 弱类型:变量可以随便变身,今天是数字,明天是字符串。
- 动态检查:代码写错了没人管,只有运行到那一行报错了才知道(这时候通常已经在用户浏览器上了)。
- 维护噩梦:接手别人的 JS 代码,全靠猜。
function getUser(id),这个id是数字还是字符串?不知道,得去翻代码或者打console.log。
1.2 TypeScript:JavaScript 的超级英雄
TypeScript 是 JavaScript 的超级 (Superset)。这意味着所有合法的 JS 代码都是合法的 TS 代码,但 TS 增加了“类型系统”。
TS 的优点:
- 🛡️ 静态类型:在代码运行前(编译阶段)就锁死变量的类型。
- 🐛 边写边检查 Bug:像有一个老师坐在你旁边,你刚写错,红线就画出来了。
- 📚 文档即代码:看到类型定义
(name: string),就不需要写注释说“请传入字符串”。 - 🧹 代码洁癖:未使用的变量会提示,强迫你写出干净的代码。
🛠️ 二、 环境搭建与初体验
2.1 安装 TypeScript
首先,我们需要给电脑装上 TS 的编译器。打开终端:
npm install -g typescript
安装完成后,你就拥有了 tsc (TypeScript Compiler) 命令。
2.2 第一个 TS 程序
新建 ts-demo/2.ts,我们要修复刚才那个加法函数:
// 强类型可以杜绝 90% 的错误
// 👇 注意这里的 : number,这就是类型注解
function addTs(a: number, b: number): number {
return a + b;
}
const result2 = addTs(10, 5); // ✅ 正常,结果 15
console.log(result2);
const result3 = addTs(10, '5');
// ❌ 报错!IDE 会直接标红:
// Argument of type 'string' is not assignable to parameter of type 'number'.
解析:
a: number:告诉 TS,参数a必须是数字。b: number:参数b必须是数字。): number:函数执行完,返回值也必须是数字。
如果你尝试传入 '5',代码还没运行,VS Code 就会划红线警告你。这就是静态类型检查的威力!
2.3 编译 TS
浏览器是不认识 .ts 文件的,它只认识 .js。所以我们需要“翻译”(编译)。
手动编译:
tsc 2.ts
这会在同级目录下生成一个 2.js 文件,里面是把类型去掉后的纯 JS 代码。
自动监听(推荐):
tsc --watch 2.ts
加上 --watch 参数,TS 编译器会一直盯着这个文件,你保存一次,它就自动编译一次。这就叫“实时反馈”。
📚 三、 TS 核心:那个重要的“T” (Type)
TS 的核心就在于对数据的分类和约束。我们要把 JS 里那些模棱两可的数据类型,全部规整清楚。
让我们深入研读 ts-demo/4.ts,这里涵盖了 TS 开发中最常用的数据类型。
3.1 基础类型:老朋友们
let a: number = 1;
// a = "11" // ❌ 报错:不能把字符串赋值给数字类型的变量
let b: string = "hello";
let c: boolean = true;
let d: null = null;
let e: undefined = undefined;
3.2 数组与元组
// 数组:定义一个“纯粹”的数组,里面只能放数字
let arr: number[] = [1, 2, 3];
// 泛型写法(后面会细讲):Array<元素类型>
let arr2: Array<string> = ['a', 'b'];
// 元组 (Tuple):
// 这里的数组长度固定为2,第一个必须是数字,第二个必须是字符串
let user: [number, string] = [1, 'Tom'];
3.3 枚举 (Enum):让魔法数字消失
在代码里直接写 status === 0 或 status === 1 是很糟糕的习惯(这叫“魔法数字”),过两个月你绝对想不起 0 代表什么。
TS 提供了 enum 来解决这个问题:
//以此类推,默认从0开始递增
enum Status {
Pending, // 0 - 进行中
Success, // 1 - 成功
Failed, // 2 - 失败
}
// 使用起来语义化极强
let s: Status = Status.Pending;
if (s === Status.Success) {
console.log("恭喜!");
}
3.4 Any vs Unknown:天使与魔鬼
-
Any (任意类型):这是 TS 的“逃生舱”,也是破坏类型的“魔鬼”。
// ts 初学,any 救命 let aa: any = 1; aa = "11"; // 没问题 aa = {}; // 没问题 // ⚠️ 慎用!用多了 TS 就退化成 JS 了(被戏称为 AnyScript) -
Unknown (未知类型):这是更安全的
any。let bb: unknown = 1; bb = 'b'; // bb.length; // ❌ 报错!因为是“未知”的,TS 不允许你随便调用它的方法,必须先判断类型。
3.5 接口 (Interface) 与 类型别名 (Type)
这是 TS 中定义对象结构最重要的两个概念。
Interface (接口): 就像是签订合同。
// 约定 User 对象必须长这样
interface User {
name: string; // 必须有 name,字符串
age: number; // 必须有 age,数字
readonly id: number; // ✨ 只读属性:初始化后不能改
hobby?: string; // ✨ 可选属性:这个属性可有可无
}
const u: User = {
name: '张三',
age: 18,
id: 1001,
// hobby 可以不写
}
u.name = '李四'; // ✅ 可以改
u.id = 1002; // ❌ 报错!id 是只读的
Type (类型别名): 给类型起个名字,更灵活。
// 自定义类型:ID 可以是字符串,也可以是数字(联合类型)
type ID = string | number;
let num: ID = 111;
num = "AX-01"; // 也没问题
// 也可以用来定义对象
type UserType = {
name: string;
age: number;
hobby?: string;
}
小贴士:在定义对象结构时,React 社区习惯用
interface,在定义联合类型(Union Types)时用type。
⚔️ 四、 实战:构建 TypeScript 版 Todo List
理论讲完了,现在我们进入实战!我们要用 React + TypeScript 做一个待办事项列表。
📂 项目目录概览 (src 文件夹):
src/
├── components/ # 组件
│ ├── TodoInput.tsx
│ ├── TodoList.tsx
│ └── TodoItem.tsx
├── hooks/ # 自定义 Hooks
│ └── useTodos.ts
├── types/ # ✨ 专门存放类型定义的地方
│ └── todo.ts
├── utils/ # 工具函数
│ └── storages.ts
└── App.tsx # 入口组件
4.1 定义灵魂:types/todo.ts
在 TS 项目中,我建议先写类型,再写逻辑。类型就是数据的骨架。
打开 src/types/todo.ts:
// 数据状态是应用的核心,TS 保护它
// 导出这个接口,方便在其他文件 import
export interface Todo {
id: number;
title: string;
completed: boolean;
}
这就定下了规矩:我们的 Todo 必须包含这三个字段,少一个都不行。
4.2 搭建骨架:App.tsx
打开 src/App.tsx,我们先把页面的结构搭起来。你会发现,虽然后缀变成了 .tsx,但写起来和 .jsx 差不多。
import { useTodos } from './hooks/useTodos.ts'; // 引入逻辑钩子
import TodoList from './components/TodoList'; // 列表组件
import TodoInput from './components/TodoInput'; // 输入组件
export default function App() {
// 从自定义 Hook 中解构出数据和方法
const {
todos,
addTodo,
toggleTodo,
removeTodo
} = useTodos();
return (
<div>
<h1>todosList</h1>
{/* 👇 这里的 onAdd 会被 TS 检查,必须符合组件定义的类型 */}
<TodoInput onAdd={addTodo}/>
<TodoList
todos={todos}
onToggle={toggleTodo}
onRemove={removeTodo}
/>
</div>
)
}
4.3 逻辑大脑:hooks/useTodos.ts
这是逻辑最密集的地方。我们要在这里使用 useState 并结合 TS。
打开 src/hooks/useTodos.ts:
import { useState, useEffect } from 'react';
// 👇 引入 Todo 接口,注意 esm 导入类型时推荐加上 type 关键字,
// 这样打包工具知道这只是个类型,编译成 JS 时可以直接删掉,减小体积。
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';
const STORAGE_KEY = 'todos'; // 方便后续维护
export function useTodos() {
// ✨ 泛型高光时刻!
// useState<Todo[]> 告诉 React:
// 这个 state 只能存 Todo 类型的数组。
// 如果你往里面存个 string,或者存个不符合 Todo 接口的对象,马上报错。
const [todos, setTodos] = useState<Todo[]>(() => getStorage<Todo[]>(STORAGE_KEY, []));
useEffect(() => {
setStorage<Todo[]>(STORAGE_KEY, todos);
}, [todos]);
// 👇 参数 title 必须是 string
const addTodo = (title: string) => {
// newTodo 必须符合 Todo 接口
const newTodo: Todo = {
id: +new Date(), // 时间戳做 id
title,
completed: false
}
// 如果这里写 newTodo.desc = "xxx",TS 会报错,因为 Todo 接口里没定义 desc
const newTodos = [...todos, newTodo];
setTodos(newTodos);
}
// 👇 参数 id 必须是 number
const toggleTodo = (id: number) => {
const newTodos = todos.map((todo) =>
todo.id === id ?
{ ...todo, completed: !todo.completed }
: todo
);
setTodos(newTodos);
}
const removeTodo = (id: number) => {
const newTodos = todos.filter((todo) => todo.id !== id);
setTodos(newTodos);
}
return {
todos,
addTodo,
toggleTodo,
removeTodo,
}
}
4.4 组件与 Props:TodoInput.tsx
在 JS 时代,父组件给子组件传 props,子组件接到了是个啥全靠运气。在 TS 时代,我们要给 props 加上“安检”。
打开 src/components/TodoInput.tsx:
import * as React from 'react';
// ✨ 定义 Props 接口
// 明确告诉父组件:你想用我?必须给我传一个 onAdd 函数!
// 并且这个函数必须接收一个 string 参数,没有返回值 (void)。
interface Props {
onAdd: (title: string) => void;
}
// ✨ React.FC<Props>
// FC = Functional Component (函数式组件)
// <Props> 是泛型,把刚才定义的接口传进去。
// 这样,在组件内部解构 { onAdd } 时,TS 就知道 onAdd 是个函数了。
const TodoInput: React.FC<Props> = function({ onAdd }) {
// useState 也可以推断类型,这里显式写 <string> 更清晰
const [value, setValue] = React.useState<string>('');
const handleAdd = () => {
if (!value.trim()) return;
// 调用父组件传来的方法
onAdd(value.trim());
setValue('');
};
return (
<div>
<input
value={value}
// 事件对象 e 的类型,React 已经帮我们定义好了,TS 会自动推导
onChange={e => setValue(e.target.value)}
/>
<button onClick={handleAdd}>添加</button>
</div>
)
}
export default TodoInput;
知识点解析:
import * as React:在较老的 React 版本或某些配置下,为了兼容性,需要这样引入才能使用React.FC等类型。interface Props:这是 TS 组件开发的标配。它约束了组件的“输入”。(title: string) => void:这是函数类型的定义方法。左边是参数类型,右边是返回值类型。React.FC<Props>:这就好比给组件穿上了一层带有“形状”的模具。如果不符合形状(比如少传了onAdd),父组件那里就会报错。
4.5 列表渲染:TodoList.tsx & TodoItem.tsx
这两个组件主要复习前面的知识点,可以试着不看注释自己来理解代码。
TodoList.tsx:
import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
// 父子组件对接的契约
interface Props {
todos: Todo[]; // 必须是个数组,数组里必须是 Todo 对象
onToggle: (id: number) => void;
onRemove: (id: number) => void;
}
const TodoList: React.FC<Props> = function({ todos, onToggle, onRemove }) {
return (
<ul>
{todos.map((todo: Todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
)
}
export default TodoList;
“套娃”组件: TodoItem.tsx:
// ... import 略 ...
interface Props {
todo: Todo; // 这是一个单体对象
onToggle: (id: number) => void;
onRemove: (id: number) => void;
}
const TodoItem: React.FC<Props> = function({ todo, onToggle, onRemove }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
</span>
<button onClick={() => onRemove(todo.id)}>删除</button>
</li>
)
}
export default TodoItem;
🧙♂️ 五、 进阶:泛型 (Generics) —— 类型的“变量”
在前面的代码中,我们多次看到了 <T> 这种写法。这在 TS 中叫泛型,是 TS 最难也最强大的功能之一。
打开 src/utils/storages.ts,我们要封装 LocalStorage 的操作。
❓ 思考:LocalStorage 存的是字符串,但取出来可能是对象、数组、数字...
我们怎么让 getStorage 函数知道它应该返回什么类型呢?
✨ 答案:泛型 T
你可以把 T 想象成一个“占位符”或者“类型的变量”。 当你调用这个函数时,你传入什么类型,T 就变成了什么类型。
export function getStorage<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key);
// 如果取到了,转成 JSON,此时它就是 T 类型
// 如果没取到,返回 defaultValue,它也是 T 类型
return value ? JSON.parse(value) : defaultValue;
}
export function setStorage<T>(key: string, value: T) {
localStorage.setItem(key, JSON.stringify(value));
}
生动理解泛型:
想象 getStorage 是一个全能模具机。
- 如果我们不告诉它模具形状(不传泛型),它可能吐出一堆烂泥(
any)。 - 当我们调用
getStorage<Todo[]>(...)时,相当于我们往机器里塞了一个“Todo数组”形状的模具。 - 于是,机器保证吐出来的东西,一定完美符合
Todo[]的形状。
使用场景回顾:
在 useTodos.ts 中:
useState<Todo[]>(() => getStorage<Todo[]>(STORAGE_KEY, []));
这里我们明确告诉 getStorage:“喂,我要取东西,但我要求你取出来的东西必须是 Todo[] 类型的,如果 localStorage 里是空的,你就给我返回默认值 [](也是 Todo[] 类型)。”
🎉 总结
今天我们完成了一次华丽的转身:
- 从 JS 到 TS:理解了静态类型的意义,告别了“猜猜我是谁”的弱类型时代。
- 类型注解:学会了用
: string、interface来约束变量和对象。 - TS in React:学会了
React.FC<Props>,让组件通信变得严谨。 - 泛型入门:理解了
<T>这个强大的类型变量,让函数更加灵活且安全。
现在的你,写出的代码自带文档、自带检查、自带提示。虽然写代码的时候多敲了几行类型定义,但在维护和 Debug 时省下的时间,绝对是成倍的!
下期预告: TypeScript 只是基础,接下来的 AI 全栈项目中,我们将面对更复杂的后端交互。如何用 TS 优雅地定义 API 接口?如何处理异步数据的类型?敬请期待!
🔥 快去重构你的代码吧!记得点赞收藏,我们下期见! 🚗
附录:常用命令速查
npm install -g typescript: 安装 TStsc filename.ts: 编译 TS 文件tsc --watch filename.ts: 实时监听编译