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,包括那些拖著 QR code 產生、檔案儲存依賴的重量級 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。