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,开始你的重构之旅吧!

相关推荐
青青家的小灰灰9 小时前
React 架构进阶:自定义 Hooks 的高级设计模式与最佳实践
前端·react.js·前端框架
jonjia10 小时前
模块、脚本与声明文件
typescript
jonjia10 小时前
配置 TypeScript
typescript
jonjia10 小时前
TypeScript 工具函数开发
typescript
jonjia10 小时前
注解与断言
typescript
jonjia10 小时前
IDE 超能力
typescript
jonjia10 小时前
对象类型
typescript
jonjia10 小时前
快速搭建 TypeScript 开发环境
typescript
jonjia10 小时前
TypeScript 的奇怪之处
typescript
jonjia10 小时前
类型派生
typescript