ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript 知识点笔记:从类型系统到 Vue3 实战与工程配置

TypeScript 知识点笔记:从类型系统到 Vue3 实战与工程配置

最近重新整理了一版 TypeScript 知识点笔记,其实是在给团队做内部分享时,发现自己很多结论是“背下来的”,而不是“想明白的”。索性把类型系统、工程配置、Vue3 里的用法,还有这些年实际踩过的坑从头捋了一遍。这份内容不是官方文档的翻译,也不是八股式速查,更像是我作为写了好几年 JS 再转 TS 的人,把代码里真正会卡住你的那部分讲清楚。适合刚上手 TS 的读者查漏补缺,也适合准备 TypeScript 面试的朋友在面试前快速过一遍重点。

1. 为什么 TypeScript 值得单独做一份笔记

1.1 TypeScript 不是“加了类型的 JS”,而是“带约束的 JS”

很多教程喜欢把 TypeScript 描述成“给 JavaScript 加类型”,这个说法没错,但容易让人低估它的价值。早期我写 TS 时也以为只要给每个变量标上string、number、boolean就够了,真正在项目里用了一段时间后才发现,TS 的核心价值不在于给基础类型做标记,而在于它让“数据结构”成为可以被编译器检查和引用的东西。

举个例子,电商后台里一个订单长什么样,原来是靠注释和人肉记忆传递,现在用类型表达:

type OrderStatus = 'pending' | 'paid' | 'shipped' | 'done' interface OrderItem { skuId: string name: string price: number count: number } interface Order { id: string status: OrderStatus items: OrderItem[] totalPrice: number createdAt: Date }

这个Order接口写完之后,所有跟订单有关的函数都不需要再啰嗦地解释参数结构,编译器会替你检查:状态是不是拼错了,totalPrice是不是传了字符串,items里有没有漏字段。这就像在开工前先把施工图定好,而不是边砌墙边改图纸。

1.2 类型是文档,也是重构的安全网

我经常跟人讲一句话:TS 的最大受益者是“未来的自己”。项目写一个月后,再打开当初的函数,没有类型标注的话,光靠变量名去猜参数结构是很痛苦的。有了类型定义,函数签名本身就是一份可执行的文档,IDE 的悬浮提示会直接展示这个函数要什么、返回什么。

更实在的好处是重构成为了“可回滚的冒险”。比如把一个函数从接收单个id改成接收整个对象,原来写 JS 的时候要全局搜调用点,还得担心漏改。在 TS 项目里,改完函数签名,编译一下,所有调用点没有一个能逃过编译器的眼睛。类型检查就像在施工现场拉了一圈警戒线,哪里碰了红线一目了然。

1.3 这份笔记适合谁

这份笔记主要给三类人看:第一类是已经会写 JS、刚接触 TS 不久,想系统梳理一遍基础概念的;第二类是在 Vue 或 React 项目里用 TS 用了几个月,但遇到报错只会用any解决,想搞明白背后的原理的;第三类是准备 TS 相关面试,需要快速过一遍重点题型和容易答错的知识点的。当然了,如果只是写一次性脚本、纯个人小玩具,继续用 JS 完全没问题,TS 的价值本来就在长期维护和团队协作里体现。

2. 先把地基打牢:核心类型系统

2.1 类型推断与显式标注的边界

TypeScript 非常擅长自己猜类型,这也是很多人刚上手时觉得“太顺畅”的原因。const count = 1,TS 自动推断为number;let name = '张三',自动推断为string;async function fetchData()返回的值自动推断为Promise<T>。这些场景不需要你手动标注,写上去反而显得冗余。

但有些地方必须依赖显式标注,不然编译器也猜不出来。最典型的是函数参数:参数是运行时才传进来,没有上下文,不标注类型就会隐式变成any,等于白写 TS。还有空数组const list = [],TS 会推成any[],后面往里塞各种类型它都接受,容易埋雷。更稳妥的写法是const list: string[] = []或者const list: Array<string> = []。

