TS模块解析

2,148 阅读6分钟
模块解析是指编译器在查找导入模块内容时所遵循的流程。假设有一个导入语句 `import { a } from "moduleA"`; 为了去检查任何对 `a`的使用,编译器需要准确的知道它表示什么,并且需要检查它的定义`moduleA`

编译器会尝试定位表示导入模块的文件。 编译器会遵循以下二种策略之一: ClassicNode。 这样会告诉编译器到哪里去找moduleA

如果编译器不能解析这个模块,它会记录一个错误 Cannot find module 'moduleA'.

相对与非相对模块导入

模块的导入方式有两种,相对与非相对。这两种模块导入方式会以不同的方式解析。

相对导入是以`/``./``../`开头的。 下面是一些例子:
  • import a from "./moduleA";
  • import { a } from "../moduleA";
  • import "/moduleA";

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

的。 下面是一些例子:

  • import a from "moduleA";
  • import { a } from "@module/moduleA";

相对导入在解析时是相对于导入它的文件,并且不能解析为一个外部模块声明。 自己写的模块应该使用相对导入,这样能确保它们在运行时的相对位置。

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

模块的解析策略

Classic

相对导入的模块是相对于导入它的文件进行解析的。 所以 /root/src/A.ts文件里的import { b } from "./moduleB"会使用下面的查找流程:

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

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

比如:

有一个对moduleB的非相对导入import { b } from "moduleB",它是在/root/src/A.ts文件里,会以如下的方式来定位"moduleB"

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

Node

Node.js如何解析模块

通常,在Node.js里导入是通过 require函数调用进行的。 Node.js会根据 require的是相对路径还是非相对路径做出不同的行为。

相对路径很简单。 例如,假设有一个文件路径为 /root/src/moduleA.js,包含了一个导入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

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

TS可以使用 --moduleResolution标记来指定使用哪种模块解析策略。若未指定,那么在使用了 --module AMD | System | ES2015时的默认值为 Classic  ,其它情况时则为 Node 。

Base URL

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

baseUrl

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

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

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

路径映射

有时模块不是直接放在baseUrl下面。 比如, "jquery"模块地导入,在运行时可能被解释为"node_modules/jquery/dist/jquery.slim.min.js"TypeScript编译器通过使用tsconfig.json文件里的"paths"来支持这样的声明映射。 下面是一个如何指定 jquery"paths"的例子。

{
  "compilerOptions": {
    "baseUrl": ".", // This must be specified if "paths" is.
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // 此处映射是相对于"baseUrl"
    }
  }
}

请注意"paths"是相对于"baseUrl"进行解析。 如果 "baseUrl"被设置成了除"."外的其它值,那么映射必须要做相应的改变。 如果你在上例中设置了 "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

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

  • 导入'folder1/file2'
    1. 匹配'*'模式且通配符捕获到整个名字。
    2. 尝试列表里的第一个替换:'*' -> folder1/file2
    3. 替换结果为非相对名 - 与baseUrl合并 -> projectRoot/folder1/file2.ts
    4. 文件存在。完成。
  • 导入'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. 文件存在。完成。