一份实用的指南,助您分析并缩减 JavaScript 包体积。学习包分析技巧,修复Tree-Shaking问题,移除重复库文件,全面优化 React 应用的 JavaScript 体积。
原文链接:www.developerway.com/posts/bundl…
作者:Nadia Makarevich
在我之前关于SSR和React服务器组件的文章中,曾提及"交互性断层"的概念。即预渲染内容已可见,但JavaScript尚未下载完成的状态。此时页面无法交互,甚至可能呈现故障状态。
这种体验相当糟糕。那么如何缩短该阶段?答案只有一个:缩减JavaScript下载与执行时间。我们可以尝试不同压缩算法、优化代码拆分策略,以及数百种其他实用技巧。
当然,我们还可以尝试...这里有个激进的建议...减少JavaScript的部署量?😅 我知道我知道!说起来容易做起来难。但若你从未深入分析过代码包的构成,这恰是理解代码库的绝佳契机。况且,若你热衷于研究图表和追踪问题根源,这个过程本身就充满乐趣。
初始项目设置
首先,为了解析某些数据包,我们需要准备一些大型数据包。为此我专门创建了一个学习项目,如果你想跟进操作并通过自己的研究验证本文内容,可以在这个仓库中找到它:github.com/developerwa…
安装依赖项:
npm install
构建:
npm run build
注意其JavaScript大小:
5,321.89 kB │ gzip: 1,146.59 kB
难以置信地跌倒在地。超过5MB!天啊🤯 你可能会问,你到底在那里实现了什么功能?难道花了超过一年时间?这肯定需要相当的努力和才华!🤪
答案是:在原本精简的项目里,仅仅添加了一个表单和一个功能简单的页面。耗时不到一小时。
实际上,这里那里稍有疏忽就可能让JavaScript文件膨胀到离谱的程度。阅读本文时你会明白具体原因。现在请启动项目,在页面间来回切换:
npm run start
将包含一个主页、一个内含多个选项卡表单的设置页,以及一个显示消息列表的收件箱页。当鼠标悬停在消息上时,会出现一排按钮。点击"删除"或"归档"按钮将打开相应的模态对话框。
功能基本如此。要实现这些功能却需要5MB的JavaScript代码,这完全不合常理。那么问题出在哪里呢?
分析Bundle大小
要探究这些令人头疼的兆字节文件,我们需要一种方法来查看它们的内部结构。
所谓"查看",并非指在IDE中打开构建文件进行逐行审阅。虽然这种做法可行,但它根本无法提供任何有价值的信息,甚至可能让人头疼不已。
这正是"打包分析器"工具的职责所在。由于项目基于Vite,我可使用"Rollup插件可视化工具"库。若使用Next.js,则需Next的打包分析器插件。若采用其他框架,只需搜索"打包工具名称 + 打包分析器"——此类工具不胜枚举。
本项目无需额外安装。直接打开 vite.config.ts 文件,找到这段代码:
visualizer({
filename: 'stats.html',
emitFile: true,
template: 'treemap',
});
这是已启用的分析器。若尚未构建项目,请先执行构建操作,然后查看src/dist/client文件夹。该文件夹内应存在一个stats.html文件。在浏览器中打开该文件并等待其完全加载(可能需要一段时间!)。加载完成后,应显示如下图表:

