TypeScript:更加安全规范的JavaScript

前言

曾经有一份真挚的 JavaScript 代码摆在我面前,我没有珍惜。直到 Uncaught TypeError: Cannot read property of undefined 这种红色报错占满屏幕,我才后悔莫及。如果上天能够给我一个再来一次的机会,我会对那个变量说三个字:"定类型!"。

如果非要在这份类型上加一个期限,我希望是------TypeScript

今天我们不聊高大上的架构,只聊怎么让你写代码时"心里有底"。我们要从最基础的弱类型痛点讲起,一路杀到 TypeScript 的核心------泛型,最后用 React 撸一个 实战小组件 TodoList。

系好安全带,我们要发车了!


第一章:JS 的温柔陷阱与 TS 的铁血秩序

1.1 弱类型的"二义性"之痛

JavaScript 是一个非常"随和"的语言,随和到什么程度?它允许你胡作非为。

看看这段代码

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 是 强类型静态语言。它在代码编译阶段(运行前)就对其进行检查。

TypeScript 复制代码
// 强类型可以杜绝 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)

打开终端,运行:

Bash 复制代码
npm install -g typescript
  • 验证是否安装成功:输入 tsc -v,看到版本号即成功。

2. 编译(翻译)

在终端运行:

Bash 复制代码
tsc index.ts

这时你会发现文件夹里多了一个 index.js 文件。这就是"翻译"后的结果。

3. 运行

运行生成的 JS 文件:

Bash 复制代码
node index.js

第二章:TS 基础武器库 ------ 不仅仅是加个冒号

在进入实战前,我们需要清点一下武器库。很多新手把 TS 写成了 AnyScript,遇见报错就加 any,这不仅违背了初衷,甚至让代码比原生 JS 更难维护。

TypeScript 的类型系统其实非常庞大,为了方便记忆,我们把它们分为五大类:基本底座、特殊兵种、对象建模、集合容器、以及逻辑运算

2.1 基本底座:JS 的老朋友与新面孔

基本数据类型: boolean, number, string, null, undefined, symbol, bigint

这部分大家最熟悉,它们直接对应 JavaScript 的原始类型。但在 TS 中,它们变得更加"铁面无私"。

TypeScript 复制代码
// 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。

TypeScript 复制代码
// 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:空无一物与万劫不复

TypeScript 复制代码
// 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)。

TypeScript 复制代码
// 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 集合容器:数组与元组

TypeScript 复制代码
// 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 是什么。

TypeScript 复制代码
enum Status {
  Pending = 0,
  Success = 1,
  Failed = 2,
}
let s: Status = Status.Pending; 
// 可读性爆炸:Status.Success 比 s = 1 强一万倍

2. 联合 (Union) 与 交叉 (Intersection)

这是类型的"逻辑或"与"逻辑与"。

TypeScript 复制代码
// 联合类型 (|):是 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 为什么需要泛型?

想象一下,你要写一个函数,把传入的内容原样返回。

如果不这用泛型:

TypeScript 复制代码
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,这是泛型最经典的应用场景:

TypeScript 复制代码
// 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;
}

代码解析:

  1. getStorage:告诉 TS,这个函数有一个"类型变量"叫 T。

  2. defaultValue: T:默认值的类型必须是 T。

  3. : T (返回值):函数返回的类型也是 T。

优势:

当我们存储 Todo 列表时,我们可以这样用:

TypeScript 复制代码
// 哪怕 localStorage 本质存储的是字符串
// 通过泛型,res 自动获得了 Todo[] 的类型提示!
const res = getStorage<Todo[]>("todos", []); 
// res.map... // 这里的 map 里面会自动提示 Todo 的属性!

如果你不用泛型,JSON.parse 返回的是 any,你后续对数据的操作将失去所有类型保护。泛型,让你的通用工具函数不仅通用,而且安全


第四章:React + TS 全栈实战 ------ TodoList 架构解析

现在,我们把 TS 的知识应用到 React 项目中。不要小看一个 TodoList,麻雀虽小,五脏俱全。我们会按照企业级的代码规范来组织结构。

4.1 项目结构:井井有条

观察我们的文件树,这是一个非常标准的分层结构:

