是否應該使用 Barrel Files 管理不同目錄的導出結構?
這是一個很糾結的問題:是否應該使用 Barrel Files 管理不同目錄的導出結構? 我個人曾經(jīng)非常推崇這種編碼模式,畢竟這確實是一種簡單但非常便于管理模塊之間依賴關系的方法,但經(jīng)過長久實踐后,發(fā)現(xiàn)潛在的弊端遠遠大于收益,因此強烈建議大家從此刻開始,停止使用 Barrel File,具體原因且聽我娓娓道來。
Barrel File 是什么
模塊化是一種非常重要且有用的技術,早期的 ECMAScript 規(guī)范一直被詬病缺乏模塊化能力,所有變量和函數(shù)都是全局的,這非常容易導致名稱沖突、代碼污染等問題,導致這時候的 Javascript 語言根本無法支撐起大規(guī)模項目開發(fā),為此開源社區(qū)及 ECMA 組織前后產出 CMD、UMD、ESM 等模塊化方案。模塊化能力使得開發(fā)能夠基于模塊粒度做好耦合度與內聚性管理,模塊之間劃定好交互與邊界,彼此獨立互不侵擾。某種程度上,這使得 Javascript 從簡單的腳本語言晉升為具備大規(guī)模開發(fā)能力的現(xiàn)代化編程語言。
但是,隨項目規(guī)模增長新的問題接踵而至,模塊數(shù)量增長容易導致模塊之間的依賴關系變得復雜,特別在大型項目中,可能需要橫跨多層目錄結構后才能引用到目標模塊,例如在下面的項目結構中:
src/
├── components/
│ ├── Button/
│ │ ├── Button.ts
│ │ └── index.ts
│ ├── Input/
│ │ ├── Input.ts
│ │ └── index.ts
│ └── Modal/
│ ├── Modal.ts
│ └── index.ts
├── utils/
│ ├── format.ts
│ └── validate.ts
└── services/
├── api/
│ ├── userApi.ts
│ └── index.ts
└── auth/
├── authService.ts
└── index.ts
假設 src/components/Button/Button.ts 模塊需要使用 src/services/api/auth/authService.ts 模塊,則相關導入語句:
// src/components/Button/Button.ts
import { authService } from '../../services/auth/authService';
這種方式存在許多缺點:
- 可讀性差:隨著目錄層級的增加,引用路徑會變得越來越長和復雜,這不僅降低了代碼的可讀性,還增加了理解代碼結構的難度;
- 強耦合:Button 強依賴于 authService 文件所在的相對路徑,目錄層級間邊界模糊不清;
- 維護成本高:在大型項目中,隨著模塊和文件數(shù)量的增加,維護相對路徑變得更加困難,任何一次目錄結構的調整都可能需要大量的路徑更新工作。
所幸這個問題并不難解決,常見解題思路有 alias 與 Barrel Files:
- alias:使用構建工具 —— 如 Typescript 的alias指定路徑別名:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@services/*": ["src/services/*"]
}
}
}
之后即可簡化引用方式為:
import { authService } from '@services/auth/authService';
- Barrel Files:設置Barrel Files統(tǒng)一導出模塊,如:
// services/index.ts
export { authService } from './auth/authService';
之后即可簡化引用方式為:
import { authService } from '../../services';
PS:alias 模式也同樣存在許多影響工程可維護性的細微問題,此處先按下不表。
結合上面的示例,Barrel files 本質上就是一種聚合多個模塊并統(tǒng)一導出的編碼模式,我們可以代碼文件夾中創(chuàng)建一個 Barrel File,通過該文件統(tǒng)一導出可用模塊,外部模塊在消費時只需引用到 Barrel 文件即可,無需關心代碼文件夾內部細節(jié),這會帶來一些好處:
- 引用方無需感知依賴模塊的具體文件結構,達到簡化導入語句,在大型工程中這有利于提升開發(fā)效率;
- Barrel Files 有助于管理模塊的可見性,對外屏蔽不必要的細節(jié),從而降低模塊間耦合;
- 模塊之間通過 Barrel Files 解耦后,后續(xù)更容易做重構,例如重命名、移動文件等,都只需要修改 barrel files 即可;
- 使用 Barrel Files 可以統(tǒng)一模塊的導出方式, 使代碼的結構和導入方式更加一致和規(guī)范,便于團隊協(xié)作。
如果嚴格遵循這種模式,只要確保 Barrel File 文件對外暴露內容的穩(wěn)定性,文件夾內部無論怎么騰挪轉移,上層甚至無需同步做出重構。
這聽著很美好,那么問題在哪呢?
問題:
1.Tree-Shaking 失效
這是 Barrel Files 模式最嚴重的問題:使用 Barrel Files 容易導致 Tree-shaking 失敗。
Tree-shaking 是前端構建工具提供的非?;A而實用的性能優(yōu)化特性,其底層依賴于 ESM 模塊規(guī)范的靜態(tài)特性,在構建過程中通過追蹤分析各模塊導入導出結構,刪除無用模塊,達到性能優(yōu)化效果。
但是,在使用 Barrel Files 模式時,情況發(fā)生了變化,舉例來說,假設項目結構如下:
src/
├── components/
│ ├── Button.js
│ ├── Input.js
│ └── index.js // Barrel file
└── index.js
對應核心代碼:
// src/components/Button.js
export const Button = () => {
console.log("Buttond");
};
// src/components/Input.js
class SingleTon { // 這里是重點
constructor() {
console.log("SingleTon");
}
}
export const instance = new SingleTon();
export const Input = () => {
console.log("input");
};
// src/components/index.js
export { Button } from "./Button";
export { Input } from "./Input";
// src/index.js
import { Button } from "./components";
Button();
結果來看,entry 文件 src/index.js 僅消費了 Button 函數(shù),但構建結果卻是:
原因很簡單,構建工具認為 SingleTon 是一段有 sideEffects 的代碼,出于安全考慮不予刪除。在 Barrel Files 模式下,這意味著下游模塊所有被判定為帶有 sideEffects 的代碼都會被保留下來,導致最終產物可能被打入許多無用代碼。注意,有許多代碼模式會被判定為具有 sideEffects,包括:
- 頂層函數(shù)調用,如:console.log('a')。
- 修改全局狀態(tài)或對象,如:document.title = 'new Title'。
- IIFE 函數(shù)。
- 動態(tài)導入語句,如:import('./mod')。
- 原型鏈污染,如:Array.prototype.xxx = function (){xxx}。
- 非 JS 資源:Tree-shaking 能力僅對 ESM 代碼生效,一旦引用非 JS 資源則無法樹搖;
- 等等;
這些都是非常常見的編碼模式,特別是非 JS 資源,在前端項目中通過 import/require 引用樣式、多媒體文件是非常常見的,但在 Barrel File 模式下卻容易打入不必要代碼。例如擴展上述示例,引入 Less 文件:
即使 s 并未被消費,產物中依然帶有 input.module.less 代碼,以及對應 CSS module 運行時代碼。
嚴格來說,并不單純是 Barrel Files 模式導致 tree-shaking 失效,而是 Barrel Files 疊加 sideEffects 的判定邏輯導致部分場景下樹搖失敗。那么相對的,假如放棄 Barrel Files 模式(雖然這會給損害 DX),直接引用具體模塊代碼,必然也就不會帶入其他無用模塊的 sideEffects。
2.循環(huán)引用
循環(huán)引用是指兩個或多個模塊相互依賴,形成一個閉環(huán),例如,模塊 A 引用了模塊 B,而模塊 B 又引用了模塊 A。而 Barrel Files 模式又非常容易導致循環(huán)引用結構,例如對于下面的項目結構:
src/
├── components/
│ ├── Button.ts
│ ├── Input.ts
│ └── index.ts // Barrel 文件
└── index.ts
// Button.ts
import { Input } from './index';
export function Button() {
Input();
}
// Input.ts
import { Button } from './index';
export function Input() {
Button();
}
這里面,Barrel File 模式看似隱蔽了 Button 與 Input 模塊的實現(xiàn)細節(jié),降低兩者耦合,但依賴關系并沒有消失而是發(fā)生轉移,兩者的循環(huán)依賴從直接變成間接,以人類的認知能力而言變得相對隱晦而難以察覺,這只是一個簡單示例,當項目規(guī)模增長十倍、百倍時,循環(huán)依賴的概率也會相應大幅增長。
這種依賴結構是非常脆弱不健康的,容易進一步引發(fā)許多工程問題:
模塊未定義問題:當出現(xiàn)循環(huán)引用時,某些模塊可能會在未完全定義之前被使用,導致 undefined
錯誤。例如:
// Button.ts
import { Input } from './index';
console.log(Input); // 可能是 undefined
程序崩潰或行為異常:循環(huán)引用會導致模塊加載順序問題,驗證時可能引發(fā)程序崩潰或行為異常。例如:
// Button.ts
import { Input } from './index';
export function Button() {
Input();
}
// Input.ts
import { Button } from './index';
export function Input() {
Button(); // Button 與 Input 遞歸調用,導致程序死循環(huán)
}
構建困難:“如何構建循環(huán)依賴”是一個非常復雜的問題,業(yè)界并沒有對此形成統(tǒng)一規(guī)范,各家構建工具的處理邏輯都有所不同,致使某些代碼在當下看似可用,但換一個構建環(huán)境可能出現(xiàn)各種細微問題;
調試困難:循環(huán)引用導致的問題往往隱蔽且難以調試。開發(fā)者需要深入理解模塊加載順序,才能找到并修復問題。
幸運的是,這類問題相對容易檢測,社區(qū)有不少工具可用于輔助檢測循環(huán)依賴,常見如 eslint-plugin-import 的 no-cycle 規(guī)則,接入成本低,但其內部實現(xiàn)需要向下遍歷被依賴模塊,IO 與 CPU 都比較密集,有較高性能成本,官方文檔也警告過需要關注性能問題:
其次,更推薦使用 oxlint 的 import/no-cycle 規(guī)則,由于底層是 rust 實現(xiàn)的,執(zhí)行性能要比 eslint-plugin-import 插件高出不少,使用方法:
npm i -g oxlint@latest
echo '{"rules": {"import/no-cycle": "error"}}' > .oxlintrc.json
oxlint -c .oxlintrc.json --quiet --import-plugin .
3.影響部分工程化工具性能
這里有一個基礎前提:Barrel Files 模式容易引入無用代碼,無用是指代碼被定義、導入?yún)s從未被業(yè)務系統(tǒng)消費,但這些無用代碼卻是實實在在影響著許多工程工具的執(zhí)行性能,包括但不限于:Typescript、VS Code、Vitest、Webpack、RSPack、ESLint 等等。
以 VS Code 為例,不同導入風格最終需要處理的空間復雜度差異極大,以 antd 為例:
- 使用 Barrel Files 時:
對應 TS Server 日志,需要處理許多無關模塊:
- 直接引用模塊:
對應 TS Server 日志,只需處理 affix 模塊即可:
類似的,使用 Barrel Files 時,tsc 也需要消費更多的時間索引那些根本不會被消費的文件:
- 使用 Barrel Files 時:
- 直接引用模塊:
從 540ms 到 141ms,兩者相差接近 4 倍的性能開銷,本質上,這是因為 Typescript 并沒有智能到能夠識別出 Barrel Files 導入的無用模塊,tsc 或 ts server 會忠實的解析編譯所有遇到的模塊及子模塊,結果,Barrel Files 模式使用的越多,越容易造成不必要的性能浪費。
類似的,這一問題在 Webpack/RSPack 等構建工具,或 bundle 中不使用 tree-shaking 時,或者 Vitest 等工具中同樣存在,都會導致大量無效計算。
4. 模塊間依賴關系變得更復雜
在使用 Barrel Files 后,對引用方而言確實無需關注具體模塊文件路徑,模塊之間的依賴規(guī)則似乎變得更簡單些,但事實是,復雜度不會消失,只是轉嫁到 Barrel Files 上而已,凌亂的關系最終匯聚到 Barrel Files 上反而可能使得最終的模塊關系圖變得愈加復雜:
- 依賴關系更隱蔽:Barrel 文件會隱藏模塊之間的直接依賴關系,使得依賴關系變得不透明。例如,在 Home.ts 文件中,我們通過 Barrel 文件導入了 Button 和 Input 組件,但實際上我們并不能直觀理解這些組件具體來自哪里,而這會使得代碼調試變得復雜晦澀;
- 增加了不必要的依賴:由于 Barrel Files 會導出所有包含的模塊,有時明明不存在消費行為,但通過 Barrel Files 搭橋后,反而導致模塊之間增加不必要的依賴關系;
舉個實際例子,開源工具 mswjs 曾經(jīng)做過一次重構,移除倉庫內部分 Barrel Files,重構之前模塊之間的依賴關系:
重構之后:
變得肉眼可見的清晰明了。復雜依賴關系會帶來許多可讀性問題,提高代碼理解成本,即使借助編程工具如 VS Code,過度復雜的關系也會讓人難以理解全貌。
最佳實踐
綜上,雖然 Barrel Files 確實能簡化導入路徑,降低模塊耦合,提升開發(fā)體驗,但代價卻是犧牲了產物與工程環(huán)境性能,且長期來看反而會讓整體模塊依賴關系變得復雜難懂,我認為應該盡量克制使用 Barrel Files,使用其他方法替代,如:
- 若項目文件結構比較簡單,建議直接引用具體模塊;若文件結構過于復雜,請重構,在 Monorepo 語境下做好拆包分解;
- 如果你正在開發(fā) NPM Package,可使用 package.json 的 exports、typesVersion 等字段聲明導出內容,以此替代 Package 的 index 文件;
其次,應該設置一些 Lint 檢測規(guī)則預防出現(xiàn)意料之外的 Barrel 文件,常用規(guī)則包括:
- 使用 eslint-plugin-import 或 oxlint 的 no-cycle 規(guī)避循環(huán)引用;
- 編寫 ESLint 規(guī)則禁止 export */import * 一類代碼,規(guī)避過度開放的 Barrel Files,不過社區(qū)似乎還沒有想過實現(xiàn),后續(xù)有機會再將我們內部實現(xiàn)的版本開源出去吧。