☰
Vue项目tsconfig/jsconfig与compilerOptions避坑
2026/10/1 14:02:51 网站建设 项目流程

前阵子帮朋友捞一个 Vue 项目的编辑器报错,症状挺有意思:VS Code 里满屏红波浪线,@/components/xxx一律“找不到模块”,可命令行一跑vite dev,页面照常渲染,一点问题没有。翻了下他的工程目录,根下同时躺着jsconfig.json和tsconfig.json,两个文件内容还各写了一半,一个配了paths没配include,另一个配了include却写错了moduleResolution。把这两个文件理清楚之后,红波浪线瞬间消失。这件事让我意识到,jsconfig.json、tsconfig.json以及里面的compilerOptions,是 Vue 项目里最容易被“复制粘贴”蒙混过去的一块配置——大家都能跑起来,但很少有人能说清楚哪一行配置到底在管谁。

这篇东西面向的读者很宽:刚学 Vue、第一次自己搭工程的新手,能从里面拿到可直接抄的配置模板;已经写过一两个项目、被类型报错折腾过的中级开发者,能搞懂compilerOptions每个开关背后的取舍;做前端基建、要给团队定规范的,也能参考后面多环境分层的那套做法。核心就一件事:把 Vue 项目里这两个 JSON 文件讲透,顺便把compilerOptions里真正影响开发体验的字段一个个拆开,告诉你它为什么这么写、不这么写会出什么事。

1. 先分清 jsconfig.json 和 tsconfig.json 到底谁在管谁

1.1 两个文件的血缘关系:一个简化版,一个完整版

很多人以为这是两套完全独立的机制,其实不是。jsconfig.json可以理解成tsconfig.json的“降级亲戚”——它本质上是被同一套语言服务解析的,格式、字段名、层级结构基本一致,只是它面向纯 JavaScript 项目,不涉及类型检查器的完整能力。在没有 TypeScript 编译器的纯 JS 工程里,编辑器需要一个信号来判断“这个目录是一个项目根”,jsconfig.json就是那个信号,同时它还能顺便承担路径别名、include/exclude范围、checkJs之类的语言服务配置。

而tsconfig.json是 TypeScript 编译器的正式配置文件,它管的不只是编辑器里的智能提示,还包括tsc、vue-tsc这类命令行工具的类型检查行为、模块解析策略、输出目标等。Vue 3 项目里如果用了<script setup lang="ts">,那tsconfig.json就是必需品;如果整个项目还是纯 JS,只用到<script setup>,其实jsconfig.json就够了。

关键点是:jsconfig.json支持的字段是tsconfig.json的子集。像noEmit、composite、declaration这种和产物输出强相关的字段,放在jsconfig.json里没有意义,因为 JS 项目根本不走 TS 的编译输出流程。反过来,你在jsconfig.json里写的paths、baseUrl、include,换成tsconfig.json一样能用,甚至写法都一模一样。

提示:如果你打算把项目从 JS 迁到 TS,不必新建一个文件重头写。直接把jsconfig.json改名成tsconfig.json,再补上strict、noEmit这类字段就行,编辑器认得出来。

1.2 同一目录下两个文件同时存在,会发生什么

这是最容易被忽略的坑。当项目根目录下同时存在jsconfig.json和tsconfig.json时,编辑器的语言服务会优先采用tsconfig.json,jsconfig.json就成了一个死文件,你改它里面的paths也不会有任何反应。我见过好几个项目是这样:早期是 JS 项目,写了jsconfig.json;后来迁到 TS,新建了tsconfig.json,但旧的jsconfig.json忘了删。结果开发者一直在改旧文件,一边改一边骂编辑器不生效。

所以我的第一条硬性建议是:一个项目根目录里,这两个文件只留一个。纯 JS 项目留jsconfig.json,沾了 TypeScript 就留tsconfig.json,不要共存,不要指望它们会合并。清理旧文件这件事花不了十秒钟,但能省掉半天排查。

1.3 真正读你配置的三方:语言服务、类型检查器、构建工具

搞清楚“谁在读配置”,后面所有问题都会顺很多。Vue 项目里至少有三个角色在跟这份 JSON 打交道,但它们读的东西完全不一样。

