Webpack4+Babel7优化70%速度

28,378 阅读12分钟
作者 DBCdouble

项目源码demo:点击这里

一、前言

随着2018年2月15号webpack4.0.0出来已经有一段时间了,webpack依靠着“零配置”,“最高可提升98%的速度”成功吸粉无数,对于饱受项目打包时间过长的我,无疑是看到了曙光,于是决定开始试水。

二、项目框架与环境

升级前:
  • Node: v8.11.4
  • webpack: ^1.12.9
  • babel相关: ^6.x
  • react: ^0.14.8(第一次看到react版本的时候,我有点懵,再看一下是真的哈哈😂,不禁赞叹最初架构这个项目的人一定是个react大佬,后续会更新文章升级到react16.x)
  • react-router: ^2.6.1(后续会更新文章升级到react-router4.x)
  • 相关loaders
  • 路由组件(页面): 130个(项目采用SPA应用,目前有130个路由页面,所以,如果在足够大的应用上能成功提升构建速度或减小文件大小,那么webpack4.0的版本更新才显得有意义)

升级后:

  • Node: v8.11.4
  • webpack: ^4.29.5
  • babel相关: ^7.x
  • react: ^0.14.8
  • react-router: ^2.6.1
  • 相关loaders(在后面会详细说明升级的loaders)
  • 路由组件(页面)数量不变

三、背景

随着项目的不断迭代,样式文件和js文件的数量越来越多,造成webpack的打包花费的时间越来越多,在开发环境下,经常需要频繁调试某一段代码ctrl+s会出现长时间等待的现象(等得好烦),日积月累,浪费了太多的时间在等待打包上。生产环境就更不用说了,平均时长100s~120s左右,通常情况情况下,输入npm run deploy打包之后,我会选择出去抽根烟。而如果情况是要解决线上的bug,则是分秒必争,所以优化打包时间势在必行

四、分析

webpack的构建流程

  • 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler
  • 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  • 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到本地。

打包分析

webpack2.x生产环境花费时间: 104.145s


webpack2.x开发环境花费时间: 68099ms


虽然能直观得看到webpack2打包所花费的时间,但我们并不知道webpack打包经过了哪些步骤,在哪个环节花费了大量时间。这里可以使用speed-measure-webpack-plugin来检测webpack打包过程中各个部分所花费的时间,在终端输入以下命令进行安装。

npm install speed-measure-webpack-plugin -D

安装完成之后,我们再webpack的配置文件中配置它

webpack.config.js



参考speed-measure-webpack-plugin的使用方式,查看这里

配置好之后,启动项目(这里只对开发环境进行分析了)后,如下图


从上图可以看出,webpack打包过程中绝大部分时间花在了loader上,也就是webpack构建流程的第二个环节,编译阶段。注意上面还能看到ProgressPlugin花费了28.87s,所以在我们不需要分析webpack打包流程花费的时间后,可在webpack.config.js中注释掉

五、安装和配置

1、webpack

先删除之前的webpack、webpack-cli、webpack-dev-server

npm uninstall webpack webpack-dev-server webpack-cli &&  npm uninstall webpacl-cli -g

安装最新版本的webpack、webpack-cli(webpack4把脚手架webpack-cli从webpack中抽离出来的,所以必须安装webpack-cli)、webpack-dev-server

npm install webpack webpack-dev-server webpack-cli -D

我这里顺便再把webpack的相关插件更新到最新版本,因为webpack做了很大的改动相对webpakc2,以防之前老版本的插件不兼容webpack4,所以我这边将项目中的webpack相关插件的模块都先删除掉,以便更新的时候分析错误

npm uninstall extract-text-webpack-plugin html-webpack-plugin webpack-dev-middleware webpack-hot-middleware

2、升级babel7

删除之前的babel相关模块

npm uninstall babel-core babel-loader babel-cli babel-eslint babel-plugin-react-transform babel-plugin-transform-runtime babel-preset-es2015 babel-preset-react babel-preset-stage-0 babel-runtime

安装babel7

npm install @babel/cli @babel/core babel-loader @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators @babel/plugin-proposal-export-default-from @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react