这是项目中每个JavaScript文件的分层可视化展示。最顶端的"根节点"代表项目根目录,即src/dist/client文件夹。其内部包含两个区块:
- 较大的红色区块是
assets/vendor——这是我们的第三方代码块。 - 左侧的蓝绿色区域是
assets/index—— 这是我们自己的代码。
区块的大小与代码体量成正比,因此即使快速浏览图表也能明显看出"index"相较于红色"vendor"区块显得微不足道。在vendor区块内部,包含node_modules区块、各类库文件等,所有文件均按文件路径分组呈现。
悬停在任意区块上可查看精确路径。
点击任意区块即可"放大"查看内部内容。
若想获得更多趣味体验,可生成不同类型的可视化效果。例如将配置修改为:
visualizer({
filename: 'stats.html',
emitFile: true,
// different types of visualizations
template: 'flamegraph',
});
重建项目后,它会生成火焰图而非二维地图。若你对解读火焰图仅有模糊概念,我当然准备了相关文章供你参考😉
获得可视化结果后,就该戴上侦探帽展开调查了。通常这意味着盯着地图直到眼睛发酸,留意异常庞大的区域,识别出对应的库(若毫无头绪就谷歌搜索),接着逐行审阅代码,判断能否移除该库的使用及其潜在代价。
让我们共同实践这个流程。后续每个待调查的包都将遵循此步骤。
调查过程
Step 1: 确定要淘汰的软件包
我首先注意到的是node_modules块中@mui标题下的所有内容,这里包含了作为项目依赖项安装的多个npm包。我们知道npm包的命名规范:要么是一个单词(带连字符),要么是两个用/分隔的单词,其中第一个单词是命名空间且以@开头。因此直接位于node_modules标题下的内容,要么是单个包,要么是多个包的命名空间。
由于@mui以@开头,它属于命名空间,其直接子项均为具体包。由此可得两个包:@mui/material和@mui/icons-material。
这些包内部包含的全部内容即为各自的具体包内容。
Step 2: 理解软件包
快速搜索告诉我们这些包的含义:material包是谷歌的Material UI组件库,而icons-material则是该库的配套图标集,需单独安装。
若聚焦"material"包,可见其包含所有可能的组件:快照、警报、工具提示等数百种组件一应俱全。同样地,当我们放大查看"icons-material"模块时,发现整套2000个图标均被打包其中。
难怪这个打包文件竟有5MB!
Step 3: 理解该软件包的使用方法
在此步骤中,我们需要阅读大量代码——必须弄清楚这些包的确切来源。首先需要确认的是:这些包是否被直接引用在代码中,还是通过其他途径间接使用。幸运的是,这两种情况都容易排查:只需在 frontend 和 src 文件夹内的代码中搜索 @mui 即可——这些文件夹是前端代码的唯一存放位置。
搜索结果将指向两个文件:frontend/icons/index.tsx 导入了 "@mui/icons-material",而 frontend/utils/ui-wrappers.tsx 则导入了 "@mui/material"。
使用图标的代码段如下所示:
import * as Material from "@mui/icons-material";
export const Icons = {
...Material,
BellIcon,
... // other icons
};
显然,有人试图统一项目中所有图标的使用规范。初衷很可能是将所有图标归入同一个 Icons 命名空间,其假设是这能减少项目中出现同名图标的可能性,并在未来需要时更便于将图标迁移至新库。采用这种模式时,代码中不会直接从"@mui/icons-material"导入图标,而是通过该文件统一导入所有图标,使用方式如下:<Icons.BellIcon />。
若在项目中搜索 Icons.(末尾的点号可缩小范围),你会发现这种模式正应用于三个文件:两个对话框组件和一个消息列表组件。当然,若你的 IDE 支持,也可直接搜索"usages"。
理论上这是个相当高明的方案,确实能大幅简化后续重构工作——更换图标时只需修改单个文件,其余代码甚至不会察觉变更。此外通过代码补全功能(若IDE支持),还能轻松查看项目中可用图标(如果你的IDE支持)。
但实际操作中,我们最终在资源包里塞进了两千个图标😬。
而"material"库的使用情况也如出一辙:
import * as Material from '@mui/material';
export const StudyUi = {
Library: Material,
Button: Button,
};
有人希望通过统一接口暴露所有可用组件,其动机可能与图标的设计初衷如出一辙。这也正是我们在此共同研究它们的原因之一🙃。请搜索StudyUi.Library以确认代码中确实存在此类用法。
Step 4: 确认问题所在
在尝试任何重构之前——现实中这可能代价高昂——我们首先需要确认问题诊断是否准确。
目前,我们只需将这两个库的导入部分暂时注释掉。
// Just comment out those imports everywhere
// import * as Material from "@mui/material";
// import * as Material from "@mui/icons-material";
然后重新构建项目。由于尚未修复这些库的使用问题,项目仍无法启动。但这足以判断包体积是否缩小,并确认这些导入是否为问题根源。
果然奏效!"vendor"文件从5MB缩减至811KB,可视化结果如下:

