ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Nuxt 4打包产物优化:删多余代码、藏私有配置的完整实践

Nuxt 4打包产物优化:删多余代码、藏私有配置的完整实践 前阵子把项目从 Nuxt 3 升到 Nuxt 4打包完之后顺手打开.output目录看了一眼当场就有点绷不住服务端目录里躺着几十个我不认识的 chunk客户端_nuxt里的 js 文件也有好几个体积明显不对劲。于是专门花了两天时间做“nuxt4 隐藏打包后多余的代码”这件事——说白了就两件事第一把打包产物里那些用不到、不该有的代码真正删掉第二把不想暴露给外人的私有代码和配置藏好别让前端 bundle 白送出去。这篇就把我的排查思路、实操配置、还有踩过的坑完整记录下来给同样被 Nuxt 4 产物体积和代码暴露问题困扰的朋友做个参考。1. 先看明白Nuxt 4 的构建产物到底由什么组成、多余代码藏在哪里想解决问题得先知道问题长什么样。Nuxt 4 和 Nuxt 3 的打包链路基本一致底层是 Vite也可以切 Webpack负责客户端 bundle服务端交给 Nitro 用 Rollup 打成一套独立的 Node 服务。两者产物都会被整理进.output目录我再把这个结构拆细一点。1.1 Nuxt 4 产物目录的结构与含义一个典型的 Nuxt 4 项目执行nuxt build之后会生成这样的目录.output/ ├── public/ │ └── _nuxt/ │ ├── index.xxxx.js │ ├── entry.xxxx.js │ └── ... 一堆按路由或手动分包拆出来的 chunk ├── server/ │ ├── chunks/ │ │ ├── _nuxt-xxx.js │ │ └── routes/ │ │ └── api-xxx.js │ ├── index.mjs │ ├── nitro.json │ └── package.json └── nitro.jsonpublic/_nuxt是浏览器端最终加载的代码而server是 Node 服务端运行时代码。Nuxt 4 默认会对服务端做比较细的 chunk 拆分每个server/api下的接口、每个动态服务端路由都可能在server/chunks/routes里生成独立文件。这个拆分本身是为了冷启动时按需加载但如果你存在大量历史遗留接口或者没用的服务端路由这里就会堆出一大堆“看着没用、实际也没被调用”的 chunk。客户端这边的多余代码则更直观Vite 会把node_modules里的第三方库打进去也会把自动导入的组件、composable、插件按依赖关系打进去。问题在于自动导入机制很方便的同时也容易把一些你根本没用到的组件代码一并拖进来尤其是在组件里间接引入了重型库的情况下。1.2 从来源上分类多余代码通常从哪里来我把实际项目里翻出来的“多余代码”归类成四类排查时对着找会快很多多余代码类型主要出现位置典型来源未使用的第三方依赖客户端_nuxt、服务端 chunkspackage.json里装了很久但早已不用的库例如某次功能迭代后遗留的 axios、moment全量引入的库客户端_nuxt主 bundle图标库、工具函数库没有按需引入例如import _ from lodash全量打入端侧混入的代码客户端或服务端 bundle服务端专用的数据库连接、密钥逻辑被 import 到了组件里打进了浏览器代码死代码与历史功能任意位置业务上已废弃的 API、被注释掉但仍然参与构建的页面路由、旧版兼容代码这里我想特别强调一个 Nuxt 项目里很容易被忽略的点服务端 chunk 不代表一定被加载但会导致构建产物非常臃肿、部署包变大、CI 时间变长。很多时候我们只关注浏览器下载体积忽略了部署和内存占用这两个指标在 Nuxt 4 这种服务端渲染框架里同样重要。1.3 一个反直觉的观察结果我在清理过程中发现一个反直觉的现象有些代码并不是直接被业务引用的而是被 Nuxt 的自动导入机制间接带进来的。比如我在某个组件里写了一个template中使用的formatPrice这个函数内部 import 了一个非常重的数字处理库而实际上那个formatPrice方法在整个项目里只被用了一次且完全可以手写。这个时候自动化导入 内部依赖链就会把重型库完整打入客户端包。这种间接依赖最隐蔽也是最值得花时间挖的。所以不要只看第一层引用要顺着“组件 → composable → 第三方库”的链路一路查下去。2. 定位多余代码靠感觉没用用工具和产物反推知道问题在哪之后第二步是精确定位。我见过很多人上来直接改nuxt.config.ts加各种压缩和分包配置结果一顿操作猛如虎体积只降了 2%。原因很简单你连哪些是多余代码都没确认就去调优化参数属于乱枪打鸟。2.1 先吃透构建日志和产物报告Nuxt 4 的构建过程本身会输出每个 chunk 的大小但默认日志比较简略。更推荐的做法是在打包时开启分析nuxt build --analyze加上--analyze之后Vite 构建完会生成rollup-plugin-visualizer的 HTML 报告里面能按文件大小查看各个模块的占比。这个报告不仅能看最终 chunk 的大小还能看某个模块被哪些 chunk 引用、为什么被打包进去是定位“多余代码”的第一利器。如果没有启用成功也可以直接在nuxt.config.ts里手动配置export default defineNuxtConfig({ vite: { build: { rollupOptions: { output: { // 开一个自定义的 plugin 做可视化这里略 } } } } })但说实话手动配 plugin 不如直接--analyze方便我最终就是靠 report 定位到问题最大的两个库一个是因为某组件里import xxx/dist/xxx.css被反复加载另一个是 lodash 全量引入。2.2 从产物目录反推直接去 .output 里找异常文件如果不想装额外工具有个土办法也很管用构建完之后直接去.output/public/_nuxt看文件大小把最大的几个 js 文件复制出来用编辑器搜索里面出现频率最高的模块名或业务关键词。例如我搜索到某个 chunk 里有大段“echarts”相关代码但我们的项目里图表功能已经下线半年了代码却没删干净。于是顺着引用关系找到某个布局组件里残留的旧图表组件引用删掉之后那个 chunk 直接消失。这个方法的优势在于它是基于最终产物的不会漏掉任何无效引用。缺点是遇到代码被压缩混淆后不好读不过一般定位到“哪个库占了多大体积”这个粒度就够用了。真正要找到具体哪一行代码引用了它还是要回到源码里用 IDE 的全局搜索配合引用分析。2.3 代码层面的清理清单用项目搜索代替“我以为”还有一种常见情况是某些组件、工具函数在项目里早就没有入口了但因为没有物理删除文件Nuxt 的自动导入还是会把它们扫描到.nuxt/imports.d.ts里。这时候可以用depcheck或knip这类工具做一次依赖和文件扫描npx knipknip 能列出没有被任何文件引用的导出项、未使用的依赖和未使用的文件对 Nuxt 项目来说准确率尚可但要注意它默认可能不识别 Nuxt 的自动导入语法需要配合配置文件指定imports等目录。我的习惯是用它做第一轮粗筛筛完再人工确认避免误删一些通过nuxt.config间接注册的模块。3. 实打实削减依赖治理、打包配置、代码结构三管齐下定位完之后就进入实操环节。这部分我按“先解决大头再处理小头”的顺序来说每一步都会给出具体的配置或者代码示例。3.1 依赖治理把 package.json 里没用的依赖物理删掉这一步听起来最简单但实际收益往往最大。npm 生态里很多库看着很小实际带上依赖树能膨胀出几百 KB。建议用knip扫出来的结果做一轮人工确认确认后直接移除npm uninstall axios moment lodash这里有个注意点删除依赖后一定要全局搜索一遍比如搜索from axios或require(axios)确认没有任何残留引用包括server/目录下的 nitro 代码。我这次清理时就翻出一个server/utils/request.ts里面还在用 axios 请求第三方接口但因为服务端没有构建报错包也一直留在 package.json 里没被发现。3.2 第三方库的按需引入lodash、图标库、日期库是重灾区依赖删完之后接着处理“还在用但被打包太多”的库。以 lodash 为例// 错误写法全量引入打包体积最大 import _ from lodash // 推荐写法按需引入 import debounce from lodash-es/debounce如果你的项目还在用 CommonJS 版本的 lodash强烈建议切到lodash-es它对 tree-shaking 更友好。类似地dayjs / date-fns 在选择时也要注意是不是支持 ESM 按需。图标库是另一个坑。我用过iconify/vue配合 Iconify API 的动态加载方式能避免把所有图标打进 bundle但如果你的项目是本地全量引入某个图标包构建时图标库常常会贡献出数百 KB 的体积。这时候要么改成按需注册要么用unplugin-icons的按需导入方案// nuxt.config.ts import Icons from unplugin-icons/vite export default defineNuxtConfig({ vite: { plugins: [Icons({ compiler: vue3, autoInstall: true })] } })这套方案能让每个图标只在被使用时才被编译进对应的组件 chunk效果立竿见影。3.3 Vite / Rollup 打包配置分包与 tree-shaking 的边界依赖层面处理完再来看构建配置。先说 tree-shakingNuxt 4 默认在构建时开启了 esbuild 压缩和 tree-shaking但 tree-shaking 能不能生效取决于你引入的包有没有sideEffects: false的声明。这也是为什么我建议优先选择现代 ESM 库它们通常在package.json里明确标注了sideEffects。如果想要更激进一点可以给 Vite 指定manualChunks把体积大且更新频率低的库单独拆出来利用浏览器缓存export default defineNuxtConfig({ vite: { build: { rollupOptions: { output: { manualChunks: (id) { if (!id.includes(node_modules)) return if (id.includes(echarts) || id.includes(zrender)) return charts if (id.includes(lodash-es)) return lodash if (id.includes(vue) || id.includes(pinia) || id.includes(vue-router)) return vue-vendor } } } } } })但这里有个比较关键的原则不要把所有第三方库都塞进同一个 vendor chunk。把 vue、pinia 这些几乎不会变的框架代码拆成vue-vendor是合理的但如果你把业务里经常更新的库也拆进去反而会让缓存失效频率变高用户的更新下载量更大。我吃过这个亏一开始把所有 node_modules 都拆到一个vendorchunk结果每次发版一个 800KB 的文件从头到尾全量更新CDN 命中率低得可怜。3.4 组件和页面级的懒加载把“以后可能用到”变成“用到时再加载”Nuxt 4 默认做了路由级代码分割也就是每个页面一个 chunk。但页面内部的组件并不会自动分割尤其是一些折叠面板里才出现的重型组件、弹窗里的图表、表格里的富文本编辑器都应该换成defineAsyncComponent延迟加载。以图表组件为例import { defineAsyncComponent } from vue const LineChart defineAsyncComponent(() import(~/components/charts/LineChart.vue))这样LineChart.vue及其依赖的 echarts 等库只有在组件真正被渲染时才会加载。配合前面对 echarts 的manualChunks拆分能有效把首屏 bundle 里的重量级代码挪到真正的使用时机。另外Nuxt 4 里服务端渲染的页面异步组件在服务端也会被正确解析不用担心 SSR 场景下的闪烁或者水合不一致问题Nuxt 在服务端会自动等待异步组件 resolve。3.5 双端代码隔离import.meta.server 与 import.meta.client 的正确用法Nuxt 体系里有个容易被忽视的优化点很多代码根本不该同时出现在客户端和服务端 bundle 里。比如一个粗暴的示例// 这段代码如果在普通 composable 里就会同时打进两端 import { createClient } from ~/server/utils/database export function getConnection() { return createClient() }只要这个 composable 被任意一个页面或组件 import数据库驱动代码就会进入客户端 bundle——浏览器用户根本不会用到它但它就是会出现在你的_nuxt产物里既拖体积又暴露内部结构。正确做法是使用 Nuxt 内置的端侧判断export function useDatabaseStatus() { if (import.meta.server) { // 只有服务端会执行import.meta.server 在客户端构建时会被替换为 false const conn createClient() return conn.status } return null }更严谨的方案是把服务端专属逻辑放到server/目录下通过server/api或server/utils暴露接口组件里只调用接口从根上避免服务端代码被打进客户端。我在项目里要求团队遵守一条铁律任何跟数据库、密钥、私有配置相关的代码都不允许出现在 app/ 组件树中需要数据就调接口。4. 把该藏的藏起来SourceMap、压缩混淆与私有配置保护“隐藏打包后多余的代码”如果只理解成“删掉多余代码”其实只说了一半。另一半是那些必须在代码里出现、但不想让用户直接看懂的私有逻辑和配置要真正藏住。这一章讲我在实际项目中实践的三种隐藏手段。4.1 SourceMap不关掉就等于把源码白送Nuxt 4 默认开发环境会生成 SourceMap生产环境默认不生成。但我见过很多人上线时为了方便排查问题会显式打开生产 SourceMapexport default defineNuxtConfig({ vite: { build: { sourcemap: true } } })一旦开启浏览器开发者工具里就能直接看到完整的.vue源文件、目录结构、甚至注释。对很多内部项目来说这等于把代码拱手送人。我建议生产环境要么关闭vite: { build: { sourcemap: false } }要么保留 SourceMap 但通过 Nginx 禁止外部访问.map文件同时把.map上传到你自己的错误监控平台比如 Sentry这样线上报错依旧能定位源码位置但普通用户无法查看源码。这个平衡是我目前最推荐的做法。4.2 压缩与混淆从 minify 到 javascript-obfuscator 的取舍Nuxt 4 生产构建默认会压缩代码可以把变量名缩短、去掉换行和注释。但如果你要保护的是某些核心算法或者业务规则压缩远远不够需要做混淆。服务端 Nitro 这边可以直接开启 minifyexport default defineNuxtConfig({ nitro: { minify: true, sourceMap: false } })客户端 Vite 这边用的是 esbuild 压缩默认足够用。如果项目里有真正需要更高强度保护的核心逻辑比如加解密、风控规则可以考虑引入javascript-obfuscator把特定文件或特定 chunk 做二次混淆。但我必须提醒你混淆不是免费的它会让代码体积增大 30%~80%运行性能打折扣且混淆后的报错栈几乎不可读。我的建议是只对真正敏感的核心模块做不要全项目无脑混淆。一个业务页面里没什么值得保护的逻辑强行混淆只是给自己后续维护挖坑。4.3 runtimeConfig 私有字段真正把密钥藏出锅Nuxt 4 的runtimeConfig有一个很多人忽略的机制只有放在public下的配置才会被注入到客户端 bundle非 public 字段只存在于服务端。也就是说你在nuxt.config.ts里这样写export default defineNuxtConfig({ runtimeConfig: { apiSecret: this-is-secret, public: { apiBase: /api } } })构建之后客户端 bundle 里只有apiBaseapiSecret不会出现在任何浏览器可访问的文件里。这个机制本身就是一种“隐藏代码”的手段前提是你得把密钥放到正确的位置并且通过环境变量注入而不是硬编码在源码里NUXT_API_SECRETxxxx nuxt build构建完成后你可以直接搜索产物里的关键字来验证grep -r this-is-secret .output/public如果输出为空说明密钥确实没有被打进客户端。这一招我强烈建议所有 Nuxt 项目都用起来它比任何混淆都更彻底、更可靠。5. 改完配置之后验证、回归与常见翻车点配置改完不等于优化完成我还得跑一轮完整的验证。这个环节最容易翻车也最容易被忽略。我把自己踩过的坑整理出来能帮你省不少查错的时间。5.1 构建产物验证清单每次改完打包相关配置我至少会核对以下指标检查项操作方式预期结果打包体积查看构建日志 / analyze 报告客户端_nuxt总大小明显下降服务端 chunks 数量减少项目依赖体积npx knip不再有“未使用依赖”的告警私有配置grep -r 密钥字段 .output/public没有任何敏感字段出现在 public 目录SourceMap浏览器 devtools 检查 Sources线上环境看不到.vue源文件功能回归跑一遍典型用户路径SSR 首屏、客户端导航、接口调用均正常水合一致性检查控制台报错没有 hydration mismatch 警告5.2 高频翻车点为什么有时候配置没生效第一个翻车点是sideEffects字段。如果你的某个依赖在package.json里没有声明sideEffects: falseVite 在做 tree-shaking 时会变得保守宁可把代码保留也不敢删。这种情况下即使你只 import 了一个函数整个模块依然可能被打进去。解决办法是检查依赖包的声明或者在 Vite 里显式配置export default defineNuxtConfig({ vite: { optimizeDeps: { include: [lodash-es] } } })这里的逻辑是让 Vite 预构建时把lodash-es当作可优化的 ESM 依赖处理提高 tree-shaking 命中率。第二个翻车点是手动分包导致循环引用。我在配置manualChunks时曾经把一个 UI 组件库拆成独立 chunk而这个 UI 库内部又引用了 vue 的某个运行时结果构建产物里出现了 chunk 互相引用的警告页面渲染直接报错。遇到这种情况别急着回滚可以先用 Rollup 的manualChunks函数形式根据模块 ID 精确控制而不是用正则粗暴匹配整个node_modules。第三个翻车点是我自己犯过的低级错误关闭 SourceMap 之后线上 Sentry 报错全部变成了压缩后的行号根本没法看。当时同事差点在群里骂人。正确做法是关闭浏览器可访问的 SourceMap但把.map文件单独上传给监控平台或者使用支持 SourceMap 白名单的托管服务。不要为了隐藏源码把自己排查问题的路径也一起埋了。5.3 一份可抄的 nuxt.config.ts 配置模板最后给一份我实际在用、验证过的配置大多数 Nuxt 4 项目可以直接拿来当起点export default defineNuxtConfig({ compatibilityVersion: 4, ssr: true, runtimeConfig: { // 服务端私有配置绝不会出现在客户端 bundle apiSecret: , public: { apiBase: /api } }, nitro: { minify: true, sourceMap: false, compressPublicAssets: true }, vite: { build: { sourcemap: false, rollupOptions: { output: { manualChunks: (id) { if (!id.includes(node_modules)) return if (id.includes(vue) || id.includes(pinia) || id.includes(vue-router)) { return vue-vendor } if (id.includes(lodash-es)) { return lodash } } } } } }, app: { head: { script: [ // 避免把大型第三方脚本打进 bundle用外部 CDN 引入 ] } } })这份配置的精髓不在于每一条都是最优解而在于它把“客户端缓存”和“服务端安全”两个目标分开了框架代码单独拆包利于缓存服务端最小化压缩私有配置全部走 runtimeConfig。后续再做优化也是在这个基础上逐步细化而不是推翻重来。写在最后这次清理 Nuxt 4 打包产物前前后后花了我差不多两个工作日客户端 bundle 总大小降了大概 36%服务端 chunks 从几十个降到十几个部署包明显变轻。过程中最有价值的一点不是某一个配置项而是终于建立了一套自己的排查链路先分清产物结构再用 analyze 定位然后从依赖、代码、配置三个层面逐层往下削最后用 grep 验证敏感字段没有泄露。之后再改任何 Nuxt 项目我基本都按这个顺序走不太会再被“打包出来一堆看不懂的东西”这种事吓到了。
返回列表