一、说明

模块解析 是指编译器在查找导入模块内容时所遵循的流程,如果有一个导入语句 import { a } from "moduleA",为了去检查任何对 a 的使用,编译器需要明确知道它表示什么,并且需要检查它的定义 moduleA

这个时候,编译器想知道 moduleA 的接口是怎样的。虽然看起来很简单,但是 moduleA 可能在某个 .ts / .tsx 文件里或者在代码所依赖的 .d.ts 中。

首先编译器会尝试定位表示导入模块的文件,编译器会遵循下面两种策略:Classic 策略 或 Node 策略,这些策略会告诉编译器去哪里查找 moduleA

如果上面的解析失败了,并且模块名是非相对的(且是在 moduleA 的情况下),编译器会尝试定位一个外部模块声明

最后如果编译器还是不能解析这个模块,会记录一个错误,错误信息可能是:error TS2307: Cannot find module 'moduleA'

二、相对 vs. 非相对导入

根据模块引用时相对的还是非相对的,模块导入会以不同的方式解析。

相对导入是以 /./../ 开头的,比如:

  • import Entry from './components/Entry'
  • import { DefaultHeaders } from '../constants/http'
  • import '/mod'

所有其他形式的导入都是 非相对导入,比如:

  • import * as $ from 'jQuery'
  • import { Component} from '@angular/core'

相对导入在解析的时候相对于导入它的文件,并且 不能 解析为一个外部模块声明。

你应该为你自己写的模块使用相对导入,这样能够确保他们在运行时的相对的位置。

非相对模块的导入可以相对于 baseUrl 或通过路径映射来进行解析。可以被解析成 外部模块声明。使用非相对路径来导入外部依赖。

三、模块解析策略

1、两种解析策略介绍

两种可用的模块解析策略分别是:ClassicNode。可以使用 --moduleResolution 标记来指定使用哪种模块解析策略。如果没有指定,则在使用了 --module AMD | System | ES2015 时默认值是 Classic,其他情况会使用 Node

Classic 策略

这种策略是以前默认的 TypeScript 的解析策略,而目前存在的主要原因是为了向后兼容

相对导入的模块式相对于导入它的文件进行解析的,因此 /root/src/folder/A.ts 文件里面的 import { b } from 'moduleB' 会使用以下的查找流程:

  • /root/src/folder/moduleB.ts
  • /root/src/folder/moduleB.d.ts

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

比如:

有一个对 moduleB 的非相对导入 import { b } from 'moduleB',它是在 /root/src/folder/A.ts 中,则会通过以下的查找流程:

  • /root/src/folder/moduleB.ts
  • /root/src/folder/moduleB.d.ts
  • /root/src/moduleB.ts
  • /root/src/moduleB.d.ts
  • /root/moduleB.ts
  • /root/moduleB.d.ts
  • /moduleB.ts
  • /moduleB.d.ts

Node 策略

这个解析策略师徒在运行时模仿 Node.js 的模块解析机制,完整的 Node.js 解析算法可以在 Node.js Module documention 找到。

2、Node.js 如何解析模块

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

相对路径很简单,比如一个文件的路径是 /root/src/moduleA 包含了一个导入 var x = require('./moduleB');,Node.js 是下面的顺序解析这个导入:

  1. 检查 /root/src/moduleB.js 是否存在
  2. 检查 /root/src/moduleB 目录是否包含一个 package.json 文件,并且 package.json 文件指定了一个 main 模块。在上面的例子中,如果 Node.js 发现文件 /root/src/moduleB/package.json 包含了 {"main": "lib/mainModule.js"},则 Node.js 会引用 /root/src/moduleB/lib/mainModule.js
  3. 检查 /root/src/moduleB 目录是否包含一个 index.js 文件,这个文件会被隐式的当做那个文件下的 main 模块。

更多信息:

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

还是用上面的例子,但假设 /root/src/moduleA.js 里使用的是费相对路径导入 var x = require('moduleB');,Node 则会以下面的顺序解析 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

上面的顺序中,会在步骤(4) 和 步骤(7) 这两个步骤上已经切换到了上级目录了。

更多的相关信息可以看:

3、TypeScript如何解析模块

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

比如,一个导入语句 import { b } from './moduleB'/root/src/moduleA.ts 里,会以下面的流程来定位 ./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

Node.js 是先查找 moduleB.js 文件,然后是合适的 package.json 然后是 index.js

类似的,非相对导入会遵循 Node.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

虽然步骤复杂,但其实只是在步骤(8)和步骤(15)向上跳了两次目录而已,和 Node.js 的流程差不多

四、附加的模块解析标记

有时候工程的源码结构和输出结构不同。通常是需要经过系统的构建步骤最后申城输出。这其中包括将 .ts 编译成 .js,将不同位置的依赖拷贝至一个输出位置。最终结果就是运行时的模块名和包含它们声明的源文件里的模块名不同,或者最终输出文件里的模块路径和编译时的源文件路径不同了。

TypeScript 编译器有一些额外的标记用来 通知 编译器在源码编译成最终输出的过程中都发生了哪个转换。

有一点要注意的是编译器 不会 进行这些转换操作;它只是利用这些信息来指导模块的导入。

1、Base URL

在利用 AMD 模块加载器的应用里面使用 baseUrl 是常见的做法,它要求在运行时模块都被放到了一个空文件夹里,这些模块的源码可以在不同的目录下, 但是构建脚本会将他们集中在一起。

设置 baseUrl 来告诉编译器到哪里去查找模块,所有非相对模块导入都会被当做 baseUrl

baseUrl 的值由以下两者之一决定:

  • 命令行中 baseUrl 的值(如果给定的路径是相对的, 那么将相对于当前路径进行计算)
  • tsconfig.json 里的 baseUrl 属性(如果给定的路径是相对的,则将相对于 tsconfig.json 路径进行计算)

