一个小工具,解决了一个困扰 Vue 开发者多年的类型检查难题

一个轻量 CLI:只检查你指定的文件,但不忽略你的 tsconfig.json。

项目地址:github.com/whiter001/v... | npm:@whiter001/vue-tsc-files


一、你一定试过,然后放弃了

项目里想给类型检查提速,最直觉的做法是:只检查这次改动的几个文件。

于是你在 package.json 里写下:

json 复制代码
{
  "scripts": {
    "typecheck": "vue-tsc src/App.vue src/components/Foo.vue"
  }
}

跑一下,一屏红色:

lua 复制代码
error TS2304: Cannot find name 'defineProps'.
error TS7016: Could not find a declaration file for module 'lodash-es'.
error TS2339: Property 'xxx' does not exist on type 'IntrinsicElements & Props'.
error TS2688: Cannot find type definition file for 'node'.

问题出在哪?------vue-tsc a.ts b.ts 这种写法,会完全绕开你的 tsconfig.json。

tsconfig.json 里的一切都不再生效:compilerOptions(paths、strict、jsx......)、types、typeRoots、include/exclude,全部被忽略。它退化成一次"裸"的文件编译,于是:

  • paths 别名 @/xxx 全部解析失败;
  • types: ["node"] 里的全局类型(process、Buffer、NodeJS.Timeout)全部消失;
  • 你写在 env.d.ts / shims-vue.d.ts 里的模块声明、ComponentCustomProperties 扩展全部失效;
  • strict 没了,strictNullChecks 一关,一堆本来干净的代码开始报错。

于是这个"提速技巧"变成了"制造噪音机器",最后你只能老老实实 vue-tsc --noEmit 全量检查,认命等待几十秒。

能不能既只检查这几个文件,又完整保留 tsconfig 的语义?

这就是 vue-tsc-files 要解决的问题。它移植自 TS 社区知名的 tsc-files(那个工具在纯 TS 世界解决了同样的问题),把同一套思路搬到 vue-tsc 上。


二、30 秒上手

sh 复制代码
npm i -D @whiter001/vue-tsc-files
# 或
pnpm add -D @whiter001/vue-tsc-files

vue-tsc 和 typescript 是 peerDependencies,按项目实际需要装:

sh 复制代码
pnpm add -D vue-tsc typescript

然后直接用:

sh 复制代码
vue-tsc-files src/App.vue src/components/Foo.vue

就这么简单。你也可以完全不改日常习惯,只在 lint-staged 里用一次:

json 复制代码
{
  "lint-staged": {
    "**/*.{vue,ts,tsx,mts,cts}": "vue-tsc-files"
  }
}

从这一刻起,你每次 git commit 都会做一次"只针对暂存文件"的类型检查,而且 tsconfig.json 该怎么生效还怎么生效。


三、真正贴心的地方:文件列表可以由 git 来给

手写文件列表,在 lint-staged 之外几乎没法用。所以这个工具直接把 git 接管了过来:

命令 语义 对应的 git 命令
vue-tsc-files --changed 工作区所有变更(已暂存 + 未暂存 + 未跟踪) git status --porcelain -z -uall
vue-tsc-files --staged 下一次 commit 会带入的文件 git diff --cached --name-only
vue-tsc-files --unstaged 工作区相对暂存区的改动(不含未跟踪) git diff --name-only

于是你不再需要 lint-staged 就能拿到经典的 pre-commit 体验:

sh 复制代码
vue-tsc-files --staged

或者装个 husky:

sh 复制代码
# .husky/pre-commit
npx vue-tsc-files --staged

几个实现上比较讲究的细节:

  • --changed 严格按 git status 语义 :已修改、新增、重命名、复制、未合并、未跟踪全部覆盖,已删除的文件跳过;-uall 让 git 直接把未跟踪目录里的每个文件展开出来,不用自己递归。
  • --staged 的诚实说明 :它按 index 选出文件,但检查的是这些文件当前磁盘上的内容 。你 git add 之后又继续改但没再 git add,那些未暂存的改动也会被检查到。这是 lint-staged 在不开 stash 时的同款限制,README 里明确写出来了,而不是让你踩坑才发现。
  • 在仓库子目录下运行也能用:git 给的是相对仓库根的路径,工具会转换成相对当前工作目录的路径,并且只收集该子目录下的变更。
  • 不会假绿 :git 子进程有 30 秒超时,maxBuffer 放宽到 64MB(未忽略文件一多,git status -uall -z 的输出很容易超过 spawnSync 默认的 1MB 上限把子进程杀掉)。命令行最终没收集到任何文件、又没带这三个 flag 时,属于用法错误,退出码 1------绝不会 exit 0 让 CI 门禁蒙混过关。

