在FEDay 2023中我讲了《从JS到TS无缝迁移的实践报告》【视频在这里在这里】,是将一个传统的JS项目(mochajs/mocha)迁移到TypeScript环境的全程。其中提到了一件事情,就是“可以通过JSDoc/TSDoc来生成.d.ts”,从而实现TypeScript的类型安全检查。
有同学希望我能将这个过程也复述一下,一方面有“从JS到TS迁移”作为对照,可以看到二者间的差异,以及评估这个方案的代价,另一方面也可以作为一份很好的实践参考,探探路也顺道解决一些问题。于是简单整理如下。
签出js项目,并简单生成.d.ts
可以直接在命令行来生成.d.ts,以及检查和评估当前项目。如下:
# 签出代码
> git clone https://github.com/mochajs/mocha && cd mocha
# 安装TypeScript
> npm install -D typescript
# 方法1:生成.d.ts到./types目录(为每一个.js生成一个对应的.d.ts)
> npx tsc ./index.js ./lib/**/*.js --allowJs --target es2018 --moduleResolution node \
--emitDeclarationOnly --declaration --outDir "./types"
# 方法2:生成打包的.d.ts(将列表中所有文件的.d.ts打包到一个)
> npx tsc ./index.js ./lib/**/*.js --allowJs --target es2018 --moduleResolution node \
--emitDeclarationOnly --declaration --outFile "./index.d.ts"
# 方法3:生成打包的.d.ts(指定唯一的入口文件,并将该文件及其关联的全部模块打包到同一个.d.ts)
> npx tsc ./index.js --allowJs --target es2018 --moduleResolution node \
--emitDeclarationOnly --declaration --outFile "./index.d.ts"
注意这里直接在命令行上指定了文件名列表,因此不需要使用目录中的tsconfig.json
。上述命令总是会根据JSDoc来尽量生成可用的.d.ts
文件。注意:./lib/**/*.js
没有添加双引号,是借助shell来处理了通配符,这在DOS命令行上是不行的。
参数说明:
--allowJs
:允许tsc
处理.js
文件并识别其中JSDoc的类型信息。--target
:目标环境的版本,编译和语法检查时会用到(会同步影响--lib
参数)。--declaration
:生成.d.ts
文件。--moduleResolution
:指定查找模块的方式,这里置node
指的是使用传统的Node.js风格。--emitDeclarationOnly
:只生成.d.ts
(不生成.d.ts.map
文件)。--outFile
or--outDir
:指定输出的单个文件名(bundle),或者每个.js
对应一个.d.ts
并输出到指定目录。
这三种方法输出.d.ts
的结果还是不错的,但也只是“大致可用”而已。这有两个方面的原因:
- 这样输出的
.d.ts
是未经查错的,因此尽管“有类型信息”,但可能某些信息访问起来会出错。 - 如果源码是CommonJS风格的,那么
.d.ts
的模块引用关系通常会存在错误(ESM模块风格会较好一些)。
具体解决问题的方法后面会讲。
简单查错
如果要查错,可以使用下面的命令:
# 简单查错
> npx tsc ./index.js ./lib/**/*.js --allowJs --checkJs --strict --noEmit --target es2018 --moduleResolution node
...
Found 1529 errors in 51 files.
Errors Files
...
参数说明:
--checkJs
:允许利用JSDoc对.js
文件做类型检查--strict
:在类型检查(例如使用了--checkJs
)时使用严格模式--noEmit
:不生成任何文件,只查错。
这样就可以简单地检查.js
源代码中可能隐含的类型错误。其中的一部分是通过JSDoc引入类型信息时的错误(例如找不到某个类型);另一部分则是静态检查.js
源代码的错误,例如试图访问某个不存在的属性。这些检查与在TypeScript开发中的类型检查是类似的,并且报出的也是TS:xxxx
这样的错误代码,只不过依赖的是JSDoc中的类型信息而已。
如此一来,你就会看到大量的错误提示(以mochajs/mocha项目来说,有1529个错误)。
在VSCode中开发
可以在这个项目根目录中塞入一个tsconfig.json
文件,然后就可以在VSCode中开发了,既可以语法提示,也方便管理错误和基于.js
来开发调试。这看起来就很令人心动,简单如下:
# 并初始化为TypeScript项目
> npx tsc --init --allowJs --checkJs --strict --noEmit --module commonjs --target es2018
参数说明:
--noEmit
:这里使用--noEmit
的目的是将该配置是说明不需要转译到.js
文件(以避免在开发过程中第三方工具尝试生成.js
而导致覆盖原始文件错)。
这是一个适合VSCode开发的基本配置,在VSCode中打开该项目目录时,就会自动启用完整的类型检查功能。——需要说明的是,在VSCode中开发,与是否生成.d.ts
并不是直接相关的。例如这个项目,在不做任何修改时也能生成.d.ts
文件,但试图通过JSDoc来替代TypeScript,在启用了--checkJs
之后能做类型检查,但开发过程中却并不需要生成.d.ts
文件(--noEmit
配置为true
)。
接下来我就具体讲一下在JSoc中(部分地)修复这些类型错误的方式,以及进一步解决上面提到的.d.ts
的两个问题的方法。
经典的错误1:模块引用
JSDoc并没有引用外部模块的能力。比如说下面这个:
/**
* Creates an error object to be thrown when done() is called multiple times in a test
*
* @param {Runnable} runnable - Original runnable
* ...
*/
function createMultipleDoneError(runnable, originalErr) ...
显然,这里的runnable
是声明在其它模块中的类型。在.ts
中,可以通过import/import type
来引入类型并声明它,但JSDoc中通常只写类型名,这就导致那些从外部模块中引用的类型会出个错误。如下图:
解决这类问题的方法,是在注释中写import ...
。——是的,感觉就是把代码写进注释一样。如下:
* @param {import("./runnable")} runnable - Original runnable
这个写法是直接使用了动态导入import()
的语法,这里的./runnable
是缺省导出Runnable
类所以简单引用一下就行。如果模块是缺省导出了一个名字空间,那么就会复杂一点。例如utils.js
模块:
* @param {import("./utils").escape} fn - callback
那么当参数fn
就可以引用到Utils.escape()
这个函数的类型信息。当然,在实际使用JSDoc来开发时,为了避免这种“随时随地的import()”,所以也会把它们单独提取出来做typedef
。例如:
/**
* 外部模块中的类型引用
* @typedef {import("./runnable")} Runnable
* @typedef {import("./utils").escape} callbackFn
* ...
*/
在VSCode中使用的效果如下:
经典的错误2:继承性
继承问题也是最常遇到的,如果是在ES6中使用的class
声明,那么TypeScript会根据extends
子句来自动推断。但是如果你使用特殊的方法来实现继承,例如setPrototypeOf()
,或者是使用ES6之前原型继承,又或者是声明构造器函数等等,那么JSDoc中能否声明这种继承性呢?
首先,需要排除掉@extends
声明。这个JSDoc的语法会要求作为类的注释,所以既不能在构造器上,也不能写给一个普通的(原型继承的)对象。
然而一旦排除这个声明,那么接下来的事情就变得复杂了。例如在error.js
中createMultipleDoneError()
的具体实现:
function createMultipleDoneError(runnable, originalErr) {
var err = new Error(message); // 从父类创建实例
err.code = constants.MULTIPLE_DONE;
err.valueType = typeof originalErr;
err.value = originalErr;
return err;
}
在ES6之前,几乎所有的Error
子类的实现——以及所有的子类类型——的实现方法都是如此:从父类创建实例(用作this),然后抄写属性。然而err
对象既然是子类的实例,那么它的类型应该是子类的,而不是父类Error
;并且由于err
创建成为父类实例,所以后续的code
等子类中的成员就不能正常访问了。所以这段代码会有如下类型错误:
所以需要创建一个子类类型,例如MultipleDoneError
,这个类型需要派生自Error
,并且具有code / valueType / value
等成员。并且,在这个项目中这些实例是通过工具函数创建出来的,所以不需要声明类,也不需要声明构造器。所以,通常能在网上找到的“使用JSDoc派生子类”的方法就失效了,例如:
/**
* @typedef {object} MultipleDoneError
* @extends {Error}
* @property {constants.MULTIPLE_DONE} code - Error code
* @property {string} valueType - type of that value
* @property {Error | undefined} value
*/
/** ... (函数界面声明,略)*/
function createMultipleDoneError(runnable, originalErr) {
/** @type {MultipleDoneError} */
var err = new Error(message);
...
}
在这个示例中,MultipleDoneError
类型的声明是正确的,并且也符合JSDoc的语法,而在VSCode中显示这个err
的类型时:
这是由于@extends
这个标记在这个上下文中不支持[*1],所以推断成了MultipleDoneError
原本的类型object
。
唯一有效的方法是采用TypeScript中的“交叉类型(Intersection Types)”语法,用类型混入来替代继承。——当然,事实上传统的原型继承(类抄写)在概念上也确实更近似于“类型交叉/混入”。如下:
/**
* 方法一:直接声明交叉类型
* @typedef {Error & {
* code: typeof constants.MULTIPLE_DONE, // Error code
* valueType?: string, // type of that value
* value?: Error | undefined
* }} MultipleDoneError
*/
/**
* 方法二:声明两个独立类型,并交叉
* @typedef {object} BaseMultipleDoneError
* @property {typeof constants.MULTIPLE_DONE} code - Error code
* @property {string} valueType - type of that value
* @property {Error | undefined} value
* @typedef {Error & BaseMultipleDoneError} MultipleDoneError
*/
上述两种方法得到的类型名MultipleDoneError
都是可用的,方法一完全使用了TypeScript的语法,但与JSDoc与TypeScript语法混用看起来有些繁琐,方法二稍好些,是完全的JSDoc语法,但是会多 一个中间类型。并且,——需要注意的是——所有这两种方法的结果,都只是使得子类能声明出正确的成员列表,而并不能“显式的”表明继承关系。
接下来,按照JSDoc中的“常规用法”,简单地用如下语法将类型信息添加给变量err
,如下:
/** @type {MultipleDoneError} */
var err = new Error(message);
...
但是很不幸,这会导致一个赋值时的类型错误。——new Error()
创建的结果类型,不能赋给MultipleDoneError
类型。简单地说:父类的实例,不能赋给子类。接下来讲这个问题。
注*1:当一个注解不支持时,它会导致后续其它注解也失效。而这一点在VSCode中反映不出来,所以表面看起来注释没有任何异常或错误提示,但代码中的类型推断结果却是
object
。
经典的错误3:类型断言(强制类型)
如果使用ES6之后的类声明,当然就没有这个问题。但在ES6之前,这种构造或创建实例的方法是常规操作,TypeScript中也有简单的应对方法。例如将Error()
声明成泛型函数,如下:
// signatures
interface MochaError {
new <T>(message: string): T;
<T>(message: string): T;
}
const Error = function <T>(message: string) {
return new globalThis.Error(message) as T;
} as MochaError;
var err = new Error<MultipleDoneError>(message);
...
这样,在实现泛型Error<>
时使用了“… as T”做类型断言,这使得Error()
总是能返回预期的类型(例如MultipleDoneError
类型),于是TypeScript就可以将它推定为err
变量的类型。然而在JSDoc中没有“断言”这种类型表达式语法,取而代之的是在表达式上的注释语法。如下:
var err = /** @type {MultipleDoneError} */(new Error(message));
...
这在语义上相当于new Error(message) as MultipleDoneError
这样的类型断言。只不过JSDoc中的类型都是通过特定语法声明在注释中,没有用到泛型而已[*2]。效果如下:
而在查看MultipleDoneError
类型时,将显示为下面这样:
注*2:在JSDoc中也有类似泛型的机制,称为“模板(@template )”。
.d.ts文件的使用问题
现在讨论一下之前生成的三种.d.ts
文件有什么用,以及如何使用的问题。
这些生成的.d.ts
与你在@types/xxx
可见的那些有着非常大的区别,“直接拿来就用”的想法可以休也。大概说明如下:
- 方法一:生成的
.d.ts
按目录结构放在./types
目录中,与源码中的.js
一一对应。是“勉强可用”的版本,但离交付结果尚远。 - 方法二、三:生成的
.d.ts
没有本质上的区别,只是由于方法三使用了“入口点(EntryPoint)”文件,所以不在依赖树中的代码未被打包。
如果要使用./types
目录下的定义文件,那么可以将它的入口写到package.json
,例如:
"types": "./types/index.d.ts"
由于对导入者来说,目标模块(例如"mocha")的package.json
定义了一个包,以及该包的结构,因此"types"定义的是这个包对外部的界面(也就是index.js)的类型定义文件。——注意这里的讲述,意思是“只有这个入口文件有类型信息”。所以严格来说,导入者只能访问index.js
中导出的东西,而不能访问其内容的东西。那么,下面的代码怎写呢?
const Mocha = require('mocha');
let ctx = new Mocha.Context(); // 注意这里的ctx的类型信息
let mocha = new Mocha(ctx, ...); // 在上下文(ctx)中创建mocha实例
...
注意这里的ctx应该是"mocha"包中某个类的类型,而Mocha.Context()
在这里只是被导出的构造器,所以在TypeScript中,"mocha"包需要通过名字空间来导出Context
的类型信息,以便用户代码在外部使用。但是在.js中没有这个机制,因此在对应的JSDoc里也没有,对应的.d.ts
里还也就同样没有。
所以严格来说上面的代码是完不成的。但是VSCode取了点巧,如果你当前在写的是.ts
代码,它会“帮助你”在./types
中去“发现(resolve)”那些定义文件。所以,当你使用了一个不存在的类型时,它会尝试着去添加相应的引用。例如:
当你添加@type {Context}
时,VSCode会自动添加import Context ...
行。——当然,既然这里是.ts
代码,那么写成下面这样,VSCode也是同样会自动完成import ...
语句的:
context ctx: Context = new Mocha.Context(); // 在给ctx添加类型时,VSCode会自动完成import语句的添加
但是这仅针对.ts
文件,如果是.js
,就需要自己在JSDoc中添加下声明了:
/**
* @typedef {import("mocha/types/lib/context")} Context
*/
说说这里的包路径(mocha/types/llib/context
)的问题。如果是习惯用AMD或UMD的同学,这样的包路径应该不陌生。因为这些模块风格中,模块是打包在一个文件中的,每个模块(各个.js
)就用这样的字符串定义在同一个Bundle里面。显然,考虑到“一个包”的性质,将所有的".d.ts"
打包到一个Bundle中是合适的做法,这样就可以将./index.d.ts
与包的根目录中的./index.js
放在一起了。[*3]
注*3:这一点的细节容后再议,这里需要先指出的是:打包,改变了输出的
index.d.ts
的性质,但并没有对当前的包(例如这里的Mocha)带来特别的好处。不过这里指用TypeScript自己打包器的输出结果,在'./types'
中的多个文件,与单独的Bundle在这里是有着本质区别的。
不可能完成的任务:打包.d.ts
就路径的表达上,上面的方法2和3的打包结果是AMD/UMD是一样的。例如在它们生成的index.d.ts
中:
declare module "lib/utils" {
export { inherits };
export function escape(html: string): string;
...
}
declare module "lib/pending" {
export = Pending;
...
}
...
declare module "index" {
const _exports: typeof import("lib/mocha");
export = _exports;
}
但这个打包的index.d.ts
其实并不是一个“模块的包”,而是一个在TypeScript中称为环境(Ambinet modules)的东西:你不能在TypeScript中用import
来引用它,而是需要使用“三斜杠(///)”来引入并装载在当前模块的顶层或全局。——换言之,你可以认为package.json
中的"types": ...
有两个作用,一是指示.d.ts
文件的位置,二是指示VSCode中的TypeScript在语法分析时将这个包作为哪种东西(环境文件/一般模块)处理。
这是因为在TypeScript中有三种语义上的“模块”:1、标准ESM模块;2、非ESM标准模块(例如CJS/AMD/UMD);3、TypeScript的模块。对此,TypeScript有一个“简单粗暴”的识别模块的原则:文件中有export/import
语句的就是模块,否则,就不是。所以TypeScript的模块(第3种)扩展了ESM模块(第1种),并全兼容它。但是对第2种,也就是使用CJS/ADM/UMD等模块风格的.js
文件,TypeScript能识别和处理,但在概念上却不认为它们是模块。——所以也就不能按照“模块”来处理。
上面的看起来有些“绕”,但它的意义在于说明:正是因此,TypeScript打包的index.d.ts
将“Mocha"
处理成了环境模块(Ambinet modules),而不是一般模块。因此在最终输出的"./index.d.ts"中,其最外层也就并没有“import/export”语句。——它是一个“.d.ts的包”,但不是一个模块。
“不是模块”带来了很多问题,比如你不能在这个文件中做多重导出:
// 假设这是在index.d.ts文件内
...
export { Mocha, Context, Hook, ... }
因为这是“.d.ts的包”,是环境模块(Ambinet modules),所以Mocha, Context, Hook, ...
这些名字都不存在,也就不能导出。于是也还有一种建议,在这个模块index.d.ts
末尾强制加上一个“export {}
”,让TypeScript当成包来处理。——如果你这样做了,很快就会发现,由于包的路径发现策略跟环境模块不同,所以一旦加入这行语句,那么一些原本还能找到的类型定义就找不到了,这个.d.ts
文件会抛出更多的错误(例如:Cannot find module ‘lib/cli/options’ or its corresponding type declarations.ts(2307))。
考虑到我们在这里并不实际使用ts开发,也不会去编译'.ts'
文件来产生实际运行代码,所以也可以写一个“专门用来导出的./index.ts
”文件,它只用于编译产出“./index.d.ts
”,例如:
// 手写的index.ts
import Mocha from './lib/mocha';
import Context from './lib/context';
...
export {Mocha, Context, ...};
export default Mocha;
或者使用如下语法:
// NOTE: 需要先在当前项目中使用`npm instal @types/node`来安装Node.js支持
// 手写的index.ts
export const Mocha: typeof import("./lib/mocha") = require('./lib/mocha');
...
export default Mocha;
比之前稍好一些的是:总算可以在index.d.ts
中包括多个导出了。
# 编译index.ts(注意--allowSyntheticDefaultImports参数用于让import语句兼容性支持“导出赋值”)
> npx tsc ./index.ts --allowJs --module es2020 --target es2018 --moduleResolution node \
--allowSyntheticDefaultImports --emitDeclarationOnly --declaration --outFile "./dist/index.d.ts"
但是使用tsc打包出来的’./index.d.ts’仍然会是环境模块。而接下来的工作,就需要第三方工具出场了。——如果你没有兴趣看下去,那么我可以先告诉你结果:
没有工具能在使用
export =
语法的传统CJS项目上生成一个.d.ts
的打包。
首先要排除一类“不能忽略错误”或“要强制开启–checkJs”的工具,因为我们这里是使用JSDoc来生成类型信息,——就目前来说,它还有1700多处Bug修呢。这就直接干掉了dts-bundle-generator、@hyrious/dts。不支持编译’.js’的,比如说在调用tsc时不发送--noEmit / --allowJs
参数的也不能用。此外,还有一大类的工具是基于rollup中的rollup-plugin-dts插件的,因为这个插件不支持子名字空间(namespace child (hoisting)),这导致多个面向commonjs的导出赋值也不能使用了,因此这类工具也全都用不了。——在commonjs风格的.js中不能用,但在esm模块风格中的不受影响。[*4]
总而言之,因为mochajs/moha是一个“传统的.js项目”,所以……许许多多的第三方工具都牺牲了。
与我们的目标相近似的,有一个rollup的插件,称为rollup-plugin-flat-dts,它的作用是将所有的delcare module ...
摊平在同一个模块中。这样一来,所以在子目录中的(例如"lib/reporters/base"
)模块都将展开在根模块(例如"mocha
")中,然而它无视了cjs/esm等模块语法的差异,当这些模块展开在一起时,就出现了“多次声明默认导出(export default …)”这样的事情。因此也不可用。
如果你只是打算了解一个这个话题(打包".d.ts
"文件)的热度和复杂程度,可以尝试用这个官方issues开始。
注*4:我一直很看好的tsup,也因为使用rollup-plugin-dts插件在这里倒下了。
——上面提到的每一个问题(小目标),都是在TypeScript圈子里广泛讨论并有“无数的”解决方案的话题。然而大都无有结论,并最终缺乏面向低版本代码的适用性。
总结
在项目中使用JSDoc来获得类型支持以及查错,并最终输出外部的,或第三方工具可用的.d.ts
是可行的。这对于不太复杂的项目尤其适用,并且它还可以同步产生规范的技术文档支持,因此是一个不错的实践。但是JSDoc描述复杂的类型(例如泛型、接口等)时力不从心,有些时候还需要反过来使用TypeScript定义全局声明(global.d.ts
),这反而使得技术栈变得复杂。
使用JSDoc与TypeScript所能遇到的问题,相比起来只多不少(例如前面提到过的1700+错误),无论哪种方案,在“定义类型并保证类型安全”这件事上的工作量都是相当的,所以最终选用哪一种,往往会是“喜欢怎样的语法”的问题。使用JSDoc的方式并不会使代码变得“更整洁”,其实多数情况下,TypeScript在函数内的都是可以类型推定的,并不会带来阅读上的压力。但是,直接使用TypeScript的社区庞大,大多数问题都可以快速找到解决方案,而JSDoc在这方面就很难,而且多数时候还是会绕回到TypeScript社区的圈子。——使用TypeScript的那些工具集,或者从TypeScript高手那里得到建议。
关于Types in JSDoc
,Gil Tayar有四点总结(参见这里)非常简明扼要,简述如下:
- JSDoc 注释中的类型定义有时笨拙且冗长。
- 无法在JSDoc中完成所有操作,所以有时候需要手写.d.ts。
- 在模拟TS的(x as …)这个类型推断语法时,嵌入在表达式中的JSDoc很丑。
- 相关的讨论太少,许多问题还缺乏最佳实践,甚至没被发现。
最后我再补充一点,就是对旧的(传统的CommonJS模块风格)的项目和语法支持太差,这主要是TypeScript相关工具集的问题(例如webpack/rollup等),但在TypeScript下面有解决方案,而到了JSDoc中就没法处理了。因此,本文中的“打包.d.ts
”这一目标从来没被实现过。——这里有两种打包方案,一种是直接从.js/.ts
开始,由打包工具完成编译到打包的全程;另一种是先由tsc生成各个文件对应的.d.ts,然后由第三方工具拼合打包。所有工具,都无法完全两种方案之任一。😦
参考
- TypeScript 官方手册 @see https://www.typescriptlang.org/docs/handbook/declaration-files/dts-from-js.html
- 一个非常大的参考入口 @https://github.com/DavidWells/types-with-jsdocs 【推荐】
- 无 Bundler 纯 JS 环境下的简易 TS 实践 @https://zhuanlan.zhihu.com/p/664653918 【这篇文章走了许多弯路,但很有趣】
- 一些Bundler之间的比较以及推荐 @https://github.com/timocov/dts-bundle-generator/discussions/68
- 一些
type in js
项目 @https://github.com/voxpelli/types-in-js/discussions/11 【这个repo有一简要指引,不错】 - JSDoc继承声明的完讨论 @https://github.com/jsdoc/jsdoc/issues/1199 【复杂。长。】