还有一个很容易忽略的场景:外部数据,比如接口返回的 JSON、localStorage里读出来的值、第三方 SDK 回调里给的数据。这些数据 TS 看不到内部结构,手动定义好类型再收回来,是项目里最常见的“防呆”动作。我的习惯是:能推断的尽量不写,但函数参数、外部数据边界这两处绝不偷懒。

2.2 联合类型、交叉类型和可辨识联合

联合类型用|表示“多选一”,交叉类型用&表示“同时满足”。这两个符号看着简单,实际使用中的差别很微妙。比如string | number表示参数既可以是字符串也可以是数字;而A & B表示一个对象要同时具备 A 和 B 的全部属性。交叉类型在组合基础接口时很顺手,但有些人喜欢拿它代替继承,我一般更推荐接口继承,后面细说。

联合类型真正的威力藏在“可辨识联合”里。用一个公共字段作为“标签”,TS 就能在不同分支里自动缩小类型范围:

type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; length: number } function area(shape: Shape) { if (shape.kind === 'circle') { return Math.PI * shape.radius ** 2 } return shape.length ** 2 }

这段代码里,circle分支里的shape一定带有radius,square分支里一定有length。你不用自己断言,也不用写if ('radius' in shape)这种绕弯的判断。可辨识联合是写复杂业务状态、表单类型、事件处理时最值得养成的习惯。

2.3 接口和类型别名,到底该怎么选

interface和type都能定义对象结构,很多人纠结怎么选。其实记住几个规律就行:

interface 支持声明合并、扩展更自然。多个代码块里可以重复声明同一个 interface 名称,合并属性;extends一个 interface 出来的对象,继承关系清晰。type 能做联合类型、交叉类型、条件类型、映射类型这些 interface 做不了的组合。比如type Nullable<T> = T | null,这个Nullable只能靠 type 表达。还要注意,type 的交叉在类型冲突时会产生never字段,排查起来比 interface 继承麻烦一点。

对比项interfacetype
对象结构声明支持支持
联合类型 / 交叉类型不支持支持
声明合并支持不支持
继承扩展extends关键字交叉&
条件类型 / 映射类型不支持直接表达支持

我的项目经验是:定义对象形状优先用interface,遇到联合类型、工具类型、函数重载这类需求再用type。这个规律不是严格的语法要求,而是进入团队协作后降低认知成本的通用约定。

2.4 泛型:把“写死”变成“写活”

泛型是所有 TS 初学者的一道坎。它的核心思想是“类型参数化”:函数或类型不用固定死具体类型,而是留一个占位符,由调用方在使用时确定。最简单的例子:

function first<T>(arr: T[]): T | undefined { return arr[0] } const num = first([1, 2, 3]) // number const str = first(['a', 'b']) // string

first不关心数组里装的是什么,它只保证返回值的类型与数组元素类型一致。这个能力让工具函数既能复用,又能保持类型安全。

泛型还可以加约束。比如我要写一个“按指定 key 对数组建 Map”的工具函数,参数里的 key 必须是真的存在于对象上的字段:

function toMap<T, K extends keyof T>(items: T[], key: K): Map<T[K], T> { return new Map(items.map(item => [item[key], item])) }

K extends keyof T表示 K 必须是 T 的属性名之一。调用时如果传错 key 名,TS 会直接报错,不会等到运行时报undefined。这一套弄明白之后,泛型就从“语法”变成了“设计工具”。

3. 进阶类型:从能用变成会设计

3.1 类型守卫

类型守卫的目的是把不确定的类型缩窄到确定的类型,让代码块里的类型推断更精确。平时最常见的写法有四种:typeof、instanceof、in和自定义类型谓词。

typeof适合原始类型,typeof value === 'string'之后,value在分支里就被视为string。instanceof适合类实例,instanceof Date、instanceof Error都是常规操作。in适合对象上的属性判断,if ('radius' in shape)能直接判断某个可选属性是否存在。自定义类型谓词是用value is Type语法,把“如何判断一个值是某种类型”封装成可复用的函数:

function isString(value: unknown): value is string { return typeof value === 'string' }

