双 11 页面轻量化改造:利用 Tree-Shaking 与按需导出剥离 70% 无用图标库
每年双 11 前夕的代码审计总能揪出一堆让人啼笑皆非的“体积刺客”。在一场主会场导购页的首屏性能体检中我们发现首屏核心 JS Bundle 居然高达 1.8MBGzip 前。用rollup-plugin-visualizer拆开依赖图谱一看最扎眼的一个方块居然是某个业务公共组件引入的图标库——整整 840KB 的 JS 代码里面密密麻麻塞满了 600 多个 SVG 矢量路径的纯文本数据。而实际上这个促销导购页面从头到尾只用到了左箭头、右箭头、购物车、优惠券、关闭、客服、火热角标等区区 14 个图标。为什么现代打包工具Vite、Rollup、Webpack 5宣称的“开箱即用 Tree-Shaking”在图标库面前彻底破功为什么明明写的是import { ShoppingCart } from corp/icons打包器还是把全家桶一股脑打包了进去今天我们就把编译器的 AST 拆开从打包元凶到工程化治本彻底拔掉这颗吞噬大促带宽的毒瘤。Tree-Shaking 为什么会失效死在动态索引与副作用声明Rollup 和 Webpack 的静态分析树摇Tree-Shaking依赖于严格的 ES ModuleESM静态静态语法。但大部分社区或公司内部自建的图标库为了让业务方“开发体验爽”往往在入口文件index.ts里写出这样的代码// 灾难根源看似优雅的万恶之源 export * from ./icons/arrows; export * from ./icons/e-commerce; export * from ./icons/finance; // ... 导出 600 个图标 // 甚至为了支持动态渲染 Icon namecart /内部维护了一个巨大的字典映射 import * as allIcons from ./all-icons; export const IconRegistry allIcons;这种写法直接判了打包器死刑动态字典引用Object Indirect Access只要有任何一行代码访问了IconRegistry编译器就无法在编译期预测运行时到底会通过IconRegistry[dynamicName]读取哪一个图标只能认定“所有导出的图标都是被引用的”完全无法摇掉。package.json漏配sideEffects: false打包器不敢轻易删除那些可能带有全局副作用例如修改 window、执行自执行函数的模块。如果图标库的package.json声明了默认的带有副作用打包器即便看到某个图标没被用到为了安全起见也必须把它留在包里。CommonJS 编译残留有些图标库在发布 npm 时把代码提前用旧版 Babel 转译成了 CommonJS 的exports.ShoppingCart ...动态导出的 CJS 代码在现代工具链里天生对 Tree-Shaking 免疫。方案一基于 SWC / Babel 插件的编译期路径精准重写要彻底剥离无用图标最立竿见影且不破坏业务方心智的方式是在编译阶段通过 Babel 或 SWC 插件把带有桶文件Barrel File引用的聚合语句// 业务源码写法 import { ShoppingCart, ArrowLeft } from corp/icons;在语法分析阶段强行重写为单文件按需物理寻址// 编译产物期望达到的真实 AST 引用 import ShoppingCart from corp/icons/es/icons/ShoppingCart.js; import ArrowLeft from corp/icons/es/icons/ArrowLeft.js;编写一个轻量级的 Babel 变换插件// babel-plugin-icon-tree-shake.cjs module.exports function ({ types: t }) { return { visitor: { ImportDeclaration(path) { const source path.node.source.value; if (source ! corp/icons) return; const specifiers path.node.specifiers; const newImports []; for (const spec of specifiers) { // 只拦截命名导入 import { IconName } from corp/icons if (t.isImportSpecifier(spec)) { const importedName spec.imported.name; const localName spec.local.name; const targetModulePath corp/icons/es/icons/${importedName}.js; // 构造全新单模块默认导入 newImports.push( t.importDeclaration( [t.importDefaultSpecifier(t.identifier(localName))], t.stringLiteral(targetModulePath) ) ); } } if (newImports.length 0) { path.replaceWithMultiple(newImports); } }, }, }; };通过这一层拦截打包器直接跳过了聚合入口index.js只将实际用到的 14 个独立图标文件加入依赖图谱。该模块的打包体积从 840KB 骤降至 18KB降幅超过 97%方案二大促终极架构——SVG Symbol 零 JS Runtime 方案上面的方案虽然瘦身显著但每个图标依然是一个独立的 JS / React 组件运行时依然需要消耗 React 的createElement、VDOM Diff 以及内存开销。对于双 11 极致追求性能的轻量化首屏最优解是彻底将图标剥离出 JS 主线程使用原生浏览器缓存机制的 SVG Spriteuse xlink:href。构建期静态提取器Vite 插件实现我们在构建期扫描所有的.vue、.tsx、.html文件用正则和 AST 提取出真实使用的图标名集合自动合成一个轻量级的sprite.svg文件// vite-plugin-svg-sprite-generator.ts import { Plugin } from vite; import fs from node:fs; import path from node:path; export function inlineSvgSpritePlugin(): Plugin { const usedIconNames new Setstring(); return { name: vite-plugin-svg-sprite-generator, transform(code, id) { if (id.endsWith(.tsx) || id.endsWith(.vue)) { // 匹配 JSX 中的 Icon nameshopping-cart / const regex /Icon\sname[]([a-zA-Z0-9_-])[]/g; let match; while ((match regex.exec(code)) ! null) { usedIconNames.add(match[1]); } } return null; }, generateBundle() { // 遍历图标源文件目录仅组装被用到的图标 let symbolsContent ; for (const name of usedIconNames) { const svgPath path.resolve(process.cwd(), src/assets/raw-icons/${name}.svg); if (fs.existsSync(svgPath)) { const svgRaw fs.readFileSync(svgPath, utf-8); // 提取 svg 内部的 viewBox 与 path组装成 symbol idicon-[name] const cleanSymbol svgRaw .replace(/svg([^])/, symbol idicon-${name} $1) .replace(/svg, /symbol); symbolsContent cleanSymbol; } } const spriteSvg svg xmlnshttp://www.w3.org/2000/svg styledisplay: none;${symbolsContent}/svg; // 输出为一个独立产物可由 HTML 预先注入或由 CDN 独立带强缓存提供 this.emitFile({ type: asset, fileName: assets/sprite.svg, source: spriteSvg, }); }, }; }运行时原生渲染在业务组件中Icon 组件简化到了极致甚至可以直接内联为极度高效的原生 DOMsvg classui-icon aria-hiddentrue !-- 直接引用 CDN 上的雪碧图资源或页面内联 symbol -- use href/assets/sprite.svg#icon-shopping-cart/use /svg.ui-icon { width: 1em; height: 1em; fill: currentColor; /* 继承外层字体颜色自由换肤 */ display: inline-block; vertical-align: middle; }这种方案带来的收益是降维打击式的JS 体积贡献严格为 0KB。没有 React/Vue 组件逻辑没有额外的 JS 依赖包。解析与编译耗时0ms。浏览器使用底层的 C 图形引擎直接解析渲染 SVG无需经过 V8 引擎解析任何 JS 字符串。独立强缓存sprite.svg可以打上内容哈希常驻 CDN 和浏览器强缓存只要图标没有新增修改跨页面跳转零重复加载开销。性能验收与上线 ROI 收益在双 11 首屏压测环境上这套轻量化改造交出了非常硬核的数据单首屏 JS Bundle 总大小从 1,840KB 削减至 1,020KB直接砍掉 820KB。移动端低端设备 FCPFirst Contentful Paint平均提前了 120ms。长任务耗时Long Tasks Duration由于砍掉了数百个图标对象的初始化与 AST 解析页面加载期的总 CPU 执行时间缩短了 34%。在大促那种寸土寸金的毫秒战场上每一个打包进 Bundle 的字节都必须经受拷问。不要让不加节制的库引用吃掉你辛辛苦苦调优出来的性能红利。