四、--errors-in-changed-only:只对自己的错误负责

这个 flag 是这个工具最有价值的特性,先说清楚它要解决什么。

你 vue-tsc-files src/App.vue 时,TypeScript 会把 App.vue 传递依赖进来的文件一起编译进来------src/utils/request.ts、src/store/user.ts、src/types/api.d.ts......这些文件你一个都没改,但它们的输出会原封不动地混在你的检查结果里。

于是你改一个文件,却要为一堆历史遗留错误负责,最后只能去修那些跟本次改动无关的地方。

加上 --errors-in-changed-only(别名 --changed-only):

sh 复制代码
vue-tsc-files --errors-in-changed-only

行为变成:

  • 完整输出照常打印,你能看到所有错误,信息不丢失;
  • 但只有命令行传入的文件(以及全局配置错误)里的错误,才影响退出码。

推荐在 lint-staged 里这么配:

json 复制代码
{
  "lint-staged": {
    "**/*.{vue,ts,tsx,mts,cts}": "vue-tsc-files --errors-in-changed-only"
  }
}

它是怎么做到的?说白了是一次输出归属解析:

  1. 不能用 stdio: "inherit" 直接透传了------要解析输出才能决定退出码。改成 encoding: "utf8" 捕获子进程输出,maxBuffer 放宽到 64MB(默认 1MB 会截断大仓库的输出,而恰恰是这个模式需要完整输出)。
  2. 逐行剥掉 ANSI 颜色码 。这一步非常关键:--pretty 模式下,error TSxxxx: 本身也带色码,不剥掉的话后面所有正则判断全部失效。
  3. 用两种正则识别带位置的诊断行------普通格式 src/a.ts(12,5): error TS2322: 和 pretty 格式 src/a.ts:12:5 - error TS2322: 都覆盖。
  4. 把诊断里的路径和命令行传入的文件都归一化成绝对路径 再比较(顺手去掉首尾可能出现的单引号;Windows 下不区分大小写,统一转小写)。这样 ./a.ts 和 a.ts 能匹配上。
  5. 不带文件位置的 error TSxxxx:(比如 TS5083: Cannot read file ...)算全局错误,一律视为失败。
  6. 最后兜一层:非零退出但输出里一条可识别的诊断都没有(比如 vue-tsc 自己崩了),保守按失败处理,绝不放过;只有"错误全部来自传递依赖"这一种情况才 exit 0,并且打印一行提示告诉你发生了什么。

最后一条写进代码里是这样的:

ts 复制代码
if (errorsInSpecifiedFiles.length > 0 || globalErrors.length > 0) {
  process.exitCode = status;
} else if (!/error TS\d+:/.test(stripAnsiCodes(output))) {
  process.exitCode = status; // 没有任何可识别诊断 → 保守判失败
} else {
  console.error("Note: vue-tsc reported errors only in transitively compiled files, ...");
}

顺带一提,这里刻意不用 process.exit(),而是设置 process.exitCode 让事件循环自然排空------对管道的 stdout.write 是异步的,立即退出可能把尾部输出截断。


五、原理:一份"临时 tsconfig"

回到最初那个问题:为什么直接传文件会丢掉 tsconfig?而 vue-tsc-files 凭什么能保住它?

答案就是:它不传文件,它传一份重新生成的 tsconfig。

arduino 复制代码
vue-tsc <files>            →  tsc 走"命令行文件列表"模式,tsconfig 被忽略
vue-tsc -p <tmp>.json      →  走"项目模式",tsconfig 完整生效
vue-tsc-files              →  读取你的 tsconfig → 生成临时 tsconfig → vue-tsc -p tmp.json