注意相对模块的导入不会被设置的 baseUrl 所影响,因为它们总是相对于导入它们的文件。

更多关于 baseUrl 的信息:

2、路径映射

有时候模块不是直接放在 baseUrl 下面的,比如 jQuery 模块的导入,在运行的时候可能被解析成 node_modules/jquery/dist/jquery.min.js。加载器使用映射配置来将模块名称映射到运行时的文件,更多信息查看 RequireJS documentationSystemJS documentation

TypeScript 的编译器通过使用 tsconfig.json 文件里的 paths 来支持这样的声明映射。下面是一个如何指定 jQuerypaths 的示例:

{
  "compilerOptions": {
    "baseUrl": "." // 如果配置了 paths 则必须配置 baseUrl
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // 此处映射是相对于"baseUrl"
    }
  }
}

paths 是相对于 baseUrl 解析的,如果 baseUrl 被设置成了除 . 之外的其他值,比如 tsconfig.json 所在的目录,则映射也必须做出相应的改变。

上面的例子中,如果设置 "baseUrl": "./src" 则 jquery 应当映射到 "../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

相应的 tscofnig.json 如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "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

    1. 匹配 * 模式且通配符捕获到整个名字
    2. 尝试列表的第一个替换:* -> folder1/file3
    3. 替换结果为非相对名 - 与 baseUrl 合并 -> projectRoot/folder1/file3.ts
    4. 文件不存在,跳转到第二个替换
    5. 第二个替换: generated/* -> generated/folder/file3
    6. 替换结果为非相对名 - 与 baseUrl 合并 -> projectRoot/generated/folder/file3.ts
    7. 文件存在,完成

3、利用 rootDirs 指定虚拟目录

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

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

比如,下面的工程结构:

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

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

src/views 里的文件时用于控制 UI 的用户代码,generated/templates 是 UI 模板,在构建时通过模板生成器自动生成。

构建中的一步会将 src/view/generated/templates/views 的输出拷贝到同一目录下。

在运行时,视图可以假设它的模板与它在同一个目录下,因此可以使用相对导入 ./template

可以使用 rootDirs 告诉编译器,rootDirs 指定了一个 roots 列表,列表里的内容会在运行时被合并,因此,上面的例子中,可以如此配置 tsconfig.json:

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

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

rootDirs 的灵活性不仅仅局限在其指定了要在逻辑上合并的物理目录列表,它提供的数组可以包含任意数量的任何名字的目录,不论它们是否存在。这允许编译器以类型安全的方式处理复杂 bundle 和 runtime 的特性,比如条件引入和工程特定的加载器插件。

比如在国家化场景中,构建工具自动插入特定的路径记号来生成不同区域的 bundle,比如将 #{locale} 作为相对模块路径,./#{locale}/messages 的一部分。在这个假定的设置下,工具会枚举支持的区域,将抽象的路径映射成 ./zh/messages./de/messages 等。

假设每个模块都会导出一个字符串的数组,比如 ./zh.messages 可能包含:

export default [
    "您好吗",
    "很高兴认识你"
]

利用 rootDirs 我们可以让编译器了解这个映射关系,就算这个目录永远都不存在,也允许编译器能够安全地解析 ./#{locale}/messages ,比如:使用下面的 tsconfig.json

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

编译器现在可以将 import messages from './#{locale}/messages' 解析为 import messages from './zh/messages' 用作工具支持的目的,并且允许在开发时不必了解区域信息。

4、跟踪模块解析

编译器在解析模块时肯呢过访问当前文件夹外的文件,这会导致很难判断模块为什么没有被解析,或解析到了错误的位置,通过 --traceResolution 启用编译器的模块解析跟中,会告诉我们在模块解析过程中发生了什么。

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

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

使用 --traceResolution 调用编译器。

tsc --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 'node_modules/typescript/lib/typescript.d.ts'. ========

需要留意的地方

  • 导入的名字及位置

======== Resolving module 'typescript' from 'src/app.ts'. ========

  • 编译器使用的策略

Module resolution kind is not specified, using 'NodeJs'.

  • 从 npm 加载 types

'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.

  • 最终结果

======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========

5、使用 --noResolve

正常来讲编译器会在开始编译之前解析模块导入, 每当它成功地解析了一个文件的 import,这个文件被会加到一个文件列表里,以供编译器稍后处理。

--noResolve 编译选项告诉编译器不要添加任何不是在命令行上传入的文件到编译列表。

编译器仍然会尝试解析模块,但是只要没有指定这个文件,那么它就不会被包含在内。

例如:

app.ts

import * as A from "moduleA" // OK, moduleA passed on the command-line
import * as B from "moduleB" // Error TS2307: Cannot find module 'moduleB'.
tsc app.ts moduleA.ts --noResolve

使用 --noResolve 编译 app.ts

  • 可能正确找到 moduleA,因为它在命令行上指定了。
  • 找不到 moduleB ,因为没有在命令行上传递。

常见问题

为什么在 exclude 列表里的模块还会被编译器使用

tsconfig.json 将文件夹转变一个 “工程” 如果不指定任何 excludefiles,文件夹里的所有文件包括 tsconfig.json 和所有的子目录都会在编译列表里。 如果你想利用 exclude 排除某些文件,甚至你想指定所有要编译的文件列表,请使用 files

有些是被 tsconfig.json 自动加入的,它不会涉及到上面讨论的模块解析, 如果编译器识别出一个文件是模块导入目标,它就会加到编译列表里,不管它是否被排除了。

因此,要从编译列表中排除一个文件,你需要在排除它的同时,还要排除所有对它进行 import 或使用了 /// <reference path="..." /> 指令的文件。