Text 复制代码
src
├── components  // 纯展示组件 (UI)
├── hooks       // 自定义 Hooks (逻辑核心)
├── types       // 类型定义 (契约)
├── utils       // 工具函数 (泛型的高发地)
├── App.tsx     // 根组件
└── assets

为什么要这样分?

  • 关注点分离 :UI 归 UI,逻辑归逻辑,类型归类型。
  • 可维护性 :当你想修改数据结构时,去 types;当你想修改业务逻辑时,去 hooks。

4.2 Step 1: 定义灵魂 ------ Model (types/Todo.ts)

一切开发,先定数据结构。这是 TS 开发者的直觉。

TypeScript 复制代码
// 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 是新手的做法。资深工程师会把业务逻辑抽离。

这里我们用到了刚才讲的 泛型接口

TypeScript 复制代码
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:

TypeScript 复制代码
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:

TypeScript 复制代码
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 里把所有积木搭起来。

TypeScript 复制代码
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 的开发语境下,FCFunction Component(函数式组件)的缩写。

它是 React 官方类型库(@types/react)提供的一个泛型接口,用来专门定义"函数组件"的类型。

简单来说,它的作用就是告诉 TypeScript:"嘿,这个变量不仅仅是一个普通的函数,它是一个 React 组件。 "

1. 它的全貌

在代码中,你通常这样看到它:

TypeScript 复制代码
// React.FC<Props>
//       ^^     ^^
//       ||     ||
//   接口名称   泛型(传入组件的Props类型)

const TodoInput: React.FC<Props> = ({ onAdd }) => { ... }

2. FC 到底帮我们做了什么?

当你把一个组件标注为 React.FC 时,TypeScript 会自动帮你做这几件事:

A. 约定返回值

它强制要求这个函数的返回值必须是 JSX 元素(或者 null)。如果你不小心返回了一个对象或者 undefined,TS 会立刻报错。

B. 泛型传参 (最重要的功能)

它接受一个泛型参数(就是尖括号 <> 里的东西)。

比如 React.FC< Props>,这意味着:

  1. 这个组件接收的 props 参数,必须符合 Props 接口的定义。
  2. 你在组件内部使用 props 时,会有自动补全提示。
  3. 父组件在使用这个组件时,必须传递 Props 里规定的属性,少传或错传都会报错。
C. 提供静态属性类型 (相对少用)

它还包含了组件的一些静态属性定义,比如 displayName、propTypes、defaultProps(注:defaultProps 在函数组件中已不推荐使用)。


3. 一个需要注意的"坑":Children (React 18 的变化)

这是面试或实战中常遇到的知识点。

  • 在 React 17 及以前

    React.FC 实际上自带了一个隐含的属性 children。也就是说,即使你的 Props 接口里是空的,你也可以在组件里写 {props.children}。

    但这被认为是不安全的,因为有些组件本来就不该包含子元素。

  • 在 React 18 (现在)

    React.FC 移除了 隐式的 children。

    如果你的组件需要包含子元素(比如一个 ... 组件),你需要显式地在接口里定义它:

    TypeScript 复制代码
    // 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 看作是一个 "类型的变量" 或者 "类型的占位符" 。就像你在数学函数

text 复制代码
f(x)=x+1f(x)=x+1

中,x 代表任意数字一样;在 TS 中,T 代表任意类型。

我们来深入剖析一下 getStorage 这个函数:

TypeScript 复制代码
// 1. 定义泛型变量 <T>
export function getStorage<T>(key: string, defaultValue: T): T {
  // ...
}

1. 把它拆解来看

这里的 T 出现了三次,分别代表不同的含义:

  1. getStorage (声明):

    这是在告诉 TypeScript:"嘿,老兄,我现在定义一个函数。我不确定用户将来要存取什么类型的数据,可能是数字,可能是字符串,也可能是 Todo 对象。所以我先用 T 占个坑。" 在这里的T就相当于一个声明,方便后续读取使用

  2. defaultValue: T (参数约束):

    这表示:"传入的默认值,必须和 T 是同一种类型。" 你不能一边说 T 是数字,一边传个字符串做默认值。

  3. : T (返回值约束):

    这表示:"这个函数运行结束吐出来的数据,一定也是 T 类型。"