第一个是编辑器的语言服务。在 Vue 3 生态里,这一角色由 Vue Language Features(也就是大家常说的 Volar)加上 TypeScript 语言服务共同承担。它负责给你补全、跳转定义、显示类型、标红波浪线。它读的是项目根目录的tsconfig.json或jsconfig.json,paths别名能不能跳转全靠它。

第二个是类型检查器,通常是vue-tsc。它是你跑npm run type-check或 CI 里那道检查时真正干活的东西。它只认tsconfig.json,只认这个文件里声明的include范围与compilerOptions规则,跟编辑器那套提示是两条独立的链路。

第三个是构建工具,Vite 或 webpack。这里最反直觉:Vite 默认不做任何类型检查,它用 esbuild 做单文件转译,速度极快,但代价是它基本不看你的compilerOptions。你在tsconfig.json里配的paths别名,Vite 是不认的——它只认vite.config.ts里的resolve.alias。这就解释了为什么会出现“编辑器不报错但跑起来 404”和“编辑器报错但能跑”这两种看似矛盾的现象。

理解了这三方分工,后面遇到任何配置问题,你第一反应就应该是:这个现象是编辑器报的,还是命令行报的?如果是编辑器报的,去查tsconfig.json的include和paths;如果是构建报的,去查 Vite 配置。

2. compilerOptions 逐项拆解:哪些字段真的在影响你

2.1 target、lib、module 与 moduleResolution 的选型逻辑

target决定 TS 编译时按哪个 ECMAScript 版本做语法降级。理论上你可以写ES5,让老浏览器也能跑,但在 Vue 3 + Vite 的场景下,这基本是自我折磨。Vite 的生产构建走 Rollup,现代浏览器的兼容由build.target单独控制,TS 这一层的target只影响类型检查和少量语法转换。所以我的做法是统一写ESNext,让 TS 不要把语法降级,兼容交给构建侧统一决策。