.babelrc 文件为babel的配置文件(我这边是直接在webpack.config.js的babel-loader的options下配置的,.babelrc文件中注意需要转换为json格式,需要将属性名加双引号)


3、安装ESlint

在项目的根目录下,安装eslinteslint-loader

npm install eslint eslint-loader -D


.eslintrc是ESlint的配置文件,我们需要在项目的根目录下增加.eslintrc文件。

{
  "parser": "babel-eslint",
  "env": {
      "browser": true,
      "es6": true,
      "node": true
  },
  "globals" : {
    "Action"       : false,
    "__DEV__"      : false,
    "__PROD__"     : false,
    "__DEBUG__"    : false,
    "__DEBUG_NEW_WINDOW__" : false,
    "__BASENAME__" : false
  },
  "parserOptions": {
      "ecmaVersion": 6,
      "sourceType": "module"
  },
  "extends": "airbnb",
  "rules": {
      "semi": [0],
      "react/jsx-filename-extension": [0]
  }}

webpack.config.js中,为需要检测的文件添加eslint-loader加载器。一般我们是在代码编译前进行检测。

webpack.config.js



注意,这里的isEslint是通过npm scripts传的参数eslint来判断当前环境是否需要进行代码格式检查,以便开发者有更多选择,并且eslint-loader必须配置在babel-loader之前,所以这里用unshift来添加eslint-loader

packack.json

在package.json文件中添加如下命令

{
    "scripts": {
        "eslint": "eslint --ext .js --ext .jsx src/"
    }
}

到这里,就可以通过执行 npm run eslint来检测src文件下的代码格式了

4、安装打包需要插件

npm install webpack-merge yargs-parser clean-webpack-plugin progress-bar-webpack-plugin webpack-build-notifier html-webpack-plugin mini-css-extract-plugin add-asset-html-webpack-plugin uglifyjs-webpack-plugin optimize-css-assets-webpack-plugin friendly-errors-webpack-plugin happypack

  • webpack-merge: 用于合并webpack的公共配置和环境配置(合并webpack.config.js和webpack.development.js或者webpack.production.js)
  • yargs-parser: 用于将我们的npm scripts中的命令行参数转换成键值对的形式如 --mode development会被解析成键值对的形式mode: "development",便于在配置文件中获取参数
  • clean-webpack-plugin: 用于清除本地文件,在进行生产环境打包的时候,如果不清除dist文件夹,那么每次打包都会生成不同的js文件或者css文件堆积在文件夹中,因为每次打包都会生成不同的hash值导致每次打包生成的文件名与上次打包不一样不会覆盖上次打包留下来的文件
  • progress-bar-webpack-plugin: 打包编译的时候以进度条的形式反馈打包进度
  • webpack-build-notifier: 当你打包之后切换到别的页面的时候,完成时会在本地系统弹出一个提示框告知你打包结果(成功或失败或警告)
  • html-webpack-plugin: 自动生成html,并默认将打包生成的js、css引入到html文件中
  • mini-css-extract-plugin: webpack打包样式文件中的默认会把样式文件代码打包到bundle.js中,mini-css-extract-plugin这个插件可以将样式文件从bundle.js抽离出来一个文件,并且支持chunk css

  • add-asset-html-webpack-plugin: 从命名可以看出,它的作用是可以将静态资源css或者js引入到html-webpack-plugin生成的html文件中

  • uglifyjs-webpack-plugin: 代码丑化,用于js压缩(可以调用系统的线程进行多线程压缩,优化webpack的压缩速度)

  • optimize-css-assets-webpack-plugin: css压缩,主要使用 cssnano 压缩器(webpack4的执行环境内置了cssnano,所以不用安装)

  • friendly-errors-webpack-plugin: 能够更好在终端看到webapck运行的警告和错误
  • happypack: 多线程编译,加快编译速度(加快loader的编译速度),注意,thread-loader不可以和 mini-css-extract-plugin 结合使用

  • splitChunks: CommonChunkPlugin 的后世,用于对bundle.js进行chunk切割(webpack的内置插件)
  • DllPlugin: 将模块预先编译,它会在第一次编译的时候将配置好的需要预先编译的模块编译在缓存中,第二次编译的时候,解析到这些模块就直接使用缓存,而不是去编译这些模块(webpack的内置插件)
  • DllReferencePlugin: 将预先编译好的模块关联到当前编译中,当 webpack 解析到这些模块时,会直接使用预先编译好的模块(webpack的内置插件)
  • HotModuleReplacementPlugin: 实现局部热加载(刷新),区别与在webpack-dev-server的全局刷新(webpack的内置插件)

