🚀 AI 全栈项目第二天:TypeScript 初体验 —— 给你的代码穿上“钢铁侠战衣”

85 阅读11分钟

大家好!欢迎回到 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

痛点分析:

  1. 弱类型:变量可以随便变身,今天是数字,明天是字符串。
  2. 动态检查:代码写错了没人管,只有运行到那一行报错了才知道(这时候通常已经在用户浏览器上了)。
  3. 维护噩梦:接手别人的 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 === 0status === 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;

知识点解析:

  1. import * as React:在较老的 React 版本或某些配置下,为了兼容性,需要这样引入才能使用 React.FC 等类型。
  2. interface Props:这是 TS 组件开发的标配。它约束了组件的“输入”。
  3. (title: string) => void:这是函数类型的定义方法。左边是参数类型,右边是返回值类型。
  4. 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[] 类型)。”


🎉 总结

今天我们完成了一次华丽的转身:

  1. 从 JS 到 TS:理解了静态类型的意义,告别了“猜猜我是谁”的弱类型时代。
  2. 类型注解:学会了用 : stringinterface 来约束变量和对象。
  3. TS in React:学会了 React.FC<Props>,让组件通信变得严谨。
  4. 泛型入门:理解了 <T> 这个强大的类型变量,让函数更加灵活且安全。

现在的你,写出的代码自带文档、自带检查、自带提示。虽然写代码的时候多敲了几行类型定义,但在维护和 Debug 时省下的时间,绝对是成倍的!

下期预告: TypeScript 只是基础,接下来的 AI 全栈项目中,我们将面对更复杂的后端交互。如何用 TS 优雅地定义 API 接口?如何处理异步数据的类型?敬请期待!

🔥 快去重构你的代码吧!记得点赞收藏,我们下期见! 🚗


附录:常用命令速查

  • npm install -g typescript: 安装 TS
  • tsc filename.ts: 编译 TS 文件
  • tsc --watch filename.ts: 实时监听编译