Ant Design Pro项目打包优化

公司项目的前端当初选择了Ant Design Pro脚手架,好用到谁用谁知道。但是当初基于2.0版本搭建的,现在Ant Design Pro已经切换到umi框架了,要对项目做升级,难度削微大了些。但是,随着这几年的搬砖积累,项目从最初打包出来2MB到现在的10+MB,而且裸奔部署在tomcat上,没有CDN,没有gzip。首屏加载越来越慢,客户埋怨的声音越来越多。

于是乎,忍不住花了好几天的时间,做了彻底的优化。

第一步,熟悉相关的打包优化方案

阅读了文章https://webpack.wuhaolin.cn/后获益匪浅,建议没有webpack优化经验的童鞋可以先仔细的阅读。同时,在对自己的项目改造的时候可以参考。

总结一下,webpack常见的优化策略如下:

  • 利用BundleAnalyzerPlugin分析依赖的包
  • 开启gzip压缩,这个需要服务器做配置即可,能降低50%左右的包体积
  • 将部分引用的外部依赖库分离为CDN静态资源引入的方式
  • 打包过滤moment的语言包
  • 分离依赖包(可以将整个node_modules)到vendor.js文件

另外,提升打包效率也是必要的一件事。因为不管是开发环境还是生产环境,尽可能的节约打包等待的时间会提升我们的工作效率。特别是在开发和热更新时,打包编译速度就会有很大的影响。打包效率的常见策略如下:

  • 开启多线程打包
  • 过滤部分非必要的且耗时的webpack插件
  • 升级代码压缩的插件,并开启缓存
  • 针对开发和生产配置不同的webpack插件
  • 利用SpeedMeasurePlugin插件分析耗时的打包环节

第二步,创建自定义的webpack配置

Ant Design Pro默认使用的时roadhog框架githubroadhog本身封装了webpack相关的能力,同时给开发者提供了很遍历的配置项,同步这些配置我们可以实现如本地代理转发、分包加载等功能。但这些配置项还不够灵活,想要做好我们的优化,需要我们用另一种方式来配置我们的webpack

roadhog到底为我们做了什么呢?查看源码,可以很清楚的看到它做的一切:github.com/umijs/umi/b…

自建webpack配置文件

在根目录创建文件webpack.config.js,该文件是webpack默认读取的配置文件。内容编写如下:

export default webpackConfig => {
    return webpackConfig;
}
复制代码

这里我们导出了一个函数,这个函数的参数即为已有的webpack配置,该配置会来自roadhog配置的信息,因此,我们可以在我们的配置项中修改已有的webpack配置信息。忽略编译时控制台输出的警告:

image.png

到此,我们就有了我们修改webpack打包的入口。读者可以自行打印log在函数中,查看相应的输出。

添加监控插件

一般我们要分析打包体积和打包时间消耗时,会选择BundleAnalyzerPlugin插件和SpeedMeasurePlugin插件。两者的用法也比较简单,如下:

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();

export default webpackConfig => {
    // 打包资源分析
    webpackConfig.plugins.push(
      new BundleAnalyzerPlugin()
    );
    
    // 打包速度监控
    return smp.wrap(webpackConfig);
}
复制代码
  • 添加了BundleAnalyzerPlugin插件后,打包完成会立即自动打开127.0.0.0:8888网页页面,该页面显示了整个项目依赖包和源文件的体积大小。可以根据此来判断依赖包的大小以及是否有重复依赖包(重复的依赖包可以在package.json中统一声明解决)。(TIPS: roadhog本身也提供了该插件,只需要在打包命令中配置ANALYZE=true环境变量即可,如:cross-env ANALYZE=true roadhog build,笔者这里为了加深插件的使用控制,因此在自己的配置中重新配置了一遍)。

image.png
http://127.0.0.0:8888

图中可以看出,node_modules目录下的文件大小几乎占了一大半整个打包体积。同时有几个依赖库比较大,如antdxlsxbizcharts等。

  • 添加了SpeedMeasurePlugin插件后,打包完成会在控制台输出如下信息:

image.png

