管理TypeScript项目中的类型声明

说来惭愧,最近才正儿八经地在生产项目中使用TypeScript,遇见一个比较棘手的问题就是:如何管理项目中定义的各种类型声明。

本文将从TS项目和声明方式开始,探究如何解决该问题。

<!--more-->

参考

1. ts项目

1.1. tsconfig.json 编译上下文

参考:tsconfig.json 官方文档

如果一个目录下存在一个tsconfig.json文件,那么它意味着这个目录是TypeScript项目的根目录。

TypeScript 将 会把此目录和子目录下的所有 .ts 文件作为编译上下文的一部分。

tsconfig.json文件中指定了用来编译这个项目的根文件和编译选项。

不带任何输入文件的情况下调用tsc,编译器会从当前目录开始去查找tsconfig.json文件,逐级向上搜索父目录。

不带任何输入文件的情况下调用tsc,且使用命令行参数--project(或-p)指定一个包含tsconfig.json文件的目录。

当命令行上指定了输入文件时,tsconfig.json文件会被忽略。

1.2. 全局模式和模块模式

在一个TypeScript项目中,当一个x.ts文件不包含importexport关键字时,它就是一个全局模式;反之就是一个文件模块

  • 全局模式,所有变量定义,类型声明都是全局的,同名interface会进行合并,同名变量或type会报错
  • 文件模块,所有变量定义,类型声明都是在模块内有效的,可以通过import引入文件模块中export出来的变量和类型

一般来说,ts 会解析项目中所有的 *.ts 文件,当然也包含以 .d.ts 结尾的文件。

如果声明文件是全局变量的模式,其他*.ts文件就可以获取到其中的类型了。

在文件模块中,也可以通过declare global的方式进入到全局命名空间。

// 当前文件为文件模块
export {}

// 进入全局命名空间
declare global { 
     // global namespace
  // 里面的声明的内容是全局作用于
 }

2. 第三方js库的类型:声明文件

参考:Typescript 书写声明文件

TypeScript是JavaScript的超集,在ts项目中可能需要依赖js的第三方库。比如现在有个库Mod1.js,暴露了一些接口

window.Mod1 = {
  // 参数为字符串
  test(msg){
        console.log(msg)
    }
}

在写代码的时候,如果不做任何操作,无法使用ts的类型检测等功能

// 1.ts
Mod1.test(123) // 无法检测到错误

这时候就需要一个描述 JavaScript 库和模块信息的声明文件,借助这个文件,就可以利用TypeScript 的各种特性来使用库文件了。

声明文件以d.ts结尾,注意它只有类型声明,不包含代码实现(类似于C语言中的.h文件)

declare namespace Mod1 {
    function test(msg:string):void;
}

然后引入该声明文件

/// <reference path = "mod1.d.ts" /> 
Mod1.test(123)

这个时候再写代码或者tsc 1.ts编译,就会看见错误提示了

error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

需要注意的是上面我们在运行tsc时指定了输入文件,会忽略tsconfig.json因此需要手动引入声明文件;如果是TypeScript项目模式下,全局模块可以自动引入,而无需再手动引入。@types

2.1. @types

第三方模块那么多,每个库都写一遍声明文件,听起来就很麻烦,DefinitelyTyped提供了大量第三方库的声明。

比如jQuery,只需要安装@types/jquery即可

npm install @types/jquery

因此在准备编写第三方库的声明文件之前,可以先去瞅一瞅社区是否已经有对应的实现了。

2.2. 三斜线指令

可以看见上面通过三斜线///引入了声明文件,这是一种特殊的指令,也是早期ts的模块化标签。

/// <reference types="sizzle" />
/// <reference path="JQuery.d.ts" />

这里展示了typespath两种语法

  • types 用于声明对另一个库的依赖
  • path用于声明对另一个文件的依赖,在同一个库中如果拆分了声明文件,就可能需要使用

其他还有一些废弃的语法,这里就不做介绍了。

由于全局声明文件不允许出现import、export等关键字(出现了就不是全局声明文件了),因此当

  • 当我们在书写一个全局变量的声明文件时,不允许出现export
  • 当我们需要依赖一个外部项目的全局变量声明文件时,不允许出现import

这些场景的时候,三斜线还是有用武之地的;除此之外,基本上不再建议使用三斜线语法了。

2.3. 命名空间

全局声明最容易出现的问题就是命名冲突,TS早期时为了解决模块化而创造的关键字module,后来ES6使用了module关键字,因此TS使用namespace代替了module,中文名为命名空间。

命名空间主要是为了解决全局命名冲突,随着es6模块的普及,现在已经不再推荐使用命名空间了。

但在全局声明文件中,declare namespace 还是比较常用的,主要用来表示全局变量是一个对象包含很多子属性和方法的情况。

namespace Mod2 {
    export function test1(msg) {
        console.log(msg);
    }
    function test2(msg) {
        console.log(msg);
    }
}

可以看到编译后的文件,就是将命名空间内的方法挂载到全局对象Mod2上

