由 shims-vue.d.ts 引发的思考
By: Kazehaiya
前言
由于项目近期进行 ts 迁移,作为第一个吃螃蟹的人,踩过了不少坑。迁移过程中遇到的大大小小的问题基本上都解决了,但是对于 shims-vue.d.ts 文件的命名以及其内的模块声明始终找不到比较贴切的解释。沉下心来读了些外网资料,总算是有点“豁开云雾见青天”的感觉了。此处就记录我对于 ts 全局模块声明的一些思考以及一些 ts 项目迁移遇到的坑。
Vue ts 声明文件
在安装 @vue/typescript 之后,项目会生成两个新文件,分别是 shims-vue.d.ts
和 shims-jsx.d.ts
,其内容分别是:
// shims-vue.d.ts
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
和
import Vue, { VNode } from 'vue';
declare global {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode { }
// tslint:disable no-empty-interface
interface ElementClass extends Vue { }
interface IntrinsicElements {
[elem: string]: any
}
}
}
那么这两个文档有什么作用呢?
shims-vue.d.ts
前者为 Ambient Declarations(通称:外部模块定义) ,主要为项目内所有的 vue 文件做模块声明,毕竟 ts 默认只识别 .d.ts、.ts、.tsx 后缀的文件;(即使补充了 Vue 得模块声明,IDE 还是没法识别 .vue 结尾的文件,这就是为什么引入 vue 文件时必须添加后缀的原因,不添加编译也不会报错)
shims-jsx.d.ts
后者为 JSX 语法的全局命名空间,这是因为基于值的元素会简单的在它所在的作用域里按标识符查找(此处使用的是**无状态函数组件 (SFC)**的方法来定义),当在 tsconfig 内开启了 jsx 语法支持后,其会自动识别对应的 .tsx 结尾的文件,可参考官网 jsx。
产生的问题
首先,官方文档的上并没有将 shims-xxx.d.ts 做为通用的模板,其仅仅给我们列举了以下模板样例:
- global-modifying-module.d.ts
- global-plugin.d.ts
- global.d.ts
- module-class.d.ts
- module-function.d.ts
- module-plugin.d.ts
- module.d.ts
那么该如何理解这两个文件?
是否能够更改在统一规范的文件内?
全局接口、命名空间、模块等声明又有那些写法来定义?该如何写?
... 对于产生的这么些问题,下面依次分析。
解惑
理解并改造 shims-xxx.d.ts
我们知道,xxx.d.ts 的文件表明,其内部的一些声明都为全局的声明,能够在项目各组件内都能获取到。因此 Vue 生成的两个 shims-xxx.d.ts 其实是为了表明,该两文件为 Vue 相关的全局声明文件。
但是从项目管理来说,随着引入的 npm 模块增多(比如公司内部 npm 源上的不带 types 的包),那么模仿 Vue 的声明文件写法,外部声明的文件也会越来越多,文件夹看起来就不是很舒服了。因此有没有一种比较好的方法来解决文件过多的问题呢?
对于我来说,我更偏向将这些简单的声明维护在一个 .d.ts 文件内,正好官网也推荐维护在一个大的 module 内,因此我们可以维护一个 module.d.ts 来总体声明所有的外部模块。基于官方的例子,我做了两个文件来管理外部模块的声明,分别是 module.d.ts
和 declarations.d.ts
。前者主要维护需要写的比较详细的外部模块,后者主要维护简写模式的模块(包括内部需要声明的 .js 文件,兼容历史遗留问题)。例如:
改造后的 module/index.d.ts
// This `declare module` is called ambient module, which is used to describe modules written in JavaScript.
// 添加 vue-clipboard2 的 Vue 插件声明
declare module 'vue-clipboard2' {
import { PluginFunction } from 'vue';
const clipboard: PluginFunction<any>;
// 定义默认导出的类型
export default clipboard;
}
// 添加 fe-monitor-sdk 的 Vue 插件声明
declare module 'fe-monitor-sdk' {
import { PluginObject } from 'vue';
// 定义解构的变量类型
export const monitorVue: PluginObject<any>;
}
// 添加所有 .vue 文件的声明
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
改造后的 module/declarations.d.ts
// Shorthand ambient modules, All imports from this shorthand module will have the any type.
declare module '@/cookie-set';
附加:对于 global 声明可视情况分类,比如通用的放在
global.d.ts
,其余可视情况(如果该类型比较多的话)按照对应类型分类,比如 table 的可全部放在global-table.d.ts
。
全局声明的写法
另一个一直比较疑惑的问题是全局声明的写法,比如模块的“单文件单模块声明”的写法“单文件多模块合并声明”的写法不太一样,“无导入的全局声明文件”和“带导入声明的全局声明文件”的写法又有些不同,这里我一一列出其可行的写法以及其不同的原因。
注:这里的一些定义都是个人总结的便于记忆的说法,为非标准定义。
单文件单模块声明
该文件支持两种写法,分别如下:
// 写法一
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
// 写法二
import Vue from 'vue';
declare module '*.vue' {
export default Vue;
}
注: 前者(写法一)主要为无 ts 声明的模块添加声明,后者(写法二)主要为已有 types 声明的模块进行声明扩展(可以参考 vue-router 源码部分)
单文件多模块合并声明
仅有一种写法(需要关闭对应的多次引入重复模块的 lint 规则或者忽略此 types 文件夹内的所有内容)
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
无导入的全局声明文件
无导入即没有 import 声明,直接定义全局接口、函数等
interface TableRenderParam extends BasicObject {
row: BasicObject,
key: string,
index?: number,
}
带导入声明的全局声明文件
带有 import 导入插件声明的必须显示定义 global,例如:
import { CreateElement } from 'vue';
// function 部分
declare global {
interface TableRenderFunc {
(h: CreateElement, { row, key, index }: TableRenderParam): JSX.Element,
}
}
// namespace 部分
declare global {}
不同的原因
如果在“单文件多模块合并声明”将 import 提出至最顶层时,会发现 ts 报错,说模块无法进一步扩大,为什么将 import 提出后会报错提示模块无法扩大?
个人研究得出的结论是,当将 import 提出至模块外时,就已经表明该文件内的其它 declare 的模块已经是存在 ts 声明的模块,此时再对其进行 declare 声明即对其原本的声明上进行扩展(可参考 vue-router 对于 vue 的扩展),但是对于没有 ts 声明的模块,我们拿不到它的 ts 声明,因此也就没发进行模块扩展,所以就会报错。
而将 import 放至模块内时,因为 module 本来就表明自己为一个模块,其就可以作为模块的声明,为没有对应声明的模块添加声明了。
此外,对于多个 declare global 的写法,此是采用了**声明合并**的方式,使得所有的模块声明都合并至同一个 global 全局声明中,因此,在对于将 import 提至外层的“带导入声明的全局声明文件”来说,分文件全局维护或者单文件声明合并式维护都是可行的。
注:TypeScript 与 ECMAScript 2015 一样,任何包含顶级 import 或者 export 的文件都被当成一个模块。相反地,如果一个文件不带有顶级的 import 或者 export 声明,那么它的内容被视为全局可见的(因此对模块也是可见的)。
项目迁移的其余 ts 问题
当然,在项目迁移过程中遇到的问题还有很多,作为附带项,以供大家参考。
动态引入无 ts 声明的文件
因为动态设置的 cookie 会随测试机不同而不同,且不同人开发,其 cookie 也会变,因此需要将此文件清除 git 跟踪并动态导入(线上不到入),同时得支持 .js/ts 的声明。
原写法:
// 对应 cookie-set 文件内判断当前环境
import '@/cookie-set';
改造一:清除 git 跟踪并提出环境判断
// git 部分
git rm --cache <cookie-set file path>
// 文件部分采用动态引入
if (process.env,NODE_ENV === 'development') {
import('@/cookie-set');
}
改造二:支持 js 文件 因为动态 import 需要 ts 声明,因为没有跟踪文件,为了支持 .js 文件,可在 declarations.d.ts 内添加简单声明
declare module '@/cookie-set';
引入的自家插件无 vue 插件声明
最初的改造例子里面又贴到过,为了方便大家理解,我就暖心的再贴一次代码,注意看更改后的注释~
// 此适用于 import vueClipboard from 'vue-clipboard2';
declare module 'vue-clipboard2' {
import { PluginFunction } from 'vue';
const clipboard: PluginFunction<any>;
export default clipboard;
}
// 此适用于 import { monitorVue } from 'fe-monitor-sdk';
declare module 'fe-monitor-sdk' {
import { PluginObject } from 'vue';
export const monitorVue: PluginObject<any>;
}
export 和 export default 可参考模块部分
vue-router 的引用路径问题
虽然 webpack 内配置了 alias,但那仅仅只是 webpack 打包时用的,ts 并不认账,它有自己的配置文件,因此,我们需要再两个地方配置来解决此问题。首先需要配置 tsconfig.json 的 path 路径
// tsconfig.json
path: [
"@/*": [
"src/*"
],
// ...
]
另一个是 ts 对于 vue 文件的引用必须添加 .vue 后缀,因为编辑器的原因使得无法识别 .vue 后缀(尤大大也有说,参考文档有链接附加,可自己查),因此所有的 vue 文件的引用都需要补上 .vue
后缀。
vue 的 mixins 文件写法
参考 ts 的 vue 入门文档,改造如下
// 原来的写法
export default {/**/}
// 当前的写法
import Vue form 'vue';
export default Vue.extend({/**/})
注意,此部分的 computed 需要添加返回值类型,否则会报错
关于 data 部分的声明
这个坑比较隐蔽,折腾了很久才发现因为 data 为函数,其内的对象为返回值,因为并没有采用 Class 风格写法(中途接入 TS 改动太大,原有的文件保持原有结构),因此此部分的声明应该这么写(个人推荐不用断言):
data(): Your Interface here {
return {};
}
// 或者
data() {
return <Your assertions here> {};
}
VS Code experimentalDecorators 问题
根据警告来做相应配置,即在 tsconfig.json 内添加属性:
"experimentalDecorators": true
因为是装饰器目前版本为实验性特性,可能在未来的发行版中发生变化,因此需要配置此参数来删除警告。
类的静态方法
关于类一般会采用 abstruct 抽象类来规范方法和属性等类的细节,但是对于“类”中 static 部分无法进行抽象规范,需要在对应静态方法部分进行单独处理,对于此部分有没有比较好的处理方法(即能提取一个 interface 之类的声明)存在疑问🤔。刚开始开发时留的此问题目前想到的比较靠谱的写法有两个。
namespace 写法
官方文档中也有说过,对于业务内的模块来说,推荐使用 namespace 来做全局命名,因此对于业务内比较通用的公共方法来说,可以使用 namespace 来处理。
对于多层命名空间的写法,可用别名写法
import NS = FirstNameSpace.SecondNameSpace
,然后直接通过NS.xxx
来直接取对应属性即可。同时区别加载模块时使用的import someModule = require('moduleName')
,此处的别名仅仅只是创建一个别名而已,简化代码量。
module 文件
另一种可用 ES6 的思想,import + export ,因为类中只有 static 方法,因此可以认为该类为一个模块,而一个模块对应一个文件,因此作为一个 ts 文件来存储对应方法,需要时在 import 引入即可。
DefinitelyTyped 的说明
- 如果你的模块需要将新的名称引入全局命名空间,那么就应该使用全局声明。
- 如果你的模块无需将新的名称引入全局命名空间,那么就应该使用模块导出声明。
拓展内容
namespace
TS 里的 namespace 主要是解决命名冲突的问题,会在全局生成一个对象,定义在 namespace 内部的类都要通过这个对象的属性访问。对于内部模块来说,尽量使用 namespace 替代 module,可参考官方文档。例如:
namespace Test {
export const USER_NAME = 'test name';
export namespace Polygons {
export class Triangle { }
export class Square { }
}
}
// 取别名
import polygons = Test.Polygons;
const username = Test.username
注意:import xx = require('xx') 为加载模块的写法,不要与取别名的写法混淆。
默认全局环境的 namespace 为 global
module
模块可理解成 Vue 中的单个 vue 文件,它是以功能为单位进行划分的,一个模块负责一个功能。其与 namespace 的最大区别在于:namespace 是跨文件的,module 是以文件为单位的,一个文件对应一个 module。类比 Java,namespace 就好比 Java 中的包,而 module 则相当于文件。
参考文档
转载自:https://juejin.cn/post/6844903882309500942