5、webpack相关文件配置

以下文件直接在你的项目copy就能使用

webpack.config.js

const path = require('path')
const webpack = require('webpack')
const os = require('os')
const merge = require('webpack-merge')
const argv = require('yargs-parser')(process.argv.slice(2))
const mode = argv.mode || 'development'
const interface = argv.interface || 'development'
const isEslint = !!argv.eslint 
const isDev = mode === 'development'
const mergeConfig = require(`./config/webpack.${mode}.js`)
const CleanWebpackPlugin = require('clean-webpack-plugin')
const ProgressBarPlugin = require('progress-bar-webpack-plugin')
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin")
const WebpackBuildNotifierPlugin = require('webpack-build-notifier')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')
const FirendlyErrorePlugin = require('friendly-errors-webpack-plugin')
const HappyPack = require('happypack')
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })
const smp = new SpeedMeasurePlugin()
const loading = {  html:"加载中..."}
const apiConfig = {
  development: 'http://xxxxx/a',
  production: 'http://xxx/b'
}
let commonConfig = {
  module: {
    rules: [{
      test: /\.js$/,
      loaders: ['happypack/loader?id=babel'],
      include: path.resolve(__dirname, 'src'),
      exclude: /node_modules/
    },{
      test: /\.css$/,
      loaders: [
        MiniCssExtractPlugin.loader,
        'css-loader'
      ]
    },{
      test: /\.less$/,
      loaders: [
        isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
        'css-loader',
        {
          loader:'less-loader?sourceMap=true',
          options:{
              javascriptEnabled: true
          },
        }
        // include: path.resolve(__dirname, 'src')
      ]
    },{
        test: /\.(png|svg|jpg|gif)$/,
        use: [
            'url-loader'
        ]
    },{
        test: /\.(woff|woff2|eot|ttf|otf|ico)$/,
        use: [
            'file-loader'
        ]
    },{
        test: /\.(csv|tsv)$/,
        use: [
            'csv-loader'
        ]
    },{
        test: /\.xml$/,
        use: [
            'xml-loader'
        ]
    },{
        test: /\.md$/,
        use: [
            "html-loader",
             "markdown-loader"
        ]
    }]
  },
  //解析  resolve: {
      extensions: ['.js', '.jsx'], // 自动解析确定的扩展
  },
  plugins: [
    new HappyPack({
      id: 'babel',
      loaders: [{
        loader: 'babel-loader',
        options: {
          cacheDirectory: true,
          presets: ['@babel/preset-env', '@babel/preset-react'],
          plugins: [
            ['@babel/plugin-proposal-decorators', { "legacy": true }],
            '@babel/plugin-proposal-class-properties',
            '@babel/plugin-proposal-export-default-from',
            '@babel/plugin-transform-runtime',
            // 'react-hot-loader/babel',
            // 'dynamic-import-webpack',
            ['import',{
              libraryName:'antd',
              libraryDirectory: 'es',
              style:true
            }]
          ]
        }
      }],
      //共享进程池
      threadPool: happyThreadPool,
      //允许 HappyPack 输出日志
      verbose: true,
    }),
    new CleanWebpackPlugin(['dist']),
    new ProgressBarPlugin(),
    new WebpackBuildNotifierPlugin({
      title: "xxx后台管理系统🍎",
      logo: path.resolve(__dirname, "src/static/favicon.ico"),
      suppressSuccess: true
    }),
    new webpack.DefinePlugin({
      'process.env'  : {
        'NODE_ENV' : JSON.stringify(mode)
      },
      'NODE_ENV'     : JSON.stringify(mode),
      'baseUrl': JSON.stringify(apiConfig[interface]),
      '__DEV__'      : mode === 'development',
      '__PROD__'     : mode === 'production',
      '__TEST__'     : mode === 'test',
      '__DEBUG__'    : mode === 'development' && !argv.no_debug,
      '__DEBUG_NEW_WINDOW__' : !!argv.nw,
      '__BASENAME__' : JSON.stringify(process.env.BASENAME || '')
    }),
    new FirendlyErrorePlugin(),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html'),
      favicon: path.resolve(__dirname, 'public/favicon.ico'),
      filename: 'index.html',
      loading
    }),
    new MiniCssExtractPlugin({
      filename: isDev ? 'styles/[name].[hash:4].css' : 'styles/[name].[hash:8].css',
      chunkFilename:isDev ? 'styles/[name].[hash:4].css' : 'styles/[name].[hash:8].css'
    }),
    // 告诉 Webpack 使用了哪些动态链接库
    new webpack.DllReferencePlugin({
      // 描述 vendor 动态链接库的文件内容
      manifest: require('./public/vendor/vendor.manifest.json')
    }),
    // 该插件将把给定的 JS 或 CSS 文件添加到 webpack 配置的文件中,并将其放入资源列表 html webpack插件注入到生成的 html 中。
    new AddAssetHtmlPlugin([
        {
            // 要添加到编译中的文件的绝对路径,以及生成的HTML文件。支持 globby 字符串
            filepath: require.resolve(path.resolve(__dirname, 'public/vendor/vendor.dll.js')),
            // 文件输出目录
            outputPath: 'vendor',
            // 脚本或链接标记的公共路径
            publicPath: 'vendor'
        }
    ]),
    new webpack.HotModuleReplacementPlugin()
  ],
  devServer: {
    host: 'localhost',
    port: 8080,
    historyApiFallback: true,
    overlay: {//当出现编译器错误或警告时,就在网页上显示一层黑色的背景层和错误信息
      errors: true
    },
    inline: true,
    open: true,
    hot: true
  },
  performance: {
    // false | "error" | "warning" // 不显示性能提示 | 以错误形式提示 | 以警告...
    hints: false,    // 开发环境设置较大防止警告
    // 根据入口起点的最大体积,控制webpack何时生成性能提示,整数类型,以字节为单位
    maxEntrypointSize: 50000000,
    // 最大单个资源体积,默认250000 (bytes)
    maxAssetSize: 30000000
  }
}
if (isEslint) {
    commonConfig.module.rules.unshift[{
        //前置(在执行编译之前去执行eslint-loader检查代码规范,有报错就不执行编译)
        enforce: 'pre',
        test: /.(js|jsx)$/,
        loaders: ['eslint-loader'],
        exclude: /node_modules/
    }]
}
module.exports = merge(commonConfig, mergeConfig)

