webpack 构建性能优化策略小结

背景

现在前端工程化的概念早已经深刻人心,选择一款合适的编译和资源管理工具已经成为了全部前端工程中的标配,而在诸多的构建工具中,webpack以其丰富的功能和灵活的配置而深受业内吹捧,逐步取代了grunt和gulp成为大多数前端工程实践中的首选,React,Vue,Angular等诸多知名项目也都相继选用其做为官方构建工具,极受业内追捧。可是,随者工程开发的复杂程度和代码规模不断地增长,webpack暴露出来的各类性能问题也愈发明显,极大的影响着开发过程当中的体验。css

图片描述

问题概括

历经了多个web项目的实战检验,咱们对webapck在构建中逐步暴露出来的性能问题概括主要有以下几个方面:html

  • 代码全量构建速度过慢,即便是很小的改动,也要等待长时间才能查看到更新与编译后的结果(引入HMR热更新后有明显改进);
  • 随着项目业务的复杂度增长,工程模块的体积也会急剧增大,构建后的模块一般要以M为单位计算;
  • 多个项目之间共用基础资源存在重复打包,基础库代码复用率不高;
  • node的单进程实如今耗cpu计算型loader中表现不佳;

针对以上的问题,咱们来看看怎样利用webpack现有的一些机制和第三方扩展插件来逐个击破。前端

慢在何处

做为工程师,咱们一直鼓励要理性思考,用数据和事实说话,“我以为很慢”,“太卡了”,“太大了”之类的表述不免显得太笼统和太抽象,那么咱们不妨从以下几个方面来着手进行分析:node

图片描述

  • 从项目结构着手,代码组织是否合理,依赖使用是否合理;
  • 从webpack自身提供的优化手段着手,看看哪些api未作优化配置;
  • 从webpack自身的不足着手,作有针对性的扩展优化,进一步提高效率;

在这里咱们推荐使用一个wepback的可视化资源分析工具:webpack-bundle-analyzer,在webpack构建的时候会自动帮你计算出各个模块在你的项目工程中的依赖与分布状况,方便作更精确的资源依赖和引用的分析。react

从上图中咱们不难发现大多数的工程项目中,依赖库的体积永远是大头,一般体积能够占据整个工程项目的7-9成,并且在每次开发过程当中也会从新读取和编译对应的依赖资源,这实际上是很大的的资源开销浪费,并且对编译结果影响微乎其微,毕竟在实际业务开发中,咱们不多会去主动修改第三方库中的源码,改进方案以下:jquery

方案1、合理配置 CommonsChunkPlugin

webpack的资源入口一般是以entry为单元进行编译提取,那么当多entry共存的时候,CommonsChunkPlugin的做用就会发挥出来,对全部依赖的chunk进行公共部分的提取,可是在这里可能不少人会误认为抽取公共部分指的是能抽取某个代码片断,其实并不是如此,它是以module为单位进行提取。webpack

假设咱们的页面中存在entry1,entry2,entry3三个入口,这些入口中可能都会引用如utils,loadash,fetch等这些通用模块,那么就能够考虑对这部分的共用部分机提取。一般提取方式有以下四种实现:git

一、传入字符串参数,由chunkplugin自动计算提取es6

new webpack.optimize.CommonsChunkPlugin('common.js')

这种作法默认会把全部入口节点的公共代码提取出来, 生成一个common.jsgithub

二、有选择的提取公共代码

new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']);

只提取entry1节点和entry2中的共用部分模块, 生成一个common.js

三、将entry下全部的模块的公共部分(可指定引用次数)提取到一个通用的chunk中

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendors',
    minChunks: function (module, count) {
       return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
       )
    }
});

提取全部node_modules中的模块至vendors中,也能够指定minChunks中的最小引用数;

四、抽取enry中的一些lib抽取到vendors中

entry = {
    vendors: ['fetch', 'loadash']
};
new webpack.optimize.CommonsChunkPlugin({
    name: "vendors",
    minChunks: Infinity
});

添加一个entry名叫为vendors,并把vendors设置为所须要的资源库,CommonsChunk会自动提取指定库至vendors中。

方案2、经过 externals 配置来提取经常使用库

在实际项目开发过程当中,咱们并不须要实时调试各类库的源码,这时候就能够考虑使用external选项了。

图片描述

简单来讲external就是把咱们的依赖资源声明为一个外部依赖,而后经过script外链脚本引入。这也是咱们早期页面开发中资源引入的一种翻版,只是经过配置后能够告知webapck遇到此类变量名时就能够不用解析和编译至模块的内部文件中,而改用从外部变量中读取,这样能极大的提高编译速度,同时也能更好的利用CDN来实现缓存。

