TypeScript学习笔记-4-模块、命名空间

1,359 阅读14分钟

模块

这篇文章中的模块是TypeScript模块,想要了解其他模块可以看看学习ES模块、CommonJS、AMD、UMD、SystemJS

普通模块

TypeScript模块和ES模块基本一样,只是它可以导出ES模块中没有的一些东西,比如类型别名(type alias)和接口(interface)。看下学习ES模块...中的ES模块部分。

举一个简单的例子,在a.ts中导出:

export const a1 = 1;

export interface A2 {
  m1: string;
  m2: number;
}

export function a3 () {
  console.log('a3');
}

a1.ts中导入:

import { a1, A2, a3 } from './a';
let a4: A2 = {
  m1: '111',
  m2: 22,
}
console.log(a1, a3, a4);

环境模块

环境模块和普通模块不同,环境模块中声明的内容是全局的,环境模块(以及环境命名空间)的声明都不能使用export导出,也不能使用任何import ...语句,否则环境模块就会失效。根据这个问答,可以使用import()方法动态地在环境模块中导入其他模块。

还记得在TypeScript学习笔记-1-基础类型、字面量类型、类型声明中说过,declare一般用于声明外部模块。接下来我们下载一个没有进行类型定义的模块jquery来进行练习,使用环境模块对jquery进行类型声明。

b.ts中引入jquery进行使用:

import $ from 'jquery';

$.aaa();

aaa是一个$上并不存在的方法,但因为没有对jquery的类型声明,在写代码的时候这里不会给出类型错误。

我们在jquery.d.ts中(粗糙地)声明环境模块jquery

declare module 'jquery' {
  export function ajax (x: object): void;
}

这样b.ts中使用$.aaa()的地方就会出现类型错误:Property 'aaa' does not exist on type 'typeof import("jquery")'.。使用$.ajax不会类型报错。

$.ajax({
  url: '',
  data: {},
});

因为环境模块是全局的,所以在随便一个其他地方,比如src/pages/Author/Author.tsx中写入如下代码也会报类型错误。

import $ from 'jquery';
$.aaa();

也可以像下面这样在一个文件中声明两个环境模块,但一个模块对应一个.d.ts文件比较直观。

declare module 'B1' {
  export const m1: string;
  export function m2(x: number): number;
  export interface M3 {
    m1: string;
    m2: number;
  }
}


declare module 'B2' {
  export const m4: string;
}

模块解析

模块可以在.ts/.tsx文件,或者代码依赖的.d.ts文件中定义。

模块解析是编译器用来找出import指向的内容的一个过程。

比如import { a } from "moduleA"。为了检查a的使用,编译器需要知道它代表的确切内容,并且需要检查moduleA的定义。

1.编译器会尝试定位代表导入模块的文件的位置。为了做到这个编译器遵循以下两种不同的策略:Classic or Node。传统的或者Node的。这些策略告诉编译器去哪找moduleA

2.如果上面的方式不生效或者模块名称不是相对的(“moduleA”就不是相对的),编译器就会尝试定位到一个环境模块声明。

3.如果编译器不能解析这个模块,就会打印出一个错误。在这个例子中,错误信息像这样: error TS2307: Cannot find module 'moduleA'.

相对和非相对模块导入

模块根据导入方式的不同解析方式也不同。相对的模块导入是以/./或者../导入的。比如:

import '/a';
import b from './components/b';
import { c } from '../components/c';

任何其他的导入都被认为是非相对的:

import { d } from '@/redux/d';
import e from 'e';

相对模块导入相对于导入文件解析,并且不能解析为环境模块声明。

非相对模块导入相对baseUrl或者路径映射,可以解析为环境模块声明。

模块解析策略

有两种可能的模块解析策略:Node的和经典的。可以使用 --moduleResolution 标记来声明模块解析策略。如果没有声明,默认是--module commonjsNode的解析方式),否则就是经典的(--module被设置为amdsystemumdes2015esnext等)。

注意:node模块解析是在TypeScript中最常用以及被推荐的解析方式。如果遇到了imports和exports的解析问题,可以尝试设置moduleResolution: "node"来看看能不能解决这些问题。

经典解析策略

曾经是TypeScript采用的默认的解决策略。如今,这个策略主要用于向后兼容。

一、相对导入会相对导入文件解析。

/root/src/folder/A.ts 中的import { b } from './moduleB'会被解析为如下这样:

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts

二、对于非相对模块导入,编译器会从包含导入文件的目录开始遍历目录树,尝试找到匹配的定义文件。

比如import { b } from "moduleB",在源文件/root/src/folder/A.ts中,会尝试下面的位置来定位"moduleB"

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts
  3. /root/src/moduleB.ts
  4. root/src/moduleB.d.ts
  5. root/moduleB.ts
  6. root/moduleB.d.ts
  7. /moduleB.ts
  8. /moduleB.d.ts

现在同级目录下依次找.ts文件和.d.ts文件,如果找到了就就定位成功,否则在上一级的文件目录下依次找.ts文件和.d.ts文件,如果直到根目录下都找不到文件就认为没有该模块,抛出错误。

node解析策略

Node.js的解析策略看学习...CommonJS...CommonJS部分。

TypeScript会模拟Node.js的运行时解析策略来在编译时定位定义的文件。

一、相对模块导入

例如,在/root/src/moduleA.tsimport { b } from "./moduleB"会尝试下面的位置来定位./moduleB:

1./root/src/moduleB.ts

2./root/src/moduleB.tsx

3./root/src/moduleB.d.ts

4./root/src/moduleB/package.json(如果声明了"types"属性)

5./root/src/moduleB/index.ts

6./root/src/moduleB/index.tsx

7./root/src/moduleB/index.d.ts

先找到./moduleB(.ts/.tsx/.d.ts),然后在./moduleB/package.json中找找有没有types字段对应的文件,最后是找./moduleB/index(.ts/.tsx/.d.ts)。

二、非相对模块导入

/root/src/moduleA.ts中的import { b } from "moduleB"会导致以下的查询顺序:

  1. /root/src/node_modules/moduleB.ts

  2. /root/src/node_modules/moduleB.tsx

  3. /root/src/node_modules/moduleB.d.ts

  4. /root/src/node_modules/moduleB/package.json (如果它声明了

    types属性的话)

  5. /root/src/node_modules/@types/moduleB.d.ts

  6. /root/src/node_modules/moduleB/index.ts

  7. /root/src/node_modules/moduleB/index.tsx

  8. /root/src/node_modules/moduleB/index.d.ts

    (如果以上步骤没有找到模块,就逐层在上一级的文件目录中重复这个查找顺序进行查找,最后在全局node_modules目录下按照以上顺序查找,如果都找不到就抛出错误)

  9. /root/node_modules/moduleB.ts

  10. /root/node_modules/moduleB.tsx

  11. /root/node_modules/moduleB.d.ts

  12. /root/node_modules/moduleB/package.json (if it specifies a "types" property)

  13. /root/node_modules/@types/moduleB.d.ts

  14. /root/node_modules/moduleB/index.ts

  15. /root/node_modules/moduleB/index.tsx

  16. /root/node_modules/moduleB/index.d.ts

  17. /node_modules/moduleB.ts

  18. /node_modules/moduleB.tsx

  19. /node_modules/moduleB.d.ts

  20. /node_modules/moduleB/package.json (if it specifies a "types" property)

  21. /node_modules/@types/moduleB.d.ts

  22. /node_modules/moduleB/index.ts

  23. /node_modules/moduleB/index.tsx

  24. /node_modules/moduleB/index.d.ts

额外的模块解析标志

项目源布局和输出的布局有时候不一致。通常情况下生成最终的输出会经过一系列的打包步骤。这些步骤包括将.ts文件编译为.js,并且复制不同位置的依赖到一个单独的输出位置。最终的结果是,运行时的模块可能和包含它们定义的源文件有不同的名字。或者最终输出的模块的模块路径和它们相应的编译时源文件的路径不匹配。

TypeScript编译器有一系列额外的标志来通知转化的编译器预期会发生在源文件上的转化来生成最终的输出。

需要注意的是,编译器不会执行任何这些转化;它只是使用这些信息来指导从一个模块导入到它的定义文件之间的处理。

baseUrl

所有非相对的模块名称都被假定为相对于baseUrlbaseUrl的值基于以下两种情况:

1.命令行参数baseUrl的值(如果给出的路径是相对的,会基于当前路径进行计算)。

2.tsconfig.json文件中的baseUrl属性(如果给出的路径是相对的,会基于tsconfig.json文件的位置进行计算)。

注意相对模块导入不会通过设置baseUrl被改变,因为他们总是被处理为相对他们的导入文件。

路径映射

