ARTICLE DETAIL

资讯详情

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

深入解析tsconfig.json:从编译选项到工程化配置实践

深入解析tsconfig.json:从编译选项到工程化配置实践 1. tsconfig.json整体设计与配置思路拆解1.1 这一份配置文件到底在管什么我刚接触TypeScript的时候一直把tsconfig.json当成一个能不碰就不碰的文件反正项目能跑就行。直到有一次我把一个纯前端项目迁移到Node.js服务端代码在浏览器里跑得好好的一上服务端就疯狂报错折腾了半天才发现是tsconfig.json里一堆编译选项根本没配明白。说白了tsconfig.json就是TypeScript编译器的总控制台。它决定了三件大事哪些文件会被编译、用什么规则编译、编译成什么样。这三件事分别对应files/include/exclude、compilerOptions和outDir等输出相关配置。你可以把它理解成一套施工图纸编译器这个施工队严格按照图纸来干活图纸画得不清楚施工质量自然没保障。很多人觉得tsconfig.json难学是因为里面的配置项实在太多TypeScript官方的配置文档列出来有上百项光看着就头大。但实际项目里经常用到的也就二三十项。我自己的经验是把这上百个配置项按照用途分成几个大类别去理解就会清晰很多文件范围类哪些文件参与编译include、exclude、files编译目标类编译成什么版本的JavaScripttarget、lib、module严格检查类类型检查的严格程度strict系家族模块解析类模块怎么找、怎么解析moduleResolution、baseUrl、paths产物输出类编译结果输出到哪里、什么格式outDir、declaration、sourceMap工程集成类和现有工具链怎么配合jsx、esModuleInterop、isolatedModules这些类别之间不是孤立的它们互相影响。比如module决定了你的代码编译后用什么模块规范CommonJS还是ES Module而moduleResolution又决定了编译器怎么去解析import语句。如果你是Node.js项目却把module配成ESNext大概率会在运行时收获一堆报错。所以配tsconfig.json不能零散地一个个选项去配先要有整体思路。1.2 先理清include、exclude和files三兄弟的分工先说文件范围类配置。include、exclude和files这仨是最容易搞混的。简单来说files用于精确指定编译哪些文件适合文件数量少且固定的场景include用于按目录或通配符匹配一组文件适合大多数项目exclude用于排除掉不需要编译的文件优先级高于include。我见过不少人把include配成[src/**/*]就完事了然后发现node_modules里的.d.ts声明文件出了问题或者构建的时候把测试文件也编译进了产物。这里有个关键细节需要注意exclude默认会排除node_modules、bower_components、jspm_packages和outDir指定的目录即使你没写exclude默认值也生效。所以很多奇怪的问题其实都是对默认行为不够了解导致的。还有一个常见的坑include配置的是编译入口文件的集合。比如你写的是include: [src]TypeScript会把src目录下所有.ts、.tsx、.d.ts文件都当作编译入口。如果其中有文件只在测试时才用到比如src/**/*.test.ts它也会被编译进去。想要排除测试文件得额外用exclude把src/**/*.test.ts排除掉或者把测试文件单独放在另一个目录比如tests/再在exclude里配上tests。files则适合配置那些游离在include范围之外的散装文件比如项目根目录下的vite.config.ts、vitest.config.ts如果你只配置了include: [src]这些根目录的配置文件不会参与编译和类型检查。这时候就需要在files里手动添加。先把这个范围搞清楚后面配所有编译选项才有意义。不然编译器连处理哪些文件都没搞清楚选项配得再精细也是白搭。2. 编译目标、模块系统与严格模式的深度配置解析2.1 target和lib代码要跑在哪个环境里target和lib是我每次配置tsconfig.json时最在意的两个选项。用大白话说target决定编译产物要兼容到什么程度的JavaScript语法版本lib决定编译时默认带上哪些标准库的类型定义。举个例子。如果项目要兼容IE11target就得设成es5。这就意味着const、箭头函数、async/await这些ES6语法会被编译成var和普通函数甚至Promise这样的全局对象可能也要引入polyfill。反过来如果你只针对最新版Chrome开发target直接设成es2022甚至esnext都行编译产物干净简洁运行效率也更高。lib是一个非常容易被忽略的配置项。TypeScript编译时不仅检查你写的代码还要检查你用的全局对象和内置方法是否存在于当前环境。比如在Node.js里你要用Buffer、process这些全局变量如果没有在lib里加上DOM或对应的types配置编辑器里就会飘红。这里有个常见的误区很多人以为设了target就不用管lib了。实际上target只决定语法降级lib决定可用的API类型定义两码事。我自己的经验是如果是纯前端项目target通常设es2017以上lib用默认就能满足多数情况因为默认会根据target自动匹配lib。如果是Node.js项目建议显式加上lib: [ES2021]之类并且尽量不包含DOM否则你会在服务端代码里误用浏览器的API而不自知类型检查根本拦不住。实际踩坑时你会发现还是别太依赖默认值重要项目显式声明target、lib、module这三个基础项是值得的。2.2 strict全家桶严格检查的攻守同盟strict是tsconfig.json里性价比最高的一个配置项没有之一。把它设成true等于一次性开启了所有严格类型检查选项。我见过很多老项目为了省事一直用非严格模式结果随着代码量增长重构一次就像在雷区里走一遍。而严格模式一开始跑起来确实难受但后面会越来越省心。strict背后是一整套检查规则的组合包括noImplicitAny不允许隐式的any、strictNullChecks严格的null和undefined检查、noImplicitThis不允许隐式的this类型、alwaysStrict编译产物自动加上use strict等。其中给我带来最大收益的是strictNullChecks。没开它之前你随时可能对一个可能是undefined的变量做方法调用运行时才炸。开了之后编辑器直接提示你相当于把运行时的坑提前到编码期就填掉了。这里得强调一下新建项目请务必把strict: true设上。老项目如果代码量特别大可以逐步开启先开strictNullChecks把空值相关的问题清完再开noImplicitAny一个个补类型。我帮一个朋友迁移老项目时就是这个策略他一开始担心改动量太大结果分了三轮迭代就搞定了而且明显感觉到代码质量提升了一个档次。这种渐进式开启既有收益又不会让团队一次性崩溃。2.3 module与moduleResolution模块系统选择的连环锁module决定编译后代码使用哪种模块规范moduleResolution决定TypeScript解析import路径时采用哪种查找规则。这两个选项必须配合使用选错一个就会导致模块找不到的诡异错误。举个例子如果你用module: ESNext但项目实际运行在Node.js的CommonJS环境下没有开启ESM支持编译产物里的import语句在运行时就会直接报错。moduleResolution的选项主要是classic针对ES6之前的旧项目和nodeNode.js的解析方式新项目基本都用node或者用bundler如果你用的是webpack、Vite这类打包器。具体选择要看你的运行环境和工具链。如果你的项目用了Vite或webpackmoduleResolution: bundler是个很好的选择它对TS路径映射、exports字段的解析更加友好而且不需要额外配置baseUrl就能使用paths。另一个和module强相关的选项是esModuleInterop。这玩意儿经常被忽略但它影响极大。简单说它解决了CommonJS模块和ES Module模块互相导入时的兼容问题。开启后你可以直接用import fs from fs的默认导入方式来导入CommonJS模块其实fs并没有default导出但开启后编译器会帮你做兼容处理。如果不开启就得写import * as fs from fs这种别扭的方式。我强烈建议新项目把esModuleInterop: true开上同时它也隐含启用了allowSyntheticDefaultImports让你在类型层面也能用默认导入。这里给一个选型速查表方便你对照自己的场景来配运行环境module推荐值moduleResolution推荐值备注浏览器 打包器webpack/ViteESNextbundler打包器自行处理模块转换Node.js 12 (CommonJS)commonjsnode最稳妥的选择Node.js 16 (原生ESM)NodeNextNodeNext需配合package.json的type字段纯浏览器、无打包器ES2020browser或node极少见需要源码支持老式Node.js项目commonjsclassic或node不建议新项目使用2.4 路径映射paths和baseUrl的黄金组合paths是tsconfig.json里最实用的配置之一也是很多项目看得见的作用。它专门解决模块路径一长串../../../../的问题。配置好之后import { getUser } from lib/user就能替代import { getUser } from ../../../src/lib/user这样可怕的长路径。{ compilerOptions: { baseUrl: ., paths: { /*: [src/*], components/*: [src/components/*] } } }这里有两点需要注意。第一paths是基于baseUrl解析的相对路径虽然新版本TypeScript允许不设baseUrl直接配paths但很多工具链还是依赖baseUrl的存在。我自己的习惯是baseUrl放心设成.然后用paths配别名这样语义最清晰。第二如果你用的是打包器Vite、webpack光在tsconfig里配paths还不够打包器那边也要做相应的别名解析配置否则编辑器不报错一跑起来就报模块找不到。3. 从零到一的实操配置示例三种典型场景3.1 前端Vue3/React浏览器项目配置模板先来一个前端项目的完整配置。这个配置适用于Vite驱动的Vue3或React项目是我反复调整过很多次之后稳定使用的版本。{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: bundler, lib: [ES2020, DOM, DOM.Iterable], jsx: react-jsx, strict: true, esModuleInterop: true, allowSyntheticDefaultImports: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, isolatedModules: true, noEmit: true, baseUrl: ., paths: { /*: [src/*] }, types: [vite/client] }, include: [src/**/*.ts, src/**/*.tsx, src/**/*.d.ts], exclude: [node_modules, dist, src/**/*.test.ts, src/**/*.test.tsx] }逐个说下关键配置的理由。target选ES2020因为现代浏览器都支持ES2020的语法特性不需要过度降级module用ESNext因为Vite在开发阶段直接使用原生ESM不会先把模块转换掉交给Vite处理才是正确姿势。noEmit: true很关键因为Vite自己负责产物的生成TypeScript在这里只做类型检查不需要真的输出编译文件。如果忘了设noEmit编译时会在项目目录里生成一堆.js文件那画面太刺激了。isolatedModules: true是配合Vite/esbuild这类单文件转译工具时必开的选项。它的核心限制是每个文件必须能被独立转译不能被其他文件影响。比如禁止在文件里导入后再导出类型import { SomeType } from ./a; export { SomeType };这样写就不行必须用export type { SomeType }这种显式方式。这个限制能逼着你写出对单文件转译友好的代码而Vite底层正是靠esbuild做单文件转译的所以这个选项必须开。3.2 Node.js服务端项目配置模板再来看Node.js服务端项目的配置。这类项目通常用CommonJS模块规范并且部署到服务器上直接运行编译产物和纯前端项目有本质区别。{ compilerOptions: { target: ES2021, module: CommonJS, moduleResolution: node, lib: [ES2021], outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, declaration: true, sourceMap: true, resolveJsonModule: true, baseUrl: ., paths: { /*: [src/*] } }, include: [src/**/*.ts], exclude: [node_modules, dist, src/**/*.test.ts] }这套配置里module选CommonJS因为大多数Node.js项目还是CommonJS的世界。如果你用的是Node.js 16以上且package.json里设置了type: module那module要改成NodeNextmoduleResolution也要跟着改成NodeNext。outDir配成distrootDir配成src保证编译产物的目录结构和源码保持一致。比如src/utils/logger.ts编译后会出现在dist/utils/logger.js。如果不配rootDirTypeScript可能会根据include的公共父目录自动推断但显式声明能避免一些莫名其妙的目录结构变化。declaration: true会生成.d.ts声明文件如果你做的是一个npm包需要给别人用这选项必须开。sourceMap: true生成代码映射文件线上排错时看堆栈信息会是源码而不是编译后的代码强烈建议开。3.3 工程化项目用extends拆分配置单项目用一份tsconfig.json没问题但如果是monorepo或者项目里同时有前端代码、服务端代码和cli工具原本一份配置就会变得越来越臃肿。这时候可以用extends实现配置继承把公共配置抽出来各子项目只写自己独有的部分。// tsconfig.base.json公共配置 { compilerOptions: { target: ES2020, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, moduleResolution: bundler } }// apps/web/tsconfig.json前端子项目 { extends: ../../tsconfig.base.json, compilerOptions: { module: ESNext, lib: [ES2020, DOM, DOM.Iterable], jsx: react-jsx, noEmit: true }, include: [src] }// apps/server/tsconfig.json服务端子项目 { extends: ../../tsconfig.base.json, compilerOptions: { module: CommonJS, moduleResolution: node, lib: [ES2021], outDir: ./dist, rootDir: ./src }, include: [src] }这种拆分方式的好处非常明显公共的严格模式、模块解析策略统一由base配置管理子项目只配置环境差异相关的选项。升级TypeScript或者调整公共检查规则时改一个base文件就全项目生效再也不用跑到每个子项目里去改tsconfig了。我们团队实践下来这种模式在代码量膨胀之后依然保持着很好的可维护性。4. 常见问题与排查技巧实录4.1 明明代码没错编辑器却一直报错我自己最早被tsconfig.json坑到的时候遇到最多的情况就是编辑器报错但代码没问题。排查下来大部分是这几个原因。第一个是编辑器用的TypeScript版本和项目里安装的不一致。VS Code默认会用自己内置的TypeScript版本而不是你项目node_modules里的。版本不一致解析配置的行为会有差异。解决办法是在VS Code里按下CtrlShiftPmacOS是CmdShiftP输入TypeScript: Select TypeScript Version选择Use Workspace Version。第二个原因是include范围没有覆盖到报错的文件。编辑器默认会对打开的文件单独创建一个临时项目来做类型检查这个临时项目用的是默认配置不是你的tsconfig配置所以如果你打开了一个不被include包含的文件就会看到一堆莫名其妙的报错。这种时候先把文件加入include范围或者直接重启一下TS Server通常能解决。第三个原因是tsconfig.json文件变了但TS Server没刷新。改完tsconfig后VS Code不会每次都自动重新加载配置需要手动触发一下。在命令面板里执行TypeScript: Restart TS Server屡试不爽。4.2 编译产物路径错乱的问题有一次我给一个项目配完tsconfig执行tsc之后发现dist目录下出现了一堆不该有的目录层级源码在src里产物居然跑到了dist/src/utils/logger.js查询后才发现是因为没有配rootDir。这里解释一下根源TypeScript会从include的所有文件里计算出最深的公共目录作为根目录。如果没有显式设置rootDir编译器就把src当成根目录产物输出时这层目录就被保留下来了。修复方法很简单显式加上rootDir: ./src产物就会乖乖变成dist/utils/logger.js。顺便提一句outDir配在tsconfig的compilerOptions里而不要依赖命令行参数命令行临时覆盖很容易让人困惑。4.3 类型声明文件.typeRoots与types的配置细节types和typeRoots也是容易被忽略但很实用的配置项。typeRoots默认指向node_modules/types编译器会自动引入这个目录下所有包的类型声明。types则用于精确指定只引入哪些类型包。比如你配置types: [node]编译器就只加载types/node其他types下的包一律不主动加载。我在项目里遇到过一个真实问题装了types/koa之后代码里倒是能用了但项目的全局类型里多了一堆Koa框架相关的接口导致和其他类型产生了冲突。设置types: [node, express]之后全局类型干净多了项目里要用哪个类型的包就显式声明哪个可预测且可维护。这个习惯在维护长期项目时非常有用。4.4 skipLibCheck到底要不要开skipLibCheck这个配置项让我纠结过很久劝你还是开上。它的作用是跳过node_modules里.d.ts声明文件的类型检查。不开的时候如果某个第三方库的类型声明有错误这在真实项目里太常见了编译就会失败。开了之后编译器不检查声明文件本身的类型正确性只检查你代码里用到那些声明时类型对不对。项目里装了几十个npm包每个包的.d.ts质量参差不齐其中必然有几个是带类型瑕疵的。这些瑕疵不影响你使用但会影响编译。所以skipLibCheck在新项目里直接开把注意力和检查资源留给你自己写的代码。关掉它提升的那点安全性远没有它带来的构建失败成本高。4.5 排查利器tsc --showConfig最后分享一个我用了无数次、但很多人不知道的排查技巧。执行tsc --showConfig能打印出当前配置的完整解析结果所有继承、默认值都展开了。比如你用了extends继承或者不知道某个选项实际生效的值是多少跑一下就全清楚了。检查配置时我会先打开这个命令的输出再逐项对照自己的预期。很多为什么我配了没生效的问题都是用这个命令定位到原因的。5. 项目迁移实战把一份老配置升级到现代配置5.1 旧项目升级配置的渐进式改造方案如果你接手的是一个老项目tsconfig.json还停留在es5甚至es3时代别急着一次性推翻重来。我改造过一个三年前的Vue2项目tsconfig看起来像是从某个远古模板里复制过来的整个项目在非严格模式下运行有上百个隐式any。我采用的策略是分三步走。第一步先把target从es5升级到es2017以上。这一步的收益最大因为现代浏览器对ES2017的支持已经非常完善没必要再用大量polyfill代码去兼容远古环境。改完target之后编译产物直接缩小了一截运行性能也有提升。第二步开启esModuleInterop同时配合修改代码里所有import * as的写法。这一步会有一些工作量但可以从每个文件里减少一堆临时变量式的导入别扭写法。第三步把strict从false改成true然后逐个修复报错。这个阶段要有人专门盯几天因为报错量一开始确实会比较大。我们是按模块推进的修完一个模块再切到下一个。全部修完之后类型安全带来的信心是之前完全没法比的。5.2 升级过程中容易翻车的几个细节升级过程中有几个细节特别容易翻车这里单独提一下。第一个是declaration和sourceMap的开关状态。老项目一般没开但升级完代码后如果要把产物发布到npm这俩得按需配置否则别人使用你的包时会找不到类型。第二个是noImplicitAny。这个选项单独开启后有些函数参数没有类型标注编译器会直接报错。但实际上很多老代码只是忘了写而已并不一定是真正的动态类型。修复过程中我用了一个小技巧把不确定的类型先标成unknown再按使用点一个个收窄成具体类型。这比直接用any掩盖问题要安全得多。第三个是useUnknownInCatchVariables。这个选项在strict开启后也会自动开启catch的异常变量类型会变成unknown如果你代码里有catch (e)然后直接用e.message的地方就要先做类型判断或者类型收窄。这个改动会对老代码造成比较大的冲击我当时是在strict打开之后单独花了一天时间处理这个问题。5.3 升级完成之后如何长期维护配置项目升级完并不等于配置工作就此结束。TypeScript每年更新两三个大版本一些配置项会被弃用新的配置项会不断加入。我给自己定的节奏是每半年检查一次项目依赖里的TypeScript版本升级之后跑一遍tsc --showConfig看看有没有新增的推荐选项适合当前项目。同时关注官方发布说明中关于严格性增强的部分新版本经常会强化原有的类型检查规则很可能升级完TypeScript编译就开始报新错误了。这些都处理好配置迭代就跟上了语言演进的节奏。6. 最后再分享一点我的真实体会配tsconfig.json这件事情看起来只是几十行JSON但它对项目的长期健康度影响非常大。在我维护项目的这些年里凡是配置写得清晰的项目后来不管是加新模块、做重构、还是升级框架都省了很多心。凡是配置靠复制粘贴、从不细看的项目基本都在某个关键节点付出过额外的时间成本。如果你现在正要开始一个新项目我建议你花30分钟认真把tsconfig.json从头到尾过一遍不要直接复制模板就完事。弄清每个选项对自己项目意味着什么值得的。如果你手头有老项目也可以挑个版本迭代的窗口期逐步把strict开起来把路径别名整理好把冗余的配置删掉。就我个人的经验来说这一份配置文件的整理投入是整个项目里性价比最高的技术投资之一。
返回列表