Barrel Export、Jest、Babel 与依赖图
学习开源项目的时候遇到的一个issue,ai的相关分析,在此做些记录。
目标:通过一个真实 Issue,系统理解 React/TypeScript 项目中的 barrel file、ES Module、module graph、Jest、Babel AST transform、Tree Shaking、side effects、feature boundary,并沉淀为可以用于实际项目和面试的判断框架。
0. 先说结论
这个 Issue 的核心并不是"React 有问题",而是一个更底层的模块系统问题:
当业务代码从一个 feature 的
index.tsbarrel file 导入一个成员时,测试环境可能需要先加载这个 barrel file,而 barrel file 又声明了多个 re-export/import,因此其他模块可能进入测试运行时的模块依赖图;如果这些模块存在顶层副作用,就会出现"我没用这个组件,但它的代码却执行了"的现象。
Issue #120 的原始描述是:features/order/index.ts 导出 useCreateOrderMutation 和 useGetOrderQuery,MyPage.tsx 只从 @/features/order 使用 useGetOrderQuery,但在 Jest 测试中,useCreateOrderMutation 内部代码也被执行。Issue 于 2023-04-05 创建,目前页面仍标记为 Open,且当前页面没有关联 PR/Development 项。
后续有人提供了一个专门针对 Bulletproof React feature barrel 的 Babel 插件:
pafry7/babel-plugin-bulletproof-features-import
它的核心思路不是取消 barrel API,而是:
text
源码层:保留漂亮的 feature import
↓
Babel 编译阶段解析 feature/index 文件
↓
把 barrel import 改写为具体模块 import
↓
Jest / bundler 获得更精确的依赖图
这个方案可以理解成:
对开发者保留稳定、漂亮的 public API;对运行时/测试环境生成更精确的 dependency graph。
但是要注意一个非常重要的现实细节:当前插件仓库 README 的示例与源码实现之间存在表达上的不一致。README 展示的是 export { Foo } from './components/Foo' 风格,而当前 collect-esm-imports.js 的实现主要遍历 ImportDeclaration,也就是更直接地支持 import { Foo } from './components/Foo'; export { Foo }; 这种模式。实际采用之前应该以当前源码和项目测试为准,而不要只根据 2023 年的 Issue 评论判断能力边界。
1. Issue 背景:Bulletproof React 为什么会出现 barrel file
Bulletproof React 的一个核心思想是 feature-based architecture。
典型结构类似:
text
src/
├── app/
├── components/
├── features/
│ ├── auth/
│ ├── users/
│ └── orders/
├── hooks/
├── lib/
├── testing/
├── types/
└── utils/
一个 feature 内部又可能继续分:
text
features/order/
├── api/
├── components/
├── hooks/
├── stores/
├── types/
├── utils/
└── index.ts
index.ts 往往承担 feature 的 public API:
ts
// features/order/index.ts
export { useCreateOrderMutation } from './api/useCreateOrderMutation';
export { useGetOrderQuery } from './api/useGetOrderQuery';
export { OrderList } from './components/OrderList';
export type { Order } from './types/order';
业务代码可以写:
ts
import { useGetOrderQuery } from '@/features/order';
而不是:
ts
import { useGetOrderQuery } from '@/features/order/api/useGetOrderQuery';
这两种写法背后代表两个不同的架构理念:
深路径 import
text
业务层
↓
feature 内部实现
调用方知道 feature 内部怎么组织文件。
Barrel import
text
业务层
↓
feature public API
↓
feature 内部实现
调用方只依赖"这个 feature 提供了什么",而不是"它内部怎么实现"。
这就是 barrel 的架构价值。
2. Barrel file 是什么
Barrel file,也叫 barrel module / aggregator module,是一个专门负责聚合其他模块导出的文件。
例如:
ts
// components/index.ts
export { Button } from './Button';
export { Dialog } from './Dialog';
export { Select } from './Select';
调用方:
ts
import { Button } from './components';
MDN 也把这种"集中多个模块导出"的方式称为 barrel module / re-export aggregation。模块之间会形成一个依赖图(dependency graph)。
参考:
- MDN JavaScript Modules
- MDN
export
3. 为什么"我只 import 一个东西",另一个模块仍然可能被加载?
这是理解 Issue #120 的最关键知识点。
假设:
ts
// features/order/index.ts
export { useCreateOrderMutation } from './api/useCreateOrderMutation';
export { useGetOrderQuery } from './api/useGetOrderQuery';
业务代码:
ts
import { useGetOrderQuery } from '@/features/order';
直觉上很多人会认为:
text
我只用了 useGetOrderQuery
=> useCreateOrderMutation 不应该碰
但 ES Module 的依赖处理不是按"最终 JS 表达式有没有使用"直接决定"完全不接触另一个模块"。
可以把过程理解成:
text
MyPage.tsx
↓
features/order/index.ts
↓
┌──────────────────────────────┐
│ │
├── useCreateOrderMutation.ts │
│ │
└── useGetOrderQuery.ts │
index.ts 本身就是一个模块,它声明了对两个模块的导出关系,因此这些模块可以进入宿主环境构建/加载的依赖图。
MDN 明确指出:模块及其依赖构成有向 dependency graph;模块求值时需要先处理其依赖。Node 的模块 API 也区分了 link / instantiate / evaluate 阶段。
更重要的是:
"模块被解析/加载/求值"和"最终 bundle 中是否保留某段未使用代码"是不同问题。
4. import、module loading、tree shaking 三件事情不要混为一谈
这是面试最容易被问到的地方。
4.1 Module graph
首先形成:
text
A
├── B
├── C
└── D
表示 A 依赖 B/C/D。
4.2 Module evaluation
模块系统会处理模块依赖并执行模块顶层代码。
例如:
ts
// foo.ts
console.log('foo module evaluated');
export const foo = 1;
只要 foo.ts 被执行环境加载并求值,它的顶层 console.log 就会发生。
这就是"模块副作用"。
4.3 Tree shaking
Tree shaking 是 bundler 做的 dead-code elimination,依赖 ESM import / export 的静态结构。webpack 文档把 tree shaking 定义为 dead-code elimination,并进一步区分了 usedExports 与 sideEffects。
所以:
text
模块进入依赖图
≠
最终一定进入生产 bundle
生产 bundler 可能判断:
text
这个 export 没有使用
↓
可被优化掉
但测试运行时并不一定有与生产构建完全相同的 tree shaking 能力。
5. 为什么 Jest 更容易暴露这个问题
Issue 发生在 Jest 测试环境。
Jest 的任务主要是:
text
解析代码
→ 转换代码
→ 建立测试运行环境
→ 执行模块
而生产 bundler 的重点往往是:
text
解析
→ 构建依赖图
→ 分析 export 使用情况
→ tree shaking
→ code splitting
→ minify
→ 输出 bundle
所以不要把:
"webpack 生产环境不会把它带进去"
误推导成:
"Jest 运行的时候这个 module 一定不会被 evaluate"。
Jest 的 ESM/CJS 与 transform 机制本身也存在差异,Jest 文档明确区分 ESM 与默认的 CommonJS transform 路径。
参考:Jest ECMAScript Modules 文档。
6. 真正触发问题的通常是"顶层副作用"
如果 useCreateOrderMutation.ts 只是:
ts
export function useCreateOrderMutation() {
// hook logic
}
那么它被加载通常没有太大的可观察影响。
真正危险的是:
ts
// useCreateOrderMutation.ts
initializeSomething();
registerGlobalEvent();
connectSomeService();
console.log('module loaded');
export function useCreateOrderMutation() {}
此时:
text
加载 module
↓
执行顶层代码
↓
副作用发生
所以 Issue 中"unused component executed"的本质更准确地描述应该是:
unused export 对应的 module 被纳入了执行路径,而该 module 的顶层行为产生了可观察副作用。
这比简单说"unused import 被执行了"更准确。
7. 这个问题和 side effects 有什么关系
webpack 文档对 side effect 的定义很重要:
除了暴露 export 之外,import 一个文件时还能产生特殊行为,这就是 side effect。
典型例子:
ts
import './polyfill';
它可能什么都不导出,但会修改全局环境。
再比如:
ts
window.foo = 'bar';
或者:
ts
registerPlugin();
都属于典型副作用。
如果项目尽可能保持 module side-effect free,Tree Shaking、测试隔离、SSR、HMR 往往更容易管理。
webpack 也支持通过 package.json 中的 sideEffects 属性向 bundler 提供信息,例如:
json
{
"sideEffects": false
}
但注意:不要因为"优化"就机械地设置 sideEffects: false。如果项目确实存在 CSS、polyfill、全局注册等副作用,错误标记可能让生产构建错误地把这些文件裁掉。
8. Issue #120 的核心代码路径
把原 Issue 简化成:
ts
// features/order/index.ts
export { useCreateOrderMutation } from './api/useCreateOrderMutation';
export { useGetOrderQuery } from './api/useGetOrderQuery';
tsx
// pages/MyPage.tsx
import { useGetOrderQuery } from '@/features/order';
tsx
// pages/MyPage.test.tsx
// 测试 MyPage,并 mock / spyOn useGetOrderQuery
问题:
text
MyPage.test
↓
MyPage
↓
@/features/order
↓
index.ts
├── useCreateOrderMutation.ts ← 也进入模块依赖图
└── useGetOrderQuery.ts
如果 useCreateOrderMutation.ts 有顶层副作用,就可以被观察到。
9. 第一类解决方案:放弃 barrel,全部使用 deep import
最直接:
ts
import { useGetOrderQuery } from '@/features/order/api/useGetOrderQuery';
而不是:
ts
import { useGetOrderQuery } from '@/features/order';
优点
- dependency 非常明确
- 测试环境容易预测
- 不容易因为聚合文件把无关模块带进来
- 对构建器来说依赖路径非常直接
缺点
- 暴露 feature 内部目录结构
- 调用方和内部实现耦合
- 文件重构成本提高
- import path 变长
- public API 不够干净
例如今天:
text
features/order/api/useGetOrderQuery.ts
明天重构成:
text
features/order/data/useGetOrderQuery.ts
全项目调用方可能都要改。
结论
这是简单可靠的方案,但会牺牲 feature encapsulation。
10. 第二类解决方案:保留 barrel,但减少 barrel 的职责
这是更值得推荐的工程实践。
不要:
ts
// ❌ 巨型 barrel
export * from './api';
export * from './components';
export * from './hooks';
export * from './stores';
export * from './utils';
export * from './types';
而是只暴露真正的 public API:
ts
// features/order/index.ts
export { OrderList } from './components/OrderList';
export { useGetOrderQuery } from './api/useGetOrderQuery';
export type { Order } from './types/order';
内部模块之间则可以使用明确路径。
这时 index.ts 真正承担的是:
Public API boundary
而不是:
"所有东西都从这里导出"。
11. 第三类解决方案:让 module 尽量 side-effect free
这是最通用的原则。
推荐:
ts
export function createOrder() {
// 调用时才产生行为
}
谨慎:
ts
initializeOrderSystem();
registerGlobalHandler();
export function createOrder() {}
如果确实需要初始化:
ts
export function initOrderSystem() {
initializeOrderSystem();
}
把"什么时候发生行为"从 module load 阶段推迟到显式调用阶段。
这有助于:
- Jest 测试隔离
- Tree Shaking
- SSR
- HMR
- 依赖图可预测性
- 代码初始化顺序
- 减少循环依赖影响
12. 第四类解决方案:测试中 mock 整个 module
如果一个测试的目标是:
测试 MyPage,不关心 order feature 的真正实现
可以在测试中 mock:
ts
jest.mock('@/features/order', () => ({
useGetOrderQuery: jest.fn(),
}));
这种方式的价值在于 test isolation。
但是:
mock 不能替代良好的模块设计。
如果原模块有大量不必要的顶层副作用,mock 只是把测试环境的表现压住了。
13. Issue 提出的核心 workaround:Babel 自动改写 import
社区后来有人发布:
pafry7/babel-plugin-bulletproof-features-import
仓库 README 明确说明,这个插件就是针对 Bulletproof React 的 feature barrel + Jest 场景设计的,并引用了 Issue #120 和 Jest #11234。
它的目标是:
确保测试期间只 import relevant files。
同时 README 提到它也可以用于本地开发或 production bundle 前的 import transform,并声称可以移除 circular dependencies、减少 bundle size,但可能影响 Fast Refresh 和 startup time。
14. 插件的核心思路
假设源码:
ts
import { LoginView, LoginButton } from '@/features/login';
Feature barrel 描述了这些 export 到哪里。
插件在 Babel 阶段做静态分析,然后改写为:
ts
import { LoginView } from '/absolute/path/to/features/login/views/LoginView';
import { LoginButton } from '/absolute/path/to/features/login/views/components/LoginButton';
最终变成:
text
开发者写
↓
@/features/login
↓
Babel plugin
↓
具体模块路径
↓
Jest / bundler
这样就同时获得了:
对开发者
ts
import { LoginView } from '@/features/login';
漂亮、稳定、隐藏内部结构。
对构建/测试环境
ts
import { LoginView } from '@/features/login/views/LoginView';
依赖更精确。
15. 为什么要求相对路径
讨论里用户追问:
如果 feature 里有
types、screens、components、hooks、utils等多层目录呢?
作者回答:
只要
index文件使用 relative imports,就应该支持 subfolders。
这个思路很好理解:
ts
export { Foo } from './components/Foo';
export { bar } from './utils/bar';
export { useBaz } from './hooks/useBaz';
相对于:
text
features/foo/index.ts
都能确定具体文件位置。
而 alias:
ts
export { Foo } from '@/shared/foo';
则需要额外知道 @/ 的解析规则。
16. 重要:当前插件源码与讨论/README 存在一个值得注意的不一致
这是阅读开源项目时非常值得学习的一点:
不要只看 Issue 评论,要继续看实现。
当前插件源码的 src/collect-esm-imports.js 中,核心逻辑是用 TypeScript parser 解析 index 文件,并遍历:
text
ImportDeclaration
然后把 import name 映射到 module path。
也就是说,它明确处理这种思路:
ts
import { Foo } from './components/Foo';
export { Foo };
而不是简单地直接 AST 处理:
ts
export { Foo } from './components/Foo';
另一方面,当前 README 的示例却直接展示了:
ts
export { LoginView } from './views/LoginView';
export { LoginButton } from './views/components/LoginButton';
这说明:
- 文档和当前实现可能没有完全同步;
- 讨论里的"应该支持 subfolders"不能自动等价于"当前版本支持所有 re-export 语法";
- 真正采用之前一定要用当前版本的实际测试覆盖自己的
index.ts写法。
这是分析开源项目时非常重要的工程习惯:
text
Issue discussion
↓
README
↓
源码
↓
测试
↓
实际验证
而不是停留在第一层。
17. 插件源码是怎么工作的
当前 index.js 的整体流程大致是:
text
Babel visitor: ImportDeclaration
↓
读取 import source
↓
判断是不是 features/<featureName>
↓
找到 feature/index.js 或 index.ts
↓
解析 barrel 文件
↓
得到 { exportName -> relativePath }
↓
遍历当前 import 的 named specifiers
↓
把每一个 import 改写到具体路径
↓
replaceWithMultiple()
源码中可以看到它:
- 读取
featuresPath - 从 import module name 中识别 feature
- 找
feature/index.js/index.ts - 使用
collectEsmImports()解析 barrel - 对
ImportSpecifier逐个生成新的 BabelImportDeclaration - 最后用
replaceWithMultiple()替换原 import
这正是一个非常典型的 Babel AST transform。
18. 这个插件为什么是 Babel Plugin,而不是普通 util
因为它要改写:
ts
import { Foo } from '@/features/foo';
这种 语法级结构。
Babel plugin 可以直接访问 AST:
text
Program
└── ImportDeclaration
├── source
└── specifiers
然后把 AST:
text
ImportDeclaration('@features/foo')
改成:
text
ImportDeclaration('/features/foo/components/Foo')
Babel 官方文档把 plugin 机制描述为:通过 plugin/preset 对代码进行 transformation;@babel/core 可以返回 transform 后的 code / source map / AST。
这个案例非常适合学习 Babel AST。
19. 为什么不用运行时 resolver
因为这个问题最好在 compile time 解决。
如果运行时再做:
text
运行时 import feature
↓
动态判断实际文件
就会增加:
- 运行时复杂度
- bundle 分析难度
- 测试复杂度
- 调试成本
而在 Babel 阶段:
text
源码
↓
静态分析
↓
确定具体路径
↓
生成普通 import
最终产物不需要知道"曾经有一个 barrel import"。
这是非常典型的:
把运行时工作提前到编译期。
20. 为什么这可能减少 bundle size
如果一个大 barrel 聚合很多模块:
text
feature/index
├── A
├── B
├── C
├── D
├── E
└── F
调用方只需要:
text
B
直接 import 会让 dependency graph 更明确。
当然,真正优秀的生产 bundler 本来就应该通过 ESM 静态分析 + tree shaking 处理很多这类情况,因此不能简单说:
"所有 barrel 都一定导致生产 bundle 变大。"
更准确的说法是:
barrel 会增加中间模块层级,而是否最终导致额外 bundle 内容,取决于 module format、side effects、bundler 能力、配置以及代码结构。
webpack 明确说明 Tree Shaking 基于 ESM 的静态结构,并进一步区分 usedExports 与 sideEffects。
21. 为什么插件可能降低 Fast Refresh / startup
因为插件本身不是免费的。
每次 transform 都要做额外工作:
text
ImportDeclaration
↓
识别 feature
↓
读 index 文件
↓
parse
↓
建立 export map
↓
重写 AST
即使插件做了 cache,也增加了编译阶段复杂度。
因此 README 自己也明确提示:
可能降低 Fast Refresh 和 startup time。
这就是典型的工程 trade-off:
text
Runtime module graph 更精准
↑
│
编译时多做工作
22. 最佳实践:到底该不该用 barrel?
不要回答:
barrel 好 / barrel 坏。
更好的判断方式是:
22.1 对外 API:可以用 barrel
例如:
ts
// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { useAuth } from './hooks/useAuth';
export type { User } from './types';
这是 public API。
22.2 feature 内部:不要强迫所有代码走 barrel
feature 内部模块可以直接:
ts
import { getUser } from '@/features/auth/api/getUser';
这能减少循环依赖和隐式耦合。
22.3 共享 UI library:barrel 通常更自然
例如:
ts
import { Button, Dialog, Select } from '@/components/ui';
因为这里的 ui 本身就是 public package-like boundary。
22.4 大型 monorepo:尤其注意跨 package barrel
跨 package 的巨大 barrel:
text
@company/ui
@company/core
@company/data
如果所有东西都从一个入口导出,很容易形成:
- 巨大依赖图
- circular dependency
- 较差的开发体验
- tree-shaking 边界不理想
所以 package exports 应该经过设计,而不是无限聚合。
23. 最佳实践:把 index.ts 当作"API contract"
一个非常好的代码品味原则:
只有真正想让外部依赖的东西,才进入 public barrel。
比如:
ts
// ✅ public
export { UserList } from './components/UserList';
export { useCurrentUser } from './hooks/useCurrentUser';
export type { User } from './types';
不要:
ts
// ❌ 把内部所有细节全部暴露
export * from './api';
export * from './components';
export * from './hooks';
export * from './utils';
export * from './stores';
export * from './internal';
前者是 API design。
后者更接近"文件索引"。
这两件事不是同一个东西。
24. 最佳实践:减少 module-level side effects
推荐:
ts
export function registerOrder() {
registerSomething();
}
谨慎:
ts
registerSomething();
export function registerOrder() {}
如果必须存在:
text
side-effects.ts
可以考虑明确隔离,并在 package/bundler 层面正确描述 sideEffects。
25. 最佳实践:依赖方向应该清晰
对于 feature-based architecture,推荐:
text
shared / common
↑
features
↑
app
更直观地说:
text
app
↓
features
↓
shared/lib/utils
而不是:
text
feature A
↕
feature B
↕
feature C
因为循环依赖和跨 feature 隐式耦合会显著增加调试难度。
一个好的 index.ts 应该帮助你形成边界,而不是破坏边界。
26. 最佳实践:不要把 Tree Shaking 当成所有问题的万能药
错误思路:
"Webpack 会 tree shake,所以随便 barrel、随便副作用都没关系。"
正确思路:
text
先保证 dependency graph 合理
↓
保证 module 尽量 side-effect free
↓
让 ESM 静态分析有机会工作
↓
再让 bundler 做 tree shaking
Tree Shaking 是优化机制,不是架构机制。
27. 什么时候值得引入这个 Babel 插件
比较适合:
text
✅ React Native + Jest
✅ Bulletproof React 风格的 feature barrel
✅ 现有代码量很大,不想改所有 import
✅ 测试中频繁遇到无关模块加载/副作用
✅ 希望继续保留 feature public API
不应该因为:
text
"我看到了这个 Issue"
就直接加入每个 Next.js / React 项目。
先确认:
- 是否真的存在 barrel 导致的实际问题;
- 是否是 module side effect 导致;
- 生产 bundler 是否已经很好地 tree-shake;
- 是否可以通过缩小 barrel public API 解决;
- 是否应该改变模块结构,而不是加工具补洞。
28. 一个推荐的决策树
遇到:
"我只 import A,但 B 似乎也执行了。"
按照下面顺序排查。
text
Q1: B 是通过 barrel/index.ts 间接进入依赖图的吗?
│
├── 否 → 看动态 import、测试 setup、全局初始化等
│
└── 是
↓
Q2: B 是否存在 module-level side effect?
│
├── 否 → 大概率只是 module 被加载,不一定是业务 bug
│
└── 是
↓
Q3: 可以把副作用移出 module top-level 吗?
│
├── 可以 → 优先重构
│
└── 不行
↓
Q4: barrel 是否真的需要暴露 B?
│
├── 不需要 → 缩小 public API
│
└── 需要
↓
Q5: 测试是否应该 isolate?
│
├── 是 → module mock
│
└── 否
↓
Q6: 是否值得做 compile-time import transform?
│
└── 根据项目规模/构建链决定
29. 推荐的项目写法
方案 A:普通 feature
text
features/order/
├── api/
│ ├── createOrder.ts
│ └── getOrder.ts
├── components/
│ └── OrderList.tsx
├── hooks/
│ └── useOrder.ts
├── types/
│ └── order.ts
└── index.ts
ts
// index.ts
export { OrderList } from './components/OrderList';
export { useOrder } from './hooks/useOrder';
export type { Order } from './types/order';
业务侧:
ts
import { OrderList, useOrder } from '@/features/order';
feature 内部:
ts
import { getOrder } from '@/features/order/api/getOrder';
这是一个很好理解的 public/internal 边界。
30. 需要特别警惕的写法
ts
// index.ts
export * from './api';
export * from './components';
export * from './hooks';
export * from './stores';
export * from './utils';
export * from './constants';
export * from './internal';
这种写法短期很爽:
ts
import { A, B, C, D, E, F } from '@/features/order';
但是长期容易出现:
text
feature/order
↓
巨型 public API
↓
任何东西都能被依赖
↓
内部重构越来越困难
↓
circular dependency / module graph 膨胀