在TypeScript的世界里,模块是构建大型应用的关键拼图🧩。它就像一个个独立的小盒子,将代码封装起来,让代码更有条理、更易于维护。今天,就让我们一起深入探索TypeScript模块的奥秘吧!
1. 模块基础:开启代码封装之旅🔑
在TypeScript中,只要文件里出现了import
或者export
语句,它就摇身一变成为了一个模块。反之,如果文件里没有export
语句,那它就是一个全局脚本文件。
模块有自己独立的作用域,就像一个封闭的小世界🌐,里面的变量、函数、类,在模块外面是看不到的。要是想把模块里的内容暴露给外界使用,就得用export
命令声明;而其他文件想要使用这个模块的内容,就必须通过import
命令引入。
有时候,我们希望一个文件虽然没有export
语句,但也能当作模块,让内部变量对外不可见,这时候在脚本头部加上export {};
就可以啦。这行代码就像一个神奇的开关🚀,虽然它本身不产生实际作用,但能让文件变成模块,里面的代码都变成内部代码。
TypeScript模块不仅支持ES模块的所有语法,还多了个厉害的功能——可以输出和输入类型。比如下面这样:
export type Bool = true | false;
上面的代码输出了一个类型别名Bool
,也可以把类型定义和接口输出分开写:
type Bool = true | false;
export { Bool };
假设这个模块文件叫a.ts
,另一个文件b.ts
就可以用import
语句引入这个类型:
import { Bool } from './a';
let foo:Bool = true;
注意哦,加载文件时可以省略.ts
后缀名,TypeScript会自动帮我们定位到对应的文件。编译的时候,可以同时编译两个脚本:
$ tsc a.ts b.ts
也可以只编译入口脚本b.ts
,因为TypeScript会自动找到它依赖的a.ts
并一起编译:
$ tsc b.ts
2. import type语句:精准区分类型与接口🧐
在一条import
语句里,我们可以同时输入类型和正常接口,就像这样:
// a.ts
export interface A {
foo: string;
export let a = 123;
// b.ts
import { A, a } from './a';
但是这样很容易混淆类型和正常接口,为了解决这个问题,TypeScript给出了两个好办法。
第一个办法是在输入的类型前面加上type
关键字:
import { type A, a } from './a';
第二个办法是使用import type
语句,它专门用来输入类型,不能输入正常接口:
// 正确
import type { A } from './a';
let b:A = 'hello';
// 报错
import type { a } from './a';
let b = a;
import type
语句还可以输入默认类型:
import type DefaultType from 'moduleA';
如果想在一个名称空间下输入所有类型,可以这样写:
import type * as TypeNS from 'moduleA';
同样的,export
语句也有两种方式表示输出的是类型:
type A = 'a';
type B = 'b';
// 方法一
export {type A, type B};
// 方法二
export type {A, B};
用export type
把类作为类型输出时,输出的不是类本身,而是类的实例类型:
class Point {
x: number;
y: number;
export type { Point };
在其他文件导入时,只能当作类型使用:
import type { Point } from './module';
const p:Point = { x: 0, y: 0 };
3. importsNotUsedAsValues编译设置:掌控编译细节⚙️
TypeScript里输入类型的import
语句,编译成JavaScript时会怎么处理呢?这就要用到importsNotUsedAsValues
编译设置项啦,它有三个可选值:
remove
:这是默认值,会自动删除输入类型的import
语句。🚫preserve
:保留输入类型的import
语句,但会删掉其中涉及类型的部分。🗄️error
:保留输入类型的import
语句(和preserve
一样),不过必须写成import type
的形式,否则会报错。⚠️
比如下面这个输入类型的import
语句:
import { TypeA } from './a';
remove
的编译结果会把这行语句删掉;preserve
的编译结果会变成这样:
import './a';
error
的编译结果和preserve
相同,但如果不写成import type
的形式,编译过程中就会报错,改成import type { TypeA } from './a';
就没问题啦。
4. CommonJS模块:TypeScript与Node.js的桥梁🌉
CommonJS是Node.js专用的模块格式,和ES模块不兼容。在TypeScript中,我们可以用import =
语句来输入CommonJS模块:
import fs = require('fs');
const code = fs.readFileSync('hello.ts', 'utf8');
也可以用import * as [接口名] from "模块文件"
这种方式输入:
import * as fs from 'fs';
// 等同于
import fs = require('fs');
输出CommonJS模块的对象时,要用export =
语句,它等同于CommonJS的module.exports
对象:
let obj = { foo: 123 };
export = obj;
用export =
语句输出的对象,只能用import =
语句加载:
import obj = require('./a');
console.log(obj.foo); // 123
5. 模块定位:寻找模块的宝藏地图🗺️
模块定位是一种算法,用来确定import
和export
语句里模块文件的位置。比如下面这两个例子:
// 相对模块
import { TypeA } from './a';
// 非相对模块
import * as $ from "jquery";
TypeScript要确定./a
和jquery
具体指哪个模块、在哪里,就靠模块定位算法啦。
编译参数moduleResolution
可以指定使用哪种定位算法,常用的有Classic
和Node
两种。如果没有指定moduleResolution
,它的默认值和编译参数module
有关。当module
设为commonjs
时,moduleResolution
默认值为Node
;其他情况(比如module
设为es2015
、esnext
、amd
、system
、umd
等),就采用Classic
定位算法。
5.1 相对模块与非相对模块:模块的不同路径标识🚧
加载模块时,模块分为相对模块和非相对模块。
- 相对模块的路径以
/
、./
、../
开头,比如:import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";
相对模块的位置是根据当前脚本的位置来计算的,一般用于项目目录里的模块脚本。📍
- 非相对模块没有路径信息,比如:
import * as $ from "jquery";
import { Component } from "@angular/core";
非相对模块的位置由baseUrl
属性或模块映射来确定,通常用于加载外部模块。🌐
5.2 Classic方法:以当前脚本为起点的探索🧭
Classic方法把当前脚本的路径当作“基准路径”来计算相对模块的位置。比如脚本a.ts
里有import { b } from "./b"
,TypeScript就会在a.ts
所在的目录查找b.ts
和b.d.ts
。
对于非相对模块,也是从当前脚本路径出发,一层层查找上级目录。比如a.ts
里有import { b } from "b"
,就会在每一级上层目录查找b.ts
和b.d.ts
。
5.3 Node方法:模拟Node.js的模块加载之路🚶
Node方法模拟的是Node.js的模块加载方法,也就是require()
的实现方式。
相对模块同样以当前脚本路径为“基准路径”。比如a.ts
里有let x = require("./b");
,TypeScript会按以下顺序查找:
- 当前目录有没有
b.ts
、b.tsx
、b.d.ts
。没有就执行下一步。🔍 - 当前目录有没有子目录
b
,子目录里的package.json
文件有没有types
字段指定模块入口文件。没有就执行下一步。📄 - 当前目录的子目录
b
有没有index.ts
、index.tsx
、index.d.ts
。没有就报错。🚫
非相对模块以当前脚本路径为起点,逐级向上查找node_modules
子目录。比如a.js
里有let x = require("b");
,TypeScript会这样查找:
- 当前目录的子目录
node_modules
有没有b.ts
、b.tsx
、b.d.ts
。🔍 - 当前目录的子目录
node_modules
里有没有package.json
文件,它的types
字段有没有指定入口文件,有的话就加载该文件。📄 - 当前目录的子目录
node_modules
里有没有子目录@types
,在里面查找b.d.ts
。🔍 - 当前目录的子目录
node_modules
里有没有子目录b
,在里面查找index.ts
、index.tsx
、index.d.ts
。🔍 - 进入上一层目录,重复上面4步,直到找到为止。🚶
5.4 路径映射:自定义模块的定位规则✍️
在tsconfig.json
文件里,我们可以手动指定脚本模块的路径。
baseUrl
字段可以指定脚本模块的基准目录:
"compilerOptions": {
"baseUrl": "."
}
上面的例子里,baseUrl
是.
,表示基准目录就是tsconfig.json
所在的目录。📍
paths
字段可以指定非相对路径的模块与实际脚本的映射:
"compilerOptions": {
"baseUrl": ".",
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"]
}
}
这样,加载jquery
模块时,实际加载的脚本就是node_modules/jquery/dist/jquery
,它的位置是根据baseUrl
字段计算出来的。注意,jquery
属性的值是个数组,可以指定多个路径,第一个路径不存在就加载第二个,以此类推。📑
rootDirs
字段指定模块定位时必须查找的其他目录:
"compilerOptions": {
"rootDirs": ["src/zh", "src/de", "src/#{locale}"]
}
这里指定了模块定位时要查找的不同国际化目录。🌍
5.5 tsc的--traceResolution
参数:查看模块定位过程👀
模块定位过程比较复杂,tsc命令有个--traceResolution
参数,编译时可以在命令行显示模块定位的每一步:
$ tsc --traceResolution
这样就能清楚地看到模块定位的判断过程啦。
5.6 tsc的--noResolve
参数:限定模块定位范围🚫
tsc命令的--noResolve
参数表示模块定位时,只考虑命令行传入的模块。
比如app.ts
里有这两行代码:
import * as A from "moduleA";
import * as B from "moduleB";
用下面的命令编译:
$ tsc app.ts moduleA.ts --noResolve
因为用了--noResolve
参数,所以能定位到moduleA.ts
(因为命令行传入了),但无法定位到moduleB
(因为没传入),就会报错。
6. 模块与命名空间:功能相似,用法有别🤔
在TypeScript里,命名空间和模块都是组织代码的好帮手,但它们有不少区别哦。
6.1 语法和定义方式:各有各的语法规则📜
- 命名空间:用
namespace
关键字定义,可以在一个文件里定义多个命名空间:
namespace MyNamespace1 {
export function func1() {
console.log('Function in namespace 1');
}
namespace MyNamespace2 {
export function func2() {
console.log('Function in namespace 2');
}
}
- 模块:一个文件就是一个模块,用
export
导出成员,用import
导入其他模块的成员:
// module1.ts
export function func1() {
console.log('Function in module 1');
}
// module2.ts
import { func1 } from './module1';
func1();
6.2 作用域和可见性:访问权限的差异🔒
- 命名空间:在同一个文件里,命名空间内的成员默认是私有的,要在外部访问,得用
export
关键字:
namespace MyNamespace {
function privateFunc() {
console.log('This is a private function');
}
export function publicFunc() {
privateFunc();
}
}
MyNamespace.publicFunc(); // 可以调用
// MyNamespace.privateFunc(); // 报错,无法访问私有函数
- 模块:模块成员默认也是私有的,只有用
export
导出的成员才能在其他模块被访问:
// module.ts
function privateFunc() {
console.log('This is a private function');
}
export function publicFunc() {
privateFunc();
}
// main.ts
import { publicFunc } from './module';
publicFunc();
// privateFunc 无法被导入和调用
6.3 兼容性和使用场景:适用场景大不同🎯
- 命名空间:在旧项目或者需要全局作用域的情况下比较常用。早期JavaScript项目里,命名空间能避免全局变量冲突,而且它支持合并,多个同名命名空间会合并成一个:
namespace MyNamespace {
export function func1() {
console.log('Function 1');
}
}
namespace MyNamespace {
export function func2() {
console.log('Function 2');
}
}
MyNamespace.func1();
MyNamespace.func2();
- 模块:是ES6及后续版本的标准机制,兼容性和可移植性更好。在现代JavaScript和TypeScript项目里,模块是组织代码的首选,它能更好地管理依赖关系,方便代码分割和复用。
6.4 编译结果:编译后的代码差异📄
- 命名空间:编译后的JavaScript代码会创建一个全局对象来包含命名空间的成员,可能会污染全局作用域:
namespace MyNamespace {
export function func() {
console.log('Function in namespace');
}
}
编译后的代码:
var MyNamespace;
(function (MyNamespace) {
function func() {
console.log('Function in namespace');
}
MyNamespace.func = func;
})(MyNamespace || (MyNamespace = {}));
- 模块:编译后的代码会根据使用的模块系统(如CommonJS、ES模块等)生成对应的模块代码,不会污染全局作用域。比如用ES模块语法编写的代码:
export function func() {
console.log('Function in module');
}
编译成ES模块后的代码:
export function func() {
console.log('Function in module');
}
6.5 引用方式:引入方式不一样🔗
- 命名空间:如果命名空间定义在不同文件中,需要用三斜杠引用(
/// <reference path="path/to/file.ts" />
)来引入:
// file1.ts
namespace MyNamespace {
export function func() {
console.log('Function in namespace');
}
}
// file2.ts
/// <reference path="file1.ts" />
MyNamespace.func();
- 模块:使用
import
和export
关键字进行导入和导出,这是现代项目中更常用的方式:
// module1.ts
export function func() {
console.log('Function in module');
}
// module2.ts
import { func } from './module1';
func();
总的来说,模块在现代TypeScript项目里更推荐使用,它的兼容性、可维护性和可移植性都更好。
7. 模块的适用场景:模块的最佳舞台🎇
7.1 大型项目开发:化繁为简的利器🗡️
大型项目代码量大、功能复杂,模块可以把代码分割成一个个独立单元,方便管理和维护。每个模块专注于特定功能,不同模块通过import
和export
交互,