把 33 篇文章兜成一個 index 檔很方便,但只要有元件 import 它,Vite 就會把整包拉進 chunk。改成一檔一篇加 import.meta.glob 之後降到 4 KB。
發布於 2026-09-06・約 4 分鐘
我的網站有 33 個工具頁,每頁底下掛一篇延伸閱讀的文章。文章寫在 JS 檔裡,一開始就照直覺兜成一個索引檔:
// toolArticles.js import psy from './tool-content/psy.js' import life from './tool-content/life.js' import travel from './tool-content/travel.js' // ...33 個 export const TOOL_ARTICLES = { psy, life, travel, /* ... */ }
元件端就一行:
import { TOOL_ARTICLES } from '../lib/toolArticles' function ToolArticleBody({ toolId }) { const article = TOOL_ARTICLES[toolId] return <article dangerouslySetInnerHTML={{ __html: article.html }} /> }
很乾淨。然後我去看 build 產出,有一個 chunk 是 259 KB(gzip 106 KB),而且每個工具頁都會載到它。
裡面是全部 33 篇文章。每個訪客只會讀其中一篇。
Vite 的 code splitting 跟 tree shaking 的單位是模組,不是「這個物件的哪一個 key 被用到」。
TOOL_ARTICLES[toolId] 這個寫法,toolId 是執行時期才知道的。打包器沒辦法證明你不會用到 TOOL_ARTICLES.travel,所以那 33 個 import 全部都得留著。
TOOL_ARTICLES[toolId]
toolId
TOOL_ARTICLES.travel
只要有一個元件 import 了那個 barrel 檔,整條相依鏈就被拉進同一個 chunk。這跟你在物件上取幾個 key 沒有關係。
同樣的事在別的地方也會發生:import { debounce } from './utils',如果 utils/index.js 裡 re-export 了三十個模組而其中有副作用,你可能整包都拿到。barrel 檔是很常見的組織方式,代價卻通常沒被量過。
import { debounce } from './utils'
utils/index.js
// 建立「路徑 → 動態 import 函式」的對應表。 // Vite 在 build 時會把每個檔案切成獨立的 chunk。 const modules = import.meta.glob('../../lib/tool-content/*.js') async function loadArticle(toolId) { const load = modules[`../../lib/tool-content/${toolId}.js`] if (!load) return null return (await load()).default }
import.meta.glob 預設是 lazy 的:它回傳的是一組函式,呼叫哪一個才會去載哪一個 chunk。加 { eager: true } 就變回同步、也就變回原本那個坑,所以那個選項在這裡絕對不能加。
import.meta.glob
{ eager: true }
React 19 可以直接用 use() 配 <Suspense>:
use()
<Suspense>
import { use, Suspense } from 'react' function ArticleBody({ promise }) { const article = use(promise) return <article dangerouslySetInnerHTML={{ __html: article.html }} /> } function ToolArticle({ toolId, title }) { return ( <section> <h2>{title}</h2> <Suspense fallback={<p>載入中…</p>}> <ArticleBody promise={loadArticle(toolId)} /> </Suspense> </section> ) }
標題不要放進會 suspend 的那個元件裡。 我第一版把整個區塊包進 <Suspense>,結果文章載入前那一整塊是空的,版面會跳。標題和標籤這種從索引資料就拿得到的東西,先畫出來,只讓內文 suspend。
實測結果:每個工具頁載到的文章 chunk 大約 4 KB gzip,而且不會隨文章數成長。加第 34 篇不會讓前 33 頁變重一克。
我沒有把 toolArticles.js 刪掉,因為建置期的腳本真的需要一次拿到全部 33 篇。它要產生每一頁的 <noscript> 靜態內容給爬蟲看。那支腳本是純 Node 跑的,不進 bundle,載多大都無所謂。
toolArticles.js
<noscript>
所以現在是:同一份資料,兩個消費端,兩條路徑。
風險是有人(包括三個月後的我)會在元件裡順手 import 那個 barrel,然後整個優化就悄悄消失了。沒有任何一個地方會報錯,build 會過,功能會正常,只是 chunk 又胖回去。
我的防線是在兩個檔案最上面各寫一句:
/** * ⚠ 前端請不要 import 這個檔案。import 它就等於把 33 篇(259 KB/106 KB gzip) * 一起拉進 chunk,而每個訪客只會讀其中一篇。 * 工具頁那條路徑走 ToolArticleBody.jsx 的 import.meta.glob。 */
註解不是很強的防線,但比沒有好。要更硬的話可以加一條 ESLint 規則:
// eslint.config.js { files: ['src/components/**', 'src/pages/**'], rules: { 'no-restricted-imports': ['error', { patterns: [{ group: ['**/lib/toolArticles', '**/lib/techPosts.generated'], message: '這是給 build 腳本用的 barrel,元件請用 import.meta.glob。', }], }], }, }
先讓 build 印出 chunk 大小。Vite 預設就會印,看有沒有異常大的:
dist/assets/toolArticles-a1b2c3.js 259.14 kB │ gzip: 106.02 kB
要看那個 chunk 裡面裝了什麼,開視覺化:
npm i -D rollup-plugin-visualizer
// vite.config.js import { visualizer } from 'rollup-plugin-visualizer' export default { plugins: [visualizer({ open: true })] }
判準很簡單:一個 chunk 裡有 N 份資料、而每個訪客只會用到其中 1 份,就是這個坑。 這種 chunk 的特徵是「大小隨內容數量線性成長」,今天 259 KB,明年就是 500 KB,而且不會有任何一天它壞給你看。
標籤:Vite、效能、JavaScript、code splitting、React