TypeScript 内置工具类型 — 官方预制件全家桶,用一遍就回不去了
前三天你学会了造类型工厂(泛型、约束、泛型类)。今天参观官方预制件仓库:Partial、Pick、Omit、Record、Exclude、ReturnType……17 个工具类型,每一个都是"泛型 + 约束 + 类型运算"的组合成品。学完今天,你写 CRUD 接口类型从手写 5 个 interface 变成派生 5 行——而且实体加字段时全部自动同步。
目录
一、工具类型是什么
1.1 定义
工具类型(Utility Type) = 接收类型作为参数、返回新类型的"类型函数"。它们全用泛型实现,是官方提炼的最高频类型变换:
// 你已经在前几天反复用过它们(可能没意识到):
type Patch = Partial<Device>; // D10 Repository.update 的参数
type Rule = Record<DeviceKind, Config>; // D5 状态配置表
type Ret = ReturnType<typeof fn>; // D6 函数反解
1.2 学习方法(重要)
// ❌ 死记 17 个名词
// ✅ 按家族理解 + 每个类型亲手写一遍"使用场景"
// 今天每个工具类型都遵循这个结构:
// 1. 签名(它长什么样)
// 2. 效果(输入 → 输出对照)
// 3. 场景(什么业务用它)
// 4. 原理预告(Day 12/13 手写它)
1.3 演示用基础类型
// 本文档统一示例实体(贯穿全文)
interface Device {
id: string;
name: string;
temp: number;
status: "running" | "standby" | "fault";
createdAt: number;
}
type AnyStatus = "running" | "standby" | "fault" | "offline";
二、结构变换家族
2.1 Partial<T>:全部变可选
type DevicePatch = Partial<Device>;
// {
// id?: string;
// name?: string;
// temp?: number;
// status?: "running" | "standby" | "fault";
// createdAt?: number;
// }
// ===== 场景 1:更新接口(PATCH 语义)=====
function updateDevice(id: string, patch: Partial<Device>): void {
// 只传要改的字段
}
updateDevice("CNC-001", { temp: 88 }); // ✅ 只改温度
updateDevice("CNC-001", { name: "新机床" }); // ✅ 只改名字
// ===== 场景 2:表单草稿(边填边存)=====
const draft: Partial<Device> = { name: "机床" }; // 还没填完
draft.temp = 65; // 逐步补全
// ===== 场景 3:配置合并(默认配置 + 用户覆盖)=====
const DEFAULT_CONFIG = { url: "ws://x", retry: 3, timeout: 5000 };
function createConfig(userCfg: Partial<typeof DEFAULT_CONFIG>) {
return { ...DEFAULT_CONFIG, ...userCfg }; // 浅合并
}
createConfig({ retry: 5 }); // ✅ 只覆盖 retry
2.2 Required<T>:全部变必填
// Partial 的反向操作
type StrictDevice = Required<Partial<Device>>;
// 还原成 Device(全必填)
// ===== 场景:强制补全可选结构 =====
interface Options {
width?: number;
height?: number;
}
// 填充默认值后,后续流程需要"必有值"的保证
function withDefaults(o: Options): Required<Options> {
return { width: o.width ?? 1920, height: o.height ?? 1080 };
}
const opts = withDefaults({});
opts.width.toFixed(0); // ✅ number(不再是 number | undefined)
2.3 Readonly<T>:全部变只读
type FrozenDevice = Readonly<Device>;
// {
// readonly id: string;
// readonly name: string;
// ...
// }
const snapshot: FrozenDevice = { id: "1", name: "机床", temp: 65, status: "running", createdAt: 0 };
snapshot.temp = 90; // ❌ 编译错误:只读
// ===== 场景 1:数据快照(防止误改历史记录)=====
function takeSnapshot(devices: Device[]): readonly Device[] {
return devices.map(d => Object.freeze(d));
}
// ===== 场景 2:配置对象冻结 =====
const THEME: Readonly<{ bg: string; accent: string }> = {
bg: "#0a1628",
accent: "#00ff88"
};
// THEME.bg = "red"; // ❌ 编译期拦截
2.4 Pick<T, K>:挑字段
type DeviceBrief = Pick<Device, "id" | "temp">;
// { id: string; temp: number }
// ===== 场景 1:列表 DTO(瘦身传输)=====
// 大屏列表页只显示编号和温度 —— 拒绝多余字段防误用
function getBriefs(devices: Device[]): Pick<Device, "id" | "temp">[] {
return devices.map(({ id, temp }) => ({ id, temp }));
}
// ===== 场景 2:权限视图(隐藏敏感字段)=====
type PublicUser = Pick<User, "id" | "name">; // 不含 password
function toPublic(user: User): PublicUser {
return { id: user.id, name: user.name };
}
// ===== 场景 3:精确解构的类型标注 =====
const brief: Pick<Device, "id" | "status"> = { id: "1", status: "running" };
2.5 Omit<T, K>:删字段(Pick 的反向)
type DeviceCreate = Omit<Device, "id" | "createdAt">;
// { name: string; temp: number; status: "running" | "standby" | "fault" }
// ===== 场景 1:创建表单(id 由后端生成)=====
function createDevice(body: DeviceCreate): Promise<Device> { /* ... */ }
createDevice({ name: "新机床", temp: 40, status: "standby" }); // ✅
createDevice({ id: "x", name: "n", temp: 1, status: "running", createdAt: 0 }); // ❌ 多传了
// ===== 场景 2:更新表单(不允许改 id/创建时间)=====
type DeviceUpdate = Partial<Omit<Device, "id" | "createdAt">>;
// 全可选 + 剔除不可改字段 —— PATCH 的完整类型
// ⚠ 注意 Omit 的 K 不受约束(拼错不报错):
type Bad = Omit<Device, "idd">; // ✅ 通过!等于没删
// 严格版(Day 13 手写):StrictOmit<T, K extends keyof T>
三、键值映射家族
3.1 Record<K, V>:完整映射表
// ===== 基本用法(第 5 天已实战,今天系统化)=====
// 键是联合 → 每个键都必须有值(完整性!)
const STATUS_CONFIG: Record<AnyStatus, { text: string; color: string }> = {
running: { text: "运行中", color: "#00ff88" },
standby: { text: "待机", color: "#ffaa00" },
fault: { text: "故障", color: "#ff4444" },
offline: { text: "离线", color: "#7a8ba0" }
};
// 删掉任何一行 → 编译错误(完整性是 Record 的核心价值)
// ===== 键也可以是字面量联合的派生 =====
type Metric = "temp" | "speed" | "pressure";
const UNITS: Record<Metric, string> = {
temp: "°C",
speed: "rpm",
pressure: "kPa"
};
// ===== 值也可以是复杂类型 =====
type Handler = (payload: unknown) => void;
const HANDLERS: Record<string, Handler> = {};
// ===== 键为 string 的"宽"用法(字典模式)=====
const index: Record<string, Device> = {};
index["CNC-001"] = device;
3.2 Record 的等价手写
// Record<K, V> 本质是映射类型(Day 13 手写):
type MyRecord<K extends keyof any, V> = {
[P in K]: V;
};
// keyof any = string | number | symbol(所有可能的键类型)
// K extends keyof any:约束 K 为"可作键的东西"
3.3 Record + keyof typeof:配置表三连(终极形态)
// 三步走(第 5 天的模式,用 Record 的视角再看一遍):
// 第 1 步:写常量(真相源)
const DEVICE_STATUS = {
running: { text: "运行中", color: "#00ff88", level: 0 },
standby: { text: "待机", color: "#ffaa00", level: 1 },
fault: { text: "故障", color: "#ff4444", level: 2 }
} as const;
// 第 2 步:派生键类型
type DeviceStatus = keyof typeof DEVICE_STATUS; // "running" | "standby" | "fault"
// 第 3 步:Record 保证任何"补充映射表"的完整性
const STATUS_ICON: Record<DeviceStatus, string> = {
running: "🟢",
standby: "🟡",
fault: "🔴"
};
// DEVICE_STATUS 加新状态 → STATUS_ICON 立刻报缺 → 编译器替你查漏 ⭐
四、联合运算家族
4.1 Exclude<T, U>:从联合中排除
type Active = "running" | "standby";
type Inactive = Exclude<AnyStatus, Active>;
// "fault" | "offline"(排除掉 running 和 standby)
// ===== 场景 1:状态分类 =====
function isCritical(s: AnyStatus): s is Inactive {
return s === "fault" || s === "offline"; // 收窄目标用派生联合(D4 谓词)
}
// ===== 场景 2:Omit 的实现原理(预告 Day 13)=====
// Omit<T, K> = Pick<T, Exclude<keyof T, K>>
// keyof T 排除 K → 剩下的键 → Pick 回来
// ===== 场景 3:过滤可写字段 =====
type AllKeys = keyof Device; // "id" | "name" | "temp" | "status" | "createdAt"
type EditableKeys = Exclude<AllKeys, "id" | "createdAt">;
// "name" | "temp" | "status"
type EditableFields = Pick<Device, EditableKeys>;
// 等价于 Omit<Device, "id" | "createdAt"> —— 两种写法一回事
4.2 Extract<T, U>:从联合中提取(交集)
type Common = Extract<AnyStatus, Active>;
// "running" | "standby"(只保留两边都有的)
// ===== 场景:接口能力的交集 =====
type BackendStatus = "running" | "standby" | "fault" | "maintain";
type FrontendSupported = Extract<BackendStatus, AnyStatus>;
// "running" | "standby" | "fault" —— maintain 前端还没做,编译期就隔离
4.3 NonNullable<T>:去空
type MaybeTemp = number | null | undefined;
type Temp = NonNullable<MaybeTemp>; // number
// ===== 场景 1:回调链的收窄固化 =====
function findDevice(id: string): Device | undefined { /* ... */ }
// 把"已检查过非空"的结果存起来
const found = findDevice("CNC-001");
if (found) {
const confirmed: NonNullable<typeof found> = found; // Device
// 后续使用 confirmed 不再带 undefined
}
// ===== 场景 2:清洗数组类型(配合 filter)=====
const list: (Device | null | undefined)[] = [device, null, undefined];
const cleaned = list.filter((d): d is Device => d != null);
// cleaned: Device[](D4 谓词版)
const cleaned2 = list.filter(d => d != null) as NonNullable<typeof list[number]>[];
// as 版(不推荐,谓词更优雅)
五、函数反解家族
5.1 ReturnType<T>:返回值类型
function fetchDevices(): Promise<Device[]> { /* ... */ }
function createAlert(): AlertRecord { /* ... */ }
type R1 = ReturnType<typeof fetchDevices>; // Promise<Device[]>
type R2 = ReturnType<typeof createAlert>; // AlertRecord
// ===== 场景 1:不导出接口也能引用返回值类型 =====
// API 函数没导出类型?从函数反解(第 6 天知识,工具化)
const result = await fetchDevices();
type Devices = Awaited<ReturnType<typeof fetchDevices>>; // Device[]
// ===== 场景 2:Vue3 store 反解(真实高频)=====
function useDeviceStore() {
const devices = ref<Device[]>([]);
const keyword = ref("");
function load() { /* ... */ }
return { devices, keyword, load };
}
type DeviceStore = ReturnType<typeof useDeviceStore>;
// { devices: Ref<Device[]>; keyword: Ref<string>; load: () => void }
5.2 Awaited<T>:解 Promise
type A1 = Awaited<Promise<Device>>; // Device
type A2 = Awaited<Promise<Device[]>>; // Device[]
type A3 = Awaited<Device>; // Device(非 Promise 原样返回)
type A4 = Awaited<Promise<Promise<Device>>>; // Device(递归解包!)
// ===== 场景:async 函数的数据类型 =====
async function loadStats(): Promise<DeviceStats> { /* ... */ }
type Stats = Awaited<ReturnType<typeof loadStats>>; // DeviceStats
// 这是"异步接口数据类型"的标准写法,背下来
5.3 Parameters<T>:参数元组
function createDevice(id: string, temp: number, status?: DeviceStatus) { /* ... */ }
type Args = Parameters<typeof createDevice>;
// [id: string, temp: number, status?: DeviceStatus](元组!)
type FirstArg = Args[0]; // string(D6 元组索引!)
type SecondArg = Args[1]; // number
// ===== 场景 1:转发参数 =====
function logAndCreate(...args: Parameters<typeof createDevice>) {
console.log("创建设备", args[0]);
return createDevice(...args); // 展开透传
}
// ===== 场景 2:debounce 的参数保持(D9 伏笔全解)=====
function debounce<F extends (...args: any[]) => any>(
fn: F,
delay: number
): (...args: Parameters<F>) => void {
let timer: number | undefined;
return (...args: Parameters<F>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay) as unknown as number;
};
}
// Parameters<F> 把原函数的参数元组"抄"给包装函数 —— 参数类型零丢失
5.4 InstanceType<T>:类的实例类型
class DataHub {
devices: Device[] = [];
refresh(): void {}
}
type Hub = InstanceType<typeof DataHub>; // DataHub(实例类型)
// ⚠ typeof DataHub 是"构造函数类型",InstanceType 才是实例
// 对比:
type Ctor = typeof DataHub; // new () => DataHub
type Inst = InstanceType<typeof DataHub>; // DataHub
// ===== 场景:不 import 类也能标注实例 =====
// 工厂函数返回实例,外部用 InstanceType 反解(避免循环依赖时常用)
function createHub(): InstanceType<typeof DataHub> {
return new DataHub();
}
六、其他实用工具
6.1 ThisType<T>(了解)
// 指定对象字面量方法里 this 的类型(Vue2 选项式 API 的类型原理)
interface Store {
state: { count: number };
increment(this: Store): void; // 显式 this 标注(更常用的方式)
}
6.2 特殊位置的 Partial/Required 组合技
// ===== 嵌套 Partial 不生效(重要认知)=====
interface Config {
server: { host: string; port: number };
client: { url: string; timeout: number };
}
type P1 = Partial<Config>;
// { server?: { host: string; port: number }; client?: {...} }
// ⚠ 只把第一层变可选 —— server 存在但 host 还是必填!
// 想全层可选 → DeepPartial(Day 12/14 自定义):
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type P2 = DeepPartial<Config>;
// { server?: { host?: string; port?: number }; ... } ✅ 全层可选
七、速查总表(打印贴墙版)
家族记忆法:
结构四兄弟:Partial(松)/ Required(紧)/ Readonly(锁)/ Pick-Omit(裁)
映射一尊佛:Record(表)
联合两兄妹:Exclude(删)/ Extract(留)
反解三剑客:Parameters(进)/ ReturnType(出)/ Awaited(拆快递)
八、实战场景全覆盖
8.1 场景一:完整 CRUD 接口的类型派生(今日主菜)
// ===== 实体(唯一的真相源)=====
interface Device {
id: string;
name: string;
temp: number;
status: "running" | "standby" | "fault";
createdAt: number;
updatedAt: number;
}
// ===== 五个接口类型全部派生(零手写重复)=====
/** 创建:不要 id/时间戳(后端生成) */
type DeviceCreate = Omit<Device, "id" | "createdAt" | "updatedAt">;
/** 更新:部分字段 + 不许改 id/时间 */
type DeviceUpdate = Partial<Omit<Device, "id" | "createdAt" | "updatedAt">>;
/** 列表查询:筛选条件 + 分页 */
type DeviceQuery = Partial<Pick<Device, "status">> & {
page?: number;
pageSize?: number;
keyword?: string;
};
/** 列表项(瘦身 DTO)*/
type DeviceListItem = Pick<Device, "id" | "name" | "temp" | "status">;
/** 详情响应 */
type DeviceDetail = Readonly<Device>; // 详情页只展示,标记只读
// ===== API 层签名(每个函数类型精确)=====
interface DeviceApi {
create(body: DeviceCreate): Promise<Device>;
update(id: string, body: DeviceUpdate): Promise<Device>;
remove(id: string): Promise<void>;
list(query: DeviceQuery): Promise<Page<DeviceListItem>>;
detail(id: string): Promise<DeviceDetail | null>;
}
// ===== 收益验证 =====
// Device 加字段 voltage: number:
// Create/Update 自动包含 voltage ✅
// ListItem 不变(白名单制)✅
// 手写 5 个 interface 的时代:改 5 处,漏 1 处就是 bug ⚠
8.2 场景二:分页 + 工具类型的组合
/** 通用分页响应 */
interface Page<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
/** 查询参数(所有列表页通用的骨架) */
type BaseQuery<T, K extends keyof T> = {
page?: number;
pageSize?: number;
orderBy?: K;
order?: "asc" | "desc";
};
type DeviceSortQuery = BaseQuery<Device, keyof Device>;
// orderBy 可以是任何 Device 字段 —— 按不存在字段排序直接爆红
8.3 场景三:状态机的类型编排
// ===== 状态机:状态 + 事件 + 转移表 全类型化 =====
type Status = "idle" | "loading" | "success" | "error";
/** 事件按状态限定(idle 只能开始,loading 只能结束)*/
type EventOf<S extends Status> =
S extends "idle" ? "START"
: S extends "loading" ? "SUCCESS" | "FAIL"
: S extends "error" ? "RETRY"
: never; // success 是终态,没有事件(条件类型 Day 12 预告)
/** 转移表:Record 的完整性保证状态机无死角 */
const TRANSITIONS: Record<Status, Partial<Record<EventOf<Status>, Status>>> = {
idle: { START: "loading" },
loading: { SUCCESS: "success", FAIL: "error" },
error: { RETRY: "loading" },
success: {} // 终态
};
// 状态机改状态/事件 → 转移表漏配编译爆红 —— 状态机 bug 的编译期防线
8.4 场景四:Vue3 组件 Props 的派生(超前预演)
// 第 29 周将写 Vue3 组件,Props 类型同样用工具类型派生:
/** 图表组件的 Props:从配置类型裁剪 */
interface ChartConfig {
option: EChartsOption;
height: number;
autoResize: boolean;
theme: "dark" | "light";
}
// 组件 Props:height 和 autoResize 允许外部不传(有默认值),其余必传
type ChartProps = Omit<Partial<ChartConfig>, "option"> & {
option: ChartConfig["option"]; // option 必传
};
// 手写等价物对比(体会派生的省心):
// {
// option: EChartsOption;
// height?: number;
// autoResize?: boolean;
// theme?: "dark" | "light";
// }
九、类比记忆:预制件仓库
把类型系统想成建筑工地:
心法:能组装就不现浇——派生类型永远和实体同步。
十、常见坑点与最佳实践
坑点 1:Partial 只浅层生效
// ❌ 期望嵌套可选,实际只第一层
type P = Partial<Config>; // server?: { host: string } ← host 仍必填
// ✅ 深层可选用 DeepPartial(自定义,Day 14 手写)
坑点 2:Omit 的键拼错不报错
type Bad = Omit<Device, "ids">; // ✅ 静默通过,等于没删!
// ✅ 严格版自定义:
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type Good = StrictOmit<Device, "ids">; // ❌ 编译错误
坑点 3:Record 的键是宽 string 时失去完整性
// ❌ 键宽化:Record 不再保证"每个状态都有配置"
const M: Record<string, Config> = { running: cfg }; // ✅ 通过但只有一项
// ✅ 键用字面量联合才有完整性保证
const M2: Record<AnyStatus, Config> = { running: cfg }; // ❌ 缺三个键
坑点 4:Readonly 是浅只读
interface Config { server: { host: string } }
const c: Readonly<Config> = { server: { host: "x" } };
c.server = { host: "y" }; // ❌ 第一层拦住
c.server.host = "y"; // ✅ 第二层放行了!
// 深只读 → DeepReadonly(Day 14 手写)
// 运行时硬保证 → Object.freeze(递归版)
坑点 5:工具类型堆叠的阅读性崩塌
// ❌ 一行套五层,review 时没人看得懂
type X = Partial<Readonly<Pick<Omit<Device, "a">, "b" | "c">>>;
// ✅ 拆中间类型 + 注释
type DeviceEditable = Omit<Device, "id" | "createdAt">; // 可编辑字段
type DeviceEditView = Pick<DeviceEditable, "name" | "temp">; // 编辑表单可见
type DraftView = Partial<DeviceEditView>; // 草稿(填一半)
坑点 6:ReturnType 用于重载函数
// 重载函数的 ReturnType 只取【最后一个】签名
function f(x: string): string;
function f(x: number): number;
function f(x: any): any { return x; }
type R = ReturnType<typeof f>; // number(只看最后一个重载)
// 多态反解失真 —— 这种场景手动导出类型更稳
坑点 7:Awaited 拆的不是"你想的那层"
type A = Awaited<Promise<Promise<Device>>>; // Device(递归全拆)
// 如果业务需要"只拆一层"—— 自定义:
type UnwrapOne<T> = T extends Promise<infer V> ? V : T;
最佳实践清单
接口类型优先派生:Create/Update/Query/DTO 全部从实体派生
Record 做配置表,键必须是字面量联合(保完整性)
Omit 换成 StrictOmit(自定义)防拼写静默失败
堆叠不超过两层,拆中间类型命名
深层结构用 Deep 系列(DeepPartial/DeepReadonly,Day 14 入库)
函数反解三剑客背熟:
Awaited<ReturnType<typeof fn>>是 async 数据类型标准式不要为派生而派生:只用一次的
{ id: string }直接写更清楚
十一、自测挑战
Q1:PATCH 更新接口的 body 类型用什么工具类型?和创建接口的有什么区别?
Q2:Partial<Config> 对嵌套对象 Config 生效几层?
Q3:Pick<Device, "id" | "temp"> 和手写 { id: string; temp: number } 谁更好?什么时候必须用前者?
Q4:Omit<Device, "ids">(拼错)会发生什么?怎么防?
Q5:Record 的完整性保证在什么前提下失效?
Q6:Exclude<AnyStatus, "fault" | "offline"> 的结果?写出计算过程。
Q7:Awaited<ReturnType<typeof loadAsync>> 中的两个工具各干什么?
Q8:Parameters<F> 返回的是什么形态的类型?怎么取第一个参数的类型?
Q9:typeof DataHub 和 InstanceType<typeof DataHub> 的区别?
Q10:实体 Device 加字段 voltage 后,下列类型哪些自动包含它:
DeviceCreate(Omit 去掉 id)、DeviceUpdate(Partial)、DeviceListItem(Pick 白名单)、STATUS_CONFIG(Record<DeviceStatus,…>)?
Q11:手写 NonNull<T> = Exclude<T, null | undefined>,验证与 NonNullable 等价。
Q12:设计"权限字段视图":User 含 password,写 PublicUser 类型并说明用了什么工具。
十二、总结与知识图谱
内置工具类型(官方预制件)
│
├── 结构变换
│ ├── Partial<T>(全可选)→ PATCH/草稿/配置合并
│ ├── Required<T>(全必填)→ 默认值填充后
│ ├── Readonly<T>(全只读)→ 快照/冻结 ⚠ 浅层
│ ├── Pick<T, K>(白名单)→ DTO/权限视图
│ └── Omit<T, K>(黑名单)→ 创建表单 ⚠ 键不受约束
│
├── 键值映射
│ └── Record<K, V>(完整性表)→ 配置/状态机
│ └── 前提:键必须是字面量联合
│
├── 联合运算
│ ├── Exclude<T, U>(删)→ Omit 的原理
│ ├── Extract<T, U>(留)→ 能力交集
│ └── NonNullable<T>(去空)→ 收窄固化
│
├── 函数反解
│ ├── Parameters<T>(参数元组)→ 透传/包装
│ ├── ReturnType<T>(返回值)→ store/API 反解
│ ├── Awaited<T>(解 Promise)→ 递归拆包
│ └── InstanceType<T>(实例)→ 类反解
│
├── 组合范式
│ ├── CRUD 五类型全派生(Create/Update/Query/DTO/Detail)
│ ├── Record + keyof typeof(配置表三连)
│ └── ⚠ 深层需 Deep 系列(自定义预告)
│
└── 使用纪律
├── 堆叠 ≤ 2 层,拆中间类型
├── StrictOmit 防拼写静默失败
└── 一次性类型直接写,不为派生而派生
一句话总结:工具类型是官方用泛型造好的预制件——结构四兄弟调松紧、Record 做完整表、反解三剑客从函数抠类型;接口类型全部从实体派生,加字段时处处自动同步。
延伸阅读
下一步
本文是 TypeScript 深入 系列的第 11 天。接下来两天拆开预制件看内部构造:
第 12 天:条件类型与 infer — 类型层三元运算(Exclude/ReturnType 的源码级原理)
第 13 天:映射类型 — 手写 Partial/Pick/Omit(今天用的,明天造)
学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。
每天花 2 小时,28 天通关 TypeScript 深入。加油!