1. 从“自用”到“贴出来”:这本笔记记录的起点
先说个实话:我电脑里躺着十几份命名格式是“XX学习笔记(自用)”的文档,有的写着写着就烂尾了,有的纯粹变成了一个收藏夹搬运工,真正派上用场的少。但TypeScript和ES6这份不一样,它是我断断续续折腾了大半年、走过不少弯路之后才沉淀下来的东西。最近清理本地笔记时翻出来,越看越觉得很多内容不该只躺在我的硬盘里,索性整理成博文贴出来。
为什么标题要强调“自用”?因为这份笔记的定位从一开始就不是写给新手看的教程,而是我给自己留的查漏补缺手册:项目里踩过的类型报错、ES6特性在真实场景里怎么用、配置项莫名其妙弹出来的弃用警告、面试前翻哪几页最管用。所以你会发现这份笔记的行文和我平时写的技术文章不太一样,它更像是一个人坐在你旁边,翻着他那本皱巴巴的笔记本跟你说“这个地方我当时卡了很久,你直接照抄就行”。
这篇内容适合谁看?我总结下来有三类人:第一类是刚接触TypeScript,被类型系统绕得头晕,想找个“过来人的手记”当参照的人;第二类是写过一些ES6但总觉得基础不牢,尤其是对深拷贝、Map这类高频考点还停留在“大概会用”这个层面的开发者;第三类是正在准备typescript面试,需要快速过一遍重点概念和踩坑记录的求职者。如果你属于其中任何一类,这篇笔记大概率能让你少走几个弯路。
有一点需要先说明:这篇博文不是我临时凭空写的,它建立在真实的开发经历之上。笔记里提到的每一个知识点、每一段报错信息、每一处工程配置,都是我在实际项目中碰过、查过、修过的东西。我会按自己的学习顺序来讲——先从ES6的语法体验说起,再深入TypeScript的类型体操,然后聊到工程化链路里的配置问题,最后站在面试视角做一轮反刍——这样读起来会更像一条真实的学习轨迹,而不是一份干燥的API目录。
2. ES6留给我的两组核心遗产:深拷贝与Map
ES6的东西太多了,箭头函数、模板字符串、解构赋值、Promise、async/await,每个单拎出来都能写一篇。但说句实在话,这些语法层面的东西只要写过两个项目基本就能上手,真正让我觉得“这玩意儿值得深入研究”的只有两组:深拷贝和Map。这两组碰到的坑最多,面试被问得也最频繁,而且它们都不是单纯的语法点,背后牵扯出的是JavaScript对象模型、引用传递、数据结构选型这些底层认知。
2.1 深拷贝:为什么JSON大法不是万能药
一说到深拷贝,很多人第一反应是JSON.parse(JSON.stringify(obj))。这个方法在90%的场景下确实好用,但剩下的10%恰恰是最容易出事故的地方。我在项目里就撞过好几次南墙,最典型的就是Date对象被序列化成字符串、undefined和Symbol属性直接被丢掉、函数被过滤掉、RegExp变成空对象。还有更隐蔽的:循环引用的对象会直接抛错Converting circular structure to JSON,当时线上一个接口返回的数据里带着一个环形引用,我这边一拷贝直接白屏,排查了半天才找到元凶。
所以后来我在笔记里给自己立了条规矩:能用结构化克隆structuredClone就用它,这是现代浏览器和Node 17+原生提供的深拷贝方案,支持循环引用、Date、RegExp、Map、Set这些类型,唯一的限制是函数的拷贝不支持(会抛DataCloneError)。但面试里手写深拷贝是躲不掉的考点,而且实际项目里有时也得自己实现,比如兼任node环境或浏览器兼容性限制时。
我的手写版本经历了三个迭代阶段,这里直接分享最终版本的核心思路:
function deepClone(target, map = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; } if (map.has(target)) { return map.get(target); } const clone = Array.isArray(target) ? [] : {}; map.set(target, clone); if (target instanceof Date) return new Date(target.getTime()); if (target instanceof RegExp) return new RegExp(target.source, target.flags); for (const key of Reflect.ownKeys(target)) { clone[key] = deepClone(target[key], map); } return clone; }这个版本的亮点在两点:一是用WeakMap缓存已经克隆过的对象,天然解决循环引用问题;二是用Reflect.ownKeys拿到包括Symbol在内的所有自有属性。写到这里我就忍不住要提一句Map了,因为WeakMap本身就是ES6中的Map家族成员,如果你不懂Map,这个深拷贝版本你就看不明白。
2.2 Map:不止是多了一种键值对容器
Map对我来说最大的认知冲击在于:它的键可以是任意类型。普通对象Object的键只能是字符串或者Symbol,但Map可以用函数、对象、NaN甚至undefined作为键。这有什么实际意义?我举两个场景。
第一个是缓存场景。我有一次要给一组状态对象做计算结果缓存,如果用Object,你得给每个对象手动拼一个字符串key,丑陋且容易碰撞;用Map的话直接把状态对象本身当作key,查缓存就是一次map.get(stateObj)的事,干净利落。
第二个是深拷贝场景里的循环引用检测。我在自己实现深拷贝时,第一版用的是{}做缓存表,结果发现当key不是字符串时全部会被转成"[object Object]",两个不同对象直接就冲突了。后来换成了WeakMap才彻底解决——WeakMap对key的引用是弱引用,不会阻止垃圾回收,这一点在做递归拷贝时尤其重要,因为你不想因为一个缓存表把所有被拷贝对象全部常驻内存。
除了Map本身,我还要提一下Map和数组的互转,这在处理接口数据时极其常用:
const map = new Map([['name', '张三'], ['age', 18]]); const arr = [...map]; // [['name', '张三'], ['age', 18]] const obj = Object.fromEntries(map); // { name: '张三', age: 18 }这三个转换我都实测过,其中最容易被忽略的是Object.fromEntries(map),它能把一个Map直接铺成普通对象,非常适合在传给后端或者塞进URL参数时用。反过来,Object.entries(obj)也能把普通对象转成二维数组再喂给new Map(),形成一个闭环。项目做多了之后你会发现,八成以上的数据转换需求在这几个API之间就能解决,根本用不着什么工具库。
2.3 迭代器与for...of:为什么遍历的顺序有讲究
既然提到了Map,就绕不开它的遍历特性。Map是有序的,它按照插入顺序排列键值对,而for...of天然支持迭代器协议。这个特性的实际价值在于:当你需要维护一个“有顺序的字典”时,Map是远比Object优秀的选择。我在一个活动配置模块里就遇到过这种需求,要让一组规则按配置顺序依次执行,用Object的话不同浏览器对字符串键的枚举顺序可能不一致,用Map则完全没问题。
for...of配合迭代器的另一个好处是可以用break、continue和return来中断或跳出循环,这一点forEach做不到——forEach里你中断循环只能用异常去模拟,相当痛苦。所以我在笔记里给自己的建议是:新代码里遍历数组、Set、Map,一律默认用for...of,只有需要索引值的时候才用for...in(实际上for...in主要用在普通对象上)或者Array.prototype.forEach。
3. TypeScript类型系统的实战修行:从基础约束到泛型绞肉机
ES6部分在我这里算是热身,真正让我花掉大把时间的是TypeScript的类型系统。说实话,一开始我对TS是有点抗拒的——写惯了JavaScript的灵活,觉得类型标注纯属加戏。但等到项目规模上去、接口联调天天出低级错误之后,我才意识到:类型系统不是约束,而是安全网,它把运行时才能暴露的错误提前到了编译期。
3.1 type与interface:到底该用哪个
这个问题我纠结了很久,网上答案也五花八门。我自己最终形成的判断标准是:定义对象的结构形状、声明类的实现契约时用interface;定义联合类型、交叉类型、工具类型、映射类型这些“计算出来的类型”时用type。
例如一个接口的响应体,我更习惯用interface来定义,因为它可以被多次合并声明,这是type做不到的(同名type会直接报错)。但一个人的身份可能是“老师”或“学生”,这种联合类型只能用type来表达:
interface Teacher { teach: () => void } interface Student { study: () => void } type Person = Teacher | Student;另外在React的props定义中,社区主流倾向是用interface还是type其实并没有绝对标准,但Vue3的defineProps泛型写法里更频繁出现的是type。我的经验是:在一个项目里保持统一比追求“最佳实践”更重要,否则代码review时每个文件都有不同的风格,维护成本会直线上升。
3.2 泛型:用过才知道它是“类型界的函数”
泛型这个抽象概念,我琢磨了很久才真正开窍。我的理解方式是:普通函数是对值做抽象,泛型是对类型做抽象。就好比你写一个add(a, b)函数,它不关心a和b具体是数字还是字符串,只要支持加号运算就能跑;泛型则是让类型也能被当作参数传给“类型函数”。
最简单的例子就是数组的map方法:Array<T>.map<U>(callback),它接收一个T类型的数组,通过callback把每个元素变成U类型,返回U类型的数组。用泛型思维理解之后,很多高级TS用法就顺理成章了。
function firstElement<T>(arr: T[]): T | undefined { return arr.length > 0 ? arr[0] : undefined; }这个函数没参数类型包袱,传string[]返回的候选类型就是string,传number[]返回的候选类型就是number,这就是泛型最直白的应用。但实际项目中光会这个还远远不够,更常用的是泛型约束,比如你想让一个函数只能操作带有length属性的类型,就得用extends来限定:
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; }我见过不少初学者写出function longest<T>(a: T, b: T): T然后一直报TS2339(属性length不存在),原因就是没有加泛型约束。这个坑我一开始也踩过,后来总结出来的经验是:当你的泛型代码里报“属性不存在”这类错误时,第一反应不是换个写法,而是想想自己是不是漏了extends约束。
3.3 条件类型与infer:类型体操的深水区
如果你翻过TS的工具类型源码,会发现很多高级类型都建立在条件类型T extends U ? X : Y之上,配合infer关键字可以做类型推断。说实话,这块内容除非你在写通用库或者工具函数,否则平时业务代码用到的频率并不高,但面试的时候问的人却不少,因为在面官眼里“能不能读懂复杂类型”很能反映一个人的TS水平。
我来拆解一个非常经典的条件类型例子:获取函数返回值的类型——这个其实TS自带了ReturnType<T>工具类型,但它的内部原理值得看一遍:
type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : never;关键就在infer R,它告诉TS“这里有一个待推断的类型变量R,你能推断出来就用它”。我刚开始完全看不懂这个语法,后来用了一个很笨的办法:把infer理解成“从结构里抽取类型”。比如(...args: any) => infer R就是从“一个函数类型”中,把返回值那部分抽取出来,命名为R。
条件类型在配合keyof、typeof、索引访问类型的时候能爆发出极强的表达能力。比如你想写一个工具类型,取出对象里所有值为函数的键名:
type FunctionKeys<T> = { [K in keyof T]: T[K] extends (...args: any) => any ? K : never }[keyof T];这段代码里用了映射类型把T的每个属性K遍历一遍,判断值的类型是否为函数,是则保留K,否则赋值为never,最后通过索引访问[keyof T]把所有never过滤掉,剩下的键名就是你要的结果。它看起来像魔术,但其实就是TS里的“数组filter”的思路——过滤的本质在于把不符合条件的类型变成never,然后让索引访问自动忽略它。
我在笔记里给这段内容标了两个星号,意思是面试前必看。不是因为面试官一定会考这个语法,而是能把这层逻辑讲明白,说明你对TS的类型运算不再是“背API”级别,而是真的理解了它的运作方式。
3.4 never、unknown与any:类型世界的三个极端
这组类型我单独列了一页,因为太多人把any当万能钥匙,把unknown当any的别名,把never当“不存在的类型”敷衍过去。实际使用中这三者的边界非常重要。
any是关闭类型检查,你用any就是在告诉TS“你别管我了,我什么类型都行”,危险性最大。unknown是安全版的any,你无法直接使用unknown类型的值,必须先通过类型守卫或断言收窄之后才可操作,好处是强迫你写安全的代码。never是所有类型的子类型,代表“永远不可能有值的类型”。比如一个函数总是抛出异常,它的返回值类型天然就是never。
我给自己的实践准则是:能不写any就不写any,遇到不确定的类型优先用unknown,等运行时判断完再收窄。实在不行写个显式的as any并加注释说明原因——这总比类型错误一片红然后一把梭全是any要强得多。
4. TypeScript 5.x配置项的弃用风波与工程链路的连锁反应
笔记写到TS类型知识之后,紧接着就是一大段工程化相关的记录。原因是某一天我升级项目里的TypeScript版本,控制台突然冒出一堆黄色的弃用警告。最醒目的两条是:“选项‘baseurl’已弃用,并将停止在TypeScript 7.0中运行。指定compilerOptions...”和“选项‘moduleresolution=node10’已弃用,并将停止在TypeScript 7.0中运行”。当时我整个人是懵的,因为这两行配置在几乎所有我见过的项目里都存在。
4.1 baseUrl和moduleResolution=node10为什么被宣判“死刑”
先来拆一下背景。很多老项目在tsconfig.json里都会这样写:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] }, "moduleResolution": "node10" } }baseUrl的作用是让模块解析在相对于baseUrl的路径上查找,paths配合它做路径别名,比如让@/components指向src/components。这套方案在早期确实解决问题,但它的缺陷也很明显:baseUrl会让模块解析变得不透明,尤其是当项目里同时存在相对路径和baseUrl相关的非相对路径时,解析顺序容易让人摸不着头脑。TypeScript官方后来推荐的做法是,路径别名完全通过paths配合相对路径来定义,不再依赖baseUrl作为解析根。
至于moduleResolution: node10,这个名字本身就有历史包袱——它是老旧的Node.js模块解析策略,对应的实际是Node.js的CommonJS时代。现在Node.js本身都支持ESM了,工具链和包管理器也都跟着演进,再用node10的解析规则去适配modern JavaScript生态就明显滞后了。新项目里强烈建议改成"moduleResolution": "bundler",这是TS 5.0引入的新取值,专门面向vite、webpack这类打包器场景。它理解package.json里的exports、imports这些字段,能正确处理ESM风格下的模块解析,比node10那套老黄历要适配得多。
4.2 修完tsconfig之后:vue-tsc和electron打包的连锁反应
这是我这篇笔记里最有含金量的一部分,因为我修配置的历程并不是只改一个tsconfig.json就结束的。我还在一个React Native相关的side project里遇到过类似情况,但更典型的是Vue3 + TypeScript项目的完整链条。
先说vue-tsc。Vue3项目通常用vue-tsc做类型检查和构建前的类型校验,它的作用和tsc类似,但额外支持.vue文件里的<script lang="ts">块。我升级到"vue-tsc": "^1.8.27"搭配"typescript": "^5.3.3"组合之后,遇到的问题特别典型:vue-tsc的版本要和typescript的主版本对齐,否则会出现一些匪夷所思的报错,比如明明类型没问题却报“找不到模块”或者“类型不兼容”。尤其是当我把moduleResolution改成bundler之后,有些第三方库的类型入口用的是exports字段,如果vue-tsc的版本过旧,它根本看不懂这种新语法,于是全线飘红。
解决这个问题的标准路径很清晰:把typescript和vue-tsc都升到兼容版本,检查vue-tsc的release notes里写的peerDependencies要求。我在笔记里记了一个关键教训:不要混着用全局typescript和一些全局tools,一个项目内部统一用项目本地依赖的TS版本。
再说electron打包。Electron应用打包时经常会在package.json里看到这样的devDependencies:
{ "scripts": { "build": "vue-tsc --noEmit && vite build", "pack": "electron-builder" } }如果vue-tsc在build阶段因为类型问题挂了,electron-builder根本走不到打包环节。但electron打包还有自己的另一层坑:主进程代码如果是TS写的,一般用单独一个tsconfig.node.json来编译,它的moduleResolution和renderer侧可能完全不同。有次我在打包后主进程里报“__dirname未定义”,排查半天发现是我在tsconfig.node.json里也顺手改了moduleResolution,导致主进程的CommonJS语义被破坏,__dirname直接消失。后来就把主进程和渲染进程的tsconfig彻底分开,一个保持node语义,一个用bundler,再也没出这种幺蛾子。
4.3 关于compilerOptions该怎么组织,我的模板
绕了这么大一圈,我把目前项目里比较稳的tsconfig配置整理成了模板,直接贴在这里供参考(注意注释很关键):
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "jsx": "preserve", "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true, "paths": { "@/*": ["./src/*"] } } }特别说明一下:我把原来习惯写的baseUrl去掉了,只用相对路径的paths来定义别名。strict这个开关我永远都是打开的,因为TS的类型检查价值主要体现在strict模式下,如果关掉strict不如直接用JavaScript。skipLibCheck建议开着,能显著缩短类型检查时间,代价是跳过对.d.ts文件的类型校验——对于大多数应用项目来说,这个代价是完全可以接受的。
5. 把自用笔记翻成面试考点:回顾与补漏
笔记的最后一段,是我在准备typescript面试的时候陆续补进去的。你会发现很多知识点在前面已经讲过,但同一件事换个问法之后,答法还真不一定一样。这段内容与其说是面试攻略,不如说是我站在“理论检验”的视角,重新逼自己把这些特性讲清楚的一次练习。
5.1 面试高频:type与interface、Map与Object、深拷贝与循环引用
这三个问题几乎每一个我都被问到过。面试官问type和interface的区别时,他真正想听的是你“对于建模工具的选择是否有自己的判断”,不是单纯背诵“interface可以被extends,type可以定义联合类型”。我的回答思路是:先从语法层面的差异展开(是否能合并声明、是否能描述联合类型),然后落到实际项目——我拿到一个接口文档时优先会用什么,处理一个复杂的模板类型计算时又会用什么,最后总结自己的统一规范。
Map与Object的问题,现在出镜率也很高。这个问题的背后其实是在考察数据结构意识:你什么情况下会用Map而不是Object,你是否了解对象原型链带来的风险(比如对象可以继承原型属性,你用Object做字典时得小心__proto__这类键),以及迭代顺序和性能差异。我的回答一般会从“Map的键可以是任意类型”和“Map天然有序”两个角度切入,然后举一个缓存或者深拷贝里实际用WeakMap的例子,把前文的经验直接搬过来用。
深拷贝手写更是老生常谈。我建议面试前把深拷贝的版本从浅到深背一遍:第一版JSON大法(说明你会用但知道局限),第二版递归+WeakMap(解决循环引用和基本复杂类型),第三版加上Reflect.ownKeys处理Symbol(提升完备性),最后补充一下structuredClone它解决什么问题、什么情况下不能用。能把这么一条演进链路上来,面试官基本上就会给你过了。
5.2 面试中的现场编码:泛型工具类型与类型守卫
除了问答题,面试里还经常有现场coding。我遇到的两次TS现场coding题目都集中在两类:
第一类是让我手写一个通用工具类型,比如Partial<T>、Pick<T, K>或Readonly<T>。这类题的底层逻辑就是映射类型和索引访问类型,平时我把keyof和in记熟之后基本都能写出来。
第二类是让我实现类型守卫。比如给一个联合类型type Animal = Dog | Cat,其中Dog有bark方法、Cat有meow方法,写一个函数判断入参到底是哪种。这里考察的要点是in操作符的使用:
function isDog(animal: Animal): animal is Dog { return (animal as Dog).bark !== undefined; } // 或者 function isCat(animal: Animal): animal is Cat { return 'meow' in animal; }用in操作符的写法更加简介,而且TS对in操作符做类型守卫的支持比传统的as更加稳定和可读。我在写业务代码时也更倾向于用in或者可选属性判别,而不是老是as一把梭。
现场coding时的心态也很重要。建议先在白板上写一个小例子跑通思路,再扩展到完整实现。面试官看重的不是你对API的背诵熟练度,而是你遇到类型问题时如何分析和拆解——在这一点上,平时那些踩坑记录反而成了最宝贵的素材。
5.3 编码规范与可维护性:TS项目里最容易忽略的隐形红线
既然笔记定位是自用工程手册,这块内容我必须记上一笔。typescript编码规范是很容易被忽略、但对代码库长期健康影响非常大的话题。
首先,明确禁止随意使用any。如果后端的某个接口字段确实不确定,我会优先把类型声明为unknown,然后在使用处做收窄。其次,泛型命名不能随便T、U、V,在业务代码里你应该让泛型具备语义,比如TEntity或者TResponse,不然过三个月连自己都看不懂。再有,模块导出的设计尽量清晰:一个文件默认只对外暴露一个核心类或函数,其余的用具名导出,这样类型推断和tree-shaking都会更友好。
我还给自己定了一条硬性规范:每个关键函数必须写返回类型,而不是依赖TS自动推断。毕竟自动推断在简单的变量赋值场景很贴心,但一旦复杂函数的返回链路出现变化,隐式推断的类型往往会和预期出入很大。显式标注返回值还有一个好处——你自己写的时候就会被迫思考这个函数到底返回了什么、语义是什么,这对代码质量本身就是一种提纯。
6. 说点笔记之外的事:这些经验是怎么沉淀下来的
写到这,这篇笔记的核心内容基本就梳理完了。但我想多说几句关于“记笔记”这件事本身——因为这半年我最大的收获其实不在TypeScript或者ES6的知识点,而在一套自己能长期坚持的学习方式。
最开始我写“自用笔记”是非常随性的,项目里遇到什么坑就往上记什么,完全没有体系。后来发现知识点太散,今天记一个Map的用法,明天记一个泛型约束,翻起来跟翻垃圾桶一样痛苦。大概在第三个月的时候,我给自己定了一个框架:每个知识点至少包含三部分——是什么、为什么、在什么场景下用。是不是用这套框架复盘过的知识,在面试时和写代码时的调用速度差别真的很大。
另外,我强烈建议在笔记里专门留一节叫“待深挖”,每次遇到模棱两可的概念先扔进去,过一两周再回头看。很多当时死活想不通的问题,在写过几个项目之后再回来,反而一眼就能看穿。比如当初让我浑身难受的infer,后来在自己写一个参数序列化工具时突然就懂了,因为那个场景里我必须提取出函数的参数类型,infer就直接成了我的工具。知识这种东西,未必要在真正需要之前就完全理解,但要确保它始终留在你的认知地图里,等到实际需求出现的时候,伸手就能摸到它。
这篇笔记后续我还会继续更新,计划补的内容包括:TypeScript装饰器在NestJS和Vue3里的两种风格差异、ES2023以后出现的新特性(数组的toSorted、findLast这些)、以及更深入的泛型进阶——把类型体操玩到连UnionToIntersection这种都能自己手写。如果你也在学TypeScript和ES6的路上,希望这篇笔记能像一本“过来人避坑地图”一样,让你少走几段我走过的弯路。