external的配置相对比较简单,只须要完成以下三步:

一、在页面中加入须要引入的lib地址,以下:

<head>
<script src="//cdn.bootcss.com/jquery.min.js"></script>
<script src="//cdn.bootcss.com/underscore.min.js"></script>
<script src="/static/common/react.min.js"></script>
<script src="/static/common/react-dom.js"></script>
<script src="/static/common/react-router.js"></script>
<script src="/static/common/immutable.js"></script>
</head>

二、在webapck.config.js中加入external配置项:

module.export = {
    externals: {
        'react-router': {
            amd: 'react-router',
            root: 'ReactRouter',
            commonjs: 'react-router',
            commonjs2: 'react-router'
        },
        react: {
            amd: 'react',
            root: 'React',
            commonjs: 'react',
            commonjs2: 'react'
        },
        'react-dom': {
            amd: 'react-dom',
            root: 'ReactDOM',
            commonjs: 'react-dom',
            commonjs2: 'react-dom'
        }
    }
}

这里要提到的一个细节是:此类文件在配置前,构建这些资源包时须要采用amd/commonjs/cmd相关的模块化进行兼容封装,即打包好的库已是umd模式包装过的,如在node_modules/react-router中咱们能够看到umd/ReactRouter.js之类的文件,只有这样webpack中的require和import * from 'xxxx'才能正确读到该类包的引用,在这类js的头部通常也能看到以下字样:

if (typeof exports === 'object' && typeof module === 'object') {
    module.exports = factory(require("react"));
} else if (typeof define === 'function' && define.amd) {
    define(["react"], factory);
} else if (typeof exports === 'object') {
    exports["ReactRouter"] = factory(require("react"));
} else {
    root["ReactRouter"] = factory(root["React"]);
}

三、很是重要的是必定要在output选项中加入以下一句话:

output: {
  libraryTarget: 'umd'
}

因为经过external提取过的js模块是不会被记录到webapck的chunk信息中,经过libraryTarget可告知咱们构建出来的业务模块,当读到了externals中的key时,须要以umd的方式去获取资源名,不然会有出现找不到module的状况。

经过配置后,咱们能够看到对应的资源信息已经能够在浏览器的source map中读到了。

externals.png

对应的资源也能够直接由页面外链载入,有效地减少了资源包的体积。

图片描述

方案3、利用 DllPlugin 和 DllReferencePlugin 预编译资源模块

咱们的项目依赖中一般会引用大量的npm包,而这些包在正常的开发过程当中并不会进行修改,可是在每一次构建过程当中却须要反复的将其解析,如何来规避此类损耗呢?这两个插件就是干这个用的。

简单来讲DllPlugin的做用是预先编译一些模块,而DllReferencePlugin则是把这些预先编译好的模块引用起来。这边须要注意的是DllPlugin必需要在DllReferencePlugin执行前先执行一次,dll这个概念应该也是借鉴了windows程序开发中的dll文件的设计理念。

相对于externals,dllPlugin有以下几点优点:

  • dll预编译出来的模块能够做为静态资源连接库可被重复使用,尤为适合多个项目之间的资源共享,如同一个站点pc和手机版等;
  • dll资源能有效地解决资源循环依赖的问题,部分依赖库如:react-addons-css-transition-group这种原先从react核心库中抽取的资源包,整个代码只有一句话:

    module.exports = require('react/lib/ReactCSSTransitionGroup');

    却由于从新指向了react/lib中,这也会致使在经过externals引入的资源只能识别react,寻址解析react/lib则会出现没法被正确索引的状况。

  • 因为externals的配置项须要对每一个依赖库进行逐个定制,因此每次增长一个组件都须要手动修改,略微繁琐,而经过dllPlugin则能彻底经过配置读取,减小维护的成本;

一、配置dllPlugin对应资源表并编译文件

那么externals该如何使用呢,其实只须要增长一个配置文件:webpack.dll.config.js:

const webpack = require('webpack');
const path = require('path');
const isDebug = process.env.NODE_ENV === 'development';
const outputPath = isDebug ? path.join(__dirname, '../common/debug') : path.join(__dirname, '../common/dist');
const fileName = '[name].js';

// 资源依赖包,提早编译
const lib = [
  'react',
  'react-dom',
  'react-router',
  'history',
  'react-addons-pure-render-mixin',
  'react-addons-css-transition-group',
  'redux',
  'react-redux',
  'react-router-redux',
  'redux-actions',
  'redux-thunk',
  'immutable',
  'whatwg-fetch',
  'byted-people-react-select',
  'byted-people-reqwest'
];