注意:这里在最后导出配置的时候并没有使用speed-measure-webpack-plugin,因为会报错,不知道是不是因为跟happypack不兼容的原因。interface用来判断当前打包js网络请求的地址,isEslint判断是否需要执行代码检测,isDev用来判断当前执行环境是development还是production,具体问题看代码


webpack.config.dll.js

const path = require('path');
const webpack = require('webpack');
const CleanWebpaclPlugin = require('clean-webpack-plugin');
const FirendlyErrorePlugin = require('friendly-errors-webpack-plugin');
module.exports = {
    mode: 'production',
    entry: {
        // 将 lodash 模块作为入口编译成动态链接库
        vendor: ['react', 'react-dom', 'react-router', 'react-redux', 'react-router-redux']
    },
    output: {
        // 指定生成文件所在目录
        // 由于每次打包生产环境时会清空 dist 文件夹,因此这里我将它们存放在了 public 文件夹下
        path: path.resolve(__dirname, 'public/vendor'),
        // 指定文件名
        filename: '[name].dll.js',
        // 存放动态链接库的全局变量名称,例如对应 vendor 来说就是 vendor_dll_lib        // 这个名称需要与 DllPlugin 插件中的 name 属性值对应起来
        // 之所以在前面 _dll_lib 是为了防止全局变量冲突
        library: '[name]_dll_lib'
    },
    plugins: [
        new CleanWebpaclPlugin(['vendor'], {
            root: path.resolve(__dirname, 'public')
        }),
        new FirendlyErrorePlugin(),                // 接入 DllPlugin
        new webpack.DllPlugin({
            // 描述动态链接库的 manifest.json 文件输出时的文件名称
            // 由于每次打包生产环境时会清空 dist 文件夹,因此这里我将它们存放在了 public 文件夹下
            path: path.join(__dirname, 'public', 'vendor', '[name].manifest.json'),
            // 动态链接库的全局变量名称,需要和 output.library 中保持一致
            // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
            // 例如 vendor.manifest.json 中就有 "name": "vendor_dll_lib"            name: '[name]_dll_lib'
        })
    ],
    performance: {
        // false | "error" | "warning" // 不显示性能提示 | 以错误形式提示 | 以警告...
        hints: "warning",        // 开发环境设置较大防止警告
        // 根据入口起点的最大体积,控制webpack何时生成性能提示,整数类型,以字节为单位
        maxEntrypointSize: 5000000,         // 最大单个资源体积,默认250000 (bytes)
        maxAssetSize: 3000000
    }}

