模块解析机制_TypeScript笔记14

写在前面

模块化机制让我们能够把代码拆分成多个模块(文件),而编译时需要知道依赖模块的确切类型,那么首先要找到它(建立模块名到模块文件路径的映射)

实际上,在 TypeScript 里,一个模块名可能对应一个.ts/.tsx.d.ts文件(开启--allowJs的话,还可能对应.js/.jsx文件)

基本思路是:

  1. 先尝试寻找模块对应的文件(.ts/.tsx
  2. 如果没有找到,并且不是相对模块引入(non-relative),就尝试寻找外部模块声明(ambient module declaration),即d.ts
  3. 如果还没找到,报错Cannot find module 'ModuleA'.

一.相对与非相对模块引入

相对模块引入(relative import)以/./../开头,例如:

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

注意,相对模块引入并不等价于相对路径引入(例如/mod

其它形式的都是非相对模块引入(non-relative import),例如:

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

二者区别在于:

  • 相对模块引入:相对于要引入的文件去寻找模块,并且不会被解析为外部模块声明。用来引入(能在运行时保持相对位置的)自定义模块

  • 非相对模块引入:相对于baseUrl或根据路径映射去寻找模块,可能被解析为外部模块声明。用来引入外部依赖模块

二.模块解析策略

具体的,有 2 种模块解析策略:

  • Classic:TypeScript 默认的解析策略,目前仅用作向后兼容

  • Node:与 NodeJS 模块机制一致的解析策略

这 2 种策略可以通过--moduleResolution编译选项来指定,默认根据目标模块形式来定(module === "AMD" or "System" or "ES6" ? "Classic" : "Node"

Classic

在 Classic 策略下,相对模块引入会相对于要引入的文件来解析,例如:

// 源码文件 /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
import { b } from "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

NodeJS 模块解析

NodeJS 中通过require来引入模块,模块解析的具体行为取决于参数是相对路径还是非相对路径

相对路径的处理策略相当简单,对于:

// 源码文件 /root/src/moduleA.js
var x = require("./moduleB");

匹配顺序如下:

  1. 尝试匹配/root/src/moduleB.js

  2. 再尝试匹配/root/src/moduleB/package.json,接着寻找主模块(例如指定了{ "main": "lib/mainModule.js" }的话,就引入/root/src/moduleB/lib/mainModule.js

  3. 否则尝试匹配/root/src/moduleB/index.js,因为index.js会被隐式地当作该目录下的主模块

P.S.具体参考 NodeJS 文档:File ModulesFolders as Modules

而非相对模块引入会从node_modules里找(node_modules可能位于当前文件的平级目录,也可能在祖先目录),NodeJS 会向上查找每个node_modules,寻找要引入的模块,例如:

// 源码文件 /root/src/moduleA.js
var x = require("moduleB");

NodeJS 会依次尝试匹配:

/root/src/node_modules/moduleB.js
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/moduleB/index.js

/root/node_modules/moduleB.js
/root/node_modules/moduleB/package.json
/root/node_modules/moduleB/index.js

/node_modules/moduleB.js
/node_modules/moduleB/package.json
/node_modules/moduleB/index.js

P.S.对于package.json,实际上是加载其main字段指向的模块

P.S.关于 NodeJS 如何从node_modules加载模块的更多信息,见Loading from node_modules Folders

TypeScript 仿 NodeJS 策略

(模块解析策略为"Node"时)TypeScript 也会模拟NodeJS 运行时的模块解析机制,以便在编译时找到模块的定义文件

具体的,会把 TypeScript 源文件后缀名加到 NodeJS 的模块解析逻辑上,还会通过package.json中的types字段来查找声明文件(相当于模拟 NodeJS 的main字段),例如:

// 源码文件 /root/src/moduleA.ts
import { b } from "./moduleB"

会尝试匹配:

/root/src/moduleB.ts
/root/src/moduleB.tsx
/root/src/moduleB.d.ts
/root/src/moduleB/package.json
/root/src/moduleB/index.ts
/root/src/moduleB/index.tsx
/root/src/moduleB/index.d.ts

P.S.对于package.json,TypeScript 加载其types字段指向的模块

这个过程与 NodeJS 非常相似(先moduleB.js,再package.json,最后index.js),只是换上了 TypeScript 的源文件后缀名

类似地,非相对模块引入也同样遵循 NodeJS 的解析逻辑,先找文件,再找适用的文件夹:

// 源码文件 /root/src/moduleA.ts
import { b } from "moduleB

模块查找顺序如下:

/root/src/node_modules/moduleB.ts|tsx|d.ts
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/@types/moduleB.d.ts
/root/src/node_modules/moduleB/index.ts|tsx|d.ts

/root/node_modules/moduleB.ts|tsx|d.ts
/root/node_modules/moduleB/package.json
/root/node_modules/@types/moduleB.d.ts
/root/node_modules/moduleB/index.ts|tsx|d.ts

/node_modules/moduleB.ts|tsx|d.ts
/node_modules/moduleB/package.json
/node_modules/@types/moduleB.d.ts
/node_modules/moduleB/index.ts|tsx|d.ts

与 NodeJS 查找逻辑几乎一致,只是会额外地从node_modules/@types里寻找d.ts声明文件

三.附加模块解析标记

构建时会把.ts编译成.js,并从不同的源位置把依赖拷贝到同一个输出位置。因此,在运行时模块可能具有不同于源文件的命名,或者编译时最后输出的模块路径与对应的源文件不匹配

针对这些问题,TypeScript 提供了一系列标记用来告知编译器期望发生在源路径上的转换,以生成最终输出

P.S.注意,编译器并不会进行任何转换,只用这些信息来指导解析模块引入到其定义文件的过程

Base URL

baseUrl在遵循AMD模块的应用中很常见,模块的源文件可以位于不同的目录,由构建脚本把它们放到一起。在运行时,这些模块会被“部署”到单个目录下

TypeScript 里通过设置baseUrl来告知编译器该去哪里找模块,所有非相对模块引入都是相对于baseUrl的,有两种指定方式:

  • 命令行参数--baseUrl(指定相对路径的话,根据当前目录计算)

  • tsconfig.jsonbaseUrl字段(相对路径的话,根据tsconfig.json所在目录计算)

注意,相对模块引入不受 baseUrl 影响,因为总是相对于引入它们的文件去解析

路径映射

某些模块并不在baseUrl下,比如jquery模块在运行时可能来自node_modules/jquery/dist/jquery.slim.min.js,此时,模块加载器通过路径映射将模块名对应到运行时的文件

TypeScript 同样支持类似的映射配置(tsconfig.jsonpaths字段),例如:

{
  "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变了,paths也要跟着改

实际上,还支持更复杂的映射规则,比如多个备选位置,具体见Path mapping

rootDirs 指定虚拟目录

在编译时,有时会把来自多个目录的项目源码整合起来生成到单个输出目录中,相当于用一组源目录创建一个“虚拟”目录

rootDirs能够告知编译器组成“虚拟”目录的那些“根”路径,让编译器能够解析那些指向“虚拟”目录的相对模块引入,就像它们已经被整合到同一目录了一样,例如:

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

generated # 自定生成的模板文件
└── templates
        └── views
            └── template1.ts (imports './view2')

假设构建工具会把它们整合到同一输出目录中(也就是说,运行时view1template1是在一起的),因此能够通过./xxx的方式引入。可以通过rootDirs将这种关系告知编译器,把源目录都列出来:

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

此后只要遇到指向rootDirs子目录的相对模块引入,都会尝试在rootDirs的每一项中查找

实际上,rootDirs非常灵活,数组中可以含有任意多个目录名称,无论目录是否真实存在。这让编译器能够以类型安全的方式,“捕捉”复杂的构建/运行时特性,比如条件引入以及项目特定的加载器插件

比如国际化的场景,构建工具通过插入特殊的路径标识(如#{locale})来自动生成当地特定 bundle,例如把./#{locale}/messages映射到具体的./zh/messages./de/messages等等。通过rootDirs也很容易解决:

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

如果本地有zh语言包的话,编译时将会引入src/zh下的文件,例如import messages from './#{locale}/messages会被解析成import messages from './zh/messages'

四.追踪解析过程

模块能够引用到当前目录之外的文件,如果要定位模块解析相关的问题(比如找不到模块、或者找错了),就不太容易了

此时可以开启--traceResolution选项追踪编译器内部的模块解析过程,例如:

$ tsc --traceResolution
# 引入的模块名及所在位置
======== Resolving module './math-lib' from '/proj/src/index.ts'. ========
# 模块解析策略
Explicitly specified module resolution kind: 'NodeJs'.
# 具体过程
Loading module as file / folder, candidate module location '/proj/src/math-lib', target file type 'TypeScript'.
File '/proj/src/math-lib.ts' does not exist.
File '/proj/src/math-lib.tsx' does not exist.
File '/proj/src/math-lib.d.ts' exist - use it as a name resolution result.
# 最终结果
======== Module name './math-lib' was successfully resolved to '/proj/src/math-lib.d.ts'. ========

五.相关选项

--noResolve

正常情况下,编译器在开始之前会尝试解析所有模块引入,每成功解析一个模块引入,就把对应的文件添加到将要处理的源文件集里

--noResolve编译选项能够禁止编译器添加任何文件(通过命令行传入的除外),此时仍会尝试解析模块对应的文件,但不再添加进来,例如源文件:

// 源码文件 app.ts
import * as A from "moduleA"
import * as B from "moduleB"

tsc app.ts moduleA.ts --noResolve将能正确引入moduleA,而moduleB则会报错找不到(因为--noResolve不允许添加其它文件)

exclude

默认情况下,tsconfig.json所在目录即 TypeScript 项目目录,不指定filesexclude的话,该目录及其子孙目录下的所有文件都会被添加到编译过程中。可以通过exclude选项排除某些文件(黑名单),或者用files选项指定想要编译的源文件(白名单)

此外,编译过程中遇到被引入的模块,也会被添加进来,无论是否被 exclude 掉。因此,要在编译时彻底排除一个文件的话,除了exclude自身之外,还要把所有引用到它的文件也都排除掉

参考资料

模块解析机制_TypeScript笔记14》上有1条评论

  1. 世纪之光

    总结的蛮好,比官网的清除一点。我看你学习的知识面挺全面的。

    PS:小哥的主题不错,可否分享一份?

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code