
之前有轉(zhuǎn)載過一篇 JavaScript 中的模塊導(dǎo)入和導(dǎo)出 ,但是沒有系統(tǒng)的進行說明,只是提到了模塊該如何使用,這里結(jié)合 TypeScript 來系統(tǒng)的啰嗦一番。
藍貓?zhí)詺馊枺?/h4>
- 前端的模塊是怎么工作的?
- TypeScript 中的模塊如何查找的,為什么會隱式查找到
index.ts、index.js,為什么會到 node_modules 中去找模塊?
- 如何定義一個全局變量供所有代碼共享?
-
tsconfig.json 文件有什么用,自定義模塊別名 @/* 是如何映射到指定目錄的?
index.ts、index.js,為什么會到 node_modules 中去找模塊?tsconfig.json 文件有什么用,自定義模塊別名 @/* 是如何映射到指定目錄的?帶著這些問題,我們開始今天的探索之旅!
什么是模塊
引用一段百度百科對模塊的解釋:
在程序設(shè)計中,為完成某一功能所需的一段程序或子程序,或指能由編譯程序、裝配程序等處理的獨立程序單位;或指大型軟件系統(tǒng)的一部分
模塊可以和大多數(shù)編程語言中的 命名空間、package 等概念進行關(guān)聯(lián),在模塊中定義的變量、函數(shù)、類,如果不經(jīng)過特殊處理,一般只有模塊內(nèi)能夠訪問,這樣可以避免與其他模塊沖突。由此可見模塊的功能是很重要的。
早期 JavaScript 并沒有模塊的概念,當(dāng) Node.js 被推出之后,JavaScript 才逐漸引入了模塊的概念,而TypeScript也沿用這個概念。有興趣的可以查看前端模塊化的歷程。
在 CommonJS && ES6 模塊化方案中, 一個模塊里的變量,函數(shù),類等等在模塊外部是不可見的,除非明確地使用 export 導(dǎo)出它們。 相反,如果想使用其它模塊導(dǎo)出的變量,函數(shù),類,接口等的時候,你必須使用import導(dǎo)入它們。
如何創(chuàng)建模塊
JavaScript 的模塊是自聲明的,事實上我們在寫代碼的時候一直在不知不覺中以模塊的形式進行書寫。
文件模塊
只要一個 JavaScript 文件中包含 imports 導(dǎo)入模塊 或者 exports 導(dǎo)出模塊 的聲明,那它就是一個模塊,嚴謹點應(yīng)該叫文件模塊。
在前端模塊實際上是通過閉包來實現(xiàn)的,一個模塊就是一個閉包,類似下面這樣:
編譯前:
// 1、依賴導(dǎo)入、變量聲明
export class module {
// 2、模塊內(nèi)部實現(xiàn)
}
編譯后:
const module = (function(){
// 1、依賴導(dǎo)入、變量聲明
// 2、模塊內(nèi)部實現(xiàn)
})();
這樣就能夠?qū)⒏鱾€文件的實現(xiàn)給隔離開,達到模塊化的目的。
全局模塊
如果一個文件沒有包含imports或exports呢,根據(jù)上面的描述這個文件不是一個模塊,那它是什么?
實際上,它是一種特殊的模塊,我們稱之為“全局模塊”,這個模塊里面的任何定義都是全局共享的!毋庸置疑,使用全局模塊是危險的,因為它會與文件內(nèi)的其他代碼命名沖突。但是全局模塊可以用在一些特殊的場景,比如使用頻繁的一些變量或方法,可以放在全局模塊進行聲明,避免每次使用都需要導(dǎo)入。
模塊的導(dǎo)出
導(dǎo)出聲明
任何聲明(比如變量,函數(shù),類,類型別名或接口)都能夠通過添加 export 關(guān)鍵字來導(dǎo)出。
export const a = "123" // 導(dǎo)出變量
export class CotpButton {} // 導(dǎo)出類
export interface ConfigInfo {}; // 導(dǎo)出接口
導(dǎo)出語句
導(dǎo)出語句支持將需要導(dǎo)出的模塊包裝到一個對象中,并且支持對導(dǎo)出的部分重命名:
import BaseComponent from "./src/base/BaseComponent"
import CotpButton from "./src/components/button/CotpButton"
export {
BaseComponent,
CotpButton as Button
}
重新導(dǎo)出
我們經(jīng)常會去擴展其它模塊,有時可能會合并之后重新導(dǎo)出供外部使用:
// 重新導(dǎo)出部分模塊
export { pushContants } from "./lib/constants"
// 重新導(dǎo)出部分模塊并且重命名
export { pushContants as sfPushContants } from "./lib/constants"
// 重新導(dǎo)出全部模塊
export * from "./lib/constants"
默認導(dǎo)出
每個模塊都可以有一個default導(dǎo)出。 默認導(dǎo)出使用default關(guān)鍵字標記;并且一個模塊只能夠有一個default導(dǎo)出。
export default 可以理解為等價于 const 任意變量名 =(這里的“任意變量名”是用來給其他模塊導(dǎo)入這個默認模塊時候使用的),導(dǎo)出類和函數(shù)的名字可以省略,也可以導(dǎo)出一個值。
export default class {} // 導(dǎo)出一個匿名類
export default function () {} // 導(dǎo)出一個匿名函數(shù)
export default "123" // 導(dǎo)出一個值
模塊的導(dǎo)入
部分導(dǎo)入
import { BasePage } from "@/common";
export default class HomePage extends BasePage {}
導(dǎo)出重命名
import { BasePage as XMFBasePage } from "@/common";
export default class HomePage extends XMFBasePage {}
全部導(dǎo)入
將整個模塊導(dǎo)入到一個變量,并通過它來訪問模塊的導(dǎo)出部分
import * as common from "@/common";
export default class HomePage extends common.BasePage {}
導(dǎo)入默認模塊
在前面導(dǎo)出默認模塊的時候提到了默認導(dǎo)出相當(dāng)于 const 任意變量名 =,所以導(dǎo)入默認模塊就是用“任意變量名”來接默認模塊,如下:
import 任意變量名 from "./my-module.js";
具有副作用的導(dǎo)入模塊
偶爾會存在這種場景,我只想導(dǎo)入模塊,而不像要這個模塊內(nèi)的具體導(dǎo)出,那么可以像下面這樣進行導(dǎo)入:
import "./my-module.js";
要注意的是如果./my-module.js是一個全局模塊,很容易產(chǎn)生變量沖突,所以說這種導(dǎo)入是具有副作用的。
模塊分類
從大類來講模塊可以分為 全局模塊 和 文件模塊
全局模塊
全局模塊的作用域是全局。一個 JavaScript 文件如果沒有export import,那么這個文件被引入后,則會是一個全局模塊,其中的任何聲明也都是全局共享的。
文件模塊
文件模塊的作用域被限定在文件內(nèi),且至少含有 export import 中的任何一個關(guān)鍵字。文件模塊按照導(dǎo)入方式又可分 相對導(dǎo)入 和 非相對導(dǎo)入
- 相對導(dǎo)入
相對導(dǎo)入是以/,./或../開頭的
import Button from "./components/Button";
import HttpConstants from "../constants/HttpConstants";
import "/mod";
- 非相對導(dǎo)入
所有其它形式的導(dǎo)入被當(dāng)作非相對導(dǎo)入
import { View } from "react-native";
import { BasePage } from "@/common";
模塊解析
Typescript 模塊解析就是指導(dǎo) ts 編譯器查找 import 導(dǎo)入內(nèi)容的流程。TypeScript 共有兩種可用的模塊解析策略: Classic 和 Node 。
先縱觀一下各種方式的解析流程,不需要牢記,主要是幫助快速對整個解析策略的理解:

Classic
這種策略以前是TypeScript默認的解析策略。 現(xiàn)在,它存在的理由主要是為了向后兼容。
- 相對路徑
相對路徑導(dǎo)入的模塊是相對于導(dǎo)入它的文件進行解析的。

例如:
// /root/src/folder/A.ts
import { b } from "./moduleB"
查找流程:
1、/root/src/folder/moduleB.ts
2、/root/src/folder/moduleB.d.ts
可以發(fā)現(xiàn)當(dāng)解析 import 導(dǎo)入的的時候,TypeScript 會優(yōu)先選擇 .ts 文件而不是 .d.ts 文件
- 非相對路徑
非相對模塊的導(dǎo)入,編譯器則會從包含導(dǎo)入文件的目錄開始依次向上級目錄遍歷,嘗試定位匹配的聲明文件。

例如:
//路徑: /root/src/folder/A.ts
import { b } from "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
Node
這個解析策略試圖在運行時模仿 Node.js 模塊解析機制, 完整的 Node.js 解析算法可以在Node.js module documentation找到
Node.js 如何解析模塊
為了理解TypeScript編譯依照的解析步驟,先弄明白Node.js模塊是非常重要的。 通常,在Node.js里導(dǎo)入是通過require函數(shù)調(diào)用進行的。 Node.js會根據(jù)require的是相對路徑還是非相對路徑做出不同的行為。
- 相對路徑
相對路徑的解析比較簡單,先以文件的模式查找,如果沒找到,再以目錄的形式進行查找。

例如:
// /root/src/moduleA.js
const b = require("./moduleB")
查找流程如下:
1、/root/src/moduleB.js
2、/root/src/moduleB/package.json (如果指定了"main"屬性) 。
3、/root/src/moduleB/index.js(這個文件會被隱式地當(dāng)作那個文件夾下的main模塊)
- 非相對路徑
非相對路徑的解析是個完全不同的過程。Node會在一個特殊的文件夾node_modules里查找你的模塊。 node_modules可能與當(dāng)前文件在同一級目錄下,或者在上層目錄里。 Node會向上級目錄遍歷,查找每個node_modules直到它找到要加載的模塊。

例如:
// /root/src/moduleA.js
const b = require("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 的 Node 模塊解析和 Node.js 有何區(qū)別
當(dāng)使用 Node 模塊解析策略是,TypeScript 是模仿 Node.js 運行時的解析策略來在編譯階段定位模塊定義文件。 因此,TypeScript 在 Node.js 解析邏輯基礎(chǔ)上增加了 TypeScript 源文件的擴展名(.ts、.tsx、.d.ts)。 同時,TypeScript在package.json里使用字段types來表示類似main的意義,編譯器會使用它來找到要使用的main定義文件。
- 相對模塊

例如:
// /root/src/moduleA.ts
import { b } from "./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
可以發(fā)現(xiàn)文件查找的優(yōu)先級依次是:.ts->.tsx->.d.ts,如果是 TypeScript 和 JavaScript 的混合項目(在 tsconfig.json 中配置 "allowJs": true,關(guān)于 tsconfig.json 文件會在下面提到),在 d.ts 之后還會去查找 .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
不要被這里步驟的數(shù)量嚇到,TypeScript只是在步驟(8)和(15)向上跳了兩次目錄。 這并不比 Node.js 里的流程復(fù)雜。
TypeScript 模塊解析配置
為了讓 TypeScript 能夠滿足工程化的需求,靈活配置類型檢查和編譯參數(shù),特意提供了一個 tsconfig.json 配置文件。我們可以通過 tsconfig.json 來自定義模塊的解析策略。
tsconfig.json 文件
TypeScript 使用 tsconfig.json 文件作為其配置文件,當(dāng)一個目錄中存在 tsconfig.json 文件,則認為該目錄為 TypeScript 項目的根目錄。熟悉移動端開發(fā)的可能會聯(lián)想到 Android 中的 build.gradle,iOS 中的 xcodeproj。
通常 tsconfig.json 文件主要包含兩部分內(nèi)容:指定待編譯文件和定義編譯選項。
tsconfig.json 的配置項可以用一張圖來簡單進行說明:

詳細說明可以查看這里
自定義模塊解析策略
tsconfig.json 中的 compilerOptions 是我們用的最多,也是最復(fù)雜的配置。其中有兩種方式來自定義模塊解析策略。
路徑映射
第一種是路徑別名映射,顧名思義是給路徑取個簡稱,通過這個簡稱我們就能夠定位到這個路徑。涉及到下面兩個配置項:
- baseUrl:解析非相對模塊的根地址,默認是當(dāng)前目錄
- paths:路徑映射別名,相對于baseUrl
比如我們項目中的基礎(chǔ)模塊,由于和業(yè)務(wù)模塊是獨立的,如果使用相對路徑進行引用,無疑會產(chǎn)生很多 ../../../../../ 的引用方式,不僅很冗長,而且增加了代碼閱讀成本。這個時候就可以用路徑別名的方式進行映射。來看下下面這個例子:
項目目錄結(jié)構(gòu)是這樣的:
├── node_modules
├── src
│ ├── common # 基礎(chǔ)模塊
│ │ ├── index.ts
│ ├── modules # 業(yè)務(wù)模塊
│ │ ├── xxx
│ │ │ ├── a.ts
└── tsconfig.json
tsconfig.json 配置如下:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/common": ["src/common"]
}
}
baseUrl 是相對于 tscofnig.json 的目錄,上面配置的是 .,說明 baseUrl 就是 tsconfig.json 所在的目錄,也就是項目根目錄。
上面的配置指定了 @/common 等價 <baseUrl>/src/common,這樣我們就可以直接用 @/common 來代替在 a.ts 中 ../../common 引用基礎(chǔ)庫的方式,最重要的是其中的層級不限,不管業(yè)務(wù)代碼所屬層級有多深,最終都會到項目根目錄下的 ./src/common 中查找模塊。
虛擬目錄
有時多個目錄下的工程源文件在編譯時會進行合并放在某個輸出目錄下。 這可以看做一些源目錄創(chuàng)建了一個虛擬目錄。
比如,有下面的工程結(jié)構(gòu):
src
└── views
└── view1.ts (imports './template1')
└── view2.ts
generated
└── templates
└── views
└── template1.ts (imports './view2')
src/views 里的文件是用于控制UI的用戶代碼。 generated/templates是UI模版,在構(gòu)建時通過模版生成器自動生成。 構(gòu)建中的一步會將/src/views和/generated/templates/views的輸出拷貝到同一個目錄下。 在運行時,視圖可以假設(shè)它的模版與它同在一個目錄下,因此可以使用相對導(dǎo)入"./template"。
利用配置項 rootDirs,可以告訴編譯器生成這個虛擬目錄的 roots; 因此編譯器可以在“虛擬”目錄下解析相對模塊導(dǎo)入,就好像它們被合并在了一起一樣。。 因此,針對這個例子,tsconfig.json 如下:
{
"compilerOptions": {
"rootDirs": [
"src/views",
"generated/templates/views"
]
}
}
每當(dāng)編譯器在某一rootDirs的子目錄下發(fā)現(xiàn)了相對模塊導(dǎo)入,它就會嘗試從rootDirs的所有子目錄中導(dǎo)入。
自定義模塊解析只是一種標記
當(dāng)你按照上面的配置完成自定義模塊解析之后,你會發(fā)現(xiàn)代碼運行起來之后依然找不到對應(yīng)的模塊,這是為什么?
事實上,通過 tsconfig.json 定義的解析策略,只是一種騙過編譯器的手段,編譯器并不會進行對應(yīng)的路徑轉(zhuǎn)換。
虛擬目錄目錄需要在編譯時將代碼按照約定拷貝到指定目錄;
路徑映射則需要使用 babel 在編譯階段進行轉(zhuǎn)換,babel 有提供現(xiàn)成的插件來完成路徑映射的轉(zhuǎn)換,如下:
安裝插件
npm install babel-plugin-root-import --save-dev
在 babel.config.js 或者 .babelrc 進行相應(yīng)的配置
module.exports = {
plugins: [
[
"babel-plugin-root-import",
{
paths: [
{
rootPathPrefix: "@/common",
rootPathSuffix: "./src/common"
}
]
}
]
]
}
跟蹤模塊解析
模塊解析是一個很復(fù)雜的流程,編譯器在解析模塊時可能訪問當(dāng)前文件夾外的文件,這會導(dǎo)致很難診斷模塊為什么沒有被解析,或解析到了錯誤的位置。 通過--traceResolution啟用編譯器的模塊解析跟蹤,它會告訴我們在模塊解析過程中發(fā)生了什么。
假設(shè)我們有一個使用了 typescript 模塊的簡單應(yīng)用。 app.ts里有一個這樣的導(dǎo)入import * as ts from "typescript"。
├─── tsconfig.json
├─── node_modules
│ └─── typescript
│ └─── lib
│ └─── typescript.d.ts
└─── src
└─── app.ts
使用--traceResolution調(diào)用編譯器。,或者在 tsconfig.json 中添加該配置
tsc app.ts --traceResolution
輸出結(jié)果如下:
======== 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
總結(jié)
這篇文章講述了 TypeScript 模塊的概念及使用方式,知道了怎么定義一個全局模塊和一個文件模塊。并且詳細描述了 TypeScript 模塊解析的流程,解析過程中文件的優(yōu)先級策略等等,讓大家對 TypeScript 模塊有了一個全面的認識。