由于大幅压缩vendor文件,青绿色的"index"区块变得清晰可见。@mui包已消失,而prosemirror-view和lodash等其他库也变得更加醒目。
现在,我们只需完成最后一步:彻底解决问题。但首先必须明确实际问题所在。难道所有使用MUI组件和图标的项目都会因此产生5MB的包体积吗?显然不会,否则根本没人会使用它们。因此问题出在我们的代码中。
要理解这点,我们需要掌握一个名为"Tree-Shaking"的概念。
Tree Shaking与Dead Code 消除
现代打包工具不仅能合并 JavaScript 模块,还会尝试识别并移除“死代码”——即任何地方都未被使用的代码。它们在这方面表现相当出色。
例如,试着在某个位置添加这段代码:
export const MyButton = () => <button>Click me</button>;
假设在 frontend/components/button/index.tsx 中,我们存放了所有按钮组件。
然后重新构建项目。
你会发现无论是否包含这段代码,index 代码块的名称和大小都完全相同。这是因为该按钮并未被实际调用,只是静置于此。
现在,尝试在 frontend/components/dialog/index.tsx 的某个位置添加一个使用此按钮的新组件 MyDialog:
import { MyButton } from '@fe/components/button';
export const MyDialog = () => {
return (
<>
<MyButton />
<div>My dialog</div>
</>
);
};
然后重新构建项目。结果应该完全相同!代码块名称相同,大小也相同。我们尚未使用这段代码——MyDialog组件依然闲置未被调用。打包工具能够检测到这一点,并在生产文件中移除了MyDialog和MyButton。超级聪明,对吧?
只有当组件在实际构建应用程序的代码中被使用时,才会被包含进来。试着在 App.tsx 的某个位置渲染 MyDialog,例如:
import { MyDialog } from '@fe/components/dialog';
export default function App() {
// keep the rest of the code as is
if (path.startsWith('/settings')) {
return (
<>
<SettingsPage />
<MyDialog />
</>
);
}
// keep the rest of the code as is
}
然后重建项目。index 块名称会改变,大小略有增加。你甚至可以打开 index 块,搜索"Click me"字符串来验证新按钮是否已包含其中。
这个消除未使用代码的过程被称为"tree shaking"。
之所以如此命名,是因为打包器会根据所有文件及其内部导出/导入关系构建抽象的"树形结构",追踪树中"存活"与"死亡"的分支,最终移除"死亡"分支。在将 MyDialog 引入 App.tsx 之前,该"树"结构大致如下(简化版):
位于frontend/components/dialog文件夹内的index.tsx导出多个组件,其中包含通用Dialog组件(在多处使用)。未被引用的MyDialog组件以灰色标记(即"死分支")。灰色分支将被排除在最终打包文件之外。
当我们在App.tsx中显式引入MyDialog后,树结构变为:
MyDialog 分支已不再失效,因此被包含在打包文件中。
现代打包工具正变得越来越智能,在Tree-Shaking方面,要欺骗它们变得越来越困难。不过,对于有决心的人来说,这仍然是可行的😅
它们目前还无法处理的一种情况是 * 导入语句与重命名相结合。* 导入语句如下:
import * as Buttons from '@fe/components/button';
这基本上是一个导入命令,用于将模块中的所有内容导入并别名为Buttons。之后,我们就能通过点表示法使用所需的按钮:
<Buttons.SmallButton />
这种模式相当流行,尤其当某个模块包含大量导出项时,这样可以避免逐个导入它们:
import { Button, SmallButton, LargeButton } from '@fe/components/button';
仅凭这个 * 导入本身其实不足以迷惑打包工具——我早就说过它们很聪明!然而当它被用作变量而非单纯提取内容的手段时……打包工具目前还无法处理这种情况。
这个场景就是经典案例:
import * as Buttons from "@fe/components/button";
import * as Dialogs from "@fe/components/dialog";
// the rest of the components
export const Ui = {
Buttons,
Dialogs,
...
};
尝试将此代码添加到 App.tsx 文件中(而非之前的示例),并使用点模式渲染一个"普通"按钮:
import * as Buttons from '@fe/components/button';
import * as Dialogs from '@fe/components/dialog';
export const Ui = {
Buttons,
Dialogs,
};
export default function App() {
// keep the rest of the code as is
if (path.startsWith('/settings')) {
return (
<>
<SettingsPage />
<Ui.Buttons.SmallButton />
</>
);
}
// keep the rest of the code as is
}
然后,重新构建项目,打开 assets 文件夹内的 index 片段,搜索"Click me"字符串——即我们在 MyButton 按钮中使用的字符串。尽管我们并未显式使用 MyButton ,但其代码现已包含在此处。
若你从未见过这种模式,可能会觉得有些荒谬。为何要这样做?
此类命名空间设计广受欢迎的原因之一,在于它能实现更简洁的导入操作和更明确的代码逻辑。例如,将以下代码放入frontend/components目录下的 index.tsx 文件中,并在此处的导入语句中添加@fe/components目录下的其余组件:
import * as Buttons from "@fe/components/button";
import * as Dialogs from "@fe/components/dialog";
// all other frontend components
export const Ui = {
Buttons,
Dialogs,
...
// all other components
};
现在,我可以将所有组件的单独导入合并为仅此一行:
import { Ui } from '@fe/components';
例如,查看 frontend/patterns/confirm-archive-dialog.tsx 文件。所有这些:
import { NormalToLargeButton } from '@fe/components/button';
import { Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogTitle } from '@fe/components/dialog';
// one million other imports
本可以直接使用 import { Ui } from "@fe/components";
其余所有内容都将通过命名空间使用:
<Ui.Dialogs.Dialog />
许多人钟爱这种模式带来的清晰度。对于每个使用的组件,我都能在函数上下文中立即看到其来源。此外,它能避免名称冲突,这总是令人愉悦的。
但随之而来的问题是,这种模式会混淆打包工具,导致代码的Tree-Shaking优化失效,最终生成的JavaScript文件比预期更大。
对于自有代码,这或许影响不大——毕竟我们编写的每个组件都计划实际使用,一两个被遗忘的函数不会造成太大影响。
但涉及外部库时,情况就截然不同了。因为这正是我们@mui组件采用的模式:
import * as Material from '@mui/material';
export const StudyUi = {
Library: Material,
};
以及图标:
import * as Material from "@mui/icons-material";
export const Icons = {
...Material,
BellIcon,
... // other icons
};
若想保留模式和命名空间,同时避免全局重构,这里的快速解决方案是:移除通配符导入(* import),仅导入实际使用的组件和图标。撤销项目前端部分的所有修改,改为执行以下操作:
// frontend/utils/ui-wrappers.tsx file
import { Button } from '@fe/components/button';
import { Snackbar } from '@mui/material';
export const StudyUi = {
Library: {
// this is the only component we use from the Material library
Snackbar: Snackbar,
},
Button: Button,
};
/// frontend/icons/index.tsx file
import { Star } from "@mui/icons-material";
// keep the rest of the imports
export const Icons = {
Star: Star, // this is the only icon we use from the Material set
... // the rest of the icons
};
重新构建项目。包体积现已从5MB缩减至878KB——显然我们成功清除了@mui中冗余的图标和组件。打开stats.html文件,其内容现已呈现如下:

我们仍然保留了@mui代码块,因为它确实被使用着。但现在它变得更小,被其他更大的代码块所掩盖。因此,暂且将"mui"问题视为已解决,转而关注其他问题。
不过在此之前,我们需要确保修复操作没有破坏应用功能。启动项目并导航至"收件箱"页面:每条消息开头应显示金色星标——这正是我们从MUI引入的星标图标。将鼠标悬停在任意消息上,点击出现的"删除"按钮,再点击"确定,执行!"按钮——页面左下角应弹出通知框。这就是我们从MUI引入的Snackbar组件。一切运行正常!
ES模块与不可Tree-Shaking的库
既然我们修复了@mui依赖项的问题,且其加载块不再占据整个屏幕,现在就能更清晰地看到打包文件中其他存在问题的引入项。例如右下角这大块的"lodash"——这究竟是怎么回事?为什么体积如此庞大?

我们将采用完全相同的调查流程。首先快速研究Lodash库——这是个JavaScript库,实现了大量针对数组、对象、列表等的实用工具,这些功能大多无法通过原生JavaScript函数实现。
在项目文件中搜索其使用位置,发现仅出现于frontend/pages/inbox.tsx文件中。以下是略经简化的代码:
// FILE: frontend/pages/inbox.tsx
import _ from "lodash";
export const InboxPage = () => {
const onChange = (val: string) => {
// This is the only place where we use the library
const cleanValue = _.trim(_.lowerCase(val));
// Send cleanValue to the server
console.info(cleanValue);
};
return ...
};
我们通过 import _ 导入整个库,然后在将文本字符串发送到后端前使用 trim 和 lowerCase 工具函数进行处理。由于这是搜索字段,可以合理推测它将用于异步自动完成功能,因此这种用法似乎合理。暂且忽略我们可能根本不需要这个库的事实——毕竟所有现代浏览器都已原生支持trim和toLowerCase。本练习的重点在于聚焦于包分析,以及可能遇到的陷阱。
关键在于:我们从庞大库中仅调用了两个简单工具函数。这两个基础功能绝不应占用如此庞大的 JavaScript 代码量。这明确表明Tree-Shaking机制失效,导致整个库及其全部内容都被导入。
为验证此推测(性能调查中所有假设都应如此验证),我们只需移除其中一个工具函数:
// remove the lowerCase util, keep only trim
const cleanValue = _.trim(val);
如果Tree-Shaking机制正常工作,vender 代码块的大小应该略有缩减,且代码块名称会发生变化——因为未使用的lowerCase实用工具将被"摇动"移除。
注意 vender 代码块的名称和大小,按上述方式修改后重新构建项目。
结果毫无变化。Tree-Shaking功能失效了。或许是因为我们使用 import _ from 导入了整个库,导致打包器产生混淆?将其改为显式导入后再次尝试:
import { trim } from 'lodash';
// inside onChange callback
const cleanValue = trim(val);
包名发生变更,且大小变化仅为两个字节,这显然不足以消除未使用的工具模块。很可能只是因为我们在导入语句中更改了名称。若在支持此类比较的IDE中对比变更前后生成的 vender 代码块,你会发现事实确实如此——仅有少数压缩变量被重命名,其余内容完全相同。
正如我们所验证的,Tree-Shaking机制在此完全失效。
核心问题在于JavaScript存在多种模块格式:ESM、CJS、AMD、UMD。"模块"作为可复用的代码单元,可被其他代码加载使用。这些格式定义了复用的具体实现方式。
要完整梳理这些模块的演变历程、全面差异以及它们在现代工具中的应用与分发方式,恐怕需要写一本书。所幸在分析打包体积时,我们只需了解一个关键点。
当你看到 import { bla } from "bla-bla" 或 export const bla 或 export { bla } 时——这就是 ESM 格式。我们整个项目都采用 ESM 规范,如今这几乎已成为标准,至少在编写前端代码时如此。现代打包工具能轻松对 ESM 格式进行Tree-Shaking优化,正如我们在自身代码实验中已见识到的那样。非ESM格式的代码则极难实现Tree-Shaking。
ESM作为相对较新的格式,并非所有库都已适配。可通过is-esm这个小型npm包检测库是否采用ESM格式。该命令行工具会给出"是/否"的明确反馈:若为"是"则表示ESM格式,可进行Tree-Shaking。
npx is-esm lodash
答案是否定的。
作为对比,在 @mui/icons-material 和 @mui/material 上运行该代码,结果将是肯定的。
这解释了为何Material库会触发Tree-Shaking,而lodash不会。
那么该如何处理?遗憾的是,对于某些库(尤其是非常老旧的库),答案只能是"无能为力"。我们只能接受包体积增大的后果,或者彻底移除该库。
不过部分库(特别是持续维护的库)会提供解决方案。虽然"主"入口文件非ESM格式,但它们可能提供更小规模的库组件入口,允许按需导入所需功能。
@mui/icons-material除ESM格式外也支持此方案。你可以直接从包中导入所需图标,并依赖Tree-Shaking能实现优化:
import { Star } from '@mui/icons-material';
或者你可以直接从图标自身的入口点导入它,不必担心Tree-Shaking因某些不明原因失败:
import Star from '@mui/icons-material/Star';
库是否提供这种额外的导入方式,通常会在文档中有所说明。例如Material Icons库就建议将精确导入作为使用图标的默认方式。
查看Lodash的文档时,也能发现他们同样提及了这类导入方式:
// Cherry-pick methods for smaller browserify/rollup/webpack bundles.
var at = require('lodash/at');
让我们在项目中尝试使用这个,看看它对包大小有什么影响。
我们最初使用的代码是这样的:
import _ from "lodash";
export const InboxPage = () => {
const onChange = (val: string) => {
// this is the only place were we use the library
const cleanValue = _.trim(_.lowerCase(val));
// Send cleanValue to the server
console.info(cleanValue);
};
return ...
};
vender 代码块的打包大小约为878 KB。
我们需要trim和lowerCase工具。若采用精确导入方式,代码将转换为如下形式:
// change the imports to be precise
import trim from "lodash/trim";
import lowerCase from "lodash/lowerCase";
export const InboxPage = () => {
const onChange = (val: string) => {
// Get rid of the _ and use the utils names
const cleanValue = trim(lowerCase(val));
// Send cleanValue to the server
console.info(cleanValue);
};
return ...
};
重新构建项目后,包大小降至812.95 KB!看来奏效了。打开stats.html文件可见,先前庞大的Lodash代码块如今几乎不可见。
不过认真说,在实际项目中我会直接移除这两项:既然不需要IE9支持,trim可替换为原生JavaScript的trim函数,lowerCase则可用原生toLowerCase替代。最终代码将简化为:
export const InboxPage = () => {
const onChange = (val: string) => {
// Get rid of lodash completely
const cleanValue = val.toLowerCase().trim();
// Send cleanValue to the server
console.info(cleanValue);
};
return ...
};
重建后,包大小又减少了约10KB。
常识与重复库
随着代码包不断精简,我越来越兴奋!删除代码的过程令人心满意足。让我们继续精简下去!
分析代码包大小时,下一个关键点在于常识。我知道这听起来很荒谬,但你马上就会明白我的意思😅。
现代开源生态的最大优势在于:几乎任何需求都能找到现成的库。但其最大弊端恰恰相同——几乎任何需求都会存在多个库。在大型项目中,尤其当多个团队协作时,同类库最终混入包中的概率极高。
这类情况尤其常见于日期处理、动画效果、尺寸调整、无限滚动、表单提交、图表绘制等领域——凡是从头实现过于痛苦或复杂,又具备通用性可封装为库的功能,往往都会被重复实现。
现在让我们审视这个经过初步清理的包图表,仔细观察标注区域。