图中可以看出,部分插件如CaseSensitivePathsPluginIgnorePlugin等耗时较大,超过1分钟,同时style-loaderbabel-loader耗时也较大。这都是我们可以在自定义配置中优化的项目。

第三步,【优化】减小打包体积

根据一开始提到的几个压缩打包体积的方法,分别做webpack的配置。

1. 开启gzip压缩

现代浏览器默认支持gzip压缩后的js文件解析,相对于未压缩的js文件会消耗一定的解压时间。但相对于网络延迟来说,压缩后解压带来的优势一般大于网络延迟,特别是在弱网环境下体验很重要。

在我们项目中使用的是SpringBoot + Tomcat作为静态资源的服务端。因此,这里介绍以下SpringBoot里配置gzip的方式。、找到resources/application.properties文件,添加如下代码:

# gzip压缩
server.compression.enabled=true
server.compression.mime-types=application/javascript,text/css,application/xml,text/html,text/xml,text/plain
复制代码

SpringBoot下开启gzip就是这么简单了。可以通过在浏览器打开页面,在控制台-网络查看我们的任意js文件或css文件能看到Response Headers中的Content-Encoding: gzip即表示gzip已生效,同时可以看到文件大小也小了很多。

image.png

image.png

其他服务器下的gzip配置方式可以自行Google,一般操作较为简单。

我们还可以再进一步在服务器上优化:开启Cache Control

我们继续在resources/application.properties中配置resourcescache:

# 缓存7天
spring.resources.cache.cachecontrol.max-age=604800
spring.resources.cache.cachecontrol.no-cache=false
spring.resources.cache.cachecontrol.no-store=false
spring.resources.cache.cachecontrol.cache-private=true
复制代码

配置完成后,第一次加载网页不会走缓存,但后面在7天内刷新网页时都会走缓存。只要文件名不变。

image.png

其他服务器下配置Cache-Control的方式请自行Google吧。

2. 部分依赖包改为CDN方式引入

为什么需要使用CDN方式引入依赖包呢?我认为有几个原因:

  • (1)CDN使用了公共的资源和服务,可以降低打包体积大小,同时降低对自己的服务器的访问和流量
  • (2)CDN可以做公共缓存,根据访问者的网络来择优读取数据,访问速度更快
  • (3)CDN不会受项目发版影响,在访问者的浏览器缓存等方面也有更好的优化
  • (4)CDN和分包类似,可以在打开网友时并行加载内容

虽然好处很多,但是并不是说我们依赖的包都需要用CDN静态引入的方式加载。 这和浏览器的并发请求数限制有关。不同的浏览器对同一时间请求的资源数有不同的限制:

image.png

这就意味着,虽然可以通过CDN引入很多依赖库,但是最终还是会分批次串行加载。同时,每一次的HTTP连接的网络开销也会有很大的影响。因此,尽可能做到在CDN资源与分包之间做到一个平衡。分包的处理后面会讲。所以,建议同学们在引入CDN的时候保持以下几个原则:

  • 尽可能选择体积占用大的包
  • 数量保证在5个以内
  • 配合分包将打包体积拆分,充分利用浏览器的并行能力
  • 有条件的可以通过使用动态CDN域名突破浏览器的并行限制

那么,我们该怎么配置?这里我们有两种方式:

  • (1)配置.webpackrc.jsexternals和手动在index.ejs中配置<script.../>
  • (2)使用插件HtmlWebpackExternalsPlugin

当然了,处理原理都一样。这里我们介绍第二种方式,这种方式更简单,且易配置维护。例如我们将reactreact-domjQuery作为CDN资源引入。

首先在package.json中去除reactreact-domjquery的依赖,在devDependencies中添加插件对应的"html-webpack-externals-plugin": "^3.8.0"依赖包。然后在webpack.config.js中添加插件的相关配置:

const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')

// ...

export default webpackConfig => {
    // ...
    
    // 引入外部cdn资源
  webpackConfig.plugins.push(
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: 'react',
          entry: isDev ?
            'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.development.js' :
            'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js',
          global: 'window.React'
        },
        {
          module: 'react-dom',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js',
          global: 'window.ReactDOM'
        },
        
        {
          module: 'jquery',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js',
          global: 'window.jQuery'
        }
      ],
      files: ['index.html'],
    })
  );
    
    // 打包速度监控
    return smp.wrap(webpackConfig);
}
复制代码

