⚡ViteDash 2.2: I halved my dashboard bundle, right after doubling it
⚡ViteDash 2.2: I halved my dashboard bundle, right after doubling it
⚡ViteDash 2.2:在 bundle 体积翻倍后,我又将其减半了
I maintain ViteDash, a free admin dashboard template built with React 19, Vite 8, and Ant Design 6. It has a clean sidebar I am genuinely proud of, twenty something pages, and a light and dark mode that runs entirely through Ant Design’s theme algorithm rather than a pile of CSS overrides. It also shipped a single 1.6 MB JavaScript file and had a dependency it never declared. Version 2.2 is out. Here is what was broken, what I added, and the one performance lesson that surprised me enough to write about. 我维护着 ViteDash,这是一个基于 React 19、Vite 8 和 Ant Design 6 构建的免费后台管理模板。它拥有一个我引以为傲的简洁侧边栏、二十多个页面,以及完全通过 Ant Design 主题算法(而非堆砌 CSS 覆盖)实现的亮色和暗色模式。它之前发布时包含一个 1.6 MB 的 JavaScript 文件,并且存在一个未声明的依赖项。现在 2.2 版本发布了。以下是修复的内容、新增的功能,以及那个让我惊讶到必须写下来分享的性能经验。
🐛 The bug that made the template unusable for some people
🐛 那个让模板在某些人电脑上无法运行的 Bug
@ant-design/icons was imported in 22 files. It was not in package.json. It worked fine on my machine. It works fine for anyone using npm, because npm flattens node_modules and antd depends on the icons package, so the import resolves through a copy that happens to be sitting at the top level. It does not work with pnpm. pnpm keeps a strict node_modules where a package can only import what it actually declared. Same story with Yarn PnP. Those users cloned the repo, ran install, ran dev, and got: Failed to resolve import "@ant-design/icons"
@ant-design/icons 在 22 个文件中被引用,但它并没有出现在 package.json 中。在我的机器上它运行正常。对于使用 npm 的用户来说也没问题,因为 npm 会扁平化 node_modules,而 antd 依赖于 icons 包,所以导入时会解析到恰好位于顶层的副本。但这在 pnpm 下行不通。pnpm 维护着严格的 node_modules,包只能导入它实际声明过的依赖。Yarn PnP 也是如此。这些用户克隆仓库、运行安装、启动开发环境后,会收到错误:Failed to resolve import "@ant-design/icons"。
My README told people to use pnpm. I had been shipping a template that a chunk of my audience could not start. The fix is one line in package.json. The lesson is not. If you maintain anything that other people install, test the install with pnpm at least once. A transitive dependency you never asked for is not a dependency you have.
我的 README 建议用户使用 pnpm。我一直在发布一个让部分用户根本无法启动的模板。修复方法只需在 package.json 中加一行代码,但从中吸取的教训却不止于此:如果你维护的项目会被他人安装,请至少用 pnpm 测试一次安装过程。你未显式声明的传递依赖,并不属于你真正的依赖。
📦 The bundle: 1.6 MB in one file
📦 Bundle 体积:一个 1.6 MB 的文件
The production build was one chunk: dist/assets/index-BkAqHA3M.js 1,620,471 bytes. Everything. The sign in screen waited on the Kanban board, the invoice drawer, and all twenty something other pages before it painted. The fix is the boring, correct one. Every route becomes a lazy import:
生产环境的构建结果是一个巨大的 chunk:dist/assets/index-BkAqHA3M.js,大小为 1,620,471 字节。所有东西都在里面。登录页面在渲染前,必须等待看板、发票抽屉以及其他二十多个页面全部加载完毕。修复方法很无聊但很正确:将每个路由改为懒加载(lazy import):
const Charts = lazy(() => import('@/pages/charts/Charts'));
const Products = lazy(() => import('@/pages/products/Products'));
with a Suspense boundary inside the shell rather than around it, so the sidebar and header stay on screen while the next page downloads: 并在 shell 内部(而不是外部)设置 Suspense 边界,这样当下一页下载时,侧边栏和页眉可以保持在屏幕上:
<MainLayout>
<ErrorBoundary resetKey={location.pathname}>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</MainLayout>
That part went exactly how you would expect. The next part did not. 这部分进展正如预期,但接下来的部分却并非如此。
⚠️ The chunking advice that made it worse
⚠️ 那个让情况变得更糟的“分包建议”
Search for “Vite bundle too large” and you will find the same answer over and over: split your vendor code by library with manualChunks. Group React here, antd there, charts somewhere else. Long term caching, smaller chunks, everyone wins. So I wrote it:
搜索“Vite bundle 太大”,你会反复看到同一个答案:使用 manualChunks 按库拆分你的第三方代码。把 React 分一组,antd 分一组,图表库分一组。长期缓存、更小的 chunk,皆大欢喜。于是我写了:
manualChunks(id) {
if (/node_modules\/(recharts|d3-)/.test(id)) return 'charts';
if (id.includes('node_modules/@ant-design/icons')) return 'icons';
if (/node_modules\/(antd|rc-|@rc-component)/.test(id)) return 'antd';
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'react';
return 'vendor';
}
It looks tidy. It builds. The chunk list looks like a job well done. Then I measured what the browser actually downloads before first paint, by reading the modulepreload tags out of the built index.html and adding up the files:
看起来很整洁。构建成功。chunk 列表看起来像是一项出色的工作。然后,我通过读取构建出的 index.html 中的 modulepreload 标签并汇总文件大小,测量了浏览器在首次渲染前实际下载的内容:
- eager raw : 2,086 kB
- eager gzip : 643 kB
Worse than shipping nothing at all. My route splitting had done its job, and my chunking config had undone it. Here is why. manualChunks is a hard instruction, not a hint. When you say “every antd module goes in the antd chunk”, the bundler obeys, including for the antd components that only the Kanban board imports. And a chunk is eager if any module in it is reachable from the entry. The app shell uses Layout, Menu, and Button, so the antd chunk is eager, so Table, Splitter, Calendar and everything else rides along. Same story with Recharts. Only the Charts page imports it. Naming it as a chunk pulled it into the entry graph anyway, and 427 kB of chart library was downloading for people who never opened a chart.
比什么都不做还要糟糕。我的路由拆分本已奏效,但我的分包配置却将其抵消了。原因如下:manualChunks 是强制指令,而非建议。当你要求“所有 antd 模块都进入 antd chunk”时,打包器会照做,包括那些只有看板页面才会用到的 antd 组件。如果一个 chunk 中的任何模块可以从入口点访问,那么该 chunk 就是“急切加载(eager)”的。应用 shell 使用了 Layout、Menu 和 Button,所以 antd chunk 变成了急切加载,导致 Table、Splitter、Calendar 等所有组件都被一并加载了。Recharts 也是一样,只有图表页面才用到它,但将其命名为一个 chunk 后,它被强行拉入了入口依赖图,导致那些从不打开图表页面的用户也下载了 427 kB 的图表库。
I deleted the whole thing and let the bundler decide: 我删除了所有这些配置,让打包器自己决定:
- eager raw : 1,004 kB across 12 files
- eager gzip : 324 kB
Half. From deleting configuration. Automatic code splitting already knows what it is doing. A module reachable from the entry lands in the entry chunk. A module used by two lazy routes lands in a shared chunk that loads when either one does. A module used by one lazy route lands in that route’s chunk. That is the behaviour you want, and manualChunks overrides it with your guess.
体积减半了。仅仅通过删除配置。自动代码拆分已经足够智能。从入口可访问的模块会进入入口 chunk;被两个懒加载路由使用的模块会进入一个共享 chunk,在其中任何一个路由加载时加载;被单个懒加载路由使用的模块则进入该路由的 chunk。这才是你想要的行为,而 manualChunks 用你的“猜测”覆盖了它。
There are still good reasons to reach for it. If the analyzer shows one library genuinely duplicated across many chunks, group that library. But group it because you measured, not because a blog post said vendor splitting is good practice.
当然,使用 manualChunks 仍有正当理由。如果分析器显示某个库确实在多个 chunk 中重复,那就对该库进行分组。但请基于测量结果进行分组,而不是因为某篇博客文章说“第三方库拆分是最佳实践”。
I left this comment in vite.config.js so future me does not repeat it:
我在 vite.config.js 中留下了这条注释,以免未来的我重蹈覆辙:
The obvious move is to group node_modules by library (“all of antd in one chunk”), and it is a trap. Measured on this app that grouping costs 643 kB gzipped on first load. Letting the bundler decide brings it down to 324 kB. “按库分组 node_modules(例如‘把所有 antd 放在一个 chunk’)看起来很直观,但这是一个陷阱。经测量,这种分组方式在首次加载时会增加 643 kB 的 gzip 体积。让打包器自行决定则可降至 324 kB。”
Final numbers:
最终数据:
| Before 2.2 | After 2.2 | |
|---|---|---|
| First load, uncompressed | 1,620 kB in one file | 1,004 kB across 12 files |
| First load, gzipped | one chunk, everything | 324 kB |
| Chart library | 324 kB | n/a only on /dashboard/charts |
| Total JavaScript | 1,620 kB | 2,183 kB across 83 chunks |
The total went up because the template gained charts, three locales, and seven pages. That is fine. What matters is that none of it loads until someone asks for it. 总大小增加是因为模板新增了图表、三种语言环境和七个页面。这没问题。重要的是,在用户真正需要之前,这些内容都不会被加载。
🔐 Roles, because a template without them teaches the wrong habit
🔐 角色权限,因为没有它的模板会教坏用户
Most free dashboard templates give you pages. Then you add your second user type and discover there is nowhere obvious to put that logic. 2.2 has role based access control in three layers. The navigation tree declares who can see what: 大多数免费后台模板只提供页面。当你添加第二种用户类型时,会发现根本没地方放权限逻辑。2.2 版本引入了三层基于角色的访问控制。导航树声明了谁能看到什么:
{ to: '/dashboard/roles', labelKey: 'nav.items.roles', roles: [ROLES.ADMIN] },
The router enforces it for anyone who types the URL: 路由器会为直接输入 URL 的用户强制执行权限检查:
<Route element={<RequireRole roles={[ROLES.ADMIN]} />}>
<Route path="roles" element={<Roles />} />
</Route>
And there is a Roles and Permissions page showing the matrix, so the concept is visible rather than buried in a config file. 此外还有一个展示权限矩阵的“角色与权限”页面,让这个概念变得直观可见,而不是埋藏在配置文件中。