# 模块解析

本节假设一些关于模块的基本知识。有关详细信息,请参阅 模块 文档。

模块解析是编译器用来确定导入所指内容的过程。考虑一个像 import { a } from "moduleA"这样的 import 语句;为了检查 a的任何使用,编译器需要确切地知道它代表什么,并且需要检查它的定义 moduleA

此时,编译器会询问 "what's the shape of moduleA?" 虽然这听起来很简单,但 moduleA可以在您自己的 .ts/.tsx文件中定义,或者在您的代码所依赖的 .d.ts中定义。

首先,编译器将尝试定位代表导入模块的文件。为此,编译器遵循以下两种不同策略之一:Classic 或 Node 。这些策略告诉编译器在哪里寻找 moduleA

如果这不起作用并且模块名称是非相对的(对于 "moduleA",它是),那么编译器将尝试定位 环境模块声明 。接下来我们将介绍非相对导入。

最后,如果编译器无法解析模块,它将记录一个错误。在这种情况下,错误将类似于 error TS2307: Cannot find module 'moduleA'.

# 相对与非相对模块导入

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

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

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

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

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

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

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

# 模块解析策略

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

注意:node模块解析是 TypeScript 社区中最常用的,推荐用于大多数项目。如果您在 TypeScript 中遇到 imports 和 exports 的解析问题,请尝试设置 moduleResolution: "node"以查看它是否可以解决问题。

# Classic

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

相对导入将相对于导入文件进行解析。因此,源文件 /root/src/folder/A.ts中的 import { b } from "./moduleB"将导致以下查找:

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

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

例如:

在源文件 /root/src/folder/A.ts中对 moduleB(例如 import { b } from "moduleB")的非相对导入将导致尝试以下位置来定位 "moduleB"

  • /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 解析算法。

# Node.js 如何解析模块

要了解 TS 编译器将遵循哪些步骤,了解 Node.js 模块很重要。传统上,Node.js 中的导入是通过调用名为 require的函数来执行的。Node.js 采取的行为将根据 require是相对路径还是非相对路径而有所不同。

相对路径相当简单。例如,让我们考虑一个位于 /root/src/moduleA.js的文件,其中包含导入 var x = require("./moduleB");Node.js 按以下顺序解析该导入:

  • 询问名为 /root/src/moduleB.js的文件是否存在。

  • 询问文件夹 /root/src/moduleB,它是否包含一个名为 package.json的文件,该文件指定了一个 "main"模块。在我们的示例中,如果 Node.js 找到包含 { "main": "lib/mainModule.js" }的文件 /root/src/moduleB/package.json,那么 Node.js 将引用 /root/src/moduleB/lib/mainModule.js

  • 询问文件夹 /root/src/moduleB是否包含名为 index.js的文件。该文件被隐式视为该文件夹的 "main" 模块。

您可以在关于 文件模块文件夹模块 的 Node.js 文档中阅读更多相关信息。

但是,非相对模块名的解析方式不同。Node 将在名为 node_modules的特殊文件夹中查找您的模块。node_modules文件夹可以与当前文件处于同一级别,或者在目录链中更高。Node 将沿着目录链向上走,遍历每个 node_modules,直到找到您尝试加载的模块。

按照我们上面的示例,考虑是否 /root/src/moduleA.js改为使用非相对路径并具有导入 var x = require("moduleB");。然后 Node 会尝试将 moduleB解析到每个位置,直到一个位置起作用。

  • /root/src/node_modules/moduleB.js
  • /root/src/node_modules/moduleB/package.json(如果它指定了 "main"属性)
  • /root/src/node_modules/moduleB/index.js
  • /root/node_modules/moduleB.js
  • /root/node_modules/moduleB/package.json(如果它指定了 "main"属性)
  • /root/node_modules/moduleB/index.js
  • /node_modules/moduleB.js
  • /node_modules/moduleB/package.json(如果它指定了 "main"属性)
  • /node_modules/moduleB/index.js

请注意,Node.js 在步骤 (4) 和 (7) 中跳转了一个目录。

您可以在 从 node_modules 加载模块 上的 Node.js 文档中阅读有关该过程的更多信息。

# TypeScript 如何解析模块

TypeScript 将模仿 Node.js 运行时解析策略,以便在编译时定位模块的定义文件。为此,TypeScript 将 TypeScript 源文件扩展名(.ts.tsx.d.ts)覆盖在 Node 的解析逻辑上。TypeScript 还将使用 package.json中名为 types的字段来反映 "main"的用途——编译器将使用它来查找 "main" 定义文件以进行查阅。

例如,像 /root/src/moduleA.ts中的 import { b } from "./moduleB"这样的导入语句将导致尝试以下位置来定位 "./moduleB"

  • /root/src/moduleB.ts
  • /root/src/moduleB.tsx
  • /root/src/moduleB.d.ts
  • /root/src/moduleB/package.json(如果它指定了 types属性)
  • /root/src/moduleB/index.ts
  • /root/src/moduleB/index.tsx
  • /root/src/moduleB/index.d.ts

回想一下,Node.js 查找了一个名为 moduleB.js的文件,然后是一个适用的 package.json,然后是一个 index.js

