我为什么删除 index.ts(barrel)文件

profile image

我曾用 barrel 文件让 import 语句更好看。本文总结它为何会引发问题、移除之后带来的改变,以及少数我仍然保留它的场景。

本帖由 Jetbrains's Coding Agent Junie junie logo翻译。如有任何翻译错误,请告知我们!

在项目里我遇到了一个循环依赖(Circular Dependency)错误。

runtime-error.png

顺藤摸瓜后发现,罪魁祸首是所谓的“barrel 文件”。barrel 文件指的是把一个文件夹内的多个模块集中在 index.ts 里统一导出的做法。

确认原因之后,我决定把项目中的 barrel 模式移除。下面记录一下我因此获得的积极体验,以及尽管如此我仍然选择保留的少数场景。

我只是想让 import 更好看

当初引入 barrel 文件的原因很简单:我想让 import 看起来更整洁。

typescript
// 与其这样写
import { validateEmail } from "@/shared/helpers/validation/email";
import { formatDate } from "@/shared/helpers/format/date";
import { calculateAge } from "@/shared/helpers/calculate/age";

// 不如这样更清爽!
import { validateEmail, formatDate, calculateAge } from "@/shared/helpers";

我没有考虑到副作用之类的问题。当时的想法就是:“能合成一行不更好吗?”而且很多项目都在用 barrel,我甚至没有怀疑这可能是个不好的模式。

然后,循环依赖出现了!

有一天我终于踩到了循环依赖。问题大致如下:

typescript
// 1. 在 shared/helpers 目录下的某个文件里
import { SnsPlatforms } from "@/shared/enums";

// 2. 而在 SnsPlatforms 文件内部
// @/shared/enums/SnsPlatforms.ts
import { createI18nMap } from "@/shared/helpers";

// 问题:barrel 文件导致循环依赖
// 因为 shared/helpers/index.ts 把所有导出集中到一起
// 于是形成了 shared/helpers → shared/enums → shared/helpers 的环

// 临时解决:绕过 barrel,直接导入
import { createI18nMap } from "@/shared/helpers/enum";

由于 barrel 把所有导出都聚合在一个地方,彼此有依赖关系的文件之间就很容易产生循环依赖。

这次经历让我开始重新审视 barrel 的使用。查资料后发现,已经有很多文章指出了它的问题:

循环依赖只是问题的一部分…

使用 barrel 文件带来的问题并不止循环依赖。

与 Tree-shaking 的冲突

现代打包器(Vite、Webpack 等)会通过 Tree-shaking 去除未使用的代码,从而优化最终产物。但 barrel 文件会干扰这个过程。

typescript
// 1. 使用 barrel(打包器:"我要分析一下 index.ts")
import { validateEmail } from "@/shared/helpers";

// 2. 直接导入(打包器:"我只需分析 email.ts!")
import { validateEmail } from "@/shared/helpers/validation/email";

在第 1 种情况下,为了判断是否只需要 validateEmail 一个函数,打包器必须分析整个 @/shared/helpers(即 index.ts)。如果该 index.ts 又重新导出了几十个文件,打包器可能会保守地认为:“其中某些也许有副作用,那就都打进包里更安全。”

结果就是:你本来只需要 validateEmail,但最终包里可能会把 helpers 文件夹下的所有工具函数都包含进去。

把 barrel 删了之后

那次事故之后,我决定移除所有 barrel 文件。执行的过程中,我得到了这些积极的变化:

1. import 依然整洁

这本来是我担心的点。行数确实多了几行,但完全不扎眼。IDE 还能把 import 区块折叠起来,所以实际并不影响阅读。

更重要的是,来源变得更明确,读代码更轻松了。@/shared/helpers 这种路径很模糊,而 @/shared/helpers/format/date 则把意图表达得非常清楚。

2. 多余的文件/目录消失了

使用 barrel 之后,我会不自觉地产生一种“只要有目录就该有个 index.ts”的强迫思维。于是就出现了这种低效结构:一个目录里只有一个文件,却还要多一个 index.ts 来转发导出。

typescript
features/
└── helpers
    └── something.ts
    └── index.ts

移除 barrel 之后,这些不必要的 index.ts 消失了,目录结构更直观、更简单。

3. 少了“忘记加导出”的人工失误

使用 barrel 时,每加一个工具函数或组件都要改两个地方:

  1. 创建实际文件(例如 new-util.ts
  2. 在 barrel(index.ts)里补上导出

当你在忙着改一堆文件时,第 2 步很容易忘。现在只要创建文件,其他地方就可以直接按路径引用了。

4. 重构更轻松

过去文件被绑在一个 index 里,调整结构时很麻烦。还得同步修改 index,而 IDE 有时也抓不全,得手动去修。现在不再被 index 捆绑,IDE 会自动更新 import 路径,重构顺滑多了。

barrel 也不是一无是处

我并没有把所有 barrel 全删光。在以下这些目的明确的场景里,我仍然保留:

  1. 像 Toast 这样的功能目录,尽管内部有不少文件,但对外只需要暴露固定接口,比如 useToastToastProvider
  2. 像 Table 这样的组件族,拆成了 Row、Cell、Header 等子组件,但通常需要一起 import 才能组成完整的 Table
typescript
components/
  Table/
    index.ts          # 保留 barrel
    Table.tsx
    TableRow.tsx
    TableCell.tsx
    TableHeader.tsx

我的体会

任何方法都有利有弊,barrel 也一样。与其把它当作“必须永远使用”的规则,不如视作“按场景选择的一个选项”。关键在于理解自己为什么这么选,并在遇到问题时保持足够的灵活性去尝试其它做法。