前端打包优化实战技巧与体积压缩方案

慕容宏骞 框架 阅读 2,045
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接手了个老项目,是个基于 Vue 2 的管理后台,打包出来的 vendor.js 超过 3.8M,首屏加载动不动就十几秒,用户投诉邮件都快炸了。老板说再不优化就“考虑换人”,我心想这哪是技术问题,这是生存问题。

前端打包优化实战技巧与体积压缩方案

本来一开始想用懒加载 + gzip 就糊弄过去,结果测完发现还是慢得离谱。后来翻了下 webpack-bundle-analyzer 的报告,好家伙,lodash 被引了七八次,axios 居然被打包了三份,还有些 UI 组件库的图标组件整个被拖进 bundle,根本没做按需引入。这时候才意识到:光靠代码分割根本救不回来,得动真格的。

最后决定搞一次彻底的打包优化,目标很现实:首包控制在 1.2M 以内,首屏时间压到 3s 以内。方案是组合拳——webpack 配置调优 + 外链公共资源 + 动态 polyfill + 自定义 loader 剔除无用模块。虽然 Vite 现在很香,但迁移成本太高,客户不同意延期,只能在 webpack 上榨出最后一点性能。

最大的坑:lodash 和 moment 的依赖黑洞

最开始以为把 element-ui 改成按需引入就完事了,结果跑完分析图一看,lodash 还是全量打进去了。原因是有些第三方库内部直接 import _ from ‘lodash’,你这边用 babel-plugin-lodash 想局部引入也拦不住。

折腾了半天,最后用了 webpack 的 NormalModuleReplacementPlugin 来强行替换:

const path = require('path');

module.exports = {
  plugins: [
    new webpack.NormalModuleReplacementPlugin(
      /^lodash$/,
      path.resolve(__dirname, 'src/utils/lodash-replace.js')
    )
  ]
};

然后自己写了个 lodash-replace.js 做白名单控制:

// src/utils/lodash-replace.js
export default {
  debounce: require('lodash/debounce'),
  throttle: require('lodash/throttle'),
  cloneDeep: require('lodash/cloneDeep'),
  // 其他只允许用这几个
  // 其他方法调用时会报错,提醒开发者别乱引
};

// 打包时如果用了没注册的方法,运行时报错
console.warn('使用了未授权的 lodash 方法,请检查引入方式');

moment.js 更恶心,不仅体积大,还自带所有语言包。我们项目只用中文,但默认全打进去。解决办法是在 webpack.config.js 里加 IgnorePlugin:

new webpack.IgnorePlugin({
  resourceRegExp: /^./locale$/,
  contextRegExp: /moment$/
});

然后再手动引入需要的语言包:

import 'moment/locale/zh-cn';

这一套下来,光 lodash + moment 就省了快 400K,效果立竿见影。

CDN 外链不是银弹,但也真香

原本计划把 vue、vuex、vue-router、axios 都扔 CDN,配置 externals 走起:

module.exports = {
  externals: {
    vue: 'Vue',
    'vue-router': 'VueRouter',
    vuex: 'Vuex',
    axios: 'axios',
    lodash: '_'
  }
};

然后在 index.html 里加上:

<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@3.5.1/dist/vue-router.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js"></script>

本地一跑,好得很。发测试环境?直接白屏。查了才知道,某些内网用户 CDN 加载超时,而且公司代理对公网 CDN 限制严,部分资源根本拿不到。

后来改了策略:只外链 vue 和 axios,其他走本地打包。同时上了 preload:

<link rel="preload" href="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js" as="script">

再配合个降级脚本:

<script>
  if (typeof Vue === 'undefined') {
    document.write(unescape('%3Cscript src="/libs/vue.min.js"%3E%3C/script%3E'));
  }
</script>

现在至少不会因为网络问题直接挂掉。不过这个方案也不是百分百稳,比如 offline 场景还是会出问题,但客户说“能接受小概率卡一下”,那就先这样吧。

动态 Polyfill:别再一股脑塞 es5 了

项目要兼容 IE11,babel-preset-env 默认把所有 polyfill 都打进去,导致 core-js 占了 600K+。后来改成 runtime 方式,用 @babel/plugin-transform-runtime + @babel/runtime-corejs3。

关键配置:

// .babelrc
{
  "presets": [
    ["@babel/preset-env", {
      "useBuiltIns": "usage",
      "corejs": { "version": 3, "proposals": true },
      "targets": "> 0.5%, last 2 versions, not dead, ie >= 11"
    }]
  ],
  "plugins": [
    ["@babel/plugin-transform-runtime", {
      "corejs": { "version": 3, "proposals": true }
    }]
  ]
}

这样只会根据实际语法使用情况注入 polyfill,比如只用了 Promise,就不会把 Array.from 也塞进去。最终 polyfill 从 600K 干到了 180K 左右,省了一大截。

注意这里踩过坑:corejs 版本必须和 @babel/runtime-corejs3 一致,不然会重复注入。我一开始装的是 runtime-corejs2,结果 babel-injector 和 runtime 各自注入一套,反而更大了。折腾了快半天才发现是版本对不上。

图片和字体:静态资源也不能放过

项目里一堆 .woff/.ttf 字体文件,全被打包进 assets,单个就 200K+。后来把非核心字体挪到异步加载,页面启动时不阻塞:

/* 异步字体 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap; /* 关键! */
}

图片方面,用 file-loader 改成 url-loader + limit 控制 base64 内联:

{
  test: /.(png|jpe?g|gif)$/i,
  use: [
    {
      loader: 'url-loader',
      options: {
        limit: 8192, // 小于 8k 编码进 js
        fallback: 'file-loader',
        name: 'imgs/[name].[hash:6].[ext]'
      }
    }
  ]
}

顺便把 svg 都转成组件引用,避免重复打包:

// 使用 vite-plugins-svg 或 webpack-svg-component-loader
import IconUser from '@/icons/user.svg';

构建速度也得管,不然开发体验崩了

优化完打包体积,build 时间从 6 分钟涨到 8 分钟……开发者天天抱怨。于是上了 cache-loader 和 thread-loader:

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: [
          'cache-loader',
          'thread-loader',
          'babel-loader'
        ],
        include: [path.resolve(__dirname, 'src')]
      }
    ]
  },
  cache: {
    type: 'filesystem',
    buildDependencies: {
      config: [__filename]
    }
  }
};

缓存命中率大概 70%,第二次 build 稳定在 3 分 20 秒左右,勉强能接受。不过 CI 环境偶尔缓存失效,还得清缓存重来,这点至今没彻底解决。

回顾与反思

改完上线后,首包降到 1.1M,首屏平均加载时间从 12s 降到 2.8s,监控显示崩溃率下降 70%。老板没再提换人,算是暂时保住饭碗。

做得好的地方:

  • 精准打击大头依赖(lodash/moment)
  • CND + 降级策略平衡了性能和可用性
  • polyfill 动态注入节省显著

还能优化的:

  • 部分页面仍存在冗余组件,可进一步拆细 chunk
  • 没有上 HTTP/2 push,静态资源仍有优化空间
  • CI 缓存不稳定,需要更健壮的缓存管理

最后说句实话:这个方案不是最优解,尤其是 CDN 降级那块挺糙的。但它是能在两周内落地、不影响业务迭代的最简单方案。有时候不是技术不够酷,而是时间和风险不允许你太理想主义。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的打包优化实践,欢迎评论区交流,我也还在路上。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论