我们拥有三套相当庞大的日期处理库:date-fns、moment 和 luxon。简单搜索发现:
🤦🏼♀️ 显然有人在引入又一个日期库时完全没有做好尽职调查。
如何处理这种情况,关键取决于:移除部分库需要重构多少代码、耗费多少精力,以及我们愿意为库提供的功能承受多少KB的打包体积。
尤其在老项目中,可能出现这样的情况:Moment库被广泛使用,而较新的Luxon和Date-fns仅在少数地方使用。因此若打包体积优化计划的时间受限,此时舍弃新库作为快速见效的方案或许更合理,以便集中精力处理其他环节。当然也可能出现相反情况——Moment可能是某次大规模重构的残留产物,只是被遗忘在几个角落未被移除。
在我们的案例中,项目非常新,且每个库仅被使用一次。因此,通过重构统一使用方式将非常容易。
这种情况下,最终取决于哪个库支持Tree-Shaking或特定导入、哪个API最符合我的偏好、哪个库得到维护,以及选择库时通常考虑的所有其他因素。
Tree-Shaking检查显示moment不支持Tree-Shaking,快速浏览其文档也未发现类似lodash的定向导入功能,故此选项出局。
Luxon看似支持Tree-Shaking,但查看打包图表发现其体积仍远大于date-fns。要么是Tree-Shaking检测存在缺陷,要么就是它本身体积庞大。不过这并不重要,既然date-fns是可选方案,我又偏好它的API,且体积更小,自然选择它。
其实直接移除这三者也是选项——我们的用例相当简单。但个人认为,日期库永远是项目中最后被移除的组件。我厌恶处理 JavaScript 原生 Date API,因此决定将所有代码重构为 date-fns。
在 frontend/patterns/message-editor.tsx 文件中,我使用了 Luxon 及其相关代码:
// FILE: frontend/patterns/message-editor.tsx
import { DateTime } from 'luxon';
// inside MessageEditor component
const formattedDate = DateTime.fromMillis(timestampDate).toFormat('MMMM dd, yyyy');
它只是将毫秒值转换为人类可读的格式,操作很简单。在 date-fns 中,替代方案如下:
import { format } from 'date-fns';
// inside MessageEditor component
const formattedDate = format(new Date(timestampDate), 'MMMM dd, yyyy');
在 frontend/patterns/messages-list.tsx 文件中,我导入了 Moment 模块,并使用了以下代码:
// FILE: frontend/patterns/messages-list.tsx
import moment from 'moment';
// inside MessageList component
moment(message.date).format('MMMM Do, YYYY');
与Luxon完全相同的用例——我有一个以毫秒为单位的日期,需要将其转换为人类可读的格式。
将其重构为date-fns:
import { format } from 'date-fns';
// inside MessageList component
format(new Date(message.date), 'MMMM do, yyyy');
重建项目后,咔嚓!包体积骤降20%,从804.34 KB缩减至672.52 KB。查看stats.html文件,你会欣喜地发现那些庞大的Moment和Luxon代码块消失了💃🏻。
趁势再精简些内容吧。当前图表呈现如下:

