Skip to content

Barrel Files

如何“正确”组织您的文件结构是前端开发人员之间热门而有争议的话题。将 文件移动到感觉正确 的位置是一个常见的梗,但根据作者 Dan Abramov 所说,这并非玩笑。

如果你是在单独工作,这可能没问题,但在团队中,不同的开发用户会拥有不同的使用想法。因此建议相当主观,并且在专业环境中并没有真正帮助。就我个人经验而言,大多数开发者乐意调整到项目已使用的结构上。拥有一个一致且易于理解的代码库,即使您个人不同意某种结构,但比每个人都按自己喜好做要好得多。我有我的偏好,(很可能)你也有自己的偏好。

我支持团队一致性,并希望尽可能静态地强制执行来避免无谓争论。unicorn/filename-case 规则 就是一个很好的例子。选择一种命名风格,在所有地方应用它。

话虽如此,最近我非常注重 避免使用桶文件 这件事情。

什么是桶文件?

桶文件是一个只重新导出其他模块所导出的内容的文件。通常,这些文件被命名为 index.jsindex.ts,也就是所谓的 索引文件。它的目的是为了隐藏目录结构,提供唯一的出口给外部使用者,对于外部使用者来说不需要关注项目内部的结构。举个例子,考虑一下你代码库中的以下目录:

bash
- tab
  - tab-list.tsx
  - tab-panel.tsx

假设上述中的每一个模块都导出一个单独的组件,那么在其他模块中就可以像如下的方式导入(依赖)上述已导出的模块:

ts
import { TabList } from '@/tab/tab-list';
import { TabPanel } from '@/tab/tab-panel';

上述这样直接的导入方式看上去没什么问题,但对于消费者来说可能存在一部分的心智负担,因为他们需要详细的了解所要导入的组件的具体路径是什么。因此,通常维护者希望通过提供一个 单一接口 来改善组件的依赖结构,隐藏内部组件结构,仅暴露一个唯一的结构给外部消费者。改善后的结构大致如下:

bash
- tab
  - tab-list.tsx
  - tab-panel.tsx
  - index.ts
ts
export { TabList } from './tab-list';
export { TabPanel } from './tab-panel';
ts
// 因为文件名为 index,可以在导入时省略,更清晰的写法如下。
import { TabList, TabPanel } from '@/tab';

这似乎看起来更加清晰,消费方(importers)不需要关注 tab 内部的具体文件结构。

一切都似乎是好的,那么问题出在哪里?

桶文件的缺点

Circular imports

桶文件会改变我们根据当前位置的导入方式,以如下例子为例,在 TabPanel 组件内使用 useTabState 钩子。

ts
export { TabList } from './tab-list';
export { TabPanel } from './tab-panel';
export { useTabState } from './use-tab-state';
ts
import { useTabState } from '@/tab';

const TabPanel = () => {
  const [selectedTab, setSelectedTab] = useTabState();
};

如果我们像往常一样从 barrel 文件中导入,将会创建一个循环导入,其中 tab-panel.ts 将导入 index.ts,而 index.ts 又导入了 tab-panel.ts

tab-panel.ts -> tab/index.ts -> tab-panel.ts

现在 JavaScript 在处理循环导入时相当宽容,但我曾见过 bundler 因此崩溃并显示最奇怪的错误消息。这些导入也可能是意外发生的,因为让我们面对现实吧:大多数情况下,我们只是自动导入然后由编辑器决定。至少我已经很久没有手动编写过 import 语句了。lint 规则 import/no-cycle 可以捕获许多这类循环依赖关系,所以我建议打开该规则。

Development speed

与桶有关的第二个问题是,当我们考虑导入桶文件时,那么程序内部会做些什么呢。

如果我们从使用以下语法

ts
import { useTabState } from '@/tab';

那么 javascript 将遍历 index.ts 文件并同步加载其中的每一个模块。换句话说,如果桶文件中包含大量重导入模块,那么程序很快就会失控。在每个 require(...) 和 import '...' 中,javascript 运行时都存在隐藏成本。如果你想从一个 barrel 文件中使用单个导出项,该文件导入了数千其他内容,那么你仍然要付出导入其他不必要模块的代价。对于许多流行的 React 包来说,仅仅是导入它们就需要 200~800 毫秒。在一些极端情况下,可能需要几秒钟。这些减速会影响本地开发和生产性能,尤其是在无服务器环境中。每次启动应用程序时,我们都必须重新导入所有内容。

