由 shims-vue.d.ts 引发的思考

20,454

By: Kazehaiya

原文:kazehaiya.github.io/2019/07/07/…

前言

由于项目近期进行 ts 迁移,作为第一个吃螃蟹的人,踩过了不少坑。迁移过程中遇到的大大小小的问题基本上都解决了,但是对于 shims-vue.d.ts 文件的命名以及其内的模块声明始终找不到比较贴切的解释。沉下心来读了些外网资料,总算是有点“豁开云雾见青天”的感觉了。此处就记录我对于 ts 全局模块声明的一些思考以及一些 ts 项目迁移遇到的坑。

Vue ts 声明文件

在安装 @vue/typescript 之后,项目会生成两个新文件,分别是 shims-vue.d.tsshims-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.tsdeclarations.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 则相当于文件。

参考文档