十三、模块解析

模块解析是编译器用来分析一个导入什么的过程。考虑一个导入语句,如 import { a } from "moduleA";为了检查对 a 的任何使用,编译器需要知道它到底代表什么,并需要检查它的定义 moduleA

在这一点上,编译器会问 “moduleA的形状是什么?” 虽然这听起来很简单,但 moduleA 可能被定义在你自己的一个.ts/.tsx文件中,或者在你的代码所依赖的一个 .d.ts 中。

首先,编译器将试图找到一个代表导入模块的文件。为了做到这一点,编译器遵循两种不同的策略之一。 Classic or Node。这些策略告诉编译器去哪里寻找模块A。

如果这没有用,并且如果模块名称是非相对的(在 "moduleA"的情况下,它是相对的),那么编译器将尝试定位一个环境模块的声明。我们接下来会讨论非相对导入。

最后,如果编译器不能解决该模块,它将记录一个错误。在这种情况下,错误会是这样的:error TS2307: Cannot find module 'moduleA'

13.1 相对与非相对的模块导入

模块导入是根据模块引用是相对的还是非相对的来解析的。

相对导入是以/././开头的导入。一些例子包括:

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

任何其他的导入都被认为是不相关的。一些例子包括:

  • import * as $ from "jquery";
  • import { Component } from "@angular/core";

相对导入是相对于导入文件进行解析的,不能解析为环境模块的声明。你应该为你自己的模块使用相对导入,以保证在运行时保持其相对位置。

非相对导入可以相对于 baseUrl来解析,也可以通过路径映射来解析,我们将在下面介绍。它们也可以解析为 环境模块声明。当导入你的任何外部依赖时,使用非相对路径。

13.2 模块解析策略

有两种可能的模块解析策略。 NodeClassic。你可以使用 moduleResolution 选项来指定模块解析策略。如果没有指定,对于--module commonjs,默认为 Node ,否则为 Classic(包括 module 设置为 amdsystemumdes2015esnext等时)。

注意: node 模块解析是TypeScript社区中最常用的,并被推荐用于大多数项目。如果你在TypeScript的导入和导出中遇到解析问题,可以尝试设置 moduleResolution:"node",看看是否能解决这个问题。

13.2.1 Classic

这曾经是TypeScript的默认解析策略。现在,这个策略主要是为了向后兼容而存在。