2. 它是如何"变身"的?

泛型的神奇之处在于,当你调用函数的时候,T 才会确定它到底是什么。

让我们看看在 useTodos.ts 是怎么用的:

TypeScript 复制代码
// 场景一:获取 Todo 列表
getStorage<Todo[]>("todos", []);

当你写下 <Todo[]> 的那一瞬间,TypeScript 会在后台自动把所有的 T 替换掉:

  • function getStorage 变成 -> function getStorage<Todo[]>

  • defaultValue: T 变成 -> defaultValue: Todo[] (所以第二个参数必须传数组 [])

  • 返回值 : T 变成 -> : Todo[]

如果换个场景:

TypeScript 复制代码
// 场景二:获取一个计数器
getStorage<number>("count", 0);

此时,所有的 T 瞬间变成了 number。

3. 为什么要用 T?(不用行不行?)

如果你不用泛型,你只能面临两个糟糕的选择:

糟糕选择 A:写死类型

TypeScript 复制代码
function getStorage(key: string, val: number): number { ... }

这样这个函数就废了,只能存取数字,存取 Todo 列表还得再写一个函数。

糟糕选择 B:使用 any

TypeScript 复制代码
function getStorage(key: string, val: any): any { ... }

这是最常见的错误。虽然函数通用了,但当你拿到返回值时,它是 any。你敲代码时,IDE 无法提示你有 todo.title 还是 todo.name。你失去了 TS 所有的保护。


第六章:总结与思考

6.1 为什么这一套流程是"高质量"的?

  1. 类型即文档:你看一眼 interface Props 或 interface Todo,就知道数据长什么样,不用去猜后端返回的 JSON 到底有没有 id 字段。

  2. 泛型的妙用:在 utils/storages.ts 和 hooks/useTodos.ts 中,泛型极大地提高了代码的复用性和安全性。它让我们可以写出既通用又类型严格的代码。

  3. 开发体验 (DX) :智能提示(IntelliSense)让你敲代码如飞,重构代码时也不用担心漏改了哪个文件。

6.2 给初学者的建议

  • 不要害怕报错:TS 的红色波浪线不是在骂你,而是在救你。

  • 多用 Interface:养成先定义数据结构,再写业务逻辑的习惯。

  • 理解泛型:把泛型想象成一个"类型插槽",它是 TS 进阶的分水岭。

  • 拒绝 Any:如果实在不知道写什么类型,先写 unknown,或者去查文档,不要轻易妥协用 any。

6.3 结语

从 JavaScript 到 TypeScript,是一次思维的升级。它让你从"大概也许可能是这样"变成了"肯定是这样"。在 AI 全栈的时代,代码的健壮性尤为重要。

希望这篇文章能帮你推开 TypeScript 的大门。记住,类型不是枷锁,而是你的铠甲。

现在,打开你的 IDE,把那个 .js 后缀改成 .ts,开始你的重构之旅吧!

相关推荐
@PHARAOH2 小时前
WHAT - Vercel react-best-practices 系列(四)
前端·react.js·前端框架
@PHARAOH2 小时前
WHAT - Vercel react-best-practices 系列(三)
javascript·react.js·ecmascript
白兰地空瓶2 小时前
Zustand:若 React 组件是公民,谁来当“中央银行”?—— 打造轻量级企业级状态管理
react.js·typescript
NEXT062 小时前
别再折磨自己了!放弃 Redux 后,我用 Zustand + TS 爽到起飞
前端·react.js
donecoding2 小时前
Sass 模块化革命:告别 @import,拥抱 @use 和 @forward
前端·css·代码规范
2501_944711432 小时前
现代 React 路由实践指南
前端·react.js·前端框架
@PHARAOH3 小时前
WHAT - Vercel react-best-practices 系列(二)
前端·javascript·react.js
桃子叔叔4 小时前
react-wavesurfer录音组件1:从需求到组件一次说清楚
前端·react.js·前端框架
@PHARAOH4 小时前
WHAT - React startTransition vs setTimeout vs debounce
前端·react.js·前端框架