如果你最近写过 TypeScript,大概率和我一样,在终端里看到过这么两行警告:选项“baseurl”已弃用,并将停止在 TypeScript 7.0 中运行;选项“moduleresolution=node10”已弃用,并将停止在 TypeScript 7.0 中运行。这两行字刚出来时我没当回事,直到项目里同时出现路径解析失败、类型版本对不上、构建和编辑器提示不一致的一堆怪问题,才意识到这不是简单“加个新选项”就能糊弄过去的。
这篇学习笔记不想搞成那种从string、number开始念经的教程,我默认你已经写过一点 TypeScript,正在被类型报错、工程配置、框架集成这些东西反复折磨。围绕最近这段时间我实际踩过的坑,我把内容分成五块:tsconfig 弃用配置的迁移、类型系统的建模思维、工程化配置与声明文件、React/Vue3/Three.js 里的实战观察、最后是学习工具和面试自测方向。每块都尽量给到“能直接拿去用”的结论和步骤。
1. 先聊最近刷屏的tsconfig弃用警告:baseUrl与node10到底该不该保留
先说结论:这两个选项迟早要删,现在不迁移,等哪天升级到 7.0,你的tsc --noEmit会直接变红。TypeScript 7.0 是官方原生编译器版本,它不再兼容这些历史包袱,留给我们的迁移时间其实不算宽裕。
1.1 baseUrl为什么会被“时代抛弃”
baseUrl 的历史价值在于:早年paths必须依赖baseUrl才能把@/foo这类别名映射到正确的目录。典型配置长这样:
{ "compilerOptions": { "baseUrl": "src", "paths": { "@/*": ["*"] } } }这段配置做的事是:告诉 TypeScript,所有以@/开头的非相对导入,都去src目录下找对应文件。问题出在哪?baseUrl只是 TypeScript 编译期的一个解析规则,它不是运行时规则。当你把 TS 编译成 CommonJS 或 ESM 产物后,require("@/utils/request")里的@/并不会被自动改写成./utils/request。于是你必须在 webpack、Vite、Rollup 里再配一份resolve.alias,两边一旦不一致,就会出现“编辑器里跳转正常,构建后直接模块找不到”的经典翻车。
后来官方发现,paths其实可以不依赖baseUrl,直接用相对路径映射:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }这种做法不再依赖baseUrl,路径映射的语义也更清晰:@/*对应./src/*,一眼就能看出根在哪。既然baseUrl在“新的 paths 用法”面前已经失去存在必要,同时它还容易掩盖运行时路径问题,被弃用是迟早的事。
1.2 moduleResolution=node10的病根在“不认识exports”
node10不是“Node.js 10 版本”的意思,它对应的是 TypeScript 内部旧版node解析策略。这种策略模拟的是 Node.js 10 年代的模块解析行为:从当前目录一层一层向上找node_modules,读取package.json的main字段,然后解析入口文件。
问题在于,这套解析规则不认识现代 npm 包最重要的exports字段。现在稍微有点追求的开源库,都会用exports声明条件导出,比如区分import条件、require条件、types条件。以 React 19 之类的现代库为例,node10策略解析时只看main,经常解析到包里那份陈旧的主类型声明,而不是exports.types指定的正确类型,于是出现运行时版本和类型版本对不上的诡异报错。
对比一下现在的解析策略:
| 解析策略 | 适用场景 | 支持exports | 推荐程度 |
|---|---|---|---|
node10(旧node) | 远古 Node 项目 | 不支持 | 不推荐 |
node16 | Node 端 ESM/CJS 混合项目 | 支持 | 普通 Node 项目可选 |
nodenext | 严格 ESM 语义的 Node 项目 | 支持,按type字段区分 | Node 项目推荐 |
bundler | Vite/webpack 等打包器项目 | 支持,不强制文件扩展名 | 前端项目推荐 |
1.3 我把项目迁移到新配置的完整步骤
我最近把一个 Vue3 老项目从旧配置迁到了最新方案,整个操作其实只有四步,但每一步都会牵扯出一些连锁问题,先把流程贴出来:
删除 tsconfig.json 里的
"baseUrl"字段。如果代码里有人用了baseUrl下的绝对路径导入(比如import request from "src/utils/request"),需要全部改成相对导入。把
paths改成相对路径写法:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"], "@components/*": ["./src/components/*"] } } }根据项目类型设置
moduleResolution。纯前端项目(Vite 构建)用"moduleResolution": "bundler",同时把module设为"ESNext"。Node 端项目用"moduleResolution": "nodenext",同时"module": "nodenext"。执行
tsc --noEmit,把报错一条条清掉。
迁移过程中最容易踩的坑是:moduleResolution改成nodenext后,整个项目的模块语法会变得极其严格。尤其是import一个 CommonJS 包时,你得用import express = require("express")这种旧语法,或者依赖esModuleInterop帮忙转换。而用bundler策略就宽松很多,因为打包器通常已经替你处理了 CJS/ESM 互操作。
如果你项目里同时用了ts-node跑脚本,那我建议直接把ts-node换成tsx。ts-node对nodenext的支持一直很别扭,我迁移时在它身上浪费了半天时间,换掉后一切清净。
提示:迁移前先看一眼
package.json的type字段。如果是"type": "module",文件扩展名.ts会被当作 ESM,容易遇到“导入路径缺少扩展名”的报错,这时候要么给相对导入补上.js后缀(TS 的惯例),要么改用bundler策略绕过这个限制。
2. TypeScript的“正确用法”不是标注,而是建模
如果你只看类型语法,会觉得 TypeScript 就是给 JS 加注释。但真正写久了你会发现,它的价值在于“建模”——用类型描述业务状态的形状,让编译器在每次赋值和函数调用的边界上做检查。
2.1 给变量写注解是最容易走偏的入门方式
很多初学者拿到 TS 后第一件事,是给所有变量写: string、: number。这其实把力气用错了地方。TypeScript 的类型推断能力很强,局部变量的类型编译器基本都能推断出来,你写注解不仅冗余,还可能把类型“写窄”。
我记得有次看到同事的代码:
const data: { name: string; age: number } = getData();getData 如果返回一个更宽的接口类型,这种注解反而会把类型固定死,后续你无法把data传给一个参数为更具体子类型的函数。我更推荐的做法是:函数参数、函数返回类型、组件 props、API 响应体这类“数据入口/出口”写显式类型,而局部变量的类型交给推断。比如:
const users = await fetchUsers(); // users 会自动推断成 User[] const firstUserName = users.map((u) => u.name);2.2 用判别式联合给真实业务“建模”
建模的第一步,是把“一个对象一堆可选字段”改成“一个联合类型多个明确分支”。看个最常见的例子,异步加载状态:
type LoadState<T> = | { status: "idle" } | { status: "loading"; startAt: number } | { status: "success"; data: T } | { status: "error"; error: Error };这种写法和“用一个对象挂满isLoading、data、error可选字段”相比,最大的优势是:你永远不可能写出“error有值同时data也有值”这种矛盾状态。到渲染层时,TS 会自动收窄:
function render(state: LoadState<User>) { switch (state.status) { case "loading": return `加载于 ${state.startAt}`; case "success": return state.data.name; // 这里知道 data 一定存在 case "error": return `出错了:${state.error.message}`; case "idle": return "尚未开始"; } }switch每往下走一个分支,TS 都会把state收窄到对应的那个具体子类型。
现在我又加入了satisfies操作符的使用习惯。它和传统: Type注解的区别在于:satisfies只负责校验“这个值是否符合某形状”,但保留值本身的字面量类型。比如:
const config = { color: "#333", border: "1px solid #eee", } satisfies Record<string, string>;这样config.color的类型就是字面量"#333",而不是string,同时还能保证所有值确实是字符串。写配置对象时非常舒服。
2.3 泛型:把类型参数从具体里抽出来
泛型是很多人都卡过的地方。我自己的理解方式很朴素:如果一个函数在编写时不确定用它的调用方到底会传什么类型,那就把类型变成一个“参数”,放到函数名后面的<...>里,让调用方来填充。
function firstOrUndefined<T>(arr: T[]): T | undefined { return arr[0]; } const num = firstOrUndefined([1, 2, 3]); // number | undefined const str = firstOrUndefined(["a", "b"]); // string | undefinedT在这里就是一个类型占位符。函数内部不能假设T是number或string,除非你用T extends XXX给它加约束。这种“委托”的思维一旦建立,你就不会一看到泛型就头皮发麻了。
真正难的地方是条件类型和infer,那是用来写工具类型的。日常业务里 90% 的场景只需要简单泛型约束,不要一上来就追求“类型体操大师”。
2.4 先吃透一批工具类型,比背一百条语法有用
TypeScript 自带了一批工具类型,我在业务里使用频率最高的是这些:
| 工具类型 | 作用 | 使用场景 |
|---|---|---|
Record<K, V> | 构造键为 K、值为 V 的对象 | 字典、配置映射 |
Partial<T> | 把 T 的所有属性变成可选 | 更新接口参数 |
Pick<T, K> | 从 T 中挑出指定属性 | 局部渲染需要的字段 |
Omit<T, K> | 从 T 中排除指定属性 | 去掉敏感字段 |
Exclude<T, U> | 从联合类型 T 中剔除 U | 过滤联合分支 |
ReturnType<T> | 取出函数类型 T 的返回值 | 从接口函数推导响应体 |
Awaited<T> | 取出 Promise 里的 T | 处理异步函数返回值 |
ReturnType是理解infer的最佳入口,它的源码其实很短:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;意思是:如果T是一个函数类型,就把它的返回类型“拿出来”赋给R。我建议你直接去lib.es5.d.ts里把每个工具类型的实现看一遍,看懂了就不再害怕类型编程了。
3. 工程化里真正吃时间的部分:tsconfig、ESLint与声明文件
类型语法学会之后,每天花时间最多的事情其实是处理“配置和类型在工程上怎么落地”。这一部分才是 TypeScript 项目和纯 JS 项目拉开差距的地方。
3.1 tsconfig里我建议长期开启的选项
下面这几个选项我是逢项目必开的,开了以后初期会有点痛,但后面几乎不用返工:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": false, "verbatimModuleSyntax": true, "isolatedModules": true } }strict是总开关,它把strictNullChecks、noImplicitAny、strictFunctionTypes等全开了,这个不用犹豫。
noUncheckedIndexedAccess是个很容易被忽略的宝藏。它让arr[i]、obj[key]这类访问返回T | undefined。一开始你会觉得很啰嗦,但真的能防止一堆越界和空值错误。写数组遍历时养成先判空或者用?访问的习惯,收益非常大。
exactOptionalPropertyTypes比较激进,它把“属性可选”和“属性值允许 undefined”完全区别对待。开它需要团队统一约定,否则你会不断被一些“多写了undefined赋值”搞烦。建议技术氛围好的小团队再开。
verbatimModuleSyntax是 5.0 引入的。它强制你区分import type和普通import,编译器不再帮你做“自动抹除类型导入”的动作。好处是运行时行为和类型行为彻底分清,特别是搭配isolatedModules,对 Vite/esbuild 这类单文件转译场景非常友好。
3.2 typescript-eslint怎么配合 flat config
TS 官方的TSLint已经废弃多年,现在统一的方案是typescript-eslint。这东西搭起来没那么玄,但现在用的都是 eslint 的 flat config,直接在项目根目录放eslint.config.js就行。我的一组最小配置:
import tseslint from "typescript-eslint"; export default tseslint.config( ...tseslint.configs.recommended, { rules: { "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/consistent-type-imports": [ "error", { "prefer": "type-imports" } ] } } );这里我最看重consistent-type-imports。它会自动把import { SomeType }拆成import type { SomeType },配合verbatimModuleSyntax后,类型导入和值导入彻底分离,运行时绝对不会因为类型没抹干净而出错。
还有一条建议:不要禁掉no-explicit-any这种规则又允许@ts-ignore到处飞。我在项目里约定:确实需要宽松类型时用unknown或Uint8Array这类具体类型,再不行就局部写any,但必须留一个说明注释。any的可怕之处是它会“传染”——一个 any 会让整条调用链全部失去保护。
3.3 第三方库没有类型时的“兜底”链路
用 TS 最大的尴尬之一,是找个 npm 包质量不错但没有自带类型。这时候有几个办法,按优先级排序:
优先找
@types/xxx包,npm 上大部分老牌库都有社区类型声明,比如@types/lodash、@types/node。没有
@types时,自己写一个最小的模块声明:
declare module "some-random-lib" { export function doSomething(input: string): boolean; export default class MyClass {} }- 如果这个库只是在这个项目里用,而且也不打算长期维护声明,可以临时用一个宽松声明兜底:
declare module "some-random-lib";这种写法的作用是让 TS 把该模块当成any处理,至少不会在tsc阶段直接挂掉,但优先级很低,能不用就不用。
全局变量和框架注入的类型则走declare global或引用类型文件:
declare global { interface Window { __APP_VERSION__: string; } }3.4 模块产物与“类型入口”的关系
如果你要发布一个 npm 包,类型入口比运行时入口更重要。现代包都会在package.json里声明exports,并给types条件单独指定:
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } } }这样你就不会遇到“运行时能跑,但编辑器里没类型”的尴尬。对普通项目来说,这一节不用花太多精力,只要记住:类型入口要和运行时入口保持一一对应,否则你写的库给别人用时会有大量类型错位问题。
4. 前端框架实战:React、Vue3与Three.js场景下的类型思维
前后端框架的 TS 用法,核心不是“记住某某类型叫什么”,而是“知道框架在哪里帮你推断,在哪里需要你显式声明”。我在 React、Vue3、Three.js 这三个场景里分别踩过不同类型的坑,逐个说。
4.1 React里常见的类型推导与泛型组件
React 项目里最容易报错的往往是事件类型和 children。常见写法是:
function SearchInput({ onChange }: { onChange: (value: string) => void }) { return ( <input type="text" onChange={(e) => onChange(e.target.value)} /> ); }这里的e不需要你手动标类型,React 自带的类型声明会根据onChange属性自动推断为React.ChangeEvent<HTMLInputElement>。
泛型组件是另一个值得掌握的点。比如你要做一个通用的选择列表组件:
interface SelectProps<T extends { id: string }> { items: T[]; selectedId?: string; onSelect: (item: T) => void; } function Select<T extends { id: string }>({ items, onSelect }: SelectProps<T>) { return <ul>{items.map((item) => <li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>)}</ul>; }这样Select被传入User[]时,onSelect的参数自动变成User,调用方的类型体验极其顺滑。React 的useState推导也很聪明,但当你需要null初始值时,记得显式给泛型:
const [user, setUser] = useState<User | null>(null);关于React.FC,我的观点是:现在没必要用。它会把children隐式加进 props,导致该有children的组件和不该有的组件无法在类型层面区分。直接写普通的 props 类型反而更清楚。
4.2 Vue3 的<script setup lang="ts">真的好用,但有个前提
Vue3 和 TS 的结合是目前三大框架里我体验最顺的。<script setup lang="ts">最大的魔法是defineProps和defineEmits可以直接用泛型推导:
<script setup lang="ts"> defineProps<{ title: string; count?: number; }>(); const emit = defineEmits<{ (e: "update:count", value: number): void; }>(); </script>这样写的好处是不需要维护两份类型声明,父组件传参时 Vue 语言工具会给出完整提示。
但这里有个很关键的前提:**Vue 单文件组件里的类型检查不能靠tsc完成,必须用vue-tsc。**我见过不止一个项目在tsconfig里配好了strict,结果跑tsc时跳过.vue文件,类型错误全漏过去了,到运行期才爆出来。正确的做法是:
vue-tsc --noEmit组合式函数返回的类型也要注意。如果你return { count, increment },Vue 会自动帮你解包 ref,返回的类型是number和() => void,不需要你写Ref<number>,写了反而容易误导调用方。只有当你要把返回值导出到别处时,才需要考虑Ref类型。
4.3 Vue3 + Three.js 做机房可视化时的类型处理经验
热搜词里那个“基于 vue3 + three.js + typescript 机房”,我一看就有画面感:大量 Mesh 循环生成、位置和透明度批量更新、轨道控制器里塞了几万个对象。Three.js 的类型其实挺强的,但容易让人头大的是它的对象类型层级很深。
我自己的组织方式是把所有 Three 相关资源收进一个组合式函数,返回一个明确类型:
interface SceneContext { scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer; controls: THREE.OrbitControls; } function useScene(container: HTMLElement): SceneContext { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, ...); // ... return { scene, camera, renderer, controls }; }这样在动画循环里就不会出现“访问不存在的方法”这种低级错误。机房可视化里的高频报错,多数发生在material和geometry的继承层级上。比如mesh.material可能被推断成Material | Material[],你要操作opacity时,得先用instanceof收窄:
if (mesh.material instanceof THREE.MeshStandardMaterial) { mesh.material.opacity = 0.5; }写 Three.js 的 TS 代码时,我还有个习惯:所有从getObjectByName这类方法拿到的对象,默认类型都是Object3D | undefined,使用时先用类型守卫判断。这不只是为了让 TS 闭嘴,更是避免运行时真的拿到 undefined 之后崩掉。
4.4 QuickJS 支持 TypeScript 吗:先说结论再看方案
这是个热搜问题,也是很多嵌入式/低代码项目会遇到的疑问。先说结论:QuickJS 本质上是一个 JavaScript 引擎,它执行的是 JavaScript。TypeScript 是编译期语言,所有类型在编译后全部消失,没有任何运行时环境能“直接执行” TS 代码。
所以“QuickJS 支持 TypeScript 吗”这个问题的正确理解方式是:
如果你要在 QuickJS 里跑 TS 写的业务逻辑,那必须先把 TS 编译成 JS,再把编译产物给 QuickJS。常见做法是用
tsc编译成 ES2019 或更低的语法,因为 QuickJS 的 ES2020+ 支持是逐渐补全的,保守一点不容易踩语法兼容性的坑。如果你想在 QuickJS 自己的宿主代码里写 TS,那需要给 QuickJS 的能力写
.d.ts声明文件。社区确实有这类绑定项目,但并不是 QuickJS 官方在“支持 TS”,而是声明文件让 TS 编译器能检查宿主 API。如果看的是
quickjs-ng这类社区分支,有些会在发布包时附带 TS 类型,但你用到的依然是从 TS 编译到 JS 后的产物。
了解这个边界很重要:在 QuickJS 这类受限运行时里,千万别指望“类型检查”能帮你在运行时拦截错误,as断言转译成 JS 后就是纯粹的 JS 对象操作。该做的运行时校验还是要做。
5. 学习工具与面试自测:把“会写”变成“会判断”
写了挺久 TS 之后,我发现阻碍进步的不是语法不熟,而是缺少“判断自己到底会不会”的检验标准。这块我给自己定了一套工具和题目。
5.1 TypeScript Playground 是最好用的课堂
官网的 TypeScript Playground 我几乎天天开。它的价值不只是写几行代码看编译结果,而是:
- 可以切换 TS 版本,复现“这个报错在 5.0 有、在 4.9 没有”这类问题。
- 有 “Explore” 面板,能看到推断出的完整类型结构,比在编辑器里 hover 更直观。
- 左侧可以开启/关闭编译选项,验证某个 tsconfig 选项对代码类型的影响。
我学条件类型和infer时,基本就是在 Playground 里拆开内置工具类型,反反复复写,直到亲手写出来才算过。
5.2 类型的“拐弯题”:用几个高价值问题自测
面试题是很好的自测材料,但别只背答案,要在 Playground 里真正敲出来。我整理了几个有区分度的问题:
any、unknown、never的区别。能一句话说清楚吗?“any关闭检查,unknown是安全但必须收窄才能用的顶层类型,never是永远不存在的值类型。”手写
Awaited<T>。如果只会用不会写,就对 Promise 解包机制理解不足。手写
DeepReadonly<T>。这个需要递归条件类型,能写出来说明你对映射类型不虚。用类型守卫写出
is关键字。function isString(x: unknown): x is string这种定义,直接体现你对“类型收窄”的理解。知道函数参数是逆变还是协变。简单说,函数类型的参数位置要允许赋值反向兼容,这也是为什么
strictFunctionTypes会拦下某些看起来很合理的赋值。
如果每个问题都能在 30 分钟内写出来并且解释“为什么这样写”,那基本可以告别“只会复制粘贴类型定义”的阶段了。
5.3 我给自己的TypeScript学习路径排序
最后分享一下我现在的排序方式,不一定适合所有人,但适合从纯 JS 转过来、被各种类型报错打击的人:
先扔掉“所有变量都要写类型”的执念,理解“类型是值的集合”,比如
string | number就是字符串和数字两个集合的并集。把
strict模式全程开着写一个小项目,比如一个 Todo 管理页,强制自己面对空值判断和数组索引警告。学泛型的委托思维,能用
T extends写一个可复用函数后,再去看条件类型和前向推断。熟练阅读
.d.ts文件。很多库类型你能看懂,就不会觉得人家“故意为难你”。培养一个习惯:每次报错先看 TS 给的“期望类型 vs 实际类型”,对比这两个形状差异在哪,而不是直接
as unknown as暴力断言。
我个人实际写项目最大的体会是:TypeScript 的报错不是敌人,它是编译器在告诉你“当前代码的状态可能和你预期的不一样”。大多数报错其实都能倒推出一个真实存在的边界情况,处理掉它们,就是在帮自己在运行前消灭一批潜在 bug。把心态从“让它闭嘴”调整为“听懂它在说什么”之后,写 TS 的感觉会顺畅很多,这也是这份学习笔记最终想分享给你的东西。