该插件的说明参考readme。我们还可以添加其他任意依赖的包,甚至是项目中的公共组件等。

3. 在生产环境中过滤moment的语言包

(项目未使用moment库的同学请跳过)

细心的同学可以看出来,在BundleAnalyzerPlugin插件显示的包体积视图中,moment占用了不少体积,同时moment包中的语言包更是占用了大半个空间。然而我们在项目中一般只用到中英文。因此,moment的语言包优化也是我们可以做的优化之一。在roadhog的配置中有一个参数ignoreMomentLocale即是在打包的时候过滤调moment的语言包。只要配置该参数为true即可达到效果。

如果我们想要自己做这件事,怎么做呢?这里我们要使用插件IgnorePlugin,这个插件是webpack包本身提供的。使用方式很简单,但是该插件耗时较高,会影响整体打包时长。所以建议在build生产包时再开启。

image.png

配置参考:

// ...
export default webpackConfig => {
    // ...
    
    // 忽略moment的语言包
    if(!isDev) {
        webpackConfig.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/));
    }
    
    // 打包速度监控
    return smp.wrap(webpackConfig);
}
复制代码

4. 分离node_modules和业务代码

一说到做前端打包优化,一堆教程关于如何打包多个chunk文件。今天,我们介绍一种简单却很有效的方式,就是将业务代码和依赖包分开打包。也就是node_modules的代码打包为vendor.js,业务代码打包为index.js。这样一来,我们的依赖包都会打包到vendor.js中,浏览器也能很好的缓存该文件。

我们这里同样要使用webpackCommonsChunkPlugin插件。但是对chunk文件的处理需要我们写函数实现:

// ...
export default webpackConfig => {
    // ...
    
    // 分离出node_modules下的包到vendor.hash8.js中
    const nodeModulesPath = path.join(__dirname, './node_modules');
    webpackConfig.plugins.push(new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      filename: '[name].[hash:8].js',
      minChunks: function (module, _) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(nodeModulesPath) >= 0
        )
      }
    }));
    
    // 打包速度监控
    return smp.wrap(webpackConfig);
}
复制代码

简单来讲就是只要是node_modules下的js文件,都统一打包到vendorchunk文件中。当然css相关的样式文件是通过loader处理,不在chunk打包处理范围中。

这样,我们打包就会生成2个js文件:index.hash8.jsvendor.hash8.js。而且插件会自动的将这两个生成产物添加到index.html中。

当然,如果分离出来的包依然很大,可以考虑配置多个chunk文件。

以上便是对打包体积的优化,在我们的项目中最终的index.js大小约为优化前的10%,优化效果很明显。

第四步,【优化】提升打包效率

1. 过滤非必须的插件

从一开始我们配置的打包耗时分析来看,CaseSensitivePathsPluginIgnorePlugin插件等,有着明显的耗时。但是这些插件本身对包体积影响不大,或者说在开发环境下影响不大。因此,我们可以在webpack的配置中过滤掉部分不需要的插件(这些插件来自于roadhog配置)。

// ...
export default webpackConfig => {
  // 过滤部分无用的插件
  webpackConfig.plugins = webpackConfig.plugins.filter(p => {
    switch (p.constructor.name) {
      case 'UglifyJsPlugin':
      case 'CaseSensitivePathsPlugin':
      case 'IgnorePlugin':
      case 'ProgressPlugin':
        return false;
      case 'HardSourceWebpackPlugin':
        return isDev;
    }
    return true;
  });
  
  //...
  return smp.wrap(webpackConfig);
}
复制代码

这里,我过滤了几个对项目打包影响不大的插件。其中UglifyJsPlugin插件对打包影响很大,但是耗时较长,因此我们用另一个插件ParallelUglifyPlugin来替代它。

2. 升级UglifyJsPluginParallelUglifyPlugin

readme

