首页 / 资讯中心 / 文章详情

TypeScript 入门指南:从 JS 到类型检查,一篇吃透核心语法与迁移实战

TypeScript 入门指南:从 JS 到类型检查,一篇吃透核心语法与迁移实战 ★ FEATURED ARTICLE
写代码最怕什么不是业务逻辑绕是你盯着一个变量盯了半天不知道它到底是什么。JS 项目跑个两三年接手的人最崩溃的就是这种场景后端返回的数据结构没人告诉你同事留下的函数你不敢改因为你猜不到调用方到底传进来了什么。变量类型全靠 console.log 排查代码提示全凭记忆改一个字段要全局搜索半天。TypeScript 解决的就是这个问题它把“这个变量是什么”从运行时排查提前到了写代码的那一瞬。这篇内容就是给有 JS 基础、想系统入门 TypeScript 的开发者准备的我会沿着 JS 开发者的思维习惯逐个拆解 TS 的核心知识点最后落到类型声明文件、迁移策略和报错排查上一篇吃透。1. 为什么从 JS 切到 TS不是多学一门语言是给 JS 装上安全网1.1 JS 开发者的痛点到底在哪先聊一个我真实遇到过的场景。之前维护一个老项目有个函数叫getUserInfo接收一个 userId返回用户信息。看着没毛病但线上偶尔报错查了半天发现是有个调用方传了空字符串函数内部userMap[id].name直接炸了。加了个空值判断过了俩月又出问题——这次是有人传了数字因为接口那边调错了参数。JS 的灵活在这里成了双刃剑调用方写什么你都接接了就得出错。这类问题在 JS 里几乎没有事前规避的手段。你写.name之前编辑器不知道你这个对象上有没有 name 属性你写parseInt(str)的时候编辑器也不知道这个 str 到底能不能转成整数。所有类型风险都被推迟到了运行那一秒在一个“老项目 多人协作 长周期迭代”的环境里这种不确定性会持续叠加直到某天上线炸一次。而 TypeScript 做的事情非常朴素在变量声明、函数参数、返回值上标注类型编译器在真正运行之前先帮你把“类型对不对、属性有没有、参数可不可”查一遍。它不改变 JS 的运行逻辑只是在运行之前多了一层静态检查。1.2 TS 不是另一个语言而是“带类型引擎的 JS”这里要先破除一个心理障碍——很多人以为学 TS 等于再学一门新语言其实不是。TS 的语法约等于 JS 加了一套类型标注系统所有 JS 代码本身也是合法的 TS 代码。这一点是关键认知。TS 源码最终会被tsc编译成 JS编译过程会做两件事第一把类型标注全部擦掉比如你写的let age: number 18编译成 JS 就是let age 18第二做类型检查发现有类型错误就直接报错不让你带着问题去运行。举个很直白的例子// TS 源码 let age: number 18; age twenty; // 这里会报错编译的时候tsc立刻提示Type string is not assignable to type number。但你如果硬着头皮编译比如关掉 strict 检查输出到 JS 其实还是let age twentyTS 在底层不会帮你做任何运行时转换。所以可以理解为TS 给你加了一层编译期安全检查但运行时该是什么样还是什么样。另一个经常被新人误解的点TS 的类型标注不会影响性能。类型在编译期就被擦除了运行时根本不存在“类型检查”这层开销所以不用担心用了 TS 项目就跑得慢。2. 五分钟搭好 TS 开发环境2.1 最简工程搭建与第一个编译学 TS 最怕卡在环境上。实际上本地搭一个最小环境只要三步测通编译就行。第一步建一个目录并初始化 npmmkdir ts-demo cd ts-demo npm init -y第二步安装 TypeScript 依赖npm install --save-dev typescript第三步创建index.ts文件写两行基础代码interface Person { name: string; age: number; } const user: Person { name: 张三, age: 26 }; console.log(Hello, ${user.name});然后在终端执行npx tsc index.ts跑完后你会看到目录下多了一个index.js文件而且内容里的 interface 已经被擦掉了只留下纯 JS 代码。这就是完整的“编译链路”TS 源码 → 类型检查 → JS 输出。如果你只是想快速试一个语法点不一定要开本地工程。TypeScript 官方提供了一个在线 PlaygroundTypeScript Playground左边写 TS 右边实时出 JS还带报错提示理解“类型擦除”这个概念特别好用。2.2 tsconfig.json 核心字段快查当工程文件逐渐变多之后就不适合一个个用npx tsc xxx.ts去编译了。更标准的方式是在根目录放一个tsconfig.json让编译器统一读取配置npx tsc --init这个命令会自动生成一个带完整注释的配置文件。新手不一定能全看懂先盯住这四个字段target编译后 JS 输出到什么版本比如 ES2020。该字段决定了你的代码能用哪些新语法。module模块化规范比如 ESNext 或 CommonJSNode 端和浏览器端的选择不一样。strict是否开启严格类型检查。建议直接true虽然一开始会多一堆报错但这是价值最大的选项。strict 模式会额外检查空值、隐式 any 等一堆场景相当于把安全网拉满。outDir编译产物输出目录比如./dist。工程化之后建议打开让源码和产物分离。实际上一个全新的前端项目tsconfig.json里一般重点配置的就是这几个其他字段等需要时再查文档即可。没必要一开始背完所有配置容易劝退。2.3 编辑器的天然优势TS 在编辑器里的体验比 JS 强了一个量级。VSCode 对 TS 的支持是内置的不需要装额外插件。你写user.name的时候编辑器会直接弹出属性提示你写错属性名编辑器有时会画红波浪线比编译报错更早发现。这点其实是很多人学 TS 之后最直观的感受——代码补全变聪明了因为类型定义给了编辑器一份“数据地图”你不需要记住每个对象的字段名编辑器会告诉你。以前写 JS 靠的是“记性 搜索”写 TS 靠的是“提示 类型约束”体验差距非常大。3. 核心语法从 JS 变量到 TS 类型3.1 基础类型标注与类型收窄先看最常用的基础类型标注。JS 里变量怎么声明TS 就在后面加冒号标注类型let isDone: boolean false; let count: number 42; let userName: string Alice; let list: number[] [1, 2, 3]; let list2: Arraystring [a, b];数组有两种写法number[]和Arraynumber效果完全一样看团队规范选一种。还有个比较常用的类型——联合类型。比如这个变量可能是字符串可能是数字let id: string | number abc; id 123; // 合法联合类型的核心价值在于配合“类型收窄”。当你用typeof判断之后TS 会自动缩小类型范围function printId(id: string | number) { if (typeof id string) { console.log(id.toUpperCase()); // 这里 TS 知道 id 一定是 string } else { console.log(id.toFixed(2)); // 这里 TS 知道 id 一定是 number } }这个写法在你处理“前端数据有时候是字符串有时候是数字”这类场景时特别常见。接口返回的 ID、时间戳、状态码后端经常不给你固定类型联合类型就是你给这种不确定场景做的“正式声明”。字面量类型也值得一提。有些变量的取值不是所有 string而是固定的几个值type Status pending | success | error; let currentStatus: Status pending; currentStatus success; // 合法 currentStatus failed; // 报错不在允许范围内这个在状态机、表单步骤等场景非常好用相当于把“运行时的判断”提前到了“编译期的枚举”。3.2 interface给对象“约定长相”JS 里你在两个文件之间传递一个对象全靠调用方自己心里有数。TS 的interface解决的就是这个问题它像一份合同把对象的“形状”固定下来。比如后端返回一个用户数据你定义一个接口interface User { id: number; name: string; email?: string; // 可选属性有没有都行 readonly createdAt: Date; // 只读属性初始化之后不可改 }可选属性?后端可能不返回 email这字段不是必填那声明时加个问号访问时就先判断。只读属性readonly比如创建时间对象生成之后不应该被修改这个约束能防止代码里有人误改。接口还可以继承这是“接口怎么继承”这个高频问题的答案interface Student extends User { grade: number; }这样Student就自动拥有User的全部字段还额外多了一个grade。在做业务扩展时用继承可以避免反复拼接口。与之配套的还有一个type关键字。type能做的不只是对象形状还能组合联合类型、元组等。那 interface 和 type 到底选哪个一个通俗的选择标准是如果需要描述一个“对象的形状”且可能被继承优先用interface如果是联合类型、元组、函数签名这类用type。两者不是谁替代谁配合使用更顺手。3.3 函数类型参数和返回值都算“签名”JS 里函数本质是对象的一部分但在 TS 里函数也有自己的“类型签名”。写函数时给参数和返回值都标注类型是日常最高频的操作function add(a: number, b: number): number { return a b; }这样以后调用add(1, 2)就会直接报错传入的类型和声明不一致编辑器当场就能发现。可选参数也是一个高频场景function greet(name: string, greeting?: string): string { return ${greeting ?? Hello}, ${name}; }注意 TS 的规则可选参数必须放在参数列表的后面不能先写可选参数再写必选参数否则编译器会直接报错原因是调用时无法区分到底传了几个参数。事件处理函数比如 DOM 事件或 React 合成事件也经常需要标注类型。React 里常见的写法是这样的const handleClick (event: React.MouseEventHTMLButtonElement) { console.log(event.clientX, event.clientY); };React.MouseEvent是 React 提供的事件类型HTMLButtonElement是事件绑定的元素类型。这么一标你在处理函数里使用event.clientX、event.currentTarget.value都会获得正确的提示写错属性名会立刻被标红。另外函数除了常规声明还有一种函数类型表达方式常见于把函数作为参数传递type Handler (msg: string) void; function setHandler(fn: Handler) { fn(hello); }这就相当于提前定义了“这种函数长什么样子”以后往里传任何不符合签名的函数都会报错。3.4 泛型把类型也变成参数泛型是新手最容易卡壳的地方但实际工作中非常值得掌握。先看一个典型场景。你写了一个取数组第一个元素的函数function firstElement(arr: any[]) { return arr[0]; }这么做的问题在于返回值的类型是any类型保护彻底失效——你明明传进来的是一个数字数组取到的第一个元素却被编译器认为是任何类型后续写.toFixed()也不会给你提示。泛型要解决的就是让这个函数“传入什么类型的数组返回什么类型的元素”。写法如下function firstElementT(arr: T[]): T | undefined { return arr[0]; } const num firstElement([1, 2, 3]); // num 类型是 number const str firstElement([a, b]); // str 类型是 string这里的T是一个类型占位符调用时 TS 会自动根据传入参数推断出T的具体类型。所以类型信息没有丢失num明确是numberstr明确是string。泛型也可以和 interface 结合。最典型的例子是“后端接口统一返回结构”interface ApiResponseT { code: number; message: string; data: T; } interface User { id: number; name: string; } function fetchUser(): PromiseApiResponseUser { // 模拟请求 return Promise.resolve({ code: 0, message: ok, data: { id: 1, name: 张三 } }); }ApiResponseT就像一个通用容器把具体的业务类型作为泛型参数传进去。以后不管 data 是用户列表还是订单详情你都能复用这一层统一结构。泛型刚开始用会觉得抽象但当你接到“业务结构差不多、只有数据主体不同”的需求时就会意识到这玩意儿多省事。4. 类型声明文件 .d.ts让别人的代码和你的代码都“有据可查”4.1 .d.ts 文件到底在解决什么问题很多 TS 新手第一次见到xxx.d.ts文件时一脸懵其实理解起来可以把它比喻成一份“类型说明书”。.js文件是实际执行的逻辑.d.ts文件不参与逻辑执行它只告诉 TypeScript“这个模块有这些函数、这些类、这些参数”让类型检查能在编译期通过同时让编辑器能给出正确的代码提示。比如你在项目里用了某个纯 JS 写的第三方库这个库没有自带类型定义。TS 编译器看到import xxx from xxx的时候不知道该模块导出什么就会报“找不到模块声明”。这时候就需要一个.d.ts文件来替它声明类型。再比如你自己写的.js文件老项目里仍在用 JS 的那部分如果想被 TS 识别也得补一份.d.ts。从原理上讲类型声明文件在编译时会被 TS 读取但在产物里不会留下任何痕迹因为它是纯类型信息运行时不产生任何代码。重要认知.d.ts文件里不要放逻辑代码它只放类型定义、接口、类型别名、declare 语句。你见过把算法写在 .d.ts 里吗没有因为那样写根本不会被执行。4.2 为全局变量与全局类型编写声明最常见的.d.ts使用场景是“全局声明”。比如项目里有一个全局变量是通过script标签在 HTML 里直接引入的老项目很常见TS 并不知道它的存在。你可以创建一个global.d.ts文件内容如下declare const API_BASE_URL: string; declare interface Window { __INITIAL_STATE__: Recordstring, unknown; }这里用了declare关键字意思是告诉 TS“这个变量确实存在只是不在当前代码里定义”。这样你在业务代码里直接写API_BASE_URL就不会报错访问window.__INITIAL_STATE__也能获得类型提示。再比如你在window上挂了一个自定义属性比如埋点 SDKwindow.tracker window.tracker || { track: (event) console.log(event) };如果不做声明TS 会认为tracker不存在。上面的declare interface Window片段就是在补这个声明声明之后就能正常使用window.tracker.track(click)了。对于团队项目这种全局类型文件建议统一放在src/types/目录下例如src/types/global.d.ts然后在tsconfig.json里通过include字段把它纳入编译范围{ include: [src] }这样src目录下的所有.ts和.d.ts文件都会被自动识别。4.3 为纯 JS 的三方库补模块声明实际项目里经常会遇到装了 package 之后 TS 报“无法找到模块声明”的情况。原因很简单——这个包的作者没有提供类型定义。这时候你可以创建一个module.d.ts内容类似这样declare module old-js-lib { export function parse(input: string): object; export const version: string; }这就告诉 TS“old-js-lib这个模块导出了parse函数和version常量你们直接用。”如果这个包太大、声明起来太费劲也有一个过渡方案——先声明它为anydeclare module old-js-lib;这样一来import oldLib from old-js-lib;就不会报错了oldLib的类型是any。注意这只是一个权宜之计不建议长期用。因为它等于放弃了类型保护后续用oldLib的任何方法都没有提示也没有检查。比较合理的节奏是先用any让项目跑通随后业务有空的时候逐步补充具体声明。如果你的项目里还用到了自己团队内部写的公共 JS 工具函数类型声明思路也是一样的。只不过这些类型通常不会散放在各个目录里而是统一放在src/types/下对外导出。4.4 自己动手给一个 JS 工具函数库写声明做一个完整的.d.ts示例。假设团队里有一个老工具库实际逻辑写在utils.js里没有 TS 版本但你想让其他 TS 新人接手时能直接有代码提示。可以写一个utils.d.tsexport function formatTime(date: Date, pattern?: string): string; export function debounceT extends (...args: any[]) void(fn: T, wait: number): T; export type FormatterT (value: T) string;第 1 行声明了一个函数参数是日期和可选的格式化模板返回值是字符串。第 2 行声明了一个泛型函数输入一个函数输出一个相同类型的防抖函数。第 3 行导出了一个通用类型别名。写完.d.ts之后其他人在import { formatTime } from ./utils时输入的参数会有提示传错了也会立刻被标红。这就是类型声明文件最直接的价值让 JS 代码也在 TS 生态里拥有完整体验。5. 实际项目中 JS 怎么平滑迁到 TS5.1 渐进式改造先把老项目“编译通过”再逐步加强作为有 JS 基础的人你学的第一个 TS 项目不该是重构老工程而应该是从零开一个小项目。但不少人学了语法就想把老项目整个改一遍结果被几千条类型报错淹没两天就放弃了。比较好的策略是渐进式迁移。第一步在tsconfig.json里打开两个选项{ allowJs: true, checkJs: false, strict: false }allowJs: true的意思是不管.js文件还是.ts文件都可以共存TS 不会因为你引入了 JS 文件就报错。有了这个配置你可以在老项目里先新增.ts文件让 TS 和 JS 并存。checkJs先关掉等后面想逐步检查 JS 文件时再打开。之后再做增量迁移每动一个 JS 文件就把它的后缀改成.ts然后修完所有报错再提交。不要一口气批量重命名一批文件改完后项目会进入“编译不过”的状态反而影响团队其他成员。5.2 优先给“数据出入口”补类型收益最大迁移不是所有文件平等推进的优先给“数据的出入口”补类型会带来最高的收益。产出质量最好的“数据出入口”我总结有这么几类后端接口请求函数请求函数接收的参数、返回的数据结构是最值得标注的。比如/api/user/list返回的数据结构你用 interface 定好之后业务层所有消费这个接口的地方都能获得提示。事件处理函数DOM 事件、自定义事件的 payload不标注类型你经常得去翻调用点才能确认参数到底是什么。配置对象比如图表配置、路由表配置、表单配置字段多而杂没有类型定义容易写错字段名且运行时不报错。先给这几个地方补完整个项目的开发体验就会明显改善。相反如果一上来先给各种小的工具函数补类型这些边界收益很小容易消磨信心。5.3 两把“逃生锁”any 和类型断言再讲究的类型安全也难免遇到一些顶层设计上没法完全约束的场景。TS 自己也想到了这个问题提供了any和类型断言。any就是绕过一切类型检查。新注入的 JS 代码、JSON 解析出来的动态对象、极其复杂的递归结构在没有更好方案时可以用any兜底。但any有一个坏习惯就是会传染。一个any变量传给另一个函数那个函数的参数类型就变成 any 了检查就失效了。所以我的习惯是any要尽量局部化——用在一个函数的内部返回值上可以但尽量不要出现在函数的入参和出参上因为那是类型信息的边界。类型断言是另一个常见工具用来告诉 TS“我比你更了解这个值的真实类型”。典型例子const input document.getElementById(username) as HTMLInputElement; input.value 张三;getElementById返回的类型是HTMLElement | null虽然在选中的情况下它肯定是HTMLInputElement但编译器不知道。用as HTMLInputElement做个断言就能直接访问value属性。注意断言不是类型转换它不会真的改变对象的类型只是把编译器的视角调整到你认为正确的方向。6. 常见报错与排查经验速查6.1 高频编译错误对照表写 TS 的过程基本就是和编译器报错搏斗的过程。把最常见的几个场景整理成了速查表遇到直接对照着查。TS2339Property does not exist on type。最常见的报错之一比如在user对象上访问name但user的 interface 里没有name字段。解决办法要么补接口字段要么用as断言调整类型。TS2345Argument of type is not assignable to parameter of type。参数类型不匹配。比如函数要求传string你却传了number。解决办法检查调用处的实际值和函数签名往往是某个数据结构没对齐。TS2531Object is possibly null。严格模式下访问一个 nullable 变量的属性。解决办法加空值判断或者用!非空断言慎用或者用可选链?.。TS7017Element implicitly has an any type。常见于遍历动态对象时访问的键不在类型定义里。解决办法给对象加上Recordstring, unknown类型或者用类型收窄。TS2769No overload matches this call。传参方式不符合函数重载或组件 props 定义。解决办法检查传参数量和类型优先看报错提示里“期望的类型”是什么。错误码常见意思修复思路TS2339属性不存在于该类型上补接口字段 / 类型断言TS2345参数类型不匹配核对函数签名与调用参数TS2531对象可能为 null空值判断 / 可选链 / 非空断言TS7017动态键访问隐式 any用 Record 或 Map 约束TS2769函数重载不匹配检查传参类型与重载定义6.2 三个真正有用的排查技巧第一先读“期望类型 vs 实际类型”的差异。TS 大部分报错信息都会把对照关系写得很清楚比如“Type string | undefined is not assignable to type string”——这里的核心信息是编译器认为你的值可能是undefined但当前需要的是确定的string。大多数人报错后第一反应是看代码这是错的应该先看报错信息里的类型对照往往一眼就知道哪里出了问题。第二善用编辑器的快速修复功能。把鼠标移到红色波浪线上VSCode 通常会给出 Quick Fix 选项比如“Add missing field to interface”“Change all references to target type”等很多重复性的改动可以直接交给编辑器批量处理。第三关于.d.ts文件放哪里团队项目我一般统一放在src/types/下不散落在各处。另外写.d.ts时最容易踩的坑是忘了export。如果这个 .d.ts 文件里有import或export关键字它就是一个模块文件里面的声明不会自动变成全局类型。如果希望某个类型是全局可见的就要确认这个文件是“全局脚本文件”没有 import/export或者走模块内部显示import type导出。6.3 把“类型报错”当成代码自审的机会最后分享一个心态层面的经验看到类型报错先别急着烦躁它本质上是编译器在告诉你“这段代码存在潜在的不一致”。大多数时候这个报错反映的确实是代码里含糊不清的地方比如某个字段可能不存在、某个参数可能为空。借着报错把真实的数据结构捋清楚反而能避免后续上线时的运行 bug。我印象很深的是有一次排查一个线上问题undefined报错反复出现在一个多年没人动的老模块里。写这段代码的人已经离职了没任何注释改起来无从下手。后来用 TS 给相关数据结构补了一套 interface再把那个模块一点点接上类型检查错误直接暴露在编译期定位问题的速度肉眼可见地快了。我个人的经验建议学 TS 最容易成功的路径不是把官方文档从头翻到尾而是先搭好环境、把基础语法过一遍然后立刻用一个小项目哪怕是一个小的待办事项应用去练手。练的过程中你会疯狂踩坑但每一个报错都会加深你对类型系统的理解。我自己带过几个零基础 TS 的新人发现大家刚开始都觉得“这不就是多写几个冒号吗”但坚持写两周之后回头去看 JS 代码就会觉得浑身难受——没有提示没有约束完全靠猜。如果你目前的主要工作是维护老 JS 项目建议从今天开始就在改动的文件中顺手加类型注解不需要一下子全量迁移。给接口返回的数据补一个 interface给你的工具函数标注参数和返回值这些微小的动作会在几个月内让整个项目的类型覆盖率肉眼可见地上升。TypeScript 不是银弹但它确实是目前 JS 生态里性价比最高的稳定性投资值得吃透。
阅读完成 · 觉得有帮助?
咨询建站