一些时候模块不是直接的基于baseUrl直接定位。

TypeScript编译器使用tsconfig.json文件中的paths属性来支持这种模块名称和文件的映射声明。这里有一个为jquery声明paths属性的例子:

{
  "compilerOptions": {
    "baseUrl": ".", // This must be specified if "paths" is.
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl"
    }
  }
}

paths属性存在的时候,必须有baseUrl属性。paths是相对于baseUrl处理的。如果将baseUrl.设置为./srcjquery应该映射到“../node_modules/jquery/dist/jquery”

可以想象成jquery模块最终的定位到的路径是path.join(baseUrl, paths.jquery)path.join('.', 'node_modules/jquery/dist/jquery')path.join('./src', '../node_modules/jquery/dist/jquery')指的路径是同一个。

使用"paths"可以实现更复杂的映射,包括多个后备位置的映射。比如像下面这样的布局:

projectRoot
├── folder1
│   ├── file1.ts (imports 'folder1/file2' and 'folder2/file3')
│   └── file2.ts
├── generated
│   ├── folder1
│   └── folder2
│       └── file3.ts
└── tsconfig.json

projectRoot/folder1/file1.ts中有两个导入,导入'folder1/file2''folder2/file3',这两个导入导入的是不同文件夹下的模块,可以像这样写tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["*", "generated/*"]
    }
  }
}

这个"paths"配置的意思是,任何与模式"*"(这里的"*"指的是所有值)匹配的模块都在位置"*"和位置"generated/*"查找:

1."*":意味着相同的名称没有改变,所以会映射<moduleName><baseUrl>/<moduleName>

2."generated/*"意味着包含一个前缀"generated"的模块名称,所以映射<modulename><baseUrl>/generated/<moduleName>

根据这个逻辑,编译器会尝试像下面这样解决两种导入:

一、导入folder1/file2

  1. 模式'*'匹配并且通配符获取整个模块名称。
  2. 尝试列表中的第一个替换: * -> folder1/file2
  3. 替换的结果是一个非相对的名称——将它和baseUrl结合-> projectRoot/folder1/file2.ts
  4. 文件存在。完成。

二、导入folder2/file3:

前3步与导入folder1/file2的时候相同 ,说白了就是导入folder1/file2的时候,第一种模式就找到了文件,导入folder2/file3的时候第一种映射没有找到文件,需要按照数组中元素的顺序进行第二种匹配了。

4.文件不存在,移动到第二种替换方式。

