深入理解 TypeScript 模块

之前有转载过一篇 JavaScript 中的模块导入和导出 ,但是没有系统的进行说明,只是提到了模块该如何使用,这里结合 TypeScript 来系统的啰嗦一番。

蓝猫淘气三千问:

  • 前端的模块是怎么工作的?
  • TypeScript 中的模块如何查找的,为什么会隐式查找到 index.ts、index.js,为什么会到 node_modules 中去找模块?
  • 如何定义一个全局变量供所有代码共享?
  • tsconfig.json 文件有什么用,自定义模块别名 @/* 是如何映射到指定目录的?

带着这些问题,我们开始今天的探索之旅!

什么是模块

引用一段百度百科对模块的解释:

在程序设计中,为完成某一功能所需的一段程序或子程序,或指能由编译程序、装配程序等处理的独立程序单位;或指大型软件系统的一部分

模块可以和大多数编程语言中的 命名空间package 等概念进行关联,在模块中定义的变量、函数、类,如果不经过特殊处理,一般只有模块内能够访问,这样可以避免与其他模块冲突。由此可见模块的功能是很重要的。

早期 JavaScript 并没有模块的概念,当 Node.js 被推出之后,JavaScript 才逐渐引入了模块的概念,而TypeScript也沿用这个概念。有兴趣的可以查看前端模块化的历程

CommonJS && ES6 模块化方案中, 一个模块里的变量,函数,类等等在模块外部是不可见的,除非明确地使用 export 导出它们。 相反,如果想使用其它模块导出的变量,函数,类,接口等的时候,你必须使用import导入它们。

如何创建模块

JavaScript 的模块是自声明的,事实上我们在写代码的时候一直在不知不觉中以模块的形式进行书写。

文件模块

只要一个 JavaScript 文件中包含 imports 导入模块 或者 exports 导出模块 的声明,那它就是一个模块,严谨点应该叫文件模块

在前端模块实际上是通过闭包来实现的,一个模块就是一个闭包,类似下面这样:

编译前:

// 1、依赖导入、变量声明
export class module {
  // 2、模块内部实现
}

编译后:

const module = (function(){
   // 1、依赖导入、变量声明
   // 2、模块内部实现
})();

这样就能够将各个文件的实现给隔离开,达到模块化的目的。

全局模块

如果一个文件没有包含importsexports呢,根据上面的描述这个文件不是一个模块,那它是什么?

实际上,它是一种特殊的模块,我们称之为“全局模块”,这个模块里面的任何定义都是全局共享的!毋庸置疑,使用全局模块是危险的,因为它会与文件内的其他代码命名冲突。但是全局模块可以用在一些特殊的场景,比如使用频繁的一些变量或方法,可以放在全局模块进行声明,避免每次使用都需要导入。

模块的导出

导出声明

任何声明(比如变量,函数,类,类型别名或接口)都能够通过添加 export 关键字来导出。

export const a = "123"  // 导出变量

export class CotpButton {} // 导出类

export interface ConfigInfo {}; // 导出接口

导出语句

导出语句支持将需要导出的模块包装到一个对象中,并且支持对导出的部分重命名:

import BaseComponent from "./src/base/BaseComponent"
import CotpButton from "./src/components/button/CotpButton"

export {
    BaseComponent,
    CotpButton as Button
}

重新导出

我们经常会去扩展其它模块,有时可能会合并之后重新导出供外部使用:

// 重新导出部分模块
export { pushContants } from "./lib/constants"

// 重新导出部分模块并且重命名
export { pushContants as sfPushContants } from "./lib/constants"

// 重新导出全部模块
export * from "./lib/constants"

默认导出

每个模块都可以有一个default导出。 默认导出使用default关键字标记;并且一个模块只能够有一个default导出。
export default 可以理解为等价于 const 任意变量名 =(这里的“任意变量名”是用来给其他模块导入这个默认模块时候使用的),导出类和函数的名字可以省略,也可以导出一个值。

export default class {} // 导出一个匿名类

export default function () {} // 导出一个匿名函数

export default "123" // 导出一个值

模块的导入

部分导入

import { BasePage } from "@/common";

export default class HomePage extends BasePage {}

导出重命名

import { BasePage as XMFBasePage } from "@/common";

export default class HomePage extends XMFBasePage {}

全部导入

将整个模块导入到一个变量,并通过它来访问模块的导出部分

import * as common from "@/common";

export default class HomePage extends common.BasePage {}

导入默认模块

在前面导出默认模块的时候提到了默认导出相当于 const 任意变量名 =,所以导入默认模块就是用“任意变量名”来接默认模块,如下:

import 任意变量名 from "./my-module.js";

具有副作用的导入模块

偶尔会存在这种场景,我只想导入模块,而不像要这个模块内的具体导出,那么可以像下面这样进行导入:

import "./my-module.js";

要注意的是如果./my-module.js是一个全局模块,很容易产生变量冲突,所以说这种导入是具有副作用的。

模块分类

从大类来讲模块可以分为 全局模块文件模块

全局模块

全局模块的作用域是全局。一个 JavaScript 文件如果没有export import,那么这个文件被引入后,则会是一个全局模块,其中的任何声明也都是全局共享的。

文件模块

文件模块的作用域被限定在文件内,且至少含有 export import 中的任何一个关键字。文件模块按照导入方式又可分 相对导入非相对导入

  • 相对导入

相对导入是以/./../开头的

import Button from "./components/Button";
import HttpConstants from "../constants/HttpConstants";
import "/mod";
  • 非相对导入

所有其它形式的导入被当作非相对导入

import { View } from "react-native";
import { BasePage } from "@/common";

模块解析

Typescript 模块解析就是指导 ts 编译器查找 import 导入内容的流程。TypeScript 共有两种可用的模块解析策略: ClassicNode

先纵观一下各种方式的解析流程,不需要牢记,主要是帮助快速对整个解析策略的理解:


各个模块解析流程.png

Classic

这种策略以前是TypeScript默认的解析策略。 现在,它存在的理由主要是为了向后兼容。

  • 相对路径

相对路径导入的模块是相对于导入它的文件进行解析的。

例如:

// /root/src/folder/A.ts
import { b } from "./moduleB"

查找流程:

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

可以发现当解析 import 导入的的时候,TypeScript 会优先选择 .ts 文件而不是 .d.ts 文件

  • 非相对路径

非相对模块的导入,编译器则会从包含导入文件的目录开始依次向上级目录遍历,尝试定位匹配的声明文件。

例如:

//路径: /root/src/folder/A.ts
import { b } from "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

Node

这个解析策略试图在运行时模仿 Node.js 模块解析机制, 完整的 Node.js 解析算法可以在Node.js module documentation找到

Node.js 如何解析模块

为了理解TypeScript编译依照的解析步骤,先弄明白Node.js模块是非常重要的。 通常,在Node.js里导入是通过require函数调用进行的。 Node.js会根据require的是相对路径还是非相对路径做出不同的行为。

  • 相对路径

相对路径的解析比较简单,先以文件的模式查找,如果没找到,再以目录的形式进行查找。

例如:

// /root/src/moduleA.js
const b = require("./moduleB")

查找流程如下:

1、/root/src/moduleB.js
2、/root/src/moduleB/package.json (如果指定了"main"属性) 。
3、/root/src/moduleB/index.js(这个文件会被隐式地当作那个文件夹下的main模块)
  • 非相对路径

非相对路径的解析是个完全不同的过程。Node会在一个特殊的文件夹node_modules里查找你的模块。 node_modules可能与当前文件在同一级目录下,或者在上层目录里。 Node会向上级目录遍历,查找每个node_modules直到它找到要加载的模块。

例如:

// /root/src/moduleA.js
const b = require("moduleB")

查找流程如下:

1、/root/src/node_modules/moduleB.js
2、/root/src/node_modules/moduleB/package.json (如果指定了"main"属性)
3、/root/src/node_modules/moduleB/index.js

4、/root/node_modules/moduleB.js // 向上级目录查找
5、/root/node_modules/moduleB/package.json (如果指定了"main"属性)
6、/root/node_modules/moduleB/index.js

7、/node_modules/moduleB.js // 向上级目录查找
8、/node_modules/moduleB/package.json (如果指定了"main"属性)
9、/node_modules/moduleB/index.js

...
TypeScript 的 Node 模块解析和 Node.js 有何区别

当使用 Node 模块解析策略是,TypeScript 是模仿 Node.js 运行时的解析策略来在编译阶段定位模块定义文件。 因此,TypeScript 在 Node.js 解析逻辑基础上增加了 TypeScript 源文件的扩展名(.ts、.tsx、.d.ts)。 同时,TypeScript在package.json里使用字段types来表示类似main的意义,编译器会使用它来找到要使用的main定义文件。

  • 相对模块

例如:

// /root/src/moduleA.ts
import { b } from "./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

可以发现文件查找的优先级依次是:.ts->.tsx->.d.ts,如果是 TypeScript 和 JavaScript 的混合项目(在 tsconfig.json 中配置 "allowJs": true,关于 tsconfig.json 文件会在下面提到),在 d.ts 之后还会去查找 .js 文件,由于查找链会很长,所以这里暂且不讨论这种情况。

  • 非相对模块

例如:

// /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/moduleB/index.ts
6、/root/src/node_modules/moduleB/index.tsx
7、/root/src/node_modules/moduleB/index.d.ts

8、/root/node_modules/moduleB.ts
9、/root/node_modules/moduleB.tsx
10、/root/node_modules/moduleB.d.ts
11、/root/node_modules/moduleB/package.json (如果指定了"types"属性)
12、/root/node_modules/moduleB/index.ts
13、/root/node_modules/moduleB/index.tsx
14、/root/node_modules/moduleB/index.d.ts

15、/node_modules/moduleB.ts
16、/node_modules/moduleB.tsx
17、/node_modules/moduleB.d.ts
18、/node_modules/moduleB/package.json (如果指定了"types"属性)
19、/node_modules/moduleB/index.ts
20、/node_modules/moduleB/index.tsx
21、/node_modules/moduleB/index.d.ts

不要被这里步骤的数量吓到,TypeScript只是在步骤(8)和(15)向上跳了两次目录。 这并不比 Node.js 里的流程复杂。

TypeScript 模块解析配置

为了让 TypeScript 能够满足工程化的需求,灵活配置类型检查和编译参数,特意提供了一个 tsconfig.json 配置文件。我们可以通过 tsconfig.json 来自定义模块的解析策略。

tsconfig.json 文件

TypeScript 使用 tsconfig.json 文件作为其配置文件,当一个目录中存在 tsconfig.json 文件,则认为该目录为 TypeScript 项目的根目录。熟悉移动端开发的可能会联想到 Android 中的 build.gradle,iOS 中的 xcodeproj
通常 tsconfig.json 文件主要包含两部分内容:指定待编译文件定义编译选项

tsconfig.json 的配置项可以用一张图来简单进行说明:

详细说明可以查看这里

自定义模块解析策略

tsconfig.json 中的 compilerOptions 是我们用的最多,也是最复杂的配置。其中有两种方式来自定义模块解析策略。

路径映射

第一种是路径别名映射,顾名思义是给路径取个简称,通过这个简称我们就能够定位到这个路径。涉及到下面两个配置项:

  • baseUrl:解析非相对模块的根地址,默认是当前目录
  • paths:路径映射别名,相对于baseUrl

比如我们项目中的基础模块,由于和业务模块是独立的,如果使用相对路径进行引用,无疑会产生很多 ../../../../../ 的引用方式,不仅很冗长,而且增加了代码阅读成本。这个时候就可以用路径别名的方式进行映射。来看下下面这个例子:

项目目录结构是这样的:

├── node_modules
├── src
│   ├── common # 基础模块
│   │   ├── index.ts
│   ├── modules # 业务模块
│   │   ├── xxx
│   │   │   ├── a.ts
└── tsconfig.json

tsconfig.json 配置如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/common": ["src/common"]
    }
}

baseUrl 是相对于 tscofnig.json 的目录,上面配置的是 .,说明 baseUrl 就是 tsconfig.json 所在的目录,也就是项目根目录。

上面的配置指定了 @/common 等价 <baseUrl>/src/common,这样我们就可以直接用 @/common 来代替在 a.ts../../common 引用基础库的方式,最重要的是其中的层级不限,不管业务代码所属层级有多深,最终都会到项目根目录下的 ./src/common 中查找模块。

虚拟目录

有时多个目录下的工程源文件在编译时会进行合并放在某个输出目录下。 这可以看做一些源目录创建了一个虚拟目录。

比如,有下面的工程结构:

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

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

src/views 里的文件是用于控制UI的用户代码。 generated/templates是UI模版,在构建时通过模版生成器自动生成。 构建中的一步会将/src/views/generated/templates/views的输出拷贝到同一个目录下。 在运行时,视图可以假设它的模版与它同在一个目录下,因此可以使用相对导入"./template"

利用配置项 rootDirs,可以告诉编译器生成这个虚拟目录的 roots; 因此编译器可以在“虚拟”目录下解析相对模块导入,就好像它们被合并在了一起一样。。 因此,针对这个例子,tsconfig.json 如下:

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

每当编译器在某一rootDirs的子目录下发现了相对模块导入,它就会尝试从rootDirs的所有子目录中导入。

自定义模块解析只是一种标记

当你按照上面的配置完成自定义模块解析之后,你会发现代码运行起来之后依然找不到对应的模块,这是为什么?

事实上,通过 tsconfig.json 定义的解析策略,只是一种骗过编译器的手段,编译器并不会进行对应的路径转换。

虚拟目录目录需要在编译时将代码按照约定拷贝到指定目录;
路径映射则需要使用 babel 在编译阶段进行转换,babel 有提供现成的插件来完成路径映射的转换,如下:

安装插件

npm install babel-plugin-root-import --save-dev

babel.config.js 或者 .babelrc 进行相应的配置

module.exports = {
    plugins: [
        [
            "babel-plugin-root-import",
            {
                paths: [
                    {
                        rootPathPrefix: "@/common",
                        rootPathSuffix: "./src/common"
                    }
                ]
            }
        ]
    ]
}

跟踪模块解析

模块解析是一个很复杂的流程,编译器在解析模块时可能访问当前文件夹外的文件,这会导致很难诊断模块为什么没有被解析,或解析到了错误的位置。 通过--traceResolution启用编译器的模块解析跟踪,它会告诉我们在模块解析过程中发生了什么。

假设我们有一个使用了 typescript 模块的简单应用。 app.ts里有一个这样的导入import * as ts from "typescript"

├─── tsconfig.json
├─── node_modules
│    └─── typescript
│        └─── lib
│             └─── typescript.d.ts
└─── src
     └─── app.ts

使用--traceResolution调用编译器。,或者在 tsconfig.json 中添加该配置

tsc app.ts --traceResolution

输出结果如下:

======== Resolving module 'typescript' from 'src/app.ts'. ========
Module resolution kind is not specified, using 'NodeJs'.
Loading module 'typescript' from 'node_modules' folder.
File 'src/node_modules/typescript.ts' does not exist.
File 'src/node_modules/typescript.tsx' does not exist.
File 'src/node_modules/typescript.d.ts' does not exist.
File 'src/node_modules/typescript/package.json' does not exist.
File 'node_modules/typescript.ts' does not exist.
File 'node_modules/typescript.tsx' does not exist.
File 'node_modules/typescript.d.ts' does not exist.
Found 'package.json' at 'node_modules/typescript/package.json'.
'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.
File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module resolution result.
======== Module name 'typescript' was successfully resolved to

总结

这篇文章讲述了 TypeScript 模块的概念及使用方式,知道了怎么定义一个全局模块和一个文件模块。并且详细描述了 TypeScript 模块解析的流程,解析过程中文件的优先级策略等等,让大家对 TypeScript 模块有了一个全面的认识。

参考