JSDoc+checkJs:旧JS项目低成本引入类型检查的实用方案
不是Typescript用不起而是JSDoc更有性价比这句话我最近在几个项目里反复验证了几遍越验证越觉得值得好好聊聊。先说个引子前阵子接了个老项目代码全是纯 JavaScript构建链路年久失修打包、转译、压缩全靠一堆 Grunt 任务硬撑动一处都怕冒烟。让我把整个项目迁到 TypeScript从工程角度讲等价于把一台跑了十年的老车直接换发动机能不能打着火都是问题。但项目规模又不小函数之间传参靠猜改一个接口要全局搜名字风险肉眼可见。这种情况下JSDoc 配合 TypeScript 编译器的 checkJs 能力几乎是我能想到的性价比最高的解法。这不是说 TypeScript 不行——TS 在大型新项目里的统治力没有任何悬念。JSDoc 的定位是“旧项目渐进增强”是“不给加编译器的项目做类型兜底”是“给纯 JS 脚本加上可以撤回的约束”。对于维护成本高、历史包袱重、或者压根儿不想引入构建步骤的团队用 JSDoc 写类型标注配合编辑器和 CI 检查能花十分之一的成本拿到六成以上的类型安全保障。这篇就把我在这几个项目里实际踩过的配置、标注方法、坑和心得一次性说清楚供同样被困在纯 JS 项目里的朋友参考。1. 先说结论什么场景下我会优先选 JSDoc我在决定一个项目到底用 TypeScript 还是 JSDoc 之前会先问自己三个问题这个项目能不能顺畅引入编译/转译步骤哪些数更重要代码稳定还是工程升级速度团队成员对类型系统的熟练度和意愿如何如果项目是一张白纸团队也愿意上工程化那别犹豫直接 TypeScript。JSDoc 再怎么说也只是“在 JS 的语法壳子里模拟 TS 的型系统”很多 TS 的高级能力它表达不出来硬靠着大段注释去凑反而让代码变得又长又难维护。但反过来如果项目满足下面这些特征JSDoc 往往比迁移 TS 更划算项目已经稳定运行两三年以上构建链路的每一环都是“能跑就不要动”。代码仓库里有大量第三方插件/配置模板/脚本工具这些文件原本就是纯 JS改成 TS 之后反而跟现有机制冲突。团队成员没有精力系统学习 TS 的编译配置、泛型约束、模块解析策略点错一个 tsconfig 选项可能卡半天。你只想要“改代码时能立刻看到类型错误”不追求编译期类型检查必须和构建流程捆在一起。我接手过的那套旧系统前端部分大概有几百个 JS 文件模块系统还是标准的 CommonJS 加一堆全局变量混用。这种情况下我第一步根本不敢开 “strict”: true也不敢碰模块化重构而是先把 JS 文件里的函数声明、对象结构、回调参数都用 JSDoc 写清楚然后开启 checkJs让编辑器把问题暴露出来。期间不需要动 build 脚本不需要 who determines JS 的加载顺序提交上去就是一个普通 JS 改动同事看起来只是在加注释。这种“无侵入感”是 JSDoc 最大的优势你完全可以要求别人“顺手写注释”但很难要求别人“配合你改构建流程”。还有一个典型场景是编写独立的 npm 包代码本身是 JS但你又不想让使用者在编辑器里看到一个满屏 any 的坏体验。此时可以用 JSDoc 标注好类型再把 tsc 的 declaration 输出打开直接从 JS 里生成 .d.ts 类型声明文件。这样对外发布的是带类型的 JS 包对内维护的还是纯 JS 源码两头都占到了。我的好几个小工具库就是这么干的效果很稳。2. JSDoc 类型标注的核心玩法JSDoc 写类型不像很多人想象的那么“弱”。除了最基础的 param、returns它其实能表达大量 TS 类型结构对象字面量、联合类型、可选属性、函数泛型、甚至工具类型。关键是你要知道每个 JSDoc 标签对应 TS 里的哪种表达然后照着写就行。2.1 最基础的 type、param、returns 怎么用出效果很多新手把 JSDoc 当成简单注释写出来不痛不痒/** * 计算两个数的和 * param {number} a * param {number} b * returns {number} */ function add(a, b) { return a b; }这种写法只能让编辑器检查出“调用 add 时传字符串会报错”至于“你注释写对了没有”“返回值真的符合预期吗”完全依赖你自己维护。偷懒的写法是这样/** * param {Object} opts * param {string} opts.name * param {number} [opts.age] * param {boolean} [opts.adminfalse] */ function init(opts) { // ... }直接把对象参数的结构在注释里展开调用方拿到的提示就跟 TS 里传入一个具名 interface 一样清晰。这里的重点在于用[]包裹参数名表示可选参数例如[opts.age]。可选参数可以带默认值标注例如[opts.adminfalse]这样编辑器会提示这个参数不传默认是 false。如果参数本身是个对象但你又不想展开它的字段可以写成param {object} opts但这基本没有类型保护意义不建议。做好这些基础标注之后函数内部写错属性名、忘传必填参数、把字符串当数字去算编辑器都会直接在你眼前画红线。这一层 JSDoc 足够覆盖项目中 60% 的纯函数场景。2.2 typedef 定义复杂结构写一次到处复用项目里最不缺的就是重复的数据结构——登录态、列表分页参数、筛选条件、接口响应包裹层。每个函数都把结构展开写在 param 里写起来累改起来更累。正确做法是用 typedef 把它们定义成别名/** * 用户信息 * typedef {Object} UserInfo * property {number} id 用户ID * property {string} name 用户昵称 * property {string[]} roles 角色列表 * property {boolean} [banned] 是否被禁言 */ /** * 获取用户信息 * param {number} uid * returns {PromiseUserInfo} */ async function fetchUserInfo(uid) { // ... }定义完之后UserInfo这个别名就能在当前文件里反复使用接口返回、函数参数、变量声明都能标注。配合编辑器鼠标悬停在 async 函数的 return 上还能看到完整的结构提示这对排查“这个接口到底返回了什么字段”特别有用。如果你想让 typedef 出的类型跨文件共用有两种方式在公共文件里定义然后在另一个文件里用typedef {import(../path/to/types).UserInfo} UserInfo引入。这个语法一开始有点别扭但其实是 JSDoc 跨文件引用类型的标准做法效果等价于 TS 的import type。定义一个types.js文件里面只写 typedef 注释不产生任何运行时代码然后所有地方引它。这个文件还能被你打包后删除不会影响产物体积。我个人的习惯是做一个types.js统一收口避免 typedef 散落在业务文件里改起来好找。2.3 template、callback 补上泛型和函数类型如果说 typedef 解决的是对象结构那函数类型和泛型就是 JSDoc 里最容易被忽略、也最体现水平的部分。函数类型用 callback 定义比如/** * 格式化回调 * callback Formatter * param {string} raw * param {number} index * returns {string} */ /** * param {string[]} list * param {Formatter} formatter * returns {string[]} */ function formatList(list, formatter) { return list.map(formatter); }这样formatList的第二个参数在编辑器里就能获得完整的参数数量、参数类型和返回类型检查。如果你直接写param {Function} formatter那跟 any 几乎没区别回调里随便传几个参数都不报错类型检查等于白做。泛型在 JSDoc 里用 template 表示/** * 安全读取对象属性 * template T * param {Object} obj * param {string} key * param {T} defaultValue * returns {T} */ function getOrDefault(obj, key, defaultValue) { return obj[key] undefined ? defaultValue : obj[key]; }在函数内部defaultValue和返回值之间就有了约束关系你传字符串默认值返回类型就会被推断成字符串传对象默认值就推断成对象。这对于封装通用工具函数特别有价值也让 JSDoc 不至于在工具库场景里过度退化。不过说实话泛型是 JSDoc 的舒适区边界。单参数泛型还好复杂对象泛型、条件类型、infer 关键字这些 TS 高级玩法JSDoc 基本表达不了。真需要那种重型泛型玩法说明这个模块复杂度已经不适合用 JSDoc 硬扛了尽早迁移 TS 更务实。3. 工具链配置与实操流程JSDoc 标注写得再好如果不把编辑器、CI 的检查开关打开也只是一堆漂亮的注释。这一步的配置是整个方案能不能落地的关键。3.1 用 jsconfig.json 开启 checkJs 类型检查我不想让一个纯 JS 项目背上 tsconfig.json 的名字——虽然功能上等价但同事一看 “tsconfig.json” 就容易紧张潜意识觉得项目被“TypeScript化”了。所以我更推荐在根目录建一个jsconfig.json既能被 VS Code 和 tsc 正确识别又明确表达“这还是一个 JS 项目”。一个比较稳妥的起步配置{ compilerOptions: { checkJs: true, allowJs: true, noEmit: true, target: ES2020, module: CommonJS, moduleResolution: Node, strict: false, skipLibCheck: true }, include: [src/**/*.js, test/**/*.js], exclude: [node_modules, dist, build] }几个容易被忽视的细节我逐一说下。checkJs是总开关必须为 true 才会真正做类型检查。allowJs在这个场景下也建议为 true否则即使 checkJs 开了一些 JS 文件也可能被忽略。noEmit一定要设成 true因为 jsconfig.json 本身就是给编辑器/检查用的我们不需要它真的产出任何 JS 或声明文件避免跑命令时把代码覆盖了。strict可以先设成 false让项目渐进式收紧如果你一开始就开严格模式老代码里成千上万个隐式 any 会直接淹没你导致没人愿意看检查结果。配置完之后VS Code 默认就会读取 jsconfig.json所有 include 覆盖范围内的 JS 文件都会获得实时类型诊断。你不用重启保存一下配置立刻生效。这里还要补充一个控制“是否被检查”的开关在 JS 文件第一行写// ts-check表示这个文件强制启用检查写// ts-nocheck表示这个文件彻底屏蔽检查。它们的作用优先级比 jsconfig 更高非常适合渐进式推行——我想让哪个文件先享受类型保护就在哪个文件加 ts-check其他文件暂时放养不会拖垮整个项目。3.2 在 CI 里跑类型检查堵住看不见的回归编辑器里的红线只能提醒你自己但漏了就是漏了。真正让 JSDoc 类型检查有价值的是把检查接入 CI让每次 MR 都自动跑一遍。在 package.json 里加一个脚本{ scripts: { type-check: tsc -p jsconfig.json --noEmit } }然后 CI 里执行npm run type-check只要有任何文件类型不匹配、参数传错、结构字段访问错误任务就会失败。这里有个经验第一次在 CI 引入类型检查输出错误数量往往多到爆炸——不是代码质量差而是老项目里大量历史代码根本没有类型标注。这时候不要硬着头皮改完再上线正确策略是先把已知文件排除掉或者用 ts-nocheck 临时屏蔽一部分文件先把 CI 跑绿之后每改一个文件就剥一层壳逐渐扩大检查范围。这个过程像“清理房间先把你暂时不用的箱子堆进储藏室再一点一点拆箱整理”比一次性搬空整个房间要实际得多。另一个经验是别把 JS 的类型检查结果和 ESLint 混在一个任务里。类型错误和风格错误是两类问题分开跑输出日志才清晰出错了也容易定位。我见过不少团队图省事把两个命令拼一起结果 CI 报错后得先猜是哪一类浪费时间。3.3 与 ESLint 和编辑器的配合既然项目还是纯 JSESLint 大概率已经在跑了。JSDoc 类型标注最好也能在 ESLint 里得到一部分校验至少让“注释格式写错”“param 和函数参数对不上”这类低级错误尽早暴露。可以装一个类似eslint-plugin-jsdoc的插件打开几个核心规则check-param-names验证 param 名称是否和函数实参完全一致check-types验证 param 里写的类型是否是有效 JS 类型表达式require-returns-type要求函数注释里有返回类型这三条规则覆盖日常高频错误。我见过最典型的低级问题就是函数重命名了但 JSDoc 注释里参数名还是旧版本要么没提示要么提示位置不对很误导人。插件能在提交前就把这种错误拦住。编辑器层面VS Code 基本开箱即用。有个小提示如果你同时装了 TypeScript 相关的扩展偶尔会遇到“tsconfig 优先于 jsconfig”的诊断冲突。判断依据很简单——根目录同时存在两个配置文件的话tsc 默认找 tsconfig而 VS Code 会优先使用 tsconfig导致 jsconfig 的 include/exclude 不生效。所以要么删掉项目里多余的 tsconfig要么统一只保留 jsconfig。我在一个老项目里就同时存在俩结果怎么配置都不生效折腾半天才排查到这个原因。4. 常见问题与排查技巧实录JSDoc checkJs 的方案看着轻巧实际跑起来坑并不少。我在这里把最常遇到的几个问题列出来每一个都是真实踩过、并且找到可行解法的。4.1 泛型标注层级有限制别硬拗复杂类型JSDoc 的 template 能处理单层泛型、默认参数甚至多泛型但表达不了工具类型、条件类型这类高级玩法。比如你想写一个“过滤出对象中指定类型的值”的高级函数在 TS 里可能是一大段 Conditional Type但 JSDoc 里几乎没法表达。遇到这种情况我的建议是“降低目标”把这个函数当成普通函数处理明确标注参数和返回值的主类型泛型细节选择性放弃。比如/** * 按类型过滤对象值 * param {Object} obj * param {Function} predicate * returns {Object} */ function filterValues(obj, predicate) { ... }这样虽然丢失了泛型映射的精度但至少调用方知道它接收对象、返回对象、谓词是个函数。比完全用 any 表达要强得多。硬要在一个工具函数里实现完整的类型体操JSDoc 最后只会变成一坨没人能看懂的注释失去维护可读性。4.2 typedef 跨文件引用路径不对、类型失效JSDoc 用typedef {import(...).UserInfo} UserInfo跨文件引入类型时最容易出现的问题是 import 路径写错。因为这里写的是相对路径而且跟在 JSDoc 注释里没有编译器的自动跳转和补全拼错一个字母就要排查很久。我给一个自己的处理原则统一在types.js里定义业务类型路径尽量简短、扁平不要嵌套多层目录。引用类型时只引types.js这一个文件不交叉引用业务模块的类型。如果项目中已经存在 TS 代码恰好有些类型是 TS 里定义的JSDoc 也可以通过import直接引用.ts文件里的类型。这个能力很多人不知道但它确实有效。另外注意在 JSDoc 里写import(./types).UserInfo时引用的目标必须是类型概念上的导出。由于 JS 文件里并没有export这个类型我们通常是在 types.js 里用一个typedef定义它然后在模块导出时顺手把它“挂”出去。简单说只要 types.js 里存在对应名字的 typedef 定义另一文件用 import 路径引用就能解析成功。4.3 开启严格模式后老代码满屏报错怎么贯彻下去很多人试水 JSDoc checkJs 之后听人劝把 strict 设成 true结果打开文件一看几百个红色波浪线直接劝退。这不是方案有问题而是推进节奏有问题。我的做法是三级推进第一级strict: false只做基础标注补充编辑器提示可选CI 不阻塞。 第二级strict: true但配合大量 ts-nocheck先把新写代码/新改文件纳入严格检查老文件继续放养。 第三级逐个文件去掉 ts-nocheck修复其中的类型问题每次 MR 只清理一两个文件不贪多。大约两个月左右项目里 80% 的文件都能在严格模式下通过检查。此时 JSDoc 的使命基本完成——如果你还想进一步把核心模块迁移到 TS也可以在此基础上顺滑转换因为 JSDoc 标注里的类型信息可以直接作为 TS 转换的“草稿”。4.4 编辑器不报错但 tsc 命令报错这个问题很容易让人懵VS Code 里看着一切正常一跑npm run type-check就报一堆错误。多数原因是 tsconfig/jsconfig 的 include 范围不一致或者编辑器进程没重启。排查思路很简单先确认 VS Code 实际加载了哪个配置文件。可以在任何 JS 文件里按下CtrlShiftP命令面板输入 “TypeScript: Select TypeScript Version”看它解析到的是哪种模式。如果项目根目录有 tsconfig.json 和 jsconfig.json 同时存在编辑器默认会优先选 tsconfig而命令行里的tsc -p jsconfig.json明确指向 jsconfig两边不一致就会产生误差。解决办法是只留一个配置文件并把 include 的路径写明确。5. 从 JSDoc 到 TS什么时候该升级以及怎么顺滑迁移JSDoc 是一个很好的中间态但不是最终态。项目体量继续增长后JSDoc 的类型表达能力会明显不够用——多文件类型导入靠注释、泛型复杂靠手工、重构重命名全凭编辑器一个个找这些痛点一旦累积到一定程度就该考虑正式迁移 TS。我总结过几个迁移信号满足其中两三条就说明该动手了新需求开始频繁修改底层数据结构JSDoc 定义的类型在多文件间被引用改动一处要全局查差不多十几处。团队人数增长新同事上手时看 JSDoc 注释的负担明显高于看 TS 类型定义。出现了不止一个“工具函数”需要复杂泛型和条件类型JSDoc 已经写不出想要的约束。构建链路已经重构过一版不再害怕引入 tsc 的编译步骤。好消息是如果之前的 JSDoc 注释写得足够规范迁移成本会被大幅降低。把 jsconfig.json 改成 tsconfig.json开启allowJs: false然后逐个把.js文件重命名为.ts原有 JSDoc 里的类型信息可以手动转成 TS 语法也可以让编辑器辅助转换。事实上很多 TS 新手在 JSDoc 阶段提前养成了“标注类型、思考结构”的习惯切换到 TS 之后只会觉得更顺手不会觉得陌生。迁移时我的建议是“从底层往上层走”先处理纯函数、工具函数、类型定义文件再处理业务逻辑模块最后再碰组件和入口文件。每个文件改完就跑一遍 tsc确保错误增量可控。我曾经在 3 个周末里把一个几百文件的项目从 JSDoc 完整迁到 TS没有特别大的返工靠的就是顺序合理和每步验证。6. 我个人在实际操作中的体会我在这个方案上前后折腾了不少项目最大的体会是JSDoc checkJs 的最优形态不是什么银弹而是一条“低成本、可回退、尊重现状”的渐进路线。它给那些暂时动不了构建链路的项目留了一扇窗你不用推翻重来也不用等着技术债总有一天爆炸还能用非常低的门槛立刻获得编辑器级类型提示和 CI 级类型校验。说句实话论类型系统能力、论开发体验TypeScript 绝对碾压 JSDoc。我这不是唱衰 TS而是对“历史包袱重、迁移成本高、团队节奏紧张”这类现实场景的一种务实回应。如果你在一个新项目里问我要不要用 JSDoc我大概率会劝你直接上 TS但如果你跟我一样面对一个旧项目、一堆历史 JS、复杂构建流程那 JSDoc 这套方案是真的香。最后分享一个我很喜欢用的小技巧在函数注释里写上 example给调用方看一眼最典型的使用方式。这不在类型检查范围内但配合 JSDoc 的类型提示能让一个刚接手项目的同事在 30 秒内搞懂一个复杂函数怎么调用。JSDoc 的注释本来就不该只给机器看它同时是给下一个工程师的说明书。该说的配置、标注、坑、迁移路径都聊完了剩下就是动手改第一个文件。从加一个 param 开始不着急慢慢来类型保护是滚雪球的事。