const plugin = [
  new webpack.DllPlugin({
    /**
     * path
     * 定义 manifest 文件生成的位置
     * [name]的部分由entry的名字替换
     */
    path: path.join(outputPath, 'manifest.json'),
    /**
     * name
     * dll bundle 输出到那个全局变量上
     * 和 output.library 同样便可。
     */
    name: '[name]',
    context: __dirname
  }),
  new webpack.optimize.OccurenceOrderPlugin()
];

if (!isDebug) {
  plugin.push(
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new webpack.optimize.UglifyJsPlugin({
      mangle: {
        except: ['$', 'exports', 'require']
      },
      compress: { warnings: false },
      output: { comments: false }
    })
  )
}

module.exports = {
  devtool: '#source-map',
  entry: {
    lib: lib
  },
  output: {
    path: outputPath,
    filename: fileName,
    /**
     * output.library
     * 将会定义为 window.${output.library}
     * 在此次的例子中,将会定义为`window.vendor_library`
     */
    library: '[name]',
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  plugins: plugin
};

而后执行命令:

$ NODE_ENV=development webpack --config  webpack.dll.lib.js --progress
 $ NODE_ENV=production webpack --config  webpack.dll.lib.js --progress

便可分别编译出支持调试版和生产环境中lib静态资源库,在构建出来的文件中咱们也能够看到会自动生成以下资源:

common
├── debug
│   ├── lib.js
│   ├── lib.js.map
│   └── manifest.json
└── dist
    ├── lib.js
    ├── lib.js.map
    └── manifest.json

文件说明:

  • lib.js能够做为编译好的静态资源文件直接在页面中经过src连接引入,与externals的资源引入方式同样,生产与开发环境能够经过相似charles之类的代理转发工具来作路由替换;
  • manifest.json中保存了webpack中的预编译信息,这样等于提早拿到了依赖库中的chunk信息,在实际开发过程当中就无须要进行重复编译;

二、dllPlugin的静态资源引入

lib.js和manifest.json存在一一对应的关系,因此咱们在调用的过程也许遵循这个原则,如当前处于开发阶段,对应咱们能够引入common/debug文件夹下的lib.js和manifest.json,切换到生产环境的时候则须要引入common/dist下的资源进行对应操做,这里考虑到手动切换和维护的成本,咱们推荐使用add-asset-html-webpack-plugin进行依赖资源的注入,可获得以下结果:

<head>
<script src="/static/common/lib.js"></script>
</head>

在webpack.config.js文件中增长以下代码:

const isDebug = (process.env.NODE_ENV === 'development');
const libPath = isDebug ? '../dll/lib/manifest.json' : 
'../dll/dist/lib/manifest.json';

// 将mainfest.json添加到webpack的构建中

module.export = {
  plugins: [
       new webpack.DllReferencePlugin({
       context: __dirname,
       manifest: require(libPath),
      })
  ]
}

配置完成后咱们能发现对应的资源包已经完成了纯业务模块的提取

图片描述

多个工程之间若是须要使用共同的lib资源,也只须要引入对应的lib.js和manifest.js便可,plugin配置中也支持多个webpack.DllReferencePlugin同时引入使用,以下:

module.export = {
  plugins: [
     new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(libPath),
      }),
      new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(ChartsPath),
      })
  ]
}

方案4、使用 Happypack 加速你的代码构建

以上介绍均为针对webpack中的chunk计算和编译内容的优化与改进,对资源的实际体积改进上也较为明显,那么除此以外,咱们可否针对资源的编译过程和速度优化上作些尝试呢?

众所周知,webpack中为了方便各类资源和类型的加载,设计了以loader加载器的形式读取资源,可是受限于node的编程模型影响,全部的loader虽然以async的形式来并发调用,可是仍是运行在单个 node的进程以及在同一个事件循环中,这就直接致使了当咱们须要同时读取多个loader文件资源时,好比babel-loader须要transform各类jsx,es6的资源文件。在这种同步计算同时须要大量耗费cpu运算的过程当中,node的单进程模型就无优点了,那么happypack就针对解决此类问题而生。

开启happypack的线程池

happypack的处理思路是将原有的webpack对loader的执行过程从单一进程的形式扩展多进程模式,本来的流程保持不变,这样能够在不修改原有配置的基础上来完成对编译过程的优化,具体配置以下:

const HappyPack = require('happypack');
 const os = require('os')
 const HappyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length}); // 启动线程池});

module:{
    rules: [
      {
        test: /\.(js|jsx)$/,
        // use: ['babel-loader?cacheDirectory'],
        use: 'happypack/loader?id=jsx',
        exclude: /^node_modules$/
      }
    ]
  },
  plugins:[
    new HappyPack({
     id: 'jsx',
     cache: true,
     threadPool: HappyThreadPool,
     loaders: ['babel-loader']
   })
  ]