该插件提升了UglifyJsPlugin插件的构建效率,作用与UglifyJsPlugin一致。都是用来做js代码压缩美化的。该插件能有效的降低构建的js包大小。耗时很大,却不影响打包运行。所以,为了进一步提升开发时的打包效率,可以仅在生产环境下开启该插件,配置参考如下:

// ...
export default webpackConfig => {
 // 生产环境
  if (!isDev) {
    // 代码压缩美化插件
    webpackConfig.plugins.push(
      new ParallelUglifyPlugin({
        // 传递给 UglifyJS 的参数
        uglifyJS: {
          output: {
            // 最紧凑的输出
            beautify: false,
            // 删除所有的注释
            comments: false,
          },
          compress: {
            // 删除所有的 `console` 语句,可以兼容ie浏览器
            drop_console: true,
            // 内嵌定义了但是只用到一次的变量
            collapse_vars: true,
            // 提取出出现多次但是没有定义成变量去引用的静态值
            reduce_vars: true,
            // 未使用的
            unused: false
          }
        },
        cacheDir: './cache',
        workerCount: os.cpus().length
      }),
    );
  }
 
  //...
  return smp.wrap(webpackConfig);
}
复制代码

3. 利用多线程处理babel-loader

webpack下多线程打包可以优化大项目下loader构建效率。因为webpack打包大部分都是在处理js文件和css文件,这时就需要各种loader来处理。而webpack多线程加载loader一般有两种方式:Happypack插件和thread-loader。两种方案的性能差不多。但是Happypackroadhog的项目上有版本冲突的影响,配置相对来说比thread-loader复杂一些。因此我们这里采用thread-loader来处理。

使用thread-loader也有一个弊端,就是它对cssloader支持不太好。同时要修改roadhogcssloader会有兼容问题,所以我们这里只处理jsbabel-loader。参考配置如下:

// ...
export default webpackConfig => {
 webpackConfig.module.rules.forEach(r => {
    switch (r.test + '') {
      case '/\\.js$/':
      case '/\\.jsx$/':
        if (r.use && Array.from(r.use).indexOf('thread-loader') < 0) {
          r.use.splice(0, 0, 'thread-loader');
          r.exclude = /node_modules/;
        }
        break;
      default:
        break;
    }
  });
  
  //...
  return smp.wrap(webpackConfig);
}
复制代码

4. 区分生产环境和开发环境

开发环境需要尽可能的让打包编译(每次热更新)更快,同时控制台输出更多有效的信息方便开发者调试。生产环境则需要尽可能降低包大小。其实在上面已经用到了环境判断标志,获取方式也很简单:

// 我们可以通过读取proccess.env的变量判断当前是什么打包环境。
const isDev = process.env.NODE_ENV === 'development';
复制代码

建议以下插件在dev环境下使用:

  • BundleAnalyzerPlugin
  • HardSourceWebpackPlugin

建议以下插件在prod环境下使用:

  • ParallelUglifyPlugin
  • IgnorePlugin

配置完成后,整体打包效率在我们的项目中提升了50%以上。

最后,附上项目中的完整配置文件,供大家参考

const os = require('os');
const webpack = require('webpack');
const path = require('path');

// 提取公共包
const CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;

// 是否为测试环境
const isDev = process.env.NODE_ENV === 'development';

// 外部资源(cdn)插件
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')

// 代码压缩
const ParallelUglifyPlugin = !isDev ? require('webpack-parallel-uglify-plugin') : null;

// 多线程并发
const threadLoader = require('thread-loader');
threadLoader.warmup({
  // pool options, like passed to loader options
  // must match loader options to boot the correct pool
}, [
  // modules to load
  // can be any module, i. e.
  'babel-loader',
  'url-loader',
  'file-loader'
]);

// 构建测速
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();

// 进度条
const WebpackBar = require('webpackbar');

// 资源分析
const BundleAnalyzerPlugin = isDev ? require('webpack-bundle-analyzer').BundleAnalyzerPlugin : null;

/**
 * 导出最新的webpack配置,会覆盖roadhog导出的配置
 * @param webpackConfig
 * @returns {*|(function(...[*]): *)}
 *
 * @author luodong
 */