所以核心就三步:

1. 读取你的 tsconfig

用 TypeScript 自己的解析器 ts.readConfigFile,而不是 JSON.parse + eval。这样注释、尾随逗号都能正常处理,配置写错时得到的是一条人话诊断,而不是一个栈溢出。

2. 生成临时 tsconfig,写在原配置的旁边

ts 复制代码
const tmpTsconfigPath = join(configDir, `tsconfig.${randomChars()}.json`);

这里有个非常关键的设计:临时文件必须和原 tsconfig 同目录 。因为 extends、baseUrl、paths、typeRoots 这些选项都是相对于 tsconfig 所在目录 解析的。写到 os.tmpdir() 里,你的 paths 别名就全废了。

配置内容大致是:

ts 复制代码
{
  ...rootTsConfig,
  compilerOptions: {
    // 用户没设才注入 skipLibCheck: true(默认值来自完整 extends 链的解析结果)
    ...(userSkipLibCheck === undefined ? { skipLibCheck: true } : {}),
    ...rootTsConfig.compilerOptions,
  },
  files: [...要检查的文件, ...项目里的 d.ts].map(f => resolve(process.cwd(), f)),
  include: [],  // 关键:清空
}

include: [] 是这里最容易忽略的一环------不把 include 清掉,你原来的 include: ["src/**/*"] 会把所有源码又拉回来,"只检查这几个文件"就白做了。改用 files 精确指定。

files 一律写成绝对路径:files 是相对临时 tsconfig 所在目录 解析的,用 cwd 相对路径在 -p 指向子目录配置时会错位。

3. 把 d.ts 一起带上

只检查 App.vue 一个文件时,你的 env.d.ts 里的这些声明不会自动被包含进来:

ts 复制代码
declare module "@vue/runtime-core" {
  interface ComponentCustomProperties {
    $custom: MyCustomType
  }
}

于是 this.$custom 又报错了。这个工具的做法是:用 ts.getParsedCommandLineOfConfigFile 把你的 tsconfig 完整解析一遍(包含 extends 链 ),从解析后的文件集合里筛出所有 .d.ts,一并写进临时配置的 files。

注意是"按你 tsconfig 自己的 include/exclude/files 范围收集"------项目自己都不会编译的声明文件不会被硬塞进来 ,这比简单粗暴地 glob 全仓 *.d.ts 干净得多。

4. 用完就删

临时文件注册了清理钩子:正常 exit 时删除,收到 SIGHUP/SIGINT/SIGTERM 时先删再退出。退出码按 shell 约定用 128 + 信号值(130 / 143 / 129)。

这里还有个曾经踩过的坑被明确修掉了:信号回调没有参数 ,如果直接 process.exit(undefined) 会以 0 退出,把你手动 Ctrl+C 的中断误报成"类型检查通过"。

pnpm 为什么能用

pnpm 的 node_modules 是符号链接布局,直接推导 ../.bin/vue-tsc 会拿不到东西。工具的做法是:定位 vue-tsc/package.json,再拼出 bin/vue-tsc.js,用当前 Node 可执行文件 process.execPath 去跑。

ts 复制代码
const packageJsonPath = req.resolve("vue-tsc/package.json")
return join(dirname(packageJsonPath), "bin", "vue-tsc.js")

还有个细节值得说:解析用的 require 锚点优先是 process.cwd(),其次才是自身位置 。因为在 npx / pnpm dlx 场景下,本体位于临时沙箱目录,从自身位置解析到的是沙箱自动安装的 peer 副本,版本可能和项目里装的对不上;优先从 cwd 解析才能命中项目自己的版本。正常 devDependency 场景两个锚点结果一致。


六、Monorepo:-p 指定别的 tsconfig

如果你的项目不是单配置(tsconfig.app.json / tsconfig.node.json,或 configs/ 下的分层配置),用 -p / --project:

sh 复制代码
vue-tsc-files -p tsconfig.app.json src/App.vue
vue-tsc-files --project=configs/tsconfig.app.json src/App.vue
vue-tsc-files -p . src/App.vue            # 传目录 → 读目录里的 tsconfig.json,与 tsc -p . 一致