如果不用类型谓词,这个判断函数返回的只是boolean,TS 不会因为你在if里调用它就自动缩窄类型。加了value is string,TS 才能明白“当这个函数返回 true 时,value 确实是 string”。这个细节是很多人写过自定义守卫之后依然报错的根源。

3.2 keyof、索引访问与索引签名

keyof取的是“对象类型的所有属性名组成的联合类型”。这个能力很基础,但配合泛型之后特别好用。比如想写一个“安全获取对象属性”的函数:

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key] }

这里T[K]是索引访问类型,表示“取 T 中 K 这个属性对应的值类型”。keyof T保证 K 不会越界,T[K]保证返回值的类型不会丢。这两兄弟总是一起出现,是映射类型、条件类型的地基。

索引签名是对“未知属性集合”的类型描述。比如处理接口返回的字典结构:

interface Dict<T> { [key: string]: T }

它表示这个对象可以有任何字符串键,值的类型统一是T。索引签名有两条容易踩的规则:值类型要兼容所有已知属性,比如number类型的age和string类型的[key: string]放一起会报错;同时[key: string]会吞掉所有字符串类型的属性键,设计时要提前想清楚。

3.3 内置工具类型是最高效的“轮子”

TS 自带了一批工具类型,能省掉大量重复的类型声明。我列一个高频清单:

工具类型作用示例
Partial<T>所有属性变为可选Partial<Config>
Required<T>所有属性变为必选Required<Config>
Readonly<T>所有属性变为只读Readonly<Point>
Pick<T, K>从 T 中挑选部分属性`Pick<User, 'id'
Omit<T, K>从 T 中排除部分属性Omit<User, 'password'>
Record<K, T>以 K 作为键、T 作为值构造对象`Record<'a'
Exclude<T, U>从联合类型 T 中剔除 U`Exclude<'a'
Extract<T, U>从联合类型 T 中保留 U`Extract<'a'
ReturnType<T>提取函数返回类型ReturnType<typeof fn>
Parameters<T>提取函数参数元组类型Parameters<typeof fn>

Pick和Omit是最常用的两个,比如接口返回了很多字段,前端只要其中几个,直接Pick<User, 'id' | 'name'>就拿到了一个子集。ReturnType在传函数引用给第三方库时特别有用,不用手写函数返回值类型,TS 自己会抽出来。

3.4 条件类型和 infer,高阶玩家的两个武器

条件类型的语法长这样:T extends U ? X : Y,意思是“如果 T 能够赋值给 U,类型就是 X,否则是 Y”。看起来像三目运算符,但它操作的是类型而不是值。有了它就可以做类型层面的分支判断。

infer是配套使用的“类型抽取”利器,它允许你在条件类型的分支里声明一个待推断的类型变量。一个经典例子是手写ReturnType:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never type Fn = (a: number, b: string) => void type Result = MyReturnType<Fn> // void

infer R表示“如果 T 是一个函数类型,把函数的返回值抽出来命名为 R”,然后直接返回 R。这种写法读起来有点绕,但从这个例子可以看出infer的本质:它不是你自己定义的固定类型,而是从现有类型里“推断”出来的变量。看到库源码里出现infer U,不要慌,先找它到底想从哪个结构里抽什么出来。

3.5 我为什么不建议所有人上来就“卷类型体操”

网上有很多“类型体操”挑战,一个DeepPick能写好几屏,确实能练基本功。但从真实项目角度讲,业务代码里 90% 以上的类型需求用基础类型、泛型、工具类型就能解决。复杂的条件类型和infer更多出现在库的类型定义里,你自己写业务需要手搓这种体操的机会真不多。

我建议把条件类型和infer学到“能读懂、能模仿写”的程度就够了,不必追求每道题都搓出最优解。真正该花时间的是把基础类型、可辨识联合、泛型约束这些练熟,它们才是每天都会碰到的工具。面试如果问到,能流畅解释ReturnType的infer实现原理,一般就足够证明你有这个能力了。

4. tsconfig 与工程配置:里面真的藏着不少门道

4.1 先看几个影响全局的编译选项