5.第二种替换方式generated/* -> generated/folder2/file3

6.替换的结果是非相对名称,将它和baseUrl合并在一起->projectRoot/generated/folder2/file3.ts

7.文件存在。完成。

包含rootDirs的虚拟路径

一些时候,来自多个目录源码会合并来生成一个单独的输出目录。这个可以被视为一系列源目录创建了一个“虚拟”目录。

使用rootDirs,你可以告诉编译器组成这个“虚拟”目录的根目录;因此编译器可以在这些“虚拟”目录内处理相对模块导入就像他们在一个目录下合并到一起了。这句话乍一看是不是有点绕,等看完下面这个例子再回来看这句话就明白了。

 src
 └── views
     └── view1.ts (imports './template1')
     └── view2.ts

 generated
 └── templates
         └── views
             └── template1.ts (imports './view2')

这个目录结构下的src/views/view1.ts文件导入了'./template1'generated/templates/views/template1.ts文件导入了'./view2'

场景是这样的,generated文件夹是模板生成器生成的模板,在构建(build)的时候,会把src/views文件夹下的文件和generated/templates/views文件夹下的文件合并到同一个目录下,这样view1.tsview2.tstemplate1.ts最终会在同一个文件中。

为了给编译器声明这个关系,使用"rootDirs"

{
  "compilerOptions": {
    "rootDirs": ["src/views", "generated/templates/views"]
  }
}

每一次当编译器看见一个在rootDirs中的其中一个子文件夹的相对模块导入的时候,它会尝试在每一个rootDirs中的入口查找这个导入。

上例中在src/views/view1.ts文件导入了'./template1'(这是相对导入),就会依次在"src/views""generated/templates/views"中查找template1文件。

上面这个例子只是一种使用情况,rootDirs能做到的不止于此。rootDirs可以是任意数量的临时的、随意的目录名称,不论这些目录是否存在。

考虑一个国际化情况:一个打包工具通过添加一个特殊的路径标志自动地生成本地特定的包,比如说#{locale},作为像./#{locale}/message这样的相对模块路径的一部分。在这个假设的设置工具支持的地址列表中,匹配抽象路径为./zh/message./de/message等。比如在中国,在源文件中import './#{locale}/messages';的地方,都会被该打包工具转换成import './zh/messages';。这个路径在编译的时候其实是不确定到底是哪个的(可能是zh也可能是de)。

通过利用rootDirs,我们可以将此映射通知编译器,从而使其可以安全地解析 ./#{locale}/messages,尽管 ./#{locale}/messages这个目录本身永远不会存在。tsconfig.json

{
  "compilerOptions": {
    "rootDirs": ["src/zh", "src/de", "src/#{locale}"]
  }
}

编译器现在依次尝试解析 import messages from './#{locale}/messages'import messages from './zh/messages'import messages from './de/messages'import messages from './#{locale}/messages'

命名空间

关于术语需要注意的地方:在TypeScript 1.5 中,命名系统变化了。为了和 ECMAScript 2015中的术语对应,"Internal modules"(内部模块的术语)现在是"namespace"(命名空间)。"External modules"(外部模块的术语)现在是"modules"(模块)。换句话说,module X {等效于现在首选的namespace X {。这个注意的说明只是避免用户对相似的术语感到疑惑,总之现在最好使用namespace X {而不是module X {,虽然它们两作用是一样的。

这里直接就用官网的例子进行练习吧 (゚∀゚ヘ)

创建一个简单的字符串验证的集合:

interface StringValidator {
  isAcceptable(s: string): boolean;
}

const lettersRegexp = /^[A-Za-z]+$/;
const numberRegexp = /^[0-9]+$/;

// 验证字符串包含的字符全部为字母
class LettersOnlyValidator implements StringValidator {
  isAcceptable(s: string): boolean {
    return lettersRegexp.test(s);
  }
}

// 验证邮政编码
class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string): boolean {
    return s.length === 5 && numberRegexp.test(s);
  }
}

// 一些用来验证的字符串
const strings = ["Hello", "98052", "101"];

// 要使用的验证器
const validators: { [s: string]: StringValidator } = {};
validators["ZIP code"] = new ZipCodeValidator();
validators["Letters only"] = new LettersOnlyValidator();

// 查看是否每一个字符串都通过了对应的验证器
for (const s of strings) {
  for (const name in validators) {
    const isMatch = validators[name].isAcceptable(s);
    console.log(`'${s}' ${isMatch ? "匹配" : "不匹配"} '${name}'.`);
  }
}

如果需要添加更过的验证器,我们为了避免冲突,不是将不同的名字放到全局的命名空间中,而是将对象包裹到一个命名空间中。

”将不同的名字放到全局的命名空间中“指的是,比如另一个验证器中也有一个LettersOnlyValidator,现在有两个LettersOnlyValidator,那么就在全局的命名空间中使用LettersOnlyValidator1LettersOnlyValidator2这样有所区别的名称。

“将对象包裹到一个命名空间中”是这样:

namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }
  
  const lettersRegexp = /^[A-Za-z]+$/;
  const numberRegexp = /^[0-9]+$/;
  
  // 验证字符串包含的字符全部为字母
  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string): boolean {
      return lettersRegexp.test(s);
    }
  }
  
  // 验证邮政编码
  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string): boolean {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
}

如果想要接口和类在命名空间外部可见,就在前面加上export

使用的地方要改成这样:

// 一些用来验证的字符串
const strings = ["Hello", "98052", "101"];

// 要使用的验证器
const validators: { [s: string]: Validation.StringValidator } = {};
validators["ZIP code"] = new Validation.ZipCodeValidator();
validators["Letters only"] = new Validation.LettersOnlyValidator();

// 查看是否每一个字符串都通过了对应的验证器
for (const s of strings) {
  for (const name in validators) {
    const isMatch = validators[name].isAcceptable(s);
    console.log(`'${s}' ${isMatch ? "匹配" : "不匹配"} '${name}'.`);
  }
}

在使用命名空间的时候报错:

Namespace not marked type-only declare. Non-declarative namespaces are only supported experimentally in Babel. 

命名空间没有被标记为仅声明类型。 非声明性命名空间仅在Babel中实验支持。需要安装一个插件:@babel/plugin-transform-typescript

.babelrc.js中加上:

module.exports = {
  ...
  plugins: [
    ...
    ["@babel/plugin-transform-typescript", {"allowNamespaces": true}]
  ],
};

allowNamespaces表示允许使用命名空间。

还有eslint报错:

ES2015 module syntax is preferred over custom TypeScript modules and namespaces  @typescript-eslint/no-namespace

最好使用ES2015的模块语法,而不是使用自定义的TypeScript模块和命名空间。这里在.eslintrc.js中关掉这个类型检查:

rules: {
  ...
  '@typescript-eslint/no-namespace': 'off'
},

跨文件分割

随着应用的壮大,会把代码拆分为多个文件,使得应用能更好地维护。

我们把上面的一个文件拆分为多个文件:

Validation.ts

namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }
}

LettersOnlyValidator.ts

/// <reference path="Validation.ts" />

namespace Validation {
  const lettersRegexp = /^[A-Za-z]+$/;
  // 验证字符串包含的字符全部为字母
  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string): boolean {
      return lettersRegexp.test(s);
    }
  }
}

ZipCodeValidator.ts

/// <reference path="Validation.ts" />

namespace Validation {
  const numberRegexp = /^[0-9]+$/;

  // 验证邮政编码
  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string): boolean {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
}

Test.ts:

/// <reference path="Validation.ts" />
/// <reference path="LettersOnlyValidator.ts" />
/// <reference path="ZipCodeValidator.ts" />

// 一些用来验证的字符串
const strings = ["Hello", "98052", "101"];

// 要使用的验证器
const validators: { [s: string]: Validation.StringValidator } = {};
validators["ZIP code"] = new Validation.ZipCodeValidator();
validators["Letters only"] = new Validation.LettersOnlyValidator();

// 查看是否每一个字符串都通过了对应的验证器
for (const s of strings) {
  for (const name in validators) {
    const isMatch = validators[name].isAcceptable(s);
    console.log(`'${s}' ${isMatch ? "匹配" : "不匹配"} '${name}'.`);
  }
}

官网中的例子使用的是三斜杠指令,三斜杠指令是用来引入依赖的。尝试使用三斜杠指令的时候会给出了这样的eslint报错:

error  Do not use a triple slash reference for Validation.ts, use `import` style instead            @typescript-eslint/triple-slash-reference

意思是应该使用import而不是使用三斜杠指令。要练习三斜杠指令的话,需要先把这个检查关掉: '@typescript-eslint/triple-slash-reference': 'off'

还有一个【未解决】问题,像上面那样写不会报类型错误,但是在运行时会报错:

Test.ts:10 Uncaught ReferenceError: Validation is not defined
    at Object../src/practice/learnTypeScript04/c/Test.ts (Test.ts:10)

如果换成export namespace Validation {import { Validation } from './Validation'

在位置:

validators["ZIP code"] = new Validation.ZipCodeValidator();
validators["Letters only"] = new Validation.LettersOnlyValidator();

又会报错:

Cannot use namespace 'Validation' as a value.

别名

另一个可以简单的使用命名空间的方式是使用import q = x.y.z来为经常使用的对象创建更短的名称。

namespace Shapes {
  export namespace Polygons {
    export class Triangle {}
    export class Square {}
  }
}

import polygons = Shapes.Polygons;
let sq = new polygons.Square(); 

环境命名空间

环境命名空间和上文已经说过的环境模块基本是一样的。

环境命名空间是全局的,如下例,以<script>标签的形式引入的d3库:

<script src="https://d3js.org/d3.v5.min.js"></script>

d3库声明类型:

declare namespace D3 {
  export interface Selectors {
    select: {
      (selector: string): Selection;
      (element: EventTarget): Selection;
    };
  }

  export interface Event {
    x: number;
    y: number;
  }

  export interface Base extends Selectors {
    event: Event;
  }
}

declare var d3: D3.Base;

这样如果在任意位置,没有按照类型声明正确地使用d3变量的话,就会报类型错误。

比如: d3.aaa;会报错: Property 'aaa' does not exist on type 'Base'.

TypeScript学习笔记
《TypeScript学习笔记-1-基础类型、字面量类型、类型声明》
《TypeScript学习笔记-2-联合类型&交叉类型、泛型、类型守卫、类型推断》
当前篇《TypeScript学习笔记-3-枚举、函数、类、装饰器》
下一篇《TypeScript学习笔记-4-模块、命名空间》
《TypeScript学习笔记-5-tsc指令、TS配置、部分更新功能、通用类型》