一个相对导入将被解析为相对于导入文件。所以在源文件`/root/src/folder/A.ts中从”./moduleB “导入{ b }会导致以下查找。

所以在源文件 /root/src/folder/A.ts 中的 import { b } from "./moduleB" 查找路径如下:

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

然而,对于非相对的模块导入,编译器从包含导入文件的目录开始沿着目录树向上走,试图找到一个匹配的定义文件。

例如:

在源文件/root/src/folder/A.ts中,对于 import { b } from "moduleB",会导致尝试在以下位置找到 "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

13.2.2 Node

这种解析策略试图在运行时模仿 Node.js 的模块解析机制。完整的Node.js解析算法在Node.js模块文档中概述。

  • Node.js如何解析模块

为了理解TS编译器将遵循哪些步骤,有必要对Node.js模块进行一些说明。传统上,Node.js的导入是通过调用一个名为require的函数来完成的。Node.js采取的行为会有所不同,这取决于require是给出相对路径还是非相对路径。

相对路径是相当直接的。举个例子,让我们考虑一个位于 /root/src/moduleA.js 的文件,其中包含 import var x = require("./moduleB");的模块导入,Node.js 按照以下顺序解析:

  1. 询问名为 /root/src/moduleB.js 的是否存在。
  2. 询问文件夹 /root/src/moduleB 是否包含一个名为 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的文件。该文件被隐含地视为该文件夹的 “主”模块。

你可以在Node.js文档中阅读更多关于 file 模块 模块 folder 模块的内容。

然而,非相关模块名称的解析是以不同方式进行的。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

注意,Node.js在步骤(4)和(7)中跳出了本目录。

你可以在Node.js文档中阅读更多关于 node_modules加载模块的过程

  • TypeScript如何解决模块

TypeScript将模仿Node.js的运行时解析策略,以便在编译时找到模块的定义文件。为了实现这一点,TypeScript在Node的解析逻辑上叠加了TypeScript源文件扩展名(.ts.tsx.d.ts)。TypeScript还将使用package.json中一个名为types的字段来达到 "main"的目的——编译器将使用它来找到 “main “定义文件来查阅。

例如,在 /root/src/moduleA.ts 中的 import { 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

回顾一下,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/@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

  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 (如果 types 属性存在)

  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 (如果 types 属性存在)

  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

不要被这里的步骤数量所吓倒——TypeScript仍然只是在步骤(9)和(17)上跳了两次目录。这其实并不比Node.js本身所做的更复杂。

13.3 额外的模块解析标志

一个项目的源代码内容有时与输出的内容不一致。通常情况下,一组构建步骤会产生最终的输出。这些步骤包括将 .ts文件编译成.js,并将不同的源文件位置的依赖关系复制到一个单一的输出位置。最终的结果是,模块在运行时的名称可能与包含其定义的源文件不同。或者最终输出中的模块路径可能与编译时对应的源文件路径不一致。

TypeScript编译器有一组额外的标志,以告知编译器预计将发生在源文件上的转换,以生成最终的输出。

值得注意的是,编译器不会执行任何这些转换;它只是使用这些信息来指导解析模块,导入到其定义文件的过程。

13.3.1 Base URL

在使用AMD模块加载器的应用程序中,使用 baseUrl 是一种常见的做法,模块在运行时被 “部署”到一个文件夹。这些模块的来源可以在不同的目录中,但构建脚本会把它们放在一起。

设置 baseUrl 会通知编译器在哪里找到模块。所有非相对名称的模块导入都被认为是相对于 baseUr的。

baseUrl 的值由以下两种情况决定:

  • baseUrl 命令行参数的值(如果给定的路径是相对的,它是基于当前目录计算的)
  • tsconfig.json 中的 baseUrl 属性值(如果给定的路径是相对的,则根据 'tsconfig.json' 的位置计算)

请注意,相对模块的导入不受设置 baseUrl 的影响,因为它们总是相对于其导入文件进行解析。

你可以在 RequireJSSystemJS 文档中找到更多关于 baseUrl 的文档。

13.3.2 路径映射

有时模块并不直接位于baseUrl下。例如,对模块 "jquery "的导入会在运行时被翻译成 "node_modules/jquery/dist/jquery.slim.min.js"。装载器使用映射配置在运行时将模块名称映射到文件,见 RequireJs 文档SystemJS 文档

TypeScript编译器支持使用 tsconfig.json 文件中的 paths 属性来声明这种映射关系。下面是一个例子,说明如何为jquery指定 paths 属性。

{
"compilerOptions": {
"baseUrl": ".", // 如果设置 "paths",这个必须指定。
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"] // 这种映射是相对于 "baseUrl"而指定的。
}
}
}

Please notice that paths are resolved relative to baseUrl. When setting baseUrl to another value than ".", i.e. the directory of tsconfig.json, the mappings must be changed accordingly. Say, you set "baseUrl": "./src" in the above example, then jquery should be mapped to "../node_modules/jquery/dist/jquery".

Using paths also allows for more sophisticated mappings including multiple fall back locations. Consider a project configuration where only some modules are available in one location, and the rest are in another. A build step would put them all together in one place. The project layout may look like:

请注意,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

相应的 tsconfig.json 将看起来像:

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

这告诉编译器对于任何符合 "*" 模式的模块导入(即所有值),要在两个地方寻找:

  1. "*": 意思是相同的名字不变,所以映射 <moduleName> => <baseUrl>/<moduleName>
  2. "generated/*":意思是模块名称有一个附加的前缀 “generated”,所以<moduleName> => <baseUrl>/generated/<moduleName>

按照这个逻辑,编译器将试图将这两个导入解析为这样:

import ‘folder1/file2’:

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

import ‘folder2/file3’:

  1. 模式'*'被匹配,通配符捕获了整个模块的名称
  2. 尝试列表中的第一个替换。'*'-> folder2/file3
  3. 替换的结果是非相对名称 - 与 baseUrl 结合 -> projectRoot/folder2/file3.ts
  4. 文件不存在,移到第二个替换项
  5. 第二个替换 'generated/*' -> generated/folder2/file3
  6. 替换的结果是非相对名称 - 与baseUrl结合 -> projectRoot/generated/folder2/file3.ts
  7. 文件存在。完成了。

13.3.3 带有rootDirs的虚拟目录

有时,在编译时来自多个目录的项目源都会被合并,以生成一个单一的输出目录。这可以被看作是一组源目录创建了一个 “虚拟 “目录。

使用rootDirs,你可以告知编译器构成这个 “虚拟 “目录的根;因此,编译器可以在这些 “虚拟 “目录中解决相对模块的导入,就像它们被合并在一个目录中一样。

例如,考虑这个项目结构:

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 "这样的相对名称来导入它。

为了向编译器指定这种关系,可以使用 rootDirsrootDirs指定了一个根的列表,这些根的内容在运行时被期望合并。所以按照我们的例子,tsconfig.json文件应该看起来像:

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

每当编译器在其中一个 rootDirs 的子文件夹中看到一个相对的模块导入,它就会尝试在 rootDirs 的每个条目中寻找这个导入。

rootDirs的灵活性并不局限于,指定一个在逻辑上合并的物理源代码目录的列表。提供的数组可以包括任何数量的特别的、任意的目录名称,不管它们是否存在。这允许编译器以类型安全的方式捕获复杂的捆绑和运行时特征,如条件性包含和项目特定的加载器插件。

考虑一个国际化的场景,构建工具通过插值一个特殊的路径标记,例如#{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' ,以便于在不影响设计时间支持的情况下,以与地区无关的方式开发。

13.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'. ========

需要注意的事项:

  • 导入的名称和位置
======== 从'src/app.ts'中解析模块'typescript'。 ========
  • 编译器所遵循的策略是
未指定模块解析种类,使用'NodeJs'。
  • 从npm包中加载类型
package.json'有'typescript'字段'./lib/typescript.d.ts',引用'node_modules/typescript/lib/typescript.d.ts'。
  • 最终结果
======== 模块名称'typescript'已成功解析为'node_modules/typescript/lib/typescript.d.ts'。========

13.5 应用--noResolve

通常情况下,编译器在开始编译过程之前会尝试解析所有模块的导入。每当它成功地解析了一个文件的导入,该文件就被添加到编译器以后要处理的文件集合中。

noResolve 编译器选项指示编译器不要 “添加 “任何未在命令行中传递的文件到编译中。它仍然会尝试将模块解析为文件,但如果没有指定文件,它将不会被包括在内。

举个例子:

app.ts

import * as A from "moduleA"; // 正确,'moduleA'在命令行上通过了
import * as B from "moduleB"; // 错误 TS2307: 无法找到模块'moduleB'
tsc app.ts moduleA.ts --noResolve

使用 noResolve 编译app.t 将导致:

  • 正确地找到模块A,因为它是在命令行上传递的。
  • 没有找到模块B,因为它没有被传递,所以出现错误。

13.6 常见问题

为什么排除列表中的模块仍然会被编译器选中?

tsconfig.json将一个文件夹变成一个 “项目”。如果不指定任何"exclude ""files "条目,包含tsconfig.json的文件夹及其所有子目录中的所有文件都会包括在你的编译中。如果你想排除某些文件,使用 "exclude",如果你想指定所有的文件,而不是让编译器去查找它们,使用 "files"

那是tsconfig.json的自动包含。这并没有嵌入上面讨论的模块解析。如果编译器将一个文件识别为模块导入的目标,它将被包含在编译中,不管它是否在前面的步骤中被排除。

所以要从编译中排除一个文件,你需要排除它和所有有import/// <reference path="..." />指令的文件。

特别声明: 本文转自 古艺散人老师 ,如有需要可前往原文预览查看。