同样,非相对导入将遵循 Node.js 解析逻辑,首先查找文件,然后查找适用的文件夹。因此,源文件 /root/src/moduleA.ts中的 import { b } from "moduleB"将导致以下查找:

  • /root/src/node_modules/moduleB.ts
  • /root/src/node_modules/moduleB.tsx
  • /root/src/node_modules/moduleB.d.ts
  • /root/src/node_modules/moduleB/package.json(如果它指定了 types属性)
  • /root/src/node_modules/@types/moduleB.d.ts
  • /root/src/node_modules/moduleB/index.ts
  • /root/src/node_modules/moduleB/index.tsx
  • /root/src/node_modules/moduleB/index.d.ts
  • /root/node_modules/moduleB.ts10./root/node_modules/moduleB.tsx11./root/node_modules/moduleB.d.ts12./root/node_modules/moduleB/package.json(如果它指定了 types属性) 13./root/node_modules/@types/moduleB.d.ts14./root/node_modules/moduleB/index.ts15./root/node_modules/moduleB/index.tsx16./root/node_modules/moduleB/index.d.ts17./node_modules/moduleB.ts18./node_modules/moduleB.tsx19./node_modules/moduleB.d.ts20。/node_modules/moduleB/package.json(如果它指定了 types属性) 21./node_modules/@types/moduleB.d.ts22./node_modules/moduleB/index.ts23./node_modules/moduleB/index.tsx24./node_modules/moduleB/index.d.ts

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

# 附加模块解析标志

项目源布局有时与输出的布局不匹配。通常一组构建步骤会生成最终输出。其中包括将 .ts文件编译为 .js,并将依赖项从不同的源位置复制到单个输出位置。最终结果是运行时的模块可能与包含其定义的源文件具有不同的名称。或者最终输出中的模块路径在编译时可能与其对应的源文件路径不匹配。

TypeScript 编译器有一组附加标志,用于通知编译器预期发生在源上的转换以生成最终输出。

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

# 基本 URL

使用 baseUrl 是使用 AMD 模块加载器的应用程序的常见做法,其中模块在运行时是 "deployed" 到单个文件夹。这些模块的源代码可以位于不同的目录中,但是构建脚本会将它们放在一起。

设置 baseUrl 通知编译器在哪里可以找到模块。所有具有非相对名称的模块导入都被假定为相对于 baseUrl

baseUrl 的值被确定为:

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

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

您可以在 RequireJSSystemJS 文档中找到有关 baseUrl 的更多文档。

# 路径映射

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

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 解析的。当将 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/*"]
    }
  }
}

这告诉编译器对于匹配模式 "*"(即所有值)的任何模块导入,在两个位置查找:

  • "*":表示同名不变,所以映射&lt;moduleName&gt;=> &lt;baseUrl>/<moduleName&gt;
  • "generated/*"表示带有附加前缀 "generated" 的模块名称,因此映射 &lt;moduleName&gt;=> &lt;baseUrl>/generated/<moduleName&gt;

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

导入 'folder1/file2':

  • 模式 '*' 匹配,通配符捕获整个模块名称
  • 尝试列表中的第一个替换: '*' -> folder1/file2
  • 替换的结果是非相对名称 - 将其与 baseUrl -> projectRoot/folder1/file2.ts结合使用。
  • 文件已存在。完毕。

导入 'folder2/file3':

  • 模式 '*' 匹配,通配符捕获整个模块名称
  • 尝试列表中的第一个替换: '*' -> folder2/file3
  • 替换的结果是非相对名称 - 将其与 baseUrl -> projectRoot/folder2/file3.ts结合使用。
  • 文件不存在,移动到第二个替换
  • 第二次替换 'generated/*' -> generated/folder2/file3
  • 替换的结果是非相对名称 - 将其与 baseUrl -> projectRoot/generated/folder2/file3.ts结合使用。
  • 文件已存在。完毕。

# rootDirs 的虚拟目录

有时,在编译时来自多个目录的项目源全部组合在一起以生成单个输出目录。这可以看作是一组源目录创建了一个"virtual"目录。

使用 rootDirs,您可以告知编译器组成此 "virtual" 目录的根;因此编译器可以解析这些 "virtual" 目录中的相关模块导入,就好像它们被合并到一个目录中一样。

例如考虑这个项目结构:

 └── 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。rootDirs 指定一个根列表,其内容预计在运行时合并。因此,按照我们的示例,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'以用于工具目的,允许在不影响设计时间支持的情况下以与语言环境无关的方式进行开发。

# 跟踪模块解析

如前所述,编译器在解析模块时可以访问当前文件夹之外的文件。在诊断模块未解析或解析为不正确定义的原因时,这可能很困难。使用 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' 具有引用 'node_modules/typescript/lib/typescript.d.ts' 的“类型”字段 './lib/typescript.d.ts'。

  • 最后结果

======== 模块名称 'typescript' 已成功解析为 'node_modules/typescript/lib/typescript.d.ts'。========

# 使用 --noResolve

通常,编译器会在开始编译过程之前尝试解析所有模块导入。每次成功地将 import解析为文件时,该文件都会添加到编译器稍后将处理的文件集中。

noResolve 编译器选项指示编译器不要 "add" 编译任何未在命令行上传递的文件。它仍会尝试将模块解析为文件,但如果未指定文件,则不会包含该文件。

例如:

# 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的错误。

# 常见问题

# 为什么排除列表中的模块仍然被编译器拾取?

tsconfig.json将一个文件夹变成一个“项目”。在不指定任何 “exclude”“files”条目的情况下,包含 tsconfig.json及其所有子目录的文件夹中的所有文件都包含在您的编译中。如果要排除某些文件,请使用 “exclude”,如果您希望指定所有文件而不是让编译器查找它们,请使用 “files”

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

因此,要从编译中排除一个文件,您需要排除它以及所有具有 import/// <reference path="..." /&gt;指令的文件。

Last Updated: 5/25/2023, 2:35:11 PM