咱们能够看到经过在loader中配置直接指向happypack提供的loader,对于文件实际匹配的处理 loader,则是经过配置在plugin属性来传递说明,这里happypack提供的loader与plugin的衔接匹配,则是经过id=happybabel来完成。配置完成后,laoder的工做模式就转变成了以下所示:

图片描述

happypack在编译过程当中除了利用多进程的模式加速编译,还同时开启了cache计算,能充分利用缓存读取构建文件,对构建的速度提高也是很是明显的,通过测试,最终的构建速度提高以下:

优化前:
图片描述

优化后:
图片描述

关于happyoack的更多介绍能够查看:

方案5、加强 uglifyPlugin

uglifyJS凭借基于node开发,压缩比例高,使用方便等诸多优势已经成为了js压缩工具中的首选,可是咱们在webpack的构建中观察发现,当webpack build进度走到80%先后时,会发生很长一段时间的停滞,经测试对比发现这一过程正是uglfiyJS在对咱们的output中的bunlde部分进行压缩耗时过长致使,针对这块咱们可使用webpack-uglify-parallel来提高压缩速度。

从插件源码中能够看到,webpack-uglify-parallel的是实现原理是采用了多核并行压缩的方式来提高咱们的压缩速度。

plugin.nextWorker().send({
    input: input,
    inputSourceMap: inputSourceMap,
    file: file,
    options: options
});

plugin._queue_len++;
                
if (!plugin._queue_len) {
    callback();
}               

if (this.workers.length < this.maxWorkers) {
    var worker = fork(__dirname + '/lib/worker');
    worker.on('message', this.onWorkerMessage.bind(this));
    worker.on('error', this.onWorkerError.bind(this));
    this.workers.push(worker);
}

this._next_worker++;
return this.workers[this._next_worker % this.maxWorkers];

使用配置也很是简单,只须要将咱们原来webpack中自带的uglifyPlugin配置:

new webpack.optimize.UglifyJsPlugin({
   exclude:/\.min\.js$/
   mangle:true,
   compress: { warnings: false },
   output: { comments: false }
})

修改为以下代码便可:

const os = require('os');
    const UglifyJsParallelPlugin = require('webpack-uglify-parallel');
    
    new UglifyJsParallelPlugin({
      workers: os.cpus().length,
      mangle: true,
      compressor: {
        warnings: false,
        drop_console: true,
        drop_debugger: true
       }
    })

目前webpack官方也维护了一个支持多核压缩的UglifyJs插件:uglifyjs-webpack-plugin,使用方式相似,优点在于彻底兼容webpack.optimize.UglifyJsPlugin中的配置,能够经过uglifyOptions写入,所以也作为推荐使用,参考配置以下:

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
  new UglifyJsPlugin({
    uglifyOptions: {
      ie8: false,
      ecma: 8,
      mangle: true,
      output: { comments: false },
      compress: { warnings: false }
    },
    sourceMap: false,
    cache: true,
    parallel: os.cpus().length * 2
  })

方案6、Tree-shaking & Scope Hoisting

wepback在2.X和3.X中从rolluo中借鉴了tree-shakingScope Hoisting,利用es6的module特性,利用AST对全部引用的模块和方法作了静态分析,从而能有效地剔除项目中的没有引用到的方法,并将相关方法调用概括到了独立的webpack_module中,对打包构建的体积优化也较为明显,可是前提是全部的模块写法必须使用ES6 Module进行实现,具体配置参考以下:

// .babelrc: 经过配置减小没有引用到的方法
  {
    "presets": [
      ["env", {
        "targets": {
          "browsers": ["last 2 versions", "safari >= 7"]
        }
      }],
      // https://www.zhihu.com/question/41922432
      ["es2015", {"modules": false}]  // tree-shaking
    ]
  }

  // webpack.config: Scope Hoisting
  {
    plugins:[
      // https://zhuanlan.zhihu.com/p/27980441
      new webpack.optimize.ModuleConcatenationPlugin()
    ]
  }

适用场景

在实际的开发过程当中,可灵活地选择适合自身业务场景的优化手段。

优化手段 开发环境 生产环境
CommonsChunk
externals  
DllPlugin
Happypack  
uglify-parallel  

工程演示demo

舒适提醒

本文中的全部例子已经从新优化,支持最新的webpack3特性,并附带有分享ppt地址,能够在线点击查看

小结

性能优化无小事,追求快没有止境,在前端工程日益庞大复杂的今天,针对实际项目,持续改进构建工具的性能,对项目开发效率的提高和工具深度理解都是极其有益的。