lib决定你有哪些全局类型可用。写["ESNext", "DOM", "DOM.Iterable"]是标配,DOM.Iterable特别重要,因为它让NodeList、FormData这些类型支持迭代器,不然你在for...of遍历document.querySelectorAll的结果时会莫名其妙报错。如果项目要跑在 Node 脚本里,那还要加["node"]到types里,注意types和lib是两件事,前者管@types/*包的引入,后者管内置类型库。

module和moduleResolution是搭档,必须成对考虑。Vue 3 + Vite 项目里我强烈建议module: "ESNext"配moduleResolution: "Bundler"。Bundler模式模拟的是打包器的解析行为,允许你写不带扩展名的导入、支持package.json里的exports字段、也允许import时省略index.ts。它比老牌的Node模式更贴合实际运行环境,Vite、esbuild、Rollup 全都按这套逻辑干活。有个约束要注意:moduleResolution: "Bundler"只能和module: "ESNext"或module: "Preserve"搭配,写成CommonJS会直接报配置错误。

2.2 paths 与 baseUrl:为什么你的 @ 别名编辑器不认

先说结论:paths是纯类型层面的路径映射,它不改变运行时行为,一分钱都不改变。它的唯一作用是让语言服务和vue-tsc在解析模块时,把@/utils/foo翻译成真实路径去找类型。真正的运行时解析,是 Vite 的resolve.alias干的活。

在早期 TS 版本里,paths必须配合baseUrl才能用,所以你会看到大量老模板写成这样:

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

baseUrl的意思是“所有非相对路径导入都从这里开始找”。但 TS 4.1 之后paths可以独立使用了,路径相对于tsconfig.json自身所在目录解析。所以现在官方模板更推荐这么写:

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

两种写法都能用,但要注意一个细节:一旦你写了baseUrl,所有非相对导入的基准目录就变了,某些第三方类型的解析路径会跟着偏移,偶尔能引发很诡异的“找不到类型定义”。没历史包袱的项目,我建议直接省掉baseUrl,只写paths。至于别名匹配规则,是“最长前缀优先”,@/a/*会优先于@/*,所以你可以定义多个层级,不用担心互相打架。

两边配置必须一致,这是最容易踩的坑。tsconfig.json里写了@/*,vite.config.ts里没写,结果就是编辑器一切正常,一刷新页面 500;反过来 Vite 里写了而 TS 里没写,跑起来没问题,但编辑器满屏红。所以我在团队里的规定是:加别名就两个文件一起改,写完立刻跑一次type-check加一次构建验证。

2.3 strict 家族:要不要一步到位

strict: true是一个总开关,它一次性打开noImplicitAny、strictNullChecks、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis、alwaysStrict等一堆子开关。新项目没得说,直接开。但如果你接手的是一个存量的 JS 项目要迁移,一步到位打开strict通常会蹦出上千个错误,开发直接停摆。

比较务实的做法是分层开。第一步只开noImplicitAny: false但打开strictNullChecks: true,因为空值问题才是线上崩溃的头号来源。等业务代码消化得差不多了,再把noImplicitAny打开,逼着大家写类型。最后再补strictPropertyInitialization,这个开关会要求 class 的每个非可选属性在构造函数里初始化,对接一些老代码时挺烦,但对于用了defineComponent加setup的 Vue 3 组件,影响其实不大。

还有几个“独立于 strict 之外”的检查项值得单独说。noUnusedLocals和noUnusedParameters会报未使用的变量和参数,提前开它能把脏代码扼杀在编辑器里,但代价是调试时临时注释掉一行代码就报错,有点烦。noFallthroughCasesInSwitch我建议一定开,switch 忘了break是真会出线上事故的。noImplicitReturns也挺值,强制函数在所有分支上都有返回值,避免出现某个分支默默返回undefined。

2.4 和打包语义强相关的几个开关

isolatedModules: true这个开关,在 Vue 3 + Vite 项目里是必须开的。原因是 esbuild 和 Babel 都是单文件转译,它们不知道其他文件的内容,只能按语法独立处理每个文件。这时候如果你写了export { SomeType }这种“导出类型但看起来像导出值”的写法,单文件转译器没法判断,运行时就会报错。打开这个开关后,TS 会强制你写export type { SomeType },把意图明确出来。

verbatimModuleSyntax: true是 TS 5.0 引入的新开关,它是importsNotUsedAsValues和preserveValueImports的替代方案,后者现在已废弃。打开它之后,你必须显式用import type来导入纯类型,否则 TS 会报错。这个规则看着严苛,但收益很大:它让“类型导入”和“值导入”清清楚楚,构建产物体积也更可控,还能避免循环依赖引发的运行时诡异问题。我自己的新项目一律开。

skipLibCheck: true,这个几乎是人人都开但很少有人知道原因的开关。它跳过对node_modules里.d.ts文件的类型检查。不开的话,你项目里任何一处第三方类型的版本不兼容,都会在tsc时炸出来,而这些错你还改不了。开了之后,检查速度会明显变快,尤其是装了几十个依赖的项目,能从几十秒降到几秒。

esModuleInterop: true配合allowSyntheticDefaultImports,解决的是 CommonJS 和 ESM 混用时的默认导入问题。比如import path from 'path',在esModuleInterop关闭时会报错,因为path模块没有默认导出。打开之后就顺了。Vue 项目里如果引用了 Node 生态的老库,这两个开关基本是刚需。

useDefineForClassFields这个开关由target隐式决定。target在ES2022及以上时默认为true,此时 class 字段用的是标准语义defineProperty,而不是构造期赋值。绝大多数 Vue 3 项目用不上 class 组件,所以这个开关基本感知不到,但如果你引入了基于装饰器的库,就得留意它的值。

resolveJsonModule: true允许你直接import data from './data.json',TS 会自动推断出 JSON 结构的字面量类型。Vue 项目里用来加载一些静态配置表很方便。注意它要求moduleResolution不是classic,现代配置都没问题。

noEmit: true在 Vite 项目里基本是标配。因为类型检查和产物输出是分离的:产物由 Vite 出,TS 只负责查类型,不负责写文件。不开这个,tsc会真的往磁盘写.js,跟 Vite 的输出互相覆盖,排查起来很折磨人。

moduleDetection: "force"是个小但实用的开关。默认情况下,TS 判断一个文件是不是模块,看它有没有import/export。没有的话就当成全局脚本处理,于是两个文件里定义的同名变量可能冲突。设成force之后,所有文件都被视为模块,这类玄学报错基本消失。

2.5 vueCompilerOptions:Volar 专属的那块配置

这一块不在compilerOptions里,而是和它平级的顶层字段,专门给 Vue 语言服务用。很多人不知道它的存在,但它对 Vue 项目的开发体验影响巨大。

{ "vueCompilerOptions": { "target": 3.5, "strictTemplates": true, "plugins": [] } }

target声明你的 Vue 版本,Volar 会据此决定模板的解析规则。写3.5、3.4这样的细分版本号,比笼统写3更准确,尤其是在用defineModel、defineSlots这类随版本演进的新宏时,写错版本会出现“宏不认识”的报错。

strictTemplates是这块配置里价值最高的开关。打开后,模板里的事件参数、props传参、v-for的迭代类型都会被严格检查。比如父组件给子组件传了个少一个字段的对象,或者事件回调参数类型写错,都能在编辑器里直接标出来,不用等运行时。代价是初期会有不少报错,尤其是用了一些动态组件、v-bind="props"的写法。我的建议是:新项目直接开,老项目可以等业务稳定了分批开。

plugins用来挂 Volar 插件,比如配合宏扩展方案时需要在编辑器侧注册。这块和构建侧的插件是两码事,两边都要配,漏一边就会出现“构建成功了但编辑器报错”的情况。另外,Volar 现在推荐启用 Take Over Mode(接管模式),也就是关掉其他提供 Vue 支持的老插件,避免两个语言服务打架。这类冲突的表现通常是:类型提示时有时无,跳转莫名其妙跳到错误位置,重启编辑器能好一会儿,然后继续出问题。

3. 手把手落地:从纯 JS 到 TS 的配置演进

3.1 纯 JS + Vite 项目的最小可用 jsconfig.json

假设你手上是一个 Vite 创建的纯 JS 项目,用了@别名,想让编辑器别乱报错。jsconfig.json写这么多就够了:

{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", "paths": { "@/*": ["./src/*"] }, "allowJs": true, "checkJs": false, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "skipLibCheck": true, "types": ["vite/client"] }, "include": ["src/**/*.js", "src/**/*.vue"], "exclude": ["node_modules", "dist"] }