export default webpackConfig => {
  // 过滤部分无用的插件
  webpackConfig.plugins = webpackConfig.plugins.filter(p => {
    switch (p.constructor.name) {
      case 'UglifyJsPlugin':
      case 'CaseSensitivePathsPlugin':
      case 'IgnorePlugin':
      case 'ProgressPlugin':
      case 'HardSourceWebpackPlugin':
        return false;
    }
    return true;
  });

  // 开发环境插件
  if (isDev) {
    // 打包资源分析插件
    webpackConfig.plugins.push(
      new BundleAnalyzerPlugin()
    );
  }

  // 生产环境
  if (!isDev) {
    // 代码压缩美化插件
    webpackConfig.plugins.push(
      new ParallelUglifyPlugin({
        // 传递给 UglifyJS 的参数
        uglifyJS: {
          output: {
            // 最紧凑的输出
            beautify: false,
            // 删除所有的注释
            comments: false,
          },
          compress: {
            // 删除所有的 `console` 语句,可以兼容ie浏览器
            drop_console: true,
            // 内嵌定义了但是只用到一次的变量
            collapse_vars: true,
            // 提取出出现多次但是没有定义成变量去引用的静态值
            reduce_vars: true,
            // 未使用的
            unused: false
          }
        },
        cacheDir: './cache',
        workerCount: os.cpus().length
      }),
    );

    // 忽略moment的语言包
    webpackConfig.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/));
  }

  // 进度条显示
  webpackConfig.plugins.push(
    new WebpackBar({
      profile: true,
      // reporters: ['profile']
    })
  );

  // 分离出node_modules下的包到vendor.hash8.js中
  const nodeModulesPath = path.join(__dirname, './node_modules');
  webpackConfig.plugins.push(new CommonsChunkPlugin({
    name: 'vendor',
    filename: '[name].[hash:8].js',
    minChunks: function (module, _) {
      return (
        module.resource &&
        /\.js$/.test(module.resource) &&
        module.resource.indexOf(nodeModulesPath) >= 0
      )
    }
  }));

  // 引入外部cdn资源
  webpackConfig.plugins.push(
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: 'react',
          entry: isDev ?
            'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.development.js' :
            'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js',
          global: 'window.React'
        },
        {
          module: 'react-dom',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js',
          global: 'window.ReactDOM'
        },
        {
          module: 'bizcharts',
          entry: 'http://gw.alipayobjects.com/os/lib/bizcharts/3.4.5/umd/BizCharts.min.js',
          global: 'window.BizCharts'
        },
        {
          module: '@antv/data-set',
          entry: 'https://cdn.jsdelivr.net/npm/@antv/data-set@0.11.8/build/data-set.min.js',
          global: 'window.DataSet'
        },
        {
          module: 'echarts',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/echarts/4.8.0/echarts.min.js',
          global: 'window.echarts'
        },
        {
          module: 'jquery',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js',
          global: 'window.jQuery'
        },
        {
          module: 'xlsx',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js',
          global: 'window.XLSX'
        },
        {
          module: 'lodash',
          entry: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js'
        }
      ],
      files: ['index.html'],
    })
  );

  // 处理rules,添加thread-loader(暂时只支持js处理)
  // 过滤不需要的loader
  // webpackConfig.module.rules = webpackConfig.module.rules.filter(r =>
  //   ['/\\.(sass|scss)$/', '/\\.html$/'].indexOf(r.test + '') < 0);
  webpackConfig.module.rules.forEach(r => {
    switch (r.test + '') {
      case '/\\.less$/':
      case '/\\.css$/':
        // r.use = ['happypack/loader?id=styles'];
        // r.exclude = /node_modules/;
        break;
      case '/\\.js$/':
      case '/\\.jsx$/':
        if (r.use && Array.from(r.use).indexOf('thread-loader') < 0) {
          r.use.splice(0, 0, 'thread-loader');
          r.exclude = /node_modules/;
        }
        break;
      default:
        break;
    }
  });

  return smp.wrap(webpackConfig);
};

复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享