tsconfig.json 是整个 TS 工程的“交通规则”,很多同学平时不怎么碰它,直到报错才打开。有几个选项值得花时间理解。

target决定编译后的 JS 语法版本,比如ES2015、ES2020。它影响 async/await、可选链这类语法是否需要被降级转译。module决定模块系统,ESNext适合现代打包器,CommonJS适合 Node 老项目,Node16或NodeNext适合现代 Node 项目。

strict是最重要的开关,没有之一。开启后它会附带启用strictNullChecks、noImplicitAny等一系列严格检查。我一直建议新项目无脑开启strict,老项目也尽量往这个方向迁移。很多人抱怨 TS“迁不动”就是因为strict开着报错太多,但那些报错恰恰是之前代码里埋下的隐患。

4.2 moduleResolution,以及“node10 已弃用”这个警告

如果你最近启动过一个较新的 TS 项目,可能会在终端里看到这么一条警告:选项moduleResolution=node10已弃用,并将停止在 TypeScript 7.0 中运行。

这里有个背景要讲清楚。历史上,TS 的模块解析默认值叫node,它模拟的是 Node.js 早期(大概 Node 10 时代)的解析规则:先按相对路径找文件,找不到就尝试加.ts、.tsx、.d.ts扩展名,再不行就去找node_modules里的包。这套规则在很长时间里是事实标准。

但后来 Node 自己进化了,引入了 ESM 的exports字段和新的解析语义,TS 也推出了node16、nodenext两种新的解析模式。旧规则和新规则差异太大,TS 官方干脆把旧值改名为node10,并计划在新版本里彻底移除。所以如果你的 tsconfig 里写着"moduleResolution": "node"或"node10",编译时大概率会看到弃用警告。

解决方式不复杂,看项目形态:如果是 Vite 或 Webpack 这类打包器项目,"moduleResolution": "Bundler"是最合适的选择;如果是 Node 服务端项目,用"moduleResolution": "Node16"或"NodeNext",同时把module也改成对应值。只有一种情况还值得继续用旧规则,就是维护一个非常老、完全无法升级依赖的存量项目。

4.3 baseUrl 已弃用,paths 的正确玩法

和moduleResolution差不多时间出现的,还有baseUrl的弃用警告。以前为了让路径别名生效,通常会在 tsconfig 里这么写:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }

baseUrl指定了所有非相对模块导入的基准目录,paths再在这个基础上做映射。于是import xxx from '@/utils'就会指向src/utils。这个写法用了很多年,但在较新的 TS 版本里同样会被标记为弃用,原因是paths本身已经可以不依赖baseUrl独立工作了。

新版推荐写法是直接删掉baseUrl,保留paths,并且把paths里的路径改写成“相对于 tsconfig 文件所在位置”的形式:

{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

删除baseUrl之后,所有路径映射都以 tsconfig 所在目录作为起点,行为反而更确定。如果项目里同时配了 Vite 的 alias,两边映射要保持一致,否则 TS 编译不报错但 Vite 运行时会找不到模块,这类“能编译但跑不起来”的问题排查起来特别费劲。

4.4 一个可以直接抄的 tsconfig 模板

如果你用 Vite 初始化一个 Vue 或 React 项目,脚手架生成的 tsconfig 一般已经合理。但如果是从零搭 Node 工具或自己组装前端工程,下面这个模板可以作为起点:

{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noUnusedLocals": true, "noUnusedParameters": true }, "include": ["src"], "exclude": ["node_modules", "dist"] }

这个配置有几个选项值得解释。esModuleInterop让 ESM 和 CommonJS 之间的导入导出更加顺手,在import React from 'react'这类场景少了很多烦恼。skipLibCheck跳过.d.ts文件内部的类型检查,可以极大减少第三方包之间的类型冲突,我基本默认开着。noUnusedLocals和noUnusedParameters会把未使用的变量和参数直接报成错误,逼着代码保持干净,让我这种强迫症非常舒服。

5. 实战落地:Vue3 + TypeScript 怎么用才顺手

5.1 ref、reactive 与 computed 的类型推导

Vue3 的响应式 API 和 TS 配合得相当好,大多数时候你不需要手动标注,类型能自动推导。ref(0)的value是number,ref('')的value是string,reactive({ name: '张三', age: 18 })推导出对应的对象结构。computed也一样:

const count = ref(0) const double = computed(() => count.value * 2)

double的类型被推导为ComputedRef<number>,模板里直接用double就行。需要显式标注的场景主要有两种:一种是ref接收外部传入、有可能是 null 的初始值,要显式声明ref<HTMLDivElement | null>(null);另一种是接口返回的数据结构比较复杂,希望先在类型里定好契约,再用ref<T>([])的方式初始化。

5.2 组件 props 和 emit 的类型化

Vue3 的defineProps和defineEmits配合 TS 泛型,可以告别以前那种靠运行时校验猜参数类型的方式。最推荐的写法是纯类型声明:

const props = defineProps<{ title: string count?: number }>() const emit = defineEmits<{ (e: 'update:modelValue', value: string): void (e: 'change', id: number): void }>()

这种写法的好处是组件外部使用时,TS 能自动检查父组件传下来的 props 是否符合声明,事件名和参数也会得到校验。需要注意,纯类型声明下,props 的默认值没法直接在defineProps里设置,要用withDefaults单独处理:

const props = withDefaults(defineProps<{ title: string count?: number }>(), { count: 0 })

withDefaults不仅让默认值生效,还会自动把对应 prop 的可选性简化掉,这个细节比老式的props对象写法轻量很多。

5.3 三个特别容易犯的错误

第一个错误是模板里用any解决问题。Vue 的模板表达式做类型检查本来就是“尽力而为”,有时候在模板里访问一个不存在的属性,IDE 不一定立刻标红。别因此觉得可以放飞自我,类型漏洞最后都会在某个深层逻辑里炸出来。

第二个错误是 emit 的参数全部标成any。有些同学图省事,把defineEmits写成(e: string, ...args: any[]),等于把事件的契约完全丢掉。哪天重构业务字段时,父组件和子组件各自不知道对方改了什么,问题就来了。事件参数和 props 一样,应该用具体类型表达清楚。

第三个错误是解构 props 后丢失响应式。const { title } = props取到的只是当前值,不是响应式的。如果确实需要在子组件里单独处理某个 prop,要么直接用props.xxx,要么用computed(() => props.xxx)包装一层。这个坑在 Vue3 刚出来时骗过很多人,现在依然层出不穷。

6. 遇到类型报错,我是怎么排查的

6.1 报错信息其实是一条“线索链”

TS 的报错信息乍一看很吓人,实际上每一条都在告诉你怎么改。最常遇到的Property 'xxx' does not exist on type 'yyy',意思是某个变量在 TS 看来是yyy类型,而你尝试访问的xxx不在它的属性列表里。这时候要先确认 TS 对变量的类型推断是否符合你的预期,不符合就在源头修正类型,别在报错处加as any盖过去。

Type 'undefined' is not assignable to type 'string'这类报错一般是strictNullChecks开着的时候发生的。函数参数可能没传,索引访问可能不存在,TS 会把undefined视为一种“不被允许”的类型。解决思路不是关掉严格检查,而是在源头做判断、给默认值或使用?.、??这类安全访问操作符。

Cannot find module './xxx.vue'在 Vue 项目里很常见,这不是业务代码的问题,而是缺少环境类型声明。需要在env.d.ts或vite-env.d.ts里补充:

declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component }

这条规则同样适用于.css、.png等非 JS 资源的导入。记住,报错是线索链,不是麻烦,顺着线索找到源头改,比在报错处打补丁靠谱得多。

6.2 常见问题速查表

典型报错可能原因推荐解法
Property 'x' does not exist on type 'Y'类型结构不符合预期检查接口定义,确认联合类型分支或可选字段
Type 'undefined' is not assignable to type 'string'开启了严格空值检查,值可能不存在用?.、??,或提前判断赋值
Parameter 'x' implicitly has an 'any' type函数参数未标注类型且上下文无法推断显式给参数标注类型,或补充类型定义
Cannot find module 'xxx'缺声明文件或路径别名未配对补.d.ts声明,检查 tsconfig 和打包工具的路径映射
选项baseurl已弃用tsconfig 里还保留着老配置删除baseUrl,改写paths
选项moduleresolution=node10已弃用tsconfig 用了旧的模块解析模式改成Bundler、Node16或NodeNext
'x' is used before being assigned严格模式检测到变量可能未初始化就使用给变量赋初始值或重新梳理控制流

这个表格基本覆盖了日常开发里绝大多数报错。遇到没见过的错误,我的习惯是先把报错信息复制进搜索引擎,看官方 issue 和 Stack Overflow 的高赞回答。不是每次都能直接找到答案,但通常能定位到问题方向。

6.3 项目里 TypeScript 编码规范:我踩出来的几条红线

第一,不要用any当万能补丁。any会把类型检查完全关闭,相当于告诉 TS“这里不用你管”,那还不如把这文件改成.js。如果确实不知道类型,优先用unknown,它至少强迫你在使用前做类型收窄。

第二,接口和类型的命名统一风格。我一般用 PascalCase(大驼峰)命名类型,比如OrderItem、UserProfile,不使用I前缀,比如IOrder这种风格在 C# 里常见,但在 TS 社区并不主流,避免制造不必要的认知负担。普通变量用小驼峰,类型和接口用大驼峰,这条约定简单且好用。

第三,优先构造不可变数据结构。能写成readonly的字段就写成readonly,能用Readonly<T>包一层就包一层。类型层面的只读约束在运行时不一定真的冻结数据,但至少能在编译阶段拦下一波误操作。团队协作时,一个字段能不能改、改了会有什么风险,从类型上直接就能看出来。

7. TypeScript 面试:被问最多的几个点

7.1 我把面试里高频问题分成了四类

面试里 TS 的考察点其实很集中。第一类是基础概念,比如interface和type的区别、any/unknown/never/void各代表什么、enum的使用场景和坑。第二类是类型编程能力,比如keyof和typeof的区别、联合类型和交叉类型、泛型约束怎么写、如何用infer获取函数返回值类型。第三类是实际工程问题,比如strict模式包含哪些检查、模块解析的几种模式区别、声明文件在哪里配。

第四类最容易被忽视,就是“类型体操题”,面试官会当场让你写一个工具类型,比如实现Pick<T, K>、Omit<T, K>的原理。这类题不是考背诵,而是考你对映射类型、条件类型和keyof的熟练度。我给的建议是:基础概念必须滚瓜烂熟,类型编程题至少能手写常用工具类型的核心实现,工程配置题结合自己项目里的 tsconfig 说一说就好。面试官想听到的往往不是标准答案,而是你在真实项目里的取舍理由。

7.2 给从零开始的人一条不走弯路的学习路径

如果你现在 TS 基础比较薄弱,我的建议是别一上来就看那种几百页的电子书,容易放弃。先看官方文档里最核心的几个章节,了解基础类型、接口、泛型、类型推导这些概念。官方文档写得清晰,但内容量很大,可以挑着看,不用从头啃到尾。

然后做一遍 Type Challenges(type-challenges),这是个社区项目,里面收集了很多类型练习题,从 easy 到 hard 分级。你会发现很多题自己写不出来,但看题解的过程本身就是最好的学习。之后可以找一两个自己熟悉的第三方库,比如 Vue、lodash-es、axios,直接去读它们的.d.ts文件或者类型定义源码,看它们是怎么设计复杂泛型的。最后找个日常小工具,比如一个请求封装、一个存储封装,用 TS 完整写一遍,踩过几个坑、翻过几次源码,基本就算入门了。

我自己写 TS 的体感是:接触第一天会觉得它“处处跟你作对”,写得很难受;坚持一两周后,你会发现编译器的报错开始变成你的“私人代码审查员”,逼你考虑边界情况、理顺数据结构。磨刀不误砍柴工,把这份笔记里的基本盘练熟,剩下的大量细节都是在项目里边用边查、慢慢沉淀下来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进