2026年8月6日
桶文件(Barrel Files):index.ts 统一导出如何拖垮 Tree Shaking、Next.js 开发内存和 tsc (2026)
桶文件(barrel file)是一个只做一件事的 index.ts:把其他模块重新导出,让使用方可以写一条整洁的 import 而不是五条。几乎每个 TypeScript 代码库都有它;几乎每个 npm 库都拿它做入口。它看起来是免费的代码组织手段——多年来整个生态也一直这么认为。
但它并不免费。现代 React 开发中最常见的三类性能抱怨,背后都悄悄站着桶文件:打包结果不像你预期的那样被 tree-shake、Next.js 开发服务器和 tsc 随着应用增长越来越慢越来越吃内存、还有以 Cannot access 'X' before initialization 现身的循环依赖 bug。我们维护着 @reactuses/core——一个 120+ React hooks 全部躲在一个桶文件后面的库——最近正因为它被迫重建了整个 dist 结构:一个只 import 一个 hook 的 Next.js 开发页面,客户端 chunk 高达 552 kB,修好桶文件后降到 64 kB。这篇文章讲清这三个问题背后的机制,以及 node_modules 边界两侧各自该怎么办。
什么是桶文件?
桶文件把一个目录的公开接口收拢进一个模块:
// src/hooks/index.ts —— 所谓的"桶"
export * from './useAuth';
export * from './useCart';
export * from './useCheckout';
export * from './useAnalytics';
// …还有四十个
使用方从目录导入,而不是从具体文件:
import { useAuth } from '@/hooks'; // 走桶文件
// 而不是
import { useAuth } from '@/hooks/useAuth'; // 直接导入
发布到 npm 的库在包级别做同样的事:react-use、lodash-es、@mui/material、date-fns——对,还有 @reactuses/core——的 main/exports 入口都是一个把全部公开模块重新导出的桶。一个 import 说明符、一个自动补全命名空间、一处定义公开 API。这就是它的吸引力。
代价来自一个容易被遗忘的事实:模块导入不是符号查找,而是图遍历。 任何运行时或工具——打包器、Node、tsc、TS language server——在解析 import { useAuth } from '@/hooks' 时,都必须加载桶文件,而桶文件的内容说的是”把我全部四十四个孩子都求值一遍”。导入一个符号变成了导入所有东西,以及这些东西传递导入的所有东西。本文的每个问题,都是这一句话换了套衣服。
危害一:Tree Shaking 变脆(甚至悄悄失效)
Tree shaking 是由 ES 模块的静态结构驱动的死代码消除:打包器构建完整模块图,标记哪些导出真正被用到,丢掉其余的。理论上桶文件对它是透明的——export * 可静态分析,一个好的打包器能顺着桶追踪到 useAuth 的所属模块,丢弃它的兄弟们。
实践中,这套理论有前提条件,而桶文件正是这些条件的坟场:
副作用会毒化整个桶。 打包器只有在”丢掉某个模块不可被观察到”时才能丢它。只要桶里有一个模块跑了顶层代码——改了全局对象、注册了 custom element、调用了 injectGlobalStyles()、甚至只是构造了一个打包器无法证明纯净的 Map——打包器就必须保留它,连同它导入的一切。package.json 里的 sideEffects: false 是库作者做出的承诺,让打包器可以跳过这套分析;忘了写(或写错),一个 200 模块的桶就会被悲观打包。一个不守规矩的模块会向其他所有模块的所有使用者征税,因为桶把它们的命运绑在了一起。
CommonJS 输出直接关掉 tree shaking。 Tree shaking 依赖 ESM 静态的 import/export。如果你的包入口解析到 CJS(老的 main 字段、配错的 exports map、被工具转译成 require 的 ESM),打包器看到的就是对 module.exports 的动态属性访问,只能全部保留。一个 120 个 hooks 的 CJS 桶就是你的 bundle,不管你导入了什么。
转译器产物会击败纯度分析。 class fields、装饰器、enum 常被编译成顶层 IIFE 和赋值语句,看起来有副作用。没有 /*#__PURE__*/ 注解,打包器就会保留它们——而在桶里,“它们”指的是图里的每个模块,不只是你导入的那个。
而且开发模式下这一切根本不会运行。 这是最让人意外的部分:tree shaking 是生产环境优化。开发服务器——dev 模式的 webpack、Next.js dev、Vite 对预打包依赖的按需转换——不做 shaking。它们照原样解析并执行模块图。开发时通过桶导入一个 hook,意味着每次冷启动、每个碰到它的页面,都要加载、转换、求值整个库。这就引出了第二个危害。
危害二:Next.js 开发和 tsc 为整张图买单——时间和内存
下面是逼我们重建 dist 的那次测量。一个 Next.js App Router 页面,开发模式,只导入一个 hook:
'use client';
import { useDebounce } from '@reactuses/core';
这个页面的开发模式客户端 chunk:552 kB。不是因为 useDebounce 大——它就是包着 setTimeout 的几百字节——而是因为包入口是个桶,而开发模式不做 shaking,于是页面编译并加载了全部 120+ hooks,包括那些拖着二维码生成、文件保存依赖的重量级 hook,页面根本没引用过它们。
把这个模式乘到一个真实应用上——几个组件库、一个图标包、一个日期库、你自己的 @/components 和 @/utils 桶——你就得到了那些很少被归因到 import 上的熟悉症状:
- 开发模式冷编译和路由切换慢。 Next.js 按需编译页面;页面导入图里的每个桶都会放大需要解析、转换、缓存的模块数量。每页多出几千个模块很常见。基于 webpack 的开发服务器还要把这些模块记录、转换后的源码和 source map 都留在内存里——这是人们抱怨的动辄几 GB 的
next dev进程的一大来源,也是内存随着你访问更多路由不断上涨的原因。 tsc的时间和内存随图而不是随你的代码扩张。 类型检查器必须加载、绑定、检查从入口可达的每个文件。桶让一切都可达。哪怕只是对一个 hook 的纯类型引用,也要解析 120 个模块和它们的.d.ts依赖链。编辑器里的 TS language server 同理——“为什么 VS Code 在这个项目上要吃 4 GB”往往是个模块图问题,而桶就是图的扇出点。- 测试启动也在买单。 Jest 和 Vitest 按测试文件解析 import。一个通过桶导入一个 helper 的单测会求值整个桶——这是”平凡的测试套件每个文件启动都要好几秒”的经典原因。
optimizePackageImports——以及我们踩到的坑
Next.js 提供了直接的反制手段:optimizePackageImports。把包列进去,编译器就会在构建时把桶导入改写为直接的按模块导入:
// 你写的
import { useDebounce } from '@reactuses/core';
// 编译器穿透桶,(概念上)生成
import { useDebounce } from '@reactuses/core/dist/useDebounce/index.mjs';
两全其美:源码里保持人体工学的导入写法,编译后的图里没有桶遍历。很多流行库(lucide-react、@mui/icons-material、date-fns……)都在默认列表里。
但有一个文档轻描淡写、却狠狠咬了我们一口的前提:优化器只能把桶展开到真实存在的文件上。 它的原理是静态分析包入口,把每个具名导出映射到定义它的真实模块文件。直到不久前,@reactuses/core 发布的 dist 还是一个内联打包产物——源码有按 hook 的文件,但构建工具(bunchee)把整个库编译成了单个 index.mjs。在优化器眼里,每个导出都定义在入口自身。无论使用方怎么配置,都无物可展开。桶只有是薄桶——纯粹的重新导出、指向真实的按模块文件、一路薄到 dist——才可被优化。
危害三:桶文件滋生循环依赖
第三个代价不是性能,是正确性。桶文件是循环导入进入代码库最常见的通道,因为它给每一条经过它的 import 都加了一条隐藏的边。
陷阱长这样:
// hooks/index.ts
export * from './useAuth';
export * from './useCart';
// hooks/useCart.ts —— 作者想用 useAuth,用"整洁的方式"导入
import { useAuth } from '.'; // ← 走了桶,而不是 './useAuth'
export function useCart() { const user = useAuth(); /* … */ }
循环出现了:index.ts → useCart.ts → index.ts。作者从没写过”useCart 依赖整个 hooks 目录”,但 import 说的就是这个——之后加进桶里的每个模块都会悄悄加入 useCart 的依赖图,反之亦然。自动导入让情况更糟:编辑器乐于从桶补全,循环在没人主动选择的情况下不断累积。
有时循环无害,你永远不会察觉。咬不咬人取决于求值顺序——运行时恰好先从哪个模块开始求值——而这恰恰是打包器、Node、Jest 之间会不一样的东西:
- ESM:import 是被提升的 live binding,所以互相递归的函数没问题——但在循环中途读取
const/箭头函数导出会抛出臭名昭著的ReferenceError: Cannot access 'useAuth' before initialization(暂时性死区)。它通常只在某一个工具里出现(“Vite 里能跑,Jest 里就挂”),因为求值顺序不同。 - CJS:没有 TDZ,有更糟的——部分初始化的
exports对象。循环中途的导入静默地变成undefined,你会在调用时拿到TypeError: useAuth is not a function,离真正的原因十万八千里;类则是extends undefined。
循环还会悄悄削弱工具链:打包器无法对困在循环里的模块做代码分割(它们必须落进同一个 chunk),HMR 失效范围会沿着循环成员扩散,让开发更新变慢。图的问题和正确性的问题,是同一个问题。
该怎么做
应用代码里
- 同包内部直接从模块导入,别走桶。 这条规则同时防住图爆炸和循环:桶是给外部使用者的;内部代码直接导入兄弟模块(
./useAuth,而不是.)。用 lint 固化它:import/no-cycle能抓循环(CI 里跑,物有所值),eslint-plugin-no-barrel-files/import/no-internal-modules可以从任一方向强制策略。 - 质疑每个桶存在的必要性。 收拢五个内聚文件的桶没问题。全应用级、300 个导出的
components/index.ts是接在每个页面上的炸弹。如果桶存在只是为了让 import “好看”,TypeScript 路径别名(@/components/Button)能给你短导入,而没有任何图代价。 - Next.js 里把重型桶包列进
optimizePackageImports——并且通过检查开发模式 chunk 大小验证它真的生效了,因为(如上所述)不是每个包发布的 dist 都可被优化。
作为库作者
这是我们这一侧的栅栏,也是 #216 在 @reactuses/core 里改的东西:
-
发布按模块的文件,而不是内联 bundle。 Rollup 术语叫
preserveModules;在 tsdown 里就是一个开关。我们的完整配置:// tsdown.config.ts import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts', 'src/useQRCode/index.ts'], format: ['esm', 'cjs'], dts: true, unbundle: true, // 每个模块一个输出文件——入口保持为真正的桶 target: 'es2015', platform: 'neutral', });现在
dist与src镜像:dist/useDebounce/index.mjs、dist/useLocalStorage/index.mjs……dist/index.mjs是货真价实的薄桶。(为此我们换了工具:bunchee 无法输出 unbundled 产物,我们试图用 120 个独立入口硬造时它直接 OOM。) -
在
package.json里声明sideEffects: false——对 hooks 库来说是真的,也是对使用者 bundle 杠杆最高的一行。 -
给
exports加子路径通配符,让想完全绕开桶的使用者可以绕开:"./*": { "import": { "types": "./dist/*/index.d.mts", "default": "./dist/*/index.mjs" }, "require": { "types": "./dist/*/index.d.ts", "default": "./dist/*/index.js" } }由此解锁零桶导入形式:
import { useDebounce } from '@reactuses/core/useDebounce'。
结果: 同一个导入 useDebounce 的 Next.js 开发页面,从 552 kB 的 chunk(全部 hook,因为桶是内联 bundle)降到 64 kB(useDebounce 及其真实依赖链)——砍掉 88%,使用方代码一行未改。optimizePackageImports 终于有真实文件可以指了。
要点
- 桶文件把”导入一个东西”变成”遍历所有东西”。这就是它的全部成本模型;每个症状都由此而来。
- Tree shaking 可以穿透桶,但前提是每个模块都无副作用、是 ESM、且声明了
sideEffects——而且它在开发模式下根本不运行,那里你要用编译时间和内存为整张图买单(Next.js dev、tsc、TS server、Jest 无一幸免)。 - 永远不要在包内部走自己的桶导入——循环就是这么开始的,而循环就是你在某个工具里看到
Cannot access 'X' before initialization、在另一个里却看不到的原因。 - 库作者:薄桶 + 按模块的 dist 文件 +
sideEffects: false+ 子路径 exports。这套组合才能让使用方的优化器(比如optimizePackageImports)真正生效——单文件内联 dist 会让它们全部失效,哪怕你的源码结构完美无缺。
@reactuses/core 提供 120+ SSR 安全、TypeScript 优先的 hooks——自 v6.5.0 起采用按模块 dist,你引入 useDebounce 时不用为其余 119 个买单。全部 hooks 见 reactuse.com。