ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

系统学一遍TypeScript:类型基础、泛型与工程配置避坑指南

系统学一遍TypeScript:类型基础、泛型与工程配置避坑指南 1. 为什么现在还要系统学一遍 TypeScript 基础1.1 从能用到会写的差距先说个我自己的真实感受TypeScript 学起来最尴尬的阶段不是完全不懂而是每个类型都知道一点但一写项目就全是 any。面试的时候问TypeScript 的了解程度大部分人能说出 typeof、interface、泛型这几个词但问到什么时候用接口、什么时候用类型别名为什么不要到处用 anytsconfig 里那些废弃提示到底在说什么就卡住了。这其实很正常因为 TypeScript 本身不是一门新语言它是给 JavaScript 加了一套类型系统。你用 JS 三年能写出能跑的功能但不一定就能写出类型合理的 TS。基础不夯实后期遇到类型报错、依赖库的类型声明不匹配、编译速度变慢等问题会非常痛苦。这一篇我就把最核心的基础盘点一遍重点不是罗列语法而是讲清楚每一种写法解决什么问题、实际项目中怎么选。1.2 新版废弃项提示给了我们什么信号最近用 TypeScript 5.x 初始化项目时很多朋友会看到这样一段警告选项“baseurl”已弃用并将停止在 TypeScript 7.0 中运行。请使用“paths”而不使用“baseurl”进行配置后者目前不需要此选项。 选项“moduleresolutionnode10”已弃用并将停止在 TypeScript 7.0 中运行。请指定“moduleresolutionnode16”或“moduleresolutionbundler”。这不是报错但它的价值比报错还大。它说明 TypeScript 正在推进模块解析策略的现代化。从前的 Node10 解析方式对应的是 Node.js 早期那种require的查找习惯现在前端构建工具百花齐放bundler模式更适合 Vite、webpack 这类工具。基础学习阶段不一定要立刻搞懂全部细节但必须知道配置选项不是随便抄的版本升级后旧写法可能失效。我在下文第五章会专门把 tsconfig 里的几个关键点和迁移思路讲透。2. 基础类型体系把 JS 的动态翻译成静态2.1 原始类型、数组、元组、枚举怎么用不拧巴最基础的 string、number、boolean 没什么好说的关键是别写出垃圾代码let name: string 张三; let age: number 18;真正容易拧巴的是数组和元组。数组是同构的元组是异构且有长度限制的。看这个例子// 数组 const list: number[] [1, 2, 3]; const list2: Arraynumber [1, 2, 3]; // 等价 // 元组 const tuple: [string, number] [张三, 18];使用元组时要注意push 操作是允许的TypeScript 并不会阻止你往元组里硬塞新元素。所以在业务代码中我一般建议把元组当作定长对来用例如useState的返回值[state, setState]不要试图在元组上做数组操作否则类型保护的意义就削减了大半。枚举的使用就更讲究了。字符串枚举比数字枚举更可读但要记住枚举本质上是对象会有运行时开销。在基础阶段可以先用字符串字面量联合类型代替枚举比如type Status pending | success | error;这种方式在类型提示上更直观也没有额外产物而且配合as const可以拿到只读对象。等真正需要做反向映射或业务集中管理时再考虑回归 enum。2.2 字面量类型与类型推断的默契配合刚接触 TS 时我有个坏习惯每个变量都手动标注类型仿佛不写类型就不叫 TypeScript。写多了才发现结构体、对象字面量、函数返回值这些场景TypeScript 的自动推断已经做得很好了你只需要在边界处标类型。字面量类型是理解推断的钥匙。比如const direction left; // 类型是 left let direction2 left; // 类型是 stringconst声明的变量本来就是不可变的所以 TS 推断出的是精确的字面量类型let可能被重新赋值所以放宽成 string。这个细节在判断函数参数时特别有用function setAlign(align: left | center | right) {}如果你用一个 string 变量传参哪怕它的值恰好是 left也会报错。因为类型系统看的是类型空间不是值空间。解决方法是使用as constconst config { align: left } as const; setAlign(config.align); // 通过用as const后对象的属性会被推断成字面量类型而不是被拓宽成 string。这个技巧在写配置对象、路由表、常量字典时能减少大量类型断言。2.3 类型别名 vs 接口什么时候选谁interface 和 type alias 能互相覆盖的功能很多但工程上的选择逻辑并不复杂。我自己的经验是对对象、类的形状描述优先用 interface对联合类型、交叉类型、工具类型转换后的结果用 type。interface 最大的优势是声明合并和 extends 的语义清晰这在写第三方库声明、给全局对象补充类型时特别重要。type alias 的优势是灵活它可以描述原始类型、联合类型、元组还能结合PartialT、PickT这类工具生成新类型。看一个实际场景。比如我们要描述一个 API 的响应结构interface ApiResponseT { code: number; data: T; message: string; } type User { id: number; name: string; }; type UserApiResponse ApiResponseUser;interface 定义了通用的容器结构type 负责组合业务类型两者配合比互相替代要顺畅得多。基础阶段最容易犯的错是能用 interface 的地方全用 interface能用 type 的地方也全用 type把二者搞成对立关系。正确做法是按语义区分而不是按心情区分。3. 函数与类面向对象的静态化实践3.1 函数重载与可选参数的最佳实践函数的类型标注最容易忽略的是调用者视角。TS 的函数类型有两种写法我直接列出来对比// 写法一直接将类型写在函数声明上 function greet(name: string, greeting?: string): string { return greeting ? ${greeting}, ${name} : Hello, ${name}; } // 写法二使用类型别名描述函数形状 type GreetFn (name: string, greeting?: string) string; const greet: GreetFn (name, greeting) greeting ? ${greeting}, ${name} : Hello, ${name};可选参数要放在必选参数之后这个规则基础但重要。还有参数默认值的问题当参数有默认值时TS 会推断它是可选参数类型是string而不是string | undefined但因为默认值保证了一定有值调用时也可以只传前两个参数这点和 JS 行为一致。函数重载则要非常小心。TS 的重载不是真正运行时多态它只是声明多个调用签名但实现体只有一个。我见过很多新手写出这样的代码function format(input: string): string; function format(input: number): string; function format(input: string | number): string { return typeof input string ? input.trim() : input.toFixed(2); }这样写没错但重载签名必须放在实现签名之前而且实现签名的参数类型必须能兼容所有重载签名。如果你把实现签名放在最上面TS 会报错。还有一个经验是重载的数量不要铺开太多超过两三个的时候建议考虑泛型或者直接收窄为联合类型参数否则维护成本很高。3.2 类里的 public/private/protected 真不一定按 Java 那套来很多有 Java/C# 背景的同学刚写 TS 时会习惯性地把所有属性都设置为 private然后通过 getter/setter 访问。这并不算错但在前端项目里过度封装会让代码变得非常啰嗦。TS 的private只是编译期的访问限制并不是真正意义上的运行时私有用#开头的 ES 私有字段才是真正的私有。这里有一个让我印象深刻的坑TS 的 private 在结构类型系统中可能造成误判。比如两个类都有私有属性#secret存在时它们之间是不兼容的。这不是大问题但你要是把一个类的实例赋给另一个类型的变量类型检查就会报错。反而是protected和public用起来更自然。实际项目中我更推荐遵循最少暴露原则但不要教条化class PaymentService { constructor(private readonly amount: number, private readonly fee: number) {} get total(): number { return this.amount this.fee; } }参数属性在构造函数参数里直接写private readonly amount是 TS 的语法糖能少写很多声明代码。这里的readonly也很重要它避免了在类方法里意外修改核心配置。3.3 抽象类与接口在工程里的分工抽象类用于模板方法场景接口用于能力契约场景。这是我在多个项目里反复验证后的结论。假设我们要实现一个数据上报模块。有的上报到日志平台有的上报到监控系统它们之间有公共逻辑数据清洗、重试、发送。但具体发送目标不同。这时候用抽象类很合适abstract class Reporter { protected constructor(protected readonly apiKey: string) {} protected cleanData(raw: unknown): unknown { return JSON.parse(JSON.stringify(raw)); } abstract send(payload: unknown): Promisevoid; async report(raw: unknown): Promisevoid { const cleaned this.cleanData(raw); await this.send(cleaned); } } class LogReporter extends Reporter { async send(payload: unknown): Promisevoid { // 发送到日志平台 } }而接口更适合描述一个类具备什么能力。比如Logger接口定义info/warn/error方法不管底层是 console 还是上报系统调用方只依赖接口。工程项目中我倾向面向接口编程的距离感但实现公共流程时用抽象类减少重复代码。二者不是替代关系各自处理不同层级的抽象。4. 泛型从类型参数到组件抽象4.1 为什么不建议把 any 当万能膏药any太香了任何类型报错都能用any抹掉。但你职业生涯里写掉的第一根头发大概率就是从any开始掉的。any会让 TypeScript 退化成 JavaScript而且在团队协作中任何人读到你的一堆any都无法确认这个函数输入输出到底是什么。基础阶段要先建立任何类型的解药是泛型不是 any的自觉。举个最常见的例子——一个获取对象属性的函数// 坏味道any 胡一把 function getProp(obj: any, key: string): any { return obj[key]; } // 正确姿势泛型 约束 function getPropT extends object, K extends keyof T(obj: T, key: K): T[K] { return obj[key]; }后者在调用时能自动推导出返回类型。如果你只是访问一层属性甚至不需要手动传泛型参数编译器自己搞定。这就是泛型的作用它把不同类型之间共同的结构抽象出来同时保留每种类型的具体信息。4.2 泛型约束、默认值与工具类型初探泛型约束用extends关键字但这里的 extends 不是继承的意思而是满足某种结构的意思。比如type Lengthwise { length: number }; function logLengthT extends Lengthwise(arg: T): T { console.log(arg.length); return arg; }这个约束表示不管传入的是什么类型都必须有一个 number 类型的 length 属性。字符串、数组都能通过因为它们的 length 属性是数字。数字类型的 length 不存在就无法通过编译。泛型默认值常用于构建组件或高阶函数。比如做一个异步请求封装interface HttpResponseT unknown { code: number; data: T; } function requestT unknown(url: string): PromiseHttpResponseT { return fetch(url).then(r r.json()); }默认值unknown能保证在不明确指定类型时也不会退化成 any这比默认T any安全得多。我在编写公共库时默认泛型参数基本都用 unknown 或具体可用的类型目的是强制调用方思考自己需要什么类型。4.3 常用内置工具类型源码级理解工具类型如PartialT、RequiredT、PickT, K、OmitT, K、RecordK, T等别看它们长得复杂源码其实很清晰。以Partial为例type PartialT { [P in keyof T]?: T[P]; };它遍历 T 的所有属性把每个属性的值类型保留但加上?表示可选。ReadonlyT是同样的映射只是加上readonly修饰符。RecordK, T用来快速构造对象类型type Role admin | user | guest; const rolePermissions: RecordRole, string[] { admin: [create, delete, update], user: [read, update], guest: [read], };Pick和Omit在很多业务场景里是真正的高频工具。比如一个 User 对象有 10 个字段创建用户接口只需要其中 4 个这时候用PickUser, name | email | password就能快速提取出需要的类型而不需要重新声明一遍。理解源码能帮你规避两个问题一是不要用OmitT, a | b去删不存在的属性类型不会报错但没意义二是工具类型不会做深层递归嵌套对象结构需要你自己设计一些递归映射类型。5. 现代 TypeScript 工程配置避坑实录5.1 tsconfig.json 里几个必懂的选项很多教程开头就是创建 tsconfig.json然后复制现成配置但复制久了就不知道自己到底开没开严格检查。我建议至少在三个选项上花点时间。strict是最核心的开关它包含了strictNullChecks、noImplicitAny、noImplicitThis等一系列严格性检查。现在的官方脚手架基本都默认开启老项目改造时可以先把 strict 设为 false逐步收拢但新项目必须开。strictNullChecks的影响最大开启后null和undefined不再赋值给普通类型这就逼着你处理空值情况。target决定输出的 JS 版本module决定模块规范。实际业务中你写代码的地方可能和构建工具的处理方式有关。比如 Vite 会建议module: ESNextNode 服务端则可能用module: NodeNext。基础阶段先记住这两项不要照抄网上旧配置要根据运行环境决定。verbatimModuleSyntax是 5.x 版本之后越来越有用的选项它要求你在导入类型时必须使用import type。这在编译器开启isolatedModules后能避免很多歧义也方便打包工具做 tree-shaking。实际上我现在的习惯是凡是一个导入只用于类型位置就一律写import type代码可读性和编译效率都能提升。5.2 baseurl 与 paths 的新旧变化怎么迁移回到开头提到的废弃提示。baseurl曾经和paths配合使用来为模块解析设置根目录和路径别名{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }在旧版本里baseurl 是 paths 的前置条件路径别名的解析依赖它。但在 TypeScript 5.0 之后单独指定paths已经不需要 baseurl 了。也就是说直接删掉baseUrl字段保留paths别名的解析依然正常。我实际迁移过一个中型项目删掉 baseUrl 后需要检查两处一是路径别名的书写方式二是某些动态导入或者import.meta相关的解析行为。如果遇到模块解析报错可以把paths里面的相对路径改为相对于 tsconfig.json 所在目录的写法。建议新项目直接不写 baseUrl只写 paths这也是当前官方推荐的做法。如果你还在维护老项目升级 TS 后看见这个 warning不用慌按提示改掉就是了。5.3 moduleResolution node10 已废弃用什么替代node10这个名字很容易让人误会它的别名是node指的是旧版 Node.js 的 CommonJS 解析算法。这个算法只支持require查找 node_modules 里的包对文件扩展名和exports字段的处理很粗糙。现在的 npm 包大量使用exports字段来控制子路径导出旧解析方式会忽略它们导致类型或运行时拿到不正确的文件。替代方案有两个主流选择{ compilerOptions: { moduleResolution: bundler, module: ESNext } }bundler模式适合 Vite/webpack 这类打包器它支持exports字段、import.meta.url、目录导入等特性。node16/nodenext则适合纯 Node.js 环境它会根据 package.json 里的type字段判断模块格式。从我自己用 webpack 5 和 Vite 的项目来看新项目无脑选bundler基本不会有问题除非你确实在写 Node 服务端且需要 ESM/CJS 互操作。遇到模块只能用 CommonJS 引用之类的报错时可以优先检查这个选项。5.4 在 vue3three.js 和 electron 打包场景中的一点体会这里补充两个真实场景因为很多读者是在这些场景里第一次接触 TS 配置的。第一个场景是 vue3 three.js。three.js 的类型定义在多年迭代后做得相当好但在使用OrbitControls等扩展时有时需要显式导入类型import type { OrbitControls } from three/examples/jsm/controls/OrbitControls.js;这类路径一般没有问题但如果你的 tsconfig 里有moduleResolution: node旧版就有可能导致 three.js 的examples/jsm路径无法解析。把它改成bundler后问题通常会消失。还有一个小技巧three.js 里很多对象构造参数是复杂的联合类型不想手动标注时可以用Parameterstypeof someFunction这种工具类型取出来减少重复劳动。第二个场景是 electron 打包。Electron 主进程和渲染进程的 TS 配置常常是分开的。主进程代码频繁使用 Node 内置模块渲染进程则使用 DOM 或框架类型。如果一份 tsconfig 混用会非常难受。我的建议是创建三个配置tsconfig.main.json、tsconfig.renderer.json、tsconfig.node.json用于 vite 配置等通过references关联。构建时分别运行vue-tsc或者tsc -p这样类型检查更严格也不会互相污染。在 electron 打包流程里遇到类型报错先看是不是moduleResolution的问题再考虑types字段是否需要显式声明node类型。6. 面试常考的基础题其实考的是思考方式6.1 如何回答satisfies 和 as 的区别as是类型断言它告诉编译器我知道我在做什么请把我的类型覆盖成目标类型。但断言有风险它只是编译期行为如果实际运行时值不符合断言就会埋雷。satisfies是 TS 4.9 引入的操作符它不改变变量的推断类型只用来检查这个值是否满足某个类型。举一个能明显体现区别的例子type Colors red | green | blue; const palette { primary: #ff0000, secondary: #00ff00, } satisfies Recordstring, string;这里如果只用as Recordstring, stringpalette.primary 的类型会变成 string丢失字面量信息。用 satisfies 后palette.primary 依然保持string的推断这里要注意satisfies 不会把类型收窄成更精确的#ff0000字面量它只负责校验。实际上当左侧对象是普通字符串字面量时推断类型是 string所以 primary 是 string。但如果是as const之后再 satisfies那就能保留字面量类型了。这种细节很难一下子说全但面试时只要点出as 是强制覆盖satisfies 是校验且保留推断类型这个核心逻辑就能体现你理解到了本质上。6.2 几个容易答错的类型题第一题typeof和keyof的区别。typeof在类型上下文里取出的是一个值的类型keyof取出的是一个类型的所有键的联合。两者经常组合使用比如const user { name: John, age: 20 }; type UserType typeof user; // { name: string; age: number } type UserKeys keyof UserType; // name | age第二题interface和type是否可以直接互相 extends答案是 interface 可以 extends type但 type alias 可以 extends interface 吗这里不严谨。正确的说法是type 能通过交叉类型包含 interface 的形状但不能像 interface 那样继承。所以严格来说 interface extends type 可以type 使用交叉类型操作符组合 interface 也可以但 type 没有 extends 关键字直接继承 interface 的语法。第三题unknown和any的区别。unknown是类型安全的顶层类型你不能在未经收窄的情况下直接使用它any则绕过了类型检查。实际写业务时遇到外部输入比如JSON.parse的返回值先用unknown接收再做类型收窄是最规范的做法。用any接收再到处断言早晚会出事。第四题optional chaining和non-null assertion的区别。a?.b是运行时保护如果 a 为 null/undefined表达式结果为 undefineda!.b是编译期声明a 一定是非空运行时如果 a 是空的照样报错。分清这两者可以避免很多类型上对了但运行崩了的问题。最后聊一点自己的体会写作这一篇的时候我特意回翻了几个老项目发现当年写下的baseurl和moduleResolution: node现在都成了废弃项这其实挺能说明问题的。TypeScript 基础语法本身变化不大但工程配置和最佳实践每年都在演进。学基础的时候不要太抠语法细节更重要的是把握设计思想类型系统是为了让代码更可预测不是在给你添堵。如果你刚上手 TypeScript先花时间把 strict 打开把 any 一点点清掉把 tsconfig 里每个选项的意义搞清楚这才是最值得投入的方向。下一篇我会继续写类型体操和实战中的声明文件设计我们到时候接着聊。
返回列表