名称中包含 prosemirror 和 tiptap 的包有很多——我们稍后再处理它们。
可见的 @mui 和 date-fns 代码块我们已经压缩过了。
还有一大块 @radix-ui 代码。这些是我用来构建"核心"组件的 UI 原语。目前不会触碰这些代码,因为本项目范围内绝对不会放弃Radix框架。
还有@floating-ui库——简单搜索发现这是用于定位下拉菜单、工具提示等悬浮元素的库。此类库存在高重复风险,就像Dates库的情况。再盯着图表几分钟,未发现明显存在功能相似的库。因此该库可保留。
还有tailwind-merge库。研究项目采用Tailwind进行样式设计,该库是处理Tailwind时不可或缺的组件,故同样保留。
最后是@emotion库。

虽然它体积相对较小,尤其与所有prosemirror相关的代码块相比,但它的存在仍令人感到意外。Emotion是一个CSS-in-JS库,即用于网站样式设计而非处理纯CSS。然而我们已有Tailwind可满足此需求!
若非捆绑问题,我们应当尽可能移除它,仅为降低项目复杂度。
在项目中搜索@emotion的使用情况,仅发现一处用例:
// FILE: frontend/patterns/confirm-delete-dialog.tsx file
import styled from '@emotion/styled';
const Center = styled.div`
text-align: center;
`;
// inside ConfirmDeleteDialog component
<DialogDescription className="px-8">
<Center>Are you sure you want to delete this message? You won't be able to recover it.</Center>
</DialogDescription>;
这是大型项目中重构稍有失误的典型案例。很可能有人通过单个拉取请求将项目重构为Tailwind框架,而与此同时另有人在另一个并行拉取请求中添加了这个div,结果两者被同时合并。这种情况屡见不鲜。现在,清除它就是我们的责任——绝不让任何破窗留存。
所幸处理起来依然简单。我们只需移除导入语句和div标签,并添加类名来居中文本,而非依赖这个组件:
// FILE: frontend/patterns/confirm-delete-dialog.tsx file
// remove the import and the Center component
// inside ConfirmDeleteDialog component
// remove the Center element and add the new className
<DialogDescription className="px-8 text-center">
Are you sure you want to delete this message? You won't be able to recover it.
</DialogDescription>
重新构建项目后……
vender 区块的大小完全没变 🤔 怎么回事?……不知为何,Emotion 包依然存在。打开 stats.html 文件确认一下。
但为什么?
传递性依赖关系
库文件不会意外出现在打包文件中。若它存在于打包文件中,就意味着在某个地方被使用过。即使它未直接出现在项目代码中,也表示某个库曾使用过它,而该库又被项目代码所调用。这种情况在"基础"级库中尤为常见——即人们用其构建更高层级功能的库,例如定位库和各类实用工具库(如lodash)。
此类依赖关系称为"传递性"依赖。要追溯库的来源,可借助npm-why工具:
npx npm-why @emotion/styled
它将列出所有直接或间接使用@emotion/styled包的位置:
Who required @emotion/styled:
study-project > @emotion/styled@11.14.0
study-project > @mui/icons-material > @mui/material > @emotion/styled@11.14.0
study-project > @mui/icons-material > @mui/material > @mui/system > @emotion/styled@11.14.0
study-project > @mui/icons-material > @mui/material > @mui/system > @mui/styled-engine > @emotion/styled@11.14.0
study-project > @mui/material > @emotion/styled@11.14.0
study-project > @mui/material > @mui/system > @emotion/styled@11.14.0
study-project > @mui/material > @mui/system > @mui/styled-engine > @emotion/styled@11.14.0
结果应该不言自明。我们在Study项目中直接使用了@emotion/styled,这点没问题——我们的package.json确实包含该库。而@mui/icons-material和@mui/material都是通过其他库的链式引用来使用它的。
这真是个麻烦——我原以为修复@mui的导入问题后就能忽略它。但现在看来我们必须做出艰难抉择。
因为若想从打包文件中移除@emotion库,唯一解决方案就是清除所有依赖项——包括我们对@emotion的直接调用,以及两个@mui库的引用。
这使解决方案瞬间从"快速修复"升级为可能极其耗时的重构工程,尤其在实际代码中。
在研究项目中我们仍可尝试,仅为验证打包体积压缩的极限。但在实际代码中,这永远是重构耗时与潜在收益之间的权衡取舍。
移除 @mui/material
首先,找出它的使用位置:
// FILE: frontend/utils/ui-wrappers.tsx file
import { Snackbar } from '@mui/material';
export const StudyUi = {
Library: {
Snackbar: Snackbar,
},
Button: Button,
};
并删除导入和使用:
import { Button } from '@fe/components/button';
export const StudyUi = {
Button: Button,
};
然后找到 Snackbar 组件的使用位置:
// FILE: frontend/patterns/messages-list.tsx file
import { StudyUi } from '@fe/utils/ui-wrappers';
// Inside MessageList component
<StudyUi.Library.Snackbar open={openSnackbar} onClose={() => setOpenSnackbar(false)} message="Message deleted!" />;
这是消息删除时显示的通知组件。由于我们其他功能都使用Radix框架,可以将此组件替换为Radix的Toast组件,其功能完全相同。由于此前未使用过该组件,可能会增加包体积。不过希望移除@mui和@emotion框架能抵消这一增长。重构完成后我们将测量实际效果。
当前只需将使用方式替换为:
// FILE: frontend/patterns/messages-list.tsx file
import * as Toast from '@radix-ui/react-toast';
// Inside MessageList component
<Toast.Provider swipeDirection="left" duration={3000}>
<Toast.Root
className="grid grid-cols-[auto_max-content] bg-blinkNeutral50 items-center gap-x-4 rounded-md bg-white p-4 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] [grid-template-areas:_'title_action'_'description_action'] data-[state=open]:animate-slide-in-left"
open={openSnackbar}
onOpenChange={() => {
setOpenSnackbar(false);
}}
>
<Toast.Title className="text-base font-medium p-2 [grid-area:_title]">Message deleted!</Toast.Title>
</Toast.Root>
<Toast.Viewport className="fixed bottom-4 right-4 z-50 m-0 flex w-[390px] max-w-[100vw] list-none flex-col gap-2.5 outline-none" />
</Toast.Provider>;
移除 @mui/icons-material
查找使用位置:
// FILE: frontend/icons/index.tsx
import { Star } from "@mui/icons-material";
export const Icons = {
Star: Star,
... // other icons
};
这是一个通用的“星标”图标,用于标记消息是否被加入收藏。实际上,我们在本地图标库中已有现成的“星标”图标,因此可以直接复用:
// frontend/icons/index.tsx file
import { StarIcon } from "@fe/icons/star-icon";
export const Icons = {
Star: StarIcon,
... // other icons
};
你甚至无需在任何地方查找它的用法。这正是此命名空间模式在此场景中的妙处——它理应直接生效。
重建项目,启动程序,导航至"收件箱"。此时所有消息都应显示"空心"星标图标,即新的非MUI图标。在实际项目中,你需要用完全相同的图标替换旧图标,但本例中图标差异反而有益——至少能验证修改生效。
将鼠标悬停在任意消息上,点击"删除",再点击"确认"按钮。此时右下角应出现白色提示气泡。
vender 代码块大小现为600.98KB——减少了约70KB!看来重构确实有效。
最后打开stats.html文件——所有与@mui和@emotion相关的代码应已消失,并在@radix内部出现新的react-toast代码块。
结果
希望这次调查过程充满乐趣,现在你应该能够自主梳理项目,识别并快速修复所有包体积问题。以本项目为例,我成功将包体积从5MB压缩至600.98KB。若将最初步骤视为"作弊"😅,则从878KB压缩至600.98KB——即便如此,压缩率仍超过30%。
而这还不是极限——包中所有@tiptap和@prosemirror相关库的处理仍是重大课题。提示:若真想知道答案,答案是延迟加载😉 但这个话题,改日再谈。