var Mod2;
(function (Mod2) {
    function test1(msg) {
        console.log(msg);
    }
    Mod2.test1 = test1;
      // test2没有被export,则没有挂载到命名空间对象上
    function test2(msg) {
        console.log(msg);
    }
})(Mod2 || (Mod2 = {}));

2.4. 模块

模块声明就跟常规的es6模块类似,可以直接在ts文件中通过export导出类型,就像是导出一个模块方法

export type1 = {
    x: number
}

export function test(){
    console.log('test')
}

类型跟模块方法的使用基本一致,在需要到模块类型的地方,直接在文件头部import即可。

但是这种类型于实现混在一起的写法,可能会导致整个项目看起来比较混乱。一种常见的做法是将类型定义单独放在文件找那个,

// types.ts

export type1 = {
    x: number
}

export interface iProps = {
    y: string
}
// ...  其他的类型

一个文件就是一个模块,当然也可以按照业务和功能拆分多个types文件。

2.5. 扩展模块类型

如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块

比如下面声明,在moment上扩展某个方法

import * as moment from 'moment';

declare module 'moment' {
    export function test(): moment.CalendarKey;
}

2.6. shims-vue.d.ts

参考:

在Vue TypeScript项目中,就需要shims-vue.d.ts为所有vue文件做模块声明

declare module '*.vue' {
  import Vue from 'vue';
  export default Vue;
}

3. 全局声明

目前看来,大部分场景下都应该使用文件模块来管理我们的代码和类型。但在某些时候,全局声明也是有一些用处的。

3.1. global.d.ts

对于一个从JavaScript迁移的项目、或者团队中包含TypeScript新手的时候,可以提供一个全局声明文件。

按照习惯一般叫做global.d.ts(当然也可以是foo.d.ts之类的),如Vue3中的全局类型文件

利用全局声明(不包含export、import等关键字),在global.d.ts中编写的类型和接口会放入全局命名空间里,这些类型可以在当前TS项目所有的TypeScript文件中直接使用。

3.2. lib.d.ts

事实上TypeScript内置了一个全局的声明文件lib.d.ts,里面主要是一些JavaScript运行时及DOM各种变量及环境声明(如:windowdocumentmath)和一些类似的接口声明(如:WindowDocumentMath)。

可以通过配置noLib选项来取消自动添加lib.d.ts(当然一般不建议这样做)。

这里介绍lib.d.ts主要是为了强调global.d.ts,在某些情况下,如果需要扩展运行时的某些字段,(如向Date原型添加一个方法、声明一个全局变量等),可以在全局声明文件中进行扩展

// interface类型会进行合并
interface Date {
  toMoment(): void;
}

为了便于维护,这些操作一般也会在global.d.ts中进行。

3.3. 全局声明的语法

在全局声明文件中,主要有下面声明全局变量、类型的方法

declare var  // 声明全局变量
declare function // 声明全局方法
declare class // 声明全局类
declare enum // 声明全局枚举类型
declare namespace //声明全局对象(含有子属性)
interface // 声明全局接口
type //声明全局类型

4. 如何管理项目中的类型

一般来讲,你组织声明文件的方式取决于库是如何被使用的。

4.1. 使用全局声明的场景

全局声明会向整个ts项目添加全局的类型、接口等,因此需要考虑命名冲突等问题。

除了全局模块之外和整个项目通用的类型之外,应尽可能避免使用全局声明。

如果需要声明全局类型,也应该统一放在global.d.ts中进行管理,而不能在模块文件中通过declare namespace global随意注册全局类型。

4.2. 使用文件模块声明的场景

上面看到了declare moduledeclare namepsace、三斜线指令、import/export等各种模块声明方式,整理下来不禁感慨,ts也残留了很多历史问题,整个模块管理比较混乱。

感觉还是统一使用ES6模块最稳妥,类型来源、依赖都一清二楚,也不用考虑命名冲突等问题。

至于频繁import多些几行代码的问题,在项目可维护性面前,应该是无足轻重的。

在大型项目中,推荐使用声明文件(Declaration Files)来管理接口或其他自定义类型,如后台服务响应model、API参数等类型

4.3. 内部类型

通用的类型声明应该单独放在声明文件中进行管理,对于那些不通用的类型,如组件props等,应该就近声明。

4.4. interface还是type

interface只能声明对象类型,支持声明合并(可扩展)

interface User {
    id: number
}

interface User {
    name: string
}

// 包含id和name两个属性
const a: User = {
    id: 1,
    name: "shymean"
}

type不支持声明合并,更像是letconst声明了一个”类型变量“,主要用于定义类型别名,可以是任意类型、类型推算等,更为通用

type A = {
    id: 1
}

type A = {
    name: string
}

// 提示 Duplicate identifier 'A'.ts(2300)

如果是在开发模块,允许别人进行类型扩展,就是用interface;如果需要定义基础类型或类型运行,就使用type

5. 小结

上面整理了TypeScript中关于类型声明的一些知识点,然后总结了管理TS项目中的类型。

上述的管理方式都是翻阅文档和个人体感得到的一些建议,打算在项目中使用一段时间后再回头补充。