一个轻量 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"
}
}
它是怎么做到的?说白了是一次输出归属解析:
- 不能用
stdio: "inherit"直接透传了------要解析输出才能决定退出码。改成encoding: "utf8"捕获子进程输出,maxBuffer放宽到 64MB(默认 1MB 会截断大仓库的输出,而恰恰是这个模式需要完整输出)。 - 逐行剥掉 ANSI 颜色码 。这一步非常关键:
--pretty模式下,error TSxxxx:本身也带色码,不剥掉的话后面所有正则判断全部失效。 - 用两种正则识别带位置的诊断行------普通格式
src/a.ts(12,5): error TS2322:和 pretty 格式src/a.ts:12:5 - error TS2322:都覆盖。 - 把诊断里的路径和命令行传入的文件都归一化成绝对路径 再比较(顺手去掉首尾可能出现的单引号;Windows 下不区分大小写,统一转小写)。这样
./a.ts和a.ts能匹配上。 - 不带文件位置的
error TSxxxx:(比如TS5083: Cannot read file ...)算全局错误,一律视为失败。 - 最后兜一层:非零退出但输出里一条可识别的诊断都没有(比如 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 里加一道"只查本次改动"的类型门禁,可以试试。
- GitHub:github.com/whiter001/v...
- npm:www.npmjs.com/package/@wh...
- 上游致敬:github.com/gustavopch/...
- License:MIT