因为临时 tsconfig 就建在 -p 指定的那个文件旁边,extends、baseUrl、paths 的解析结果和你的原始配置完全一致。


七、还有一点:它也懂 AI Agent

现在越来越多项目用 AI 编码助手改代码,而 Agent 遇到"检查这几个文件"时,本能反应是直接调 vue-tsc <files>------然后被一堆误报带偏,甚至去"修"根本不存在的错误。

所以这个仓库自带了一份 agent skill:.agents/skills/vue-tsc-files/SKILL.md。它教会识别 .agents/skills/ 约定的 Agent(Kimi Code 及其他同类工具)在合适的时机改用 vue-tsc-files。

skill 随 npm 包一起分发。装好包之后复制到项目里即可:

sh 复制代码
mkdir -p .agents/skills
cp -r node_modules/@whiter001/vue-tsc-files/.agents/skills/vue-tsc-files .agents/skills/

不想装包也行,直接从 GitHub 拉:

sh 复制代码
mkdir -p .agents/skills/vue-tsc-files
curl -fsSL -o .agents/skills/vue-tsc-files/SKILL.md \
  https://raw.githubusercontent.com/whiter001/vue-tsc-files/master/.agents/skills/vue-tsc-files/SKILL.md

八、需要知道的边界

好的工具不只说自己好。这几个限制写在 README 里,也值得你在用之前过一眼:

  • 不跟随项目 references 。对 solution 风格的 tsconfig 做检查时,被引用项目的 composite / declaration 约束不会生效(检测到 references 时会打印一条提示)。
  • 模块声明必须放在 .d.ts 里 ,且要在你 tsconfig 的 include/exclude/files 范围内,否则不会被带上(这是刻意的:项目自己不编译的声明不该被拉进来)。
  • skipLibCheck 默认为 true 以加速检查;如果你在 tsconfig 里显式设了(true 或 false 都算),以你的为准,包括写在 extends 链上游 base 配置里的情况。
  • 始终向底层 vue-tsc 传 --noEmit,其他参数原样透传。
  • Node >= 20.19。
  • 未跟踪的 node_modules 会在收集阶段被直接丢弃(-uall 下未跟踪目录会逐文件展开,可能产生几十万行输出;根因是 .gitignore 没配好,这里只是兜底)。

九、小结

vue-tsc-files 的全部价值可以浓缩成一句话:

让你在"只检查这几个文件"和"完整遵守 tsconfig"之间,不必二选一。

它的实现只有两个文件、几百行代码,核心思路朴素得几乎有点直白------不要传文件,要传一份改写过的 tsconfig 。围绕这个思路补上了:git 驱动的文件收集、错误归属过滤(--errors-in-changed-only)、同目录生成的临时配置、.d.ts 按项目真实范围收集、pnpm / dlx 下的包解析、以及一堆"别假绿"的兜底判断。

如果你也被 vue-tsc <files> 的误报折磨过,或者想在 CI 里加一道"只查本次改动"的类型门禁,可以试试。


相关推荐
shmily麻瓜小菜鸡1 小时前
Axios 中 params 与 data 的区别与原理
javascript·typescript
xcs1940513 小时前
前端 vue 的前端页面debugger 进不去
前端·javascript·vue.js
雪芽蓝域zzs16 小时前
第 5 节:Marker 点击弹出 InfoWindow 信息窗口
vue.js
大包子18 小时前
structuredClone vs lodash.cloneDeep:核心差异与选型指南
javascript·vue.js
雪芽蓝域zzs19 小时前
第 3 节:地图点击事件,点击获取经纬度
vue.js
WayneX19 小时前
开源 Vue 3 组件库 Morya UI:把组件、文档、AI 工具链一起做进一个包
前端·vue.js·前端框架
hai_android19 小时前
Chat 聊天模块功能总结
前端·javascript·vue.js
志尊宝1 天前
Vue3 零基础每日笔记(076):防抖节流的正确使用——搜索框、按钮防重复提交、滚动监听
javascript·vue.js·笔记
顽疲1 天前
从零用 Java 实现小红书 SpringBoot Vue UniApp(23)笔记话题多对多:一条笔记进多个话题池
java·vue.js·spring boot