逐个说下为什么要这么写。allowJs必须开,不然.js文件根本不在语言服务的管辖范围。checkJs我建议先关,因为一开就会在 JS 文件上做类型推断检查,存量代码会瞬间爆红;想逐步加强的话,可以在单个文件顶部加// @ts-check注释,只让那一份文件参与检查,这个做法在渐进迁移时特别好用。types: ["vite/client"]是为了让import.meta.env这类 Vite 注入的全局变量有类型,不写的话编辑器会提示import.meta.env未定义,或者给出的类型是any。include里显式写上.vue,是为了确保单文件组件被纳入项目范围,不加的话,某些跨文件类型推导会失效。

一个实操细节:改完jsconfig.json后,编辑器不一定立刻生效。比较稳的做法是Ctrl+Shift+P,执行重启 TS 服务的命令,或者干脆重启 VS Code 窗口。还有一种情况,语言服务会缓存上次解析的结果,配置文件写错了它不报错,只是默默不生效,这时候可以通过命令面板里的“打开 TS 项目配置”来确认编辑器当前到底读的是哪个文件、解析出来的最终配置是什么。这个功能排查配置问题非常有用,比反复猜要快得多。

3.2 引入 vue-tsc 后的 tsconfig.json 完整版

项目一旦开始写 TS,就换成tsconfig.json。现代 Vue 3 模板通常采用“根文件 + 两个子配置”的结构,根文件只做引用,不写实际规则:

{ "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] }

这种写法的好处是职责分离。应用代码和构建脚本的编译环境完全不同:前者跑在浏览器里,需要DOM类型;后者跑在 Node 里,需要@types/node,还要允许导入fs、path。混在一个tsconfig.json里,要么给应用代码塞进 Node 类型污染全局,要么构建脚本拿不到 Node 类型。

应用侧的tsconfig.app.json大致长这样:

{ "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "exclude": ["src/**/__tests__/*"], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "paths": { "@/*": ["./src/*"] } } }

extends指向官方的 Vue TS 基础配置,里面已经预设好了target、lib、module、moduleResolution、strict、isolatedModules、skipLibCheck这一整套推荐值,你不用重复写。composite: true是配合项目引用要求的,它会让 TS 生成增量编译信息,加快第二次检查的速度。tsBuildInfoFile把这些中间产物丢到node_modules/.tmp下,避免污染项目根目录。

构建脚本侧的tsconfig.node.json:

{ "extends": "@tsconfig/node20/tsconfig.json", "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], "compilerOptions": { "composite": true, "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", "types": ["node"] } }

这里types: ["node"]是关键,它让path、process、__dirname这些在vite.config.ts里能正常使用。include只圈定配置文件本身,不要写成src/**/*,否则应用代码会被 Node 环境规则检查一遍,容易出现莫名其妙的报错。

注意:composite: true和noEmit: true在部分 TS 版本下会冲突。如果你的vue-tsc报“复合项目不能禁用输出”之类的错,把noEmit换成emitDeclarationOnly加一个outDir指到临时目录里,就能绕过去。

3.3 从 JS 逐步迁移到 TS 的四步走法

迁移这事最忌讳“大爆炸”。我自己的做法分四步,每一步都可独立回滚。

第一步,保留jsconfig.json,先只加include和paths,把编辑器的别名跳转打通。这一步不改任何业务代码,风险为零。

第二步,把jsconfig.json改名为tsconfig.json,加上allowJs: true、checkJs: false、strict: false。此时项目里一个.ts文件都没有,但类型系统已经就位。这个状态可以保持很久,团队没有任何感知。

第三步,从工具函数、常量、类型定义这些“叶子模块”开始改成.ts。这些模块依赖少、逻辑独立,改起来的收益也最快。同时把strictNullChecks打开,让新写的代码先享受严格检查。

第四步,逐步收紧。把noImplicitAny打开,让any显式化;等any消化得差不多,再打开整体strict。这个过程可能持续几个月,不用急,重要的是每次收紧都在 CI 里跑通,别让报错堆积成山。

有个小技巧:迁移期间可以在tsconfig.json里用include的差异做“白名单”控制。先把要检查的目录列进去,其他目录暂时排除,等改完一批再往里加。这样错误总数始终是可控的,不至于每天打开编辑器就是一片红。

3.4 多环境分层:别把所有规则塞进一个文件

除了应用和构建脚本,稍微成规模的项目还会有测试、服务端渲染、Electron 主进程等不同运行环境。这时候可以考虑按环境拆更多子配置,比如tsconfig.vitest.json专门给测试文件用,加上types: ["vitest/globals"],让describe、it、expect这些全局函数有类型。

分层配置有一个容易忽略的坑:extends是单向覆盖的,子配置里写的compilerOptions会整体替换父配置里的同名键,而不是做深合并。比如父配置里lib是["ESNext", "DOM"],子配置里写lib: ["ESNext"],结果就是DOM类型全没了,所有document相关的代码全报错。所以子配置里如果要动lib、types这类数组字段,记得把父配置里需要的项也一起写全。

另外,TS 5.0 之后extends支持数组写法,可以一次继承多个基础配置,后面的覆盖前面的。这个特性在多框架混用的仓库里挺有用,不用再写一个中间文件做拼接。

4. 常见报错与排查技巧实录

4.1 典型报错速查表

报错现象大概率原因排查动作
找不到模块@/xxx或提示缺少类型声明tsconfig的paths缺失,或 Vite 的resolve.alias缺失两边一起查,用“打开 TS 项目配置”确认编辑器读的是哪份配置
.vue文件无法被导入include里没写src/**/*.vue,或语言服务未启用检查include,确认 Vue 语言服务插件已启用且没有冲突插件
import.meta.env类型为any或报未定义types里没加vite/client,或缺少env.d.ts补上类型声明文件与types字段
检查通过但构建报模块解析错误moduleResolution与实际打包器不匹配改成Bundler,并检查module是否兼容
报“无法在模块外部使用 import 语句”文件被判定为脚本而非模块加moduleDetection: "force"
tsc输出文件把 Vite 产物覆盖了没开noEmit打开noEmit,把类型检查与产物输出解耦
事件回调参数在模板里类型是any没开strictTemplates在vueCompilerOptions里打开
升级 TS 后一堆第三方类型报错skipLibCheck未开打开skipLibCheck

4.2 “找不到模块 @/xxx”的三层排查法

这个报错我处理过太多次了,已经形成了固定套路。第一层查tsconfig:确认paths里的通配符写法和实际导入路径能对上。注意一个细节,"@/*": ["./src/*"]里的./不能省,省了之后解析基准会变成baseUrl,如果你同时写了baseUrl,路径就会跑偏一层,导入@/utils/a实际会去找src/src/utils/a。

第二层查 Vite 配置:resolve.alias里必须有对应的@指向path.resolve(__dirname, 'src')。Vite 的别名是字符串前缀匹配,'@'和'@/'效果不一样,写'@'会把所有以@开头的包名也吃掉,所以要么写成'@/',要么用正则精确匹配。这个坑很容易被忽略,直到你装了一个名字以@开头的组织包才发现。

第三层查语言服务是否真的在干活:命令面板执行“选择 TypeScript 版本”,确认用的是工作区里安装的版本,而不是编辑器内置的老版本。编辑器内置版本通常比项目里装的旧,某些新的解析规则它不认识,就会出现“命令行能过、编辑器报错”的分裂现象。统一用工作区版本能消除这类差异,也保证了团队成员之间的一致性。

4.3 编辑器与命令行结论不一致怎么办

这种“分裂”现象有四个常见成因,按发生频率排序。

最常见的是编辑器用了内置 TS 版本,解决方式就是上面说的切到工作区版本。第二常见的是语言服务换过插件,旧的 Vue 支持插件和新插件同时启用,两个服务各说各话,表现是提示时好时坏。这时候要统一到一套插件上,并开启接管模式。

第三个原因是配置没有被重新加载。修改tsconfig.json之后,语言服务需要重新解析,但这个重载不是总那么及时。手动重启 TS 服务或者重开窗口,能解决大部分“改了没反应”的疑惑。

第四个原因是项目引用没有被正确识别。用了根配置加子配置的结构后,如果子配置里的composite没开,或者根配置的references路径写错,语言服务就找不到对应子项目,回退到默认配置,表现就是“编辑器读不到任何自定义规则”。排查办法是在命令面板里查一下当前文件属于哪个项目,找不到的话就是引用链断了。

4.4 几个不常见但很折磨人的坑

有个坑是大小写敏感。macOS 和 Windows 的文件系统默认不区分大小写,Linux 区分。本地开发时import Foo from './foo'和实际文件Foo.vue混用,本地一点问题没有,一上 CI 就全挂。开启forceConsistentCasingInFileNames(在新版配置里已默认开启)能在写代码阶段就拦住这类错误。这个开关建议永远保持开启,尤其是在多人协作的项目里。

还有个坑是类型导入被当成值导入。表现为构建产物里多出一堆本该被擦除的代码,或者在打包时提示某个只包含类型的模块不存在。根源就是没开verbatimModuleSyntax或者没写import type。开了这个开关之后,TS 会强制你把类型和值分清楚,虽然写起来多打几个字,但能避免很多构建期的诡异问题。

最后一个坑是装饰器与新 class 字段语义的冲突。用了一些基于装饰器的库,同时target设得很高,就会触发useDefineForClassFields的标准语义,导致装饰器行为和预期不符。解决办法要么调低target并显式关掉这个开关,要么升级到支持标准装饰器的库版本。这类问题排查起来很耗时,因为报错信息通常和实际原因离得很远。

5. compilerOptions 与构建工具的边界在哪

5.1 Vite 到底读了 tsconfig 里的哪些字段

这是个值得记住的清单。Vite 用 esbuild 做开发期转译,esbuild 会读取tsconfig.json中的少数几个字段:target、jsx、jsxFactory、jsxFragmentFactory、useDefineForClassFields、experimentalDecorators、emitDecoratorMetadata、verbatimModuleSyntax这几项会影响它的转译行为。除此之外的字段,esbuild 一概不看。

也就是说,strict、noUnusedLocals、strictNullChecks这些纯检查类的开关,Vite 完全无视——你写多少都没用,构建照样成功。这就是“为什么代码里有一堆类型错误,项目还能跑起来”的根本原因。类型错误只会在vue-tsc跑的时候或者编辑器里出现,构建流程根本不在这个链路上。

paths更是个典型。Vite 完全不读它,所以你必须额外配resolve.alias。如果你实在不想维护两份别名配置,可以引入vite-tsconfig-paths之类的插件,让 Vite 去读tsconfig.json的paths。这个方案在中小项目里挺好用,能消除两处不一致的风险;但大型项目里我倾向于手动配,因为显式声明更可控,排查问题时一眼就能看到映射关系。

5.2 需要双份配置的几个要点

除了paths,还有两个地方容易出现“编辑器一套、构建一套”的情况。

一是target。tsconfig里的target影响类型检查和 esbuild 的语法降级范围,但真正决定生产产物的兼容目标是vite.config.ts里的build.target。如果你需要支持比较老的浏览器,两个地方都要调低,只调一处就会出现“本地测好、线上白屏”的情况。

二是jsx。如果你在项目里用了tsx写组件,tsconfig里通常写"jsx": "preserve",意思是保留 JSX 语法交给构建器处理。但 Vite 侧的 esbuild 也需要知道怎么转换,虽然它会读tsconfig的jsx字段,但如果你在vite.config.ts里覆盖了相关配置,两边就可能不一致。建议这类项目在两个文件里把 JSX 相关的注释写清楚,避免后来接手的人搞混。

5.3 三份可直接复制的配置模板

第一份,纯 JS 项目的jsconfig.json:

{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", "paths": { "@/*": ["./src/*"] }, "allowJs": true, "checkJs": false, "isolatedModules": true, "skipLibCheck": true, "resolveJsonModule": true, "moduleDetection": "force", "types": ["vite/client"] }, "include": ["src/**/*.js", "src/**/*.vue"], "exclude": ["node_modules", "dist"] }

第二份,Vue 3 + TS 项目的tsconfig.app.json:

{ "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "paths": { "@/*": ["./src/*"] }, "strict": true, "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "moduleDetection": "force" }, "vueCompilerOptions": { "target": 3.5, "strictTemplates": true } }

第三份,工具脚本用的tsconfig.node.json:

{ "extends": "@tsconfig/node20/tsconfig.json", "include": ["vite.config.*", "vitest.config.*", "scripts/**/*"], "compilerOptions": { "composite": true, "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", "types": ["node"] } }

这三份覆盖了绝大多数 Vue 项目的场景。抄的时候注意按项目实际情况调整include路径,env.d.ts如果你的类型声明文件叫别的名字,也要跟着改。

6. 几条我从实际项目里踩出来的固定习惯

关于include的写法,我现在的习惯是尽量精确,不要图省事写**/*。原因很实际:把node_modules、dist、测试产物全圈进来之后,类型检查耗时能翻好几倍,而且偶尔会因为某些依赖自带的声明文件有瑕疵而报一堆莫名其妙的错。精确圈定src、类型声明文件、必要的配置文件,是性价比最高的一种优化。项目变大之后,这一条能省下来的时间是以分钟计的。

关于版本管理,有一条经验值得强调:TypeScript 和vue-tsc的版本要锁定。这两个东西对类型解析的行为差异相当大,团队里如果 A 同学装的是 TS 5.4,B 同学装的是 5.7,同一个文件很可能呈现不同的报错结果,然后大家就开始互相怀疑代码。在package.json里把版本写死,用锁文件统一安装,能避免大量无效沟通。CI 里也应该跑一次类型检查,让版本差异在合入前就暴露出来。

关于符号链接类的路径映射,我建议只在确实需要大量映射时才用。有的项目会在tsconfig里定义十几个别名,@api、@store、@hooks、@composables全都来一遍,看起来很有条理,实际上维护成本很高——每加一个别名,就要改tsconfig和 Vite 两个文件,还得记住每条映射对应哪个目录。我自己的做法是保留@指向src,剩下的靠相对路径和目录结构解决。目录命名足够清晰的话,别名并没有想象中那么必要。

关于检查脚本,我的习惯是在package.json里加一条独立的类型检查命令,比如"type-check": "vue-tsc --noEmit",并且和构建命令分开。不要试图在构建流程里塞类型检查,那会把构建速度拖慢好几倍,而且失败信息也不如单独跑清晰。开发时用编辑器实时反馈,提交前本地跑一次,CI 里再跑一次作为兜底,这个节奏比较舒服。

最后说个关于配置调试的小技巧。当你不确定某条配置到底有没有生效时,别猜,直接跑tsc --showConfig,它会把经过所有extends合并、按环境解析之后最终的完整配置打印出来。这个输出是最权威的,包含了所有默认值和继承结果。我排查配置问题的第一步永远是它,比翻文档快得多,也比在编辑器里瞎试靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询