运行 npm run dll 指令之后,可以看到项目中 public 目录下多出了一个 vendor 的文件夹,可以看到其中包含两个文件:

  • vendor.dll.js 里面包含 react react-dom react-router react-redux react-router-redux 的基础运行环境,将这些基础模块打到一个包里,只要这些包的包的版本没升级,以后每次打包就不需要再编译这些模块,提高打包的速率
  • vendor.manifest.json 也是由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块

config/webpack.development.js

module.exports = {
  mode: 'development',
  //devtool: 'cheap-module-source-map',
  devtool: 'eval',
  output: {
    filename: 'scripts/[name].bundle.[hash:4].js'
  }
}

在开发环境下,我们不做js压缩和css压缩,来提高开发环境下调试保存页面打包的速度


config/webpack.production.js

const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); //开启多核压缩
const OptmizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const os = require('os');
module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',
  output: {
    filename: 'scripts/[name].bundle.[hash:8].js'
  },
  optimization: {
    splitChunks: {
      chunks: 'all',   // initial、async和all
      minSize: 30000,   // 形成一个新代码块最小的体积
      maxAsyncRequests: 5,   // 按需加载时候最大的并行请求数
      maxInitialRequests: 3,   // 最大初始化请求数
      automaticNameDelimiter: '~',   // 打包分割符
      name: true,
      cacheGroups: {
        vendors: { // 项目基本框架等
          chunks: 'all',
          test: /antd/,
          priority: 100,
          name: 'vendors',
        }
      }
    },
    minimizer: [
      new UglifyJsPlugin({
        parallel: os.cpus().length,
        cache:true,
        sourceMap:true,
        uglifyOptions: {
          compress: {
              // 在UglifyJs删除没有用到的代码时不输出警告
              warnings: false,
              // 删除所有的 `console` 语句,可以兼容ie浏览器
              drop_console: true,
              // 内嵌定义了但是只用到一次的变量
              collapse_vars: true,
              // 提取出出现多次但是没有定义成变量去引用的静态值
              reduce_vars: true,
          },
          output: {
              // 最紧凑的输出
              beautify: false,
              // 删除所有的注释
              comments: false,
          }
        }
      }),
      new OptmizeCssAssetsWebpackPlugin({
        assetNameRegExp: /\.css$/g,
        cssProcessor: require('cssnano'),
        cssProcessorOptions: {
           safe: true,
           discardComments: {
             removeAll: true
          }
        }
      })
    ],
  }}

在生产环境的配置中,做了js的压缩和css压缩,还有从打包的入口文件中使用splitChunks分离出来了antd来减小bundle.js的大小


public/index.html

<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    <body>
    </body>
</html>

package.json



六、打包应用

1、执行 npm run dll 生成public/vendor(之后打包不再需要执行此命令,除非vendor中的包版本有变更)


2、执行 npm run start:dev  本地自动开启webpack-dev-server


3、执行 npm run deploy 打包生产环境


4、打包时长比对分析


使用异步加载组件的分割代码的方式进行体积优化见《Webpack按需加载秒开应用》(最重要的一步)