例如,如果我们不需要从另一个桶中导入的其他导入之一或者来自再次导入许多模块的第三方库。在我们的 NextJs 项目中,我看到页面加载了超过 11k 模块,并花费了 5-10 秒才能启动页面。在开始清除大部分内部桶文件后,我们将其减少到约 3.5k 模块 - 减少了 68%。结果表明,如果您拥有通过一个桶输出大量内容且只需要其中一个模块的共享包,则情况并不理想😅 NextJs 团队也意识到,在开发模式下桶文件是真正的问题,并已开始提供 optimizePackageImports 功能来自动将来自桶文件的导入转换为它们真实的模块路径。Shu Ding 的博客文章 How we optimized package imports in Next.js 详细解释了这是如何运作的。

最有趣之处在于这些优化仅适用于“真正”的桶文件,意味着这个桶文件除了输出其他内容外其他什么都不做,一旦您添加一个不是重新导出的行,比如:

ts
export { TabList } from './tab-list';
export { TabPanel } from './tab-panel';
export { useTabState } from './use-tab-state';

// ❌ bad: this makes the whole file non-optimizable
export const foo = 5;

将使整个桶文件无法进行优化。同时如果桶文件中包含了潜在的副作用,那么它也是无法进行优化的。因此,最好的做法是尽量避免使用 barrel 文件。

Can’t we tree-shake it?

Tree-shaking 是 bundlers 的功能(例如 Webpack、Rollup、Parcel、esbuild 等),而不是 JavaScript 运行时功能。如果库被标记为 external 的,它将保持为黑盒子。bundlers 无法在该盒子内进行优化,因为依赖项需要在运行时引入。

如果我们选择将库与应用程序代码捆绑在一起,那么只要导入没有副作用(package.json 中的 sideEffects),树摇会起作用。然而,编译所有模块、分析整个模块图,然后正确地进行树摇需要更多时间。这会导致构建速度明显变慢。

桶文件的优点

在我看来,桶并不是用来将应用程序中目录的内容分组的。除非您添加更严格的 lint 规则,否则没有任何东西会强制其他开发人员只从桶中导入,因此您无法使用它们使某些模块“私有”。

需要桶的地方是在编写库时。像 @tanstack/react-query 这样的库需要一个单一入口文件,然后在 package.jsonmain 字段中引导入口。意味着这个 入口 是这个库作者提供给所有 importers 的公共接口。对我来说,这是唯一一个合理使用桶的地方。但如果您正在编写应用程序代码,则通过将 index.ts 文件放入任意目录只会增加工作量。所以请停止使用桶文件。

社区看法

  1. dd 我担心这篇文章对桶文件有些过分妖魔化。诸如循环导入和引入比所需更多的模块等问题确实值得考虑,但它们并不是桶文件独有的。例如,如果我有一个包含某个大型 DnD 库配置的文件,并且还从中导出一些常量,那么当我尝试访问这些常量时就会遇到相同的问题。我是 Feature-Sliced Design 项目的维护者之一,这是一种前端架构方法论,我们在很大程度上依赖桶文件来为一组模块创建公共 API,并保护代码库其余部分免受该组模块内部更改的影响。关键在于不要让该组模块变得太庞大或依赖关系过于复杂,那么就可以避免桶文件带来的开发性能下降问题。我个人认为桶文件是一个非常有用且没有合理替代方案的概念,,我们应该推动打包工具生成合理错误消息(或尽可能解决循环导入),以及推动更好地捕获这些问题并提早发现它们的工具化进程。

  2. 没有桶文件,可能会出现太多的导入语句问题,例如以下情况:

ts
// someComp.tsx

import A from '@/A';
import B from '@/A/B';
import C from '@/A/B/C';
//There are still many import statements ...

const Comp = () => {};
export { Comp };

使用桶文件的话可以减少许多代码量,项目代码简洁。

ts
// someComp.tsx

import { A, B, C } from '@/A';

const Comp = () => {};
export { Comp };

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)