ARTICLE DETAIL

资讯详情

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

前端项目从JS迁移到TypeScript:类型系统、泛型与声明文件实战指南

前端项目从JS迁移到TypeScript:类型系统、泛型与声明文件实战指南 从JS项目里跳进TypeScript是我做前端第五年才下定决心做的事。当时手里的业务代码接近十万行每次改一个接口字段都得全局搜索三四个小时还总有漏网之鱼等到用户反馈了才发现某个页面崩了。换成TypeScript之后这类“因为类型写错而爆炸”的问题在编辑器里就提前被红波浪线拦住了。如果你也处在“JS写得挺熟但项目一复杂就总出类型事故”的阶段这篇笔记应该能帮你少走很多弯路。这篇文章不打算把TypeScript官方文档里的所有特性都搬一遍而是按我从实际项目里转化过来的顺序来盘环境怎么搭、核心类型怎么用、interface和type怎么选、泛型怎么理解、.d.ts声明文件怎么读怎么写以及JS项目迁移时最容易踩的坑。学完这套你在日常开发里遇到的八成TS场景都能自己搞定面试问到的核心点也基本覆盖了。1. 先搞清楚TypeScript 到底解决了 JS 的什么问题1.1 JS 最深的坑类型要到运行时才暴露我们写JS时都有过这种经历一个函数接收两个参数本来传数字结果某个调用处传了字符串函数内部一相加变成了字符串拼接页面上的金额直接显示成“100200”。更难受的是这种bug在开发环境根本看不出来测试的时候也不一定触发偏偏上线后某个特定操作踩到了。底层原因就一句话JavaScript是动态类型语言变量是什么类型只有在代码执行到那一行时才能确定。这意味着你没法在“写代码”这个最早的阶段发现类型不匹配只能靠运行时去撞。小项目还好代码量上来之后函数之间互相调用数据层层传递任何一层的返回值类型变了下游所有用到它的地方都可能跟着出错。TypeScript的核心价值就是把这个“运行时猜谜”改成“开发期体检”。它给变量、函数参数、返回值都加上类型标注在代码编译成JS之前先做一次全量类型的静态检查把所有类型不匹配的地方揪出来。这样你写代码时面对的不再是一堆“可能出错”的隐患而是一套有边界的规则。1.2 TypeScript 不是新语言而是 JS 的“体检报告”很多人第一次接触TS会误以为要学一门全新的语言其实不是。TypeScript是JavaScript的超集任何一个合法的JS文件只要改一下扩展名变成.ts都能被TS编译器解析。它只是在JS原有的语法上增加了一套“类型系统”和对应的编译工具。打个比方JS像是一个医生凭经验看病只有症状出现时才知道哪里有问题TS则是先让你做一遍全套体检把血压、血糖、肝功能都量好再用这些指标来判断风险。但体检不会改变你的身体TS也始终只负责“检查”等编译到最后所有类型都会被擦除输出的是干净、纯正的JS代码。所以你必须先建立一个认知TS的类型系统只活在开发期运行时完全不存在。这也解释了为什么TS没有牺牲任何JS的运行性能因为它根本不参与运行时逻辑。理解了这一点后面看到“类型断言”“映射类型”这些概念就不会把它和JS运行时行为混在一起。2. 环境搭建从 tsc 到 tsconfig一次配好2.1 三分钟装上 TypeScript跑通第一个 .ts 文件搭建环境没什么玄学。装Node之后我用全局安装的方式先搞定编译器npm install -g typescript装完验证一下版本tsc -v能输出版本号就算成了。接着建一个test.ts写上一行const greet: string hello ts;用tsc直接编译tsc test.ts同级目录会多出一个test.js文件打开看会发现类型标注已经被抹掉了。第一次亲眼看到类型擦除这个过程比看十篇文档都有用。不过实际项目里很少会直接tsc编译单个文件更多是用构建工具集成。新建Vite项目时选TypeScript模板脚手架会帮你配置好一切的编译链路。如果是在现有项目里接TS建议按“tsc做类型检查 构建工具做转译”的分工来理解二者不冲突前者管质量后者管效率。2.2 tsconfig.json 这几个配置建议每一行都看懂项目根目录下执行tsc --init会生成一份tsconfig.json里面注释比代码还多。真正天天影响你的配置其实就那么几个。{ compilerOptions: { target: ES2020, module: ESNext, strict: true, outDir: ./dist, rootDir: ./src, include: [src/**/*.ts, types/**/*.d.ts], exclude: [node_modules, dist] } }target决定编译成哪个版本的ES语法module决定模块规范。strict建议一打开就开着它聚合了noImplicitAny、strictNullChecks等一堆严格检查选项。严格模式下的TS会逼着你把类型写清楚刚开始觉得烦但项目上了规模你会感谢这个选项帮你从源头拦截掉“隐式any”这种偷懒写法。outDir和rootDir是编译输出的目录映射include和exclude则控制编译器到底处理哪些文件。这里重点提示include不仅包含src下的.ts还需要包含专门的types目录如果你在项目里放了自定义声明文件漏掉这条TS会直接忽略它们然后给你报一堆找不到类型的错误。这个坑我踩了不止一次后面讲.d.ts时会细说。2.3 现代前端项目怎么接 TSVite 与 Nuxt 场景如果你用的是Vite那基本不用纠结tsc的配置因为开发环境下Vite直接esbuild转译TS类型检查交给vscode插件和构建时的vue-tsc以Vue项目为例。但我要提醒一件事Vite只转译不做类型检查所以你会发现有些类型错误在dev server下根本不报错只有执行vue-tsc --noEmit做构建检查时才蹦出来。Nuxt、Next这类服务端渲染框架对TS支持也都很完整新手不需要自己从零配. d.ts框架自带类型会帮你把页面、组件的props推导得明明白白。我的建议是练手别在环境搭建上花太多时间直接拿Vite脚手架起一个TS项目真正需要手写配置往往是你开始给老的JS项目补TS支持或者维护纯TS的npm包时。3. 类型系统的核心从 let a: number 到泛型逐个吃透3.1 基础类型与类型推断先戒掉过度标类型的坏习惯TS基础类型列表并不长string、number、boolean、null、undefined、symbol、bigint以及对象、数组、元组、枚举、any、unknown、never。新手最容易犯的错是给每个变量都写全类型标注比如let count: number 1。实际上TS有很完善的类型推断机制像这种字面量初始化编译器一眼就能看出count是number你写标注纯属多余。正确的习惯是能推断出来的不写推断不出来的才标。判断标准很简单——如果删掉类型标注编辑器报错了那就是需要写不报错还写着就是冗余。需要显式标注的地方主要有几类函数参数必须有类型否则就是隐式any函数返回值建议标尤其是返回逻辑复杂时以及空数组、空对象这类初始化后类型会被推断为any的场景。基础类型里我最想强调的是any和unknown的区别。any是彻底放弃检查让类型回到JS的裸奔状态unknown是“我知道这里可能是任意值但使用时必须先做判断”。能用unknown就不要用any这是和团队协作时的安全底线。3.2 interface 到底怎么继承和 type 有什么区别interface是TS里描述对象结构的主力。定义一个用户对象interface User { id: number; name: string; email?: string; readonly createdAt: Date; }?表示可选属性readonly表示只读属性这两个点平时很常用。interface支持继承而且支持多继承interface Admin extends User { permissions: string[]; }继承了User的Admin自动带上id、name、email、createdAt再追加permissions。这个继承的语义和JS里的类继承很接近很多初学者会把interface和class搞混其实interface只是描述数据的形状不产生任何运行时代码class则真正定义了一个可实例化的结构。和interface最容易混淆的是type类型别名。大多数人都会纠结“我到底该用哪个”。我的经验是优先用interface来描述对象和类因为它支持声明合并——同一个名字的interface可以定义多次TS会自动把它们合并到一起。这个特性在写第三方扩展、给全局对象补类型时特别有用。type则更适合表达联合类型、元组、函数签名这类“不是一个具体对象”的类型比如type Status pending | success | error; type Callback (data: string) void; type Point [number, number];type也能写对象类型但一旦涉及联合、交叉类型或者需要为数字/字符串字面量定义别名type就是唯一选择。interface继承的替代写法在type里叫交叉类型A B但两者有细微差别建议日常以interface继承为主type处理组合类型。3.3 联合类型、交叉类型与类型守卫把运行时判断提前联合类型让你允许一个值在有限几种类型中变化最常见的场景就是接口字段可能返回数组也可能返回对象。你写type ResponseData User[] | UserTS就自动帮你在后续每一个使用时做类型收窄的判断约束。类型收窄最基础的手段是typeof比如function format(value: string | number) { if (typeof value string) { return value.toUpperCase(); } return value.toFixed(2); }除了typeof遇到对象类型时可以用in操作符判断某个属性是否存在或者直接自定义一个类型谓词函数function isUser(item: User | Admin): item is Admin { return permissions in item; }item is Admin这个写法就是类型谓词它告诉TS这个函数返回true时传入的参数就是Admin类型。有了它后面的分支代码里TS能把类型精确收窄到Admin你就能安全访问permissions。类型守卫是这个阶段最值得练透的能力因为实际业务里拿着一个联合类型做判断是家常便饭判断写得对类型系统才会配合你。交叉类型和联合类型正好相反联合表示“或者”交叉表示“并且”。type FullUser User ExtraInfo表示同时具备两边的全部属性。交叉类型在扩展基础类型、组合多个来源的数据时很顺手我通常在type里需要合并对象时用interface场景则直接用extends。4. 泛型把类型当参数告别“万能的 any”4.1 泛型函数看不懂自己写一遍就通了泛型这个概念很多从JS转来的同事一看到就头疼。我用来解释它的一句话是“你在写代码时还不知道这个值是哪种类型但你可以把类型本身也当成一个参数等真正调用时再确定。”这个思路和JS里函数参数很像只是参数传的是值泛型传的是类型。看一个最简单的例子function identityT(value: T): T { return value; } const str identity(hello); // T 推断为 string const num identity(42); // T 推断为 number同样的一个函数可以用在字符串上也可以用在数字上并且返回值的类型永远和传入值一致。如果不用泛型你只能用any然后失去所有类型提示用了泛型这个函数就变成了一把“万能但精确”的钥匙。学会泛型后最值得掌握的是泛型约束。你不希望T真的是任意类型而是希望它“至少具备某些属性”于是加一个extends约束function getLengthT extends { length: number }(arg: T): number { return arg.length; }现在调用getLength时传入的必须是一个有length属性的参数字符串、数组都能过数字直接报错。这种约束在写通用工具函数时极其常见。4.2 泛型接口与内置工具类型你每天都在用的 Partial泛型不止用于函数也广泛用于接口。最典型的场景是封装接口响应结构interface ApiResponseT { code: number; message: string; data: T; } const res: ApiResponseUser { code: 0, message: ok, data: { id: 1, name: 张三 } };这样只要声明ApiResponse data字段自动就是User类型而不用为每个接口写一个单独的Response类型。Promise.resolve的返回值、后端列表分页、资源池等场景的泛型封装思路都一样让类型跟着数据结构走而不是写死。掌握泛型基础后一定要认识几个内置工具类型它们本质都是泛型操作。Partial 把T的所有属性变成可选在更新接口时常用PickT, K从T中挑选一部分属性OmitT, K反过来剔除一部分属性RecordK, V快速构造一个“键为K、值为V”的对象类型ReturnType 取出函数的返回类型。这几个工具类型能显著减少重复类型定义比如“编辑用户”和“创建用户”的区别往往就是“所有属性必填”和“部分属性可选”一个Partial就解决了。5. .d.ts 类型声明文件从读懂了到自己写5.1 什么场景需要 .d.ts拿到一个没有类型的 JS 库怎么办TS项目里常出现一行刺眼的红色报错“Could not find a declaration file for module jquery”。意思很简单代码里import了一个JS库但TS找不到它的类型描述。这时你有两条路一是装社区维护的类型包比如types/jquery二是找不到现成类型时自己写一个声明文件。.d.ts文件本质上就是“给别处写好的JS加一份类型说明书”。它的后缀里有个d开头就是declaration的意思。在这类文件里你只能写类型声明不能写逻辑代码因为TS不会编译它输出任何JS它只是给编译器看的。还有一种常见的自建场景团队的公共JS模块还是老代码你没空把整个模块改成TS但又希望TS项目引它时有类型提示。这时在项目里新建一个.d.ts用declare把模块的类型描述出来TS就能和这个JS模块愉快协作。我自己接过一个老项目就是靠一批.d.ts文件让占总量一半的旧JS代码在新TS代码里引用时完全不报错迁移压力小了很多。5.2 .d.ts 文件怎么写declare 灵魂三问写.d.ts先记住一个关键字declare。它的意思是“我声明一个东西存在但它的实现在别处”。三个最常用的场景如下。声明模块类型declare module my-old-lib { export function foo(arg: string): number; export const version: string; }声明全局变量declare global { interface Window { __INITIAL_STATE__: Recordstring, unknown; } }这段代码把window对象上新增一个__INITIAL__STATE__属性并且标了类型之后在全局就能安全访问window.__INITIAL__STATE__。声明函数和类也是同一套套路用declare function或declare class开头后面跟完整的类型签名。注意.d.ts里如果用了export那这个模块是“有导出的模块声明”如果不写export默认是全局声明可能污染全局命名空间。我的建议是工具和库类型一律用模块导出只有真的需要放在全局环境的东西才用declare global。5.3 types 文件夹的声明文件到底怎么用很多人在项目根目录建一个types文件夹把.d.ts往里面一放结果发现根本不起作用。原因十有八九是tsconfig.json里的include没覆盖到这个目录。我一直在用的是下面这套配置{ compilerOptions: { typeRoots: [./node_modules/types], types: [] }, include: [src, types] }include里加上types编译器才会去读这个文件夹下的.d.ts。typeRoots默认指向node_modules/types那是给第三方类型包用的自己写的声明文件不要塞进去。还有一种方法是直接把.d.ts文件放在src目录里只要被include覆盖到同样能生效。关键就一句话声明文件必须在编译器的扫描范围内否则写了等于白写。types文件夹下最常见的戏码是给全局变量、环境变量补类型。比如Vite项目的import.meta.env或服务端渲染项目挂到window上的全局数据都可以在types文件夹里集中声明方便统一管理。团队的公共类型定义也建议统一放在这里比如我给你列举的热门场景在一个types/global.d.ts里声明所有环境变量再在types/api.d.ts里放接口返回结构类型团队用起来很顺eslint也不会因为有“类型文件里混着运行时代码”而抱怨。6. JS 项目转 TS 的实战类型断言与常见报错排查6.1 类型断言as 不是万能钥匙从JS迁移到TS你会遇到很多“我比编译器更懂这个类型”的场景。比如后端返回的JSON你明确知道data是User对象但它在TS眼里只是any或unknown。这时可以用类型断言const user data as User;as就把data的类型强制指定成User后续属性访问就有提示了。还有一种更极端的双重断言写法data as unknown as User一般只在数据源非常可信时使用因为它绕过了TS的所有检查门槛。另一个常用的是非空断言写法是在变量后加感叹号props.name!它告诉TS“我确定它不是null或undefined”。这个在模板里处理可能为空的字段很方便但要克制使用因为它本质上是你跟编译器“拍胸脯保证”一旦保证错了还是会回到运行时崩溃的老路。我个人对as的态度是尽量少用用之前先问自己一句“这里是不是应该有类型守卫而不是断言”。类型守卫是TS主动帮你收窄的合法途径断言则是你单方面宣布“我说的就是对的”。能用守卫不用断言这个习惯能让你少很多隐性风险。6.2 迁移踩坑实录JS 转 TS 最常见的 5 个报错我前前后后帮三个老项目做过JS转TS的迁移工作踩过的坑基本可以归纳成五类。第一类模块找不到类型声明。解决方法就按上一节说的先找types包找不到再自己写.d.ts。第二类对象属性可能为null或undefined报strictNullChecks相关红字。处理方法是用可选链?.和空值合并??同时把类型定义成string | null而不是用非空断言硬扛。第三类隐式any。函数参数没写类型、数组取出来元素类型不明确都可能触发。这类报错就安静地把参数类型补上也算借机梳理一遍代码逻辑。第四类某个第三方库的API类型和实际用法对不上。可能是版本不匹配或者库本身类型定义过时。我的建议是先用as绕过去同时提一个issue等官方修千万别直接any过一阵没人记得它是谁的锅时你就得自己调查这个any埋在哪。第五类strict模式下很多历史写法被判为“不安全”比如给可能为undefined的变量赋值、或者在回调里没有处理错误分支。处理方式一般是重构代码拆出更明确的分支逻辑而不是粗暴放松strict配置。迁移顺序上我强烈建议“从依赖图的叶子节点开始”。也就是说先迁移被大量模块依赖的工具函数、常量定义、公共接口类型把它们的基础类型铺好再逐步向业务页面层推进。叶子节点类型稳了上层迁移时自动就能获得类型推导报错量少一半以上。反过来先迁页面你会被一大串依赖链上的类型问题淹没很容易心态崩溃。最后分享一点个人的学习节奏如果让我重新学一遍TypeScript我会把顺序锁定为基础类型和interface、type先过一遍再练类型守卫和联合类型因为业务里天天用泛型只要理解“类型当参数”这一个点就够了工具类型碰到一个查一个.d.ts不用一开始就深入研究等真正遇到“无类型库”报错时再针对性学记忆效果比我当初从头啃文档好得多。还有一个实操小技巧在我自己学习期间会专门挑一个已经写好的JS原生项目强制自己用TS把它重新写一遍不追求功能创新就追求每个函数、每个对象结构都有明确的类型定义。这个动作做完你对TS的熟悉程度会远超只看文档的同事。代码里的类型就是你的文档团队协作时所有人都在你定义好的边界里写代码这大概就是我从JS走向TS后体会最深的一点。
返回列表