Webpack 从基础入门到常见优化

180 阅读9分钟

Webpack 从基础入门到常见优化

导语:本文主要内容为 webpack 基本配置中常见的 loader 及 plugin,打包体积及速度的优化。如果以上内容你已经熟知,完全不必浪费时间再看这篇文章啦。

一点点历史

在 webpack 之前,前端工具也有不少,从 grunt 到 gulp, 从 browserify 再到 webpack、rollup、parcel。虽然 webpack 有让人吐槽的地方,但目前它的确是最最流行的打包工具。如果喜欢考古的话,可以看一下 Vue 最初的版本,就是通过 grunt 来构建的。

webpack 基础配置

//  webpack.config.js
module.exports = {
 // webpack4 版本升级后宣称零配置,entry 和 output 有默认配置,可省略
 entry: './src/index.js'// 打包的默认入口文件,可省略
 output: './dist/main.js'// 打包后的默认输出,可省略
 mode: 'production'// 以何种环境打包,可省略
 module: {
   rules: [ // loader 配置:test 指定规则,use 指定 loader
     {test/\.js$/use'babel-loader'}
   ]
 },
 plugins: [ // 插件配置
 ]
};

上面是一个最简单的配置文件,如果不需要对文件进行特殊处理的话,我们甚至可以不需要进行任何配置。webpack 默认仅支持 js 及 json 文件的处理,所以当我们需要处理其他文件,或者需要对 js 文件进行一些特殊处理(比如转义、压缩)的时候,就需要 loader 和 plugin 了。

常见 loader

loader 可以被当成为一个函数,输入一个资源,经过处理之后,返回新的资源。

名称 描述
babel-loader 转换ES6语法
ts-loader ts语法转换为js语法
less-loader less语法转换为css语法
css-loader css文件的加载和解析
file-loader 图片、字体等静态资源打包
thread-loader 多进程打包

常见 plugins

plugins 作用于构建过程的生命周期,可实现对打包资源的优化、资源管理、环境变量注入等功能。

名称 描述
CleanWebpackPlugin 清理构建目录
ExtractTextWebpackPlugin 将css提取为独立文件
CopyWebpackPlugin 把文件拷贝到输出目录
HtmlWebpackPlugin 使用html模板承载输出的 bundle 文件
UglifyjsWebpackPlugin js 压缩
ZipWebpackPlugin 将打包后的资源压缩为 zip 包

不同 mode 下的优化

mode 有三种模式:development、production、none

模式 描述
development 设置process.env.NODE_ENV的值为development。
开启 NamedChunksPlugin 和NamedModulesPlugin 方便调试。
production 会将 process.env.NODE_ENV 的值设为 production。
启用 FlagDependencyUsagePlugin(编译时标记依赖),
FlagIncludedChunksPlugin(标记子chunks,防子chunks多次加载),
ModuleConcatenationPlugin(预编译所有模块到一个闭包中),
NoEmitOnErrorsPlugin(在输出阶段时,遇到编译错误跳过),
OccurrenceOrderPlugin(给经常使用的ids更短的值),
SideEffectsFlagPlugin(安全地删除未用到的 export 导出)
, UglifyJsPlugin(代码压缩)
none 无任何优化

文件指纹策略

策略 描述
hash 和整个项目构建相关,只要项目文件有修改,整个构建的hash都会更改
ChunkHash 和webpack打包的chunk 有关,不同的entry回生成不同的 chunkhash
ContentHash 根据文件内容来定义 hash,文件内容不变,则 ContentHash不变

根据以上描述,在打包不同的文件时,应该也就能做出合适的选择了: js 文件指纹使用 chunkhash;因为在入口改变的时候,hash 改变,可以改变缓存; css 文件使用 contenthash;如果使用 chunkhash 的话,那么如果我们在 js 文件中引入了 css,即使 css 内容没有更改,在改变 js 的时候,hash 值也会变更; 图片、字体资源使用hash;

js的hash 设置比较简单,只需要设置出口文件的占位符即可:

// js 文件配置
 entry: {
    index:'./src/main.js',
  },
  output: {
    path:path.join(__dirname,'dist')
    filename:'[name][chunkhash:8].js'
  }

如果是将 css 文件通过 style-loader 导入到页面中,是不需要设置hash的;只有当把 css 文件单独导出的时候,才需要配置 hash。

// css 文件配置
plugins:[
  new MiniCssExtractPlugin({
    filename:'[name]_[contenthash:8].css'
  })
]

图片资源一般需要 配合 file-loader 或者 url-loader 来进行解析,file-loader 在配置文件名称的时候,提供了丰富的占位符配置:

file-loader 的name配置:

占位符 含义
ext 资源后缀名
name 文件名称
path 文件相对路径
folder 文件所在文件夹
hash 文件内容的hash,默认 md5 生成
emoji 随机的指代文件内容的emoji
// 图片资源配置
rules:[
  {
    test:/\.(jpg|png|gif|jpeg)$/,
    use:{
      'file-loader',
      options:{
        name:'img/[name]_[hash:8].[ext]'
      }
    }
  }
]

source-map的使用

关键字 含义
eval 使用 eval 包裹代码
source-map 生成.map文件
cheap 不包含列信息,也不包括loader的source-map
module 包括loader的sourcemap
inline 将.map作为DataURL嵌入,不单独生成.map文件

根据需求不同,开发环境下可选择的 source-map:

  • eval - 每个模块都使用 eval() 执行,并且都有 //@ sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。

  • eval-source-map - 每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。

  • cheap-eval-source-map - 类似 eval-source-map,每个模块使用 eval() 执行。这是 “cheap(低开销)” 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。

  • cheap-module-eval-source-map - 类似 cheap-eval-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。

生产环境下可选择的 sourcemap:

  • (none)(省略 devtool 选项) - 不生成 source map。如果不需要记录生产环境的脚本错误,这是一个不错的选择。

  • hidden-source-map - 与 source-map 相同,但不会为 bundle 添加引用注释。如果你只想 source map 映射那些源自错误报告的错误堆栈跟踪信息,但不想为浏览器开发工具暴露你的 source map,这个选项会很有用。

webpack 优化

优化主要从两方面来体现,即打包速度、打包体积。

打包速度一般通过 speed-measure-webpack-plugin 这个插件来统计,这个插件用起来十分简单,一般通过包裹起webpack 的默认配置即可:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
 
const smp = new SpeedMeasurePlugin();
 
const webpackConfig = smp.wrap({
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
});

运行后,可以根据不同 loader 消耗的时间,做出针对性的优化。 截屏2020-04-19 下午3.55.39.png

打包体积一般通过 webpack-bundle-analyzer 插件来统计,可以非常方便地统计第三方模块文件地大小及业务组件代码地大小。使用也非常简单,并且提供了丰富的配置选项

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

减小打包体积

减小打包体积最直接地手段是对打包资源进行压缩:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
  // ...
  module: {
    rules: [
      {
        test/\.less$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader''less-loader', {
          loader'postcss-loader',
          options: {
            plugins() => [
              require('autoprefixer')(),
            ],
          },
        }],
      }, 
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename'[name]_[contenthash:8].css',
    }),
    new OptimizeCssAssetsPlugin({
      assetNameRegExp/\.css$/g,
      cssProcessorrequire('cssnano'),
    }),
  ],
};
  • 图片资源压缩:量少地时候可以通过 tinypng 等网站进行压缩,量大地时候,可以使用image-webpack-loader。此外,图片也可以通过懒加载技术(不同的框架都有对应的实现),来显著减少不必要的请求;
rules: [{
  test/\.(gif|png|jpe?g|svg)$/i,
  use: [
    // loader 地执行顺序为从右到左
    'file-loader',
    {
      loader'image-webpack-loader',
      options: {
        mozjpeg: {
          progressivetrue,
          quality65
        },
        // optipng.enabled: false will disable optipng
        optipng: {
          enabledfalse,
        },
        pngquant: {
          quality: [0.650.90],
          speed4
        },
        gifsicle: {
          interlacedfalse,
        },
        // the webp option will enable WEBP
        webp: {
          quality75
        }
      }
    },
  ],
}]
  • html:使用 html-webpack-plugin 压缩并且删除注释;当然,html-webpack-plugin 地作用不止于此,可查看官方文档解锁更多功能:
new HtmlWebpackPlugin({
  template: path.join(__dirname, 'src/template/index.html'),
  filename'index.html',
  injecttrue,
  minify: {
    html5true,
    collapseWhitespacetrue,
    preserveLineBreaksfalse,
    minifyCSStrue,
    minifyJStrue,
    removeCommentstrue,
  },
}),
  • 资源内联:除了压缩之外,也可以考虑通过使用 style-loader 内联 css,减少请求,避免页面闪动;通过 raw-loader 来内联页面初始化脚本,raw-loader 也可以被用来引入 html 模板:
<%= require('raw-loader!./meta.html')} %>
  • 使用 SplitChunks 分离公共脚本,虽然不会显著减小包的体积,但是通过抽离出基本不会变更的公共代码,能够更好地利用缓存——即使业务代码变更,公共代码可继续使用缓存;webpack4 中已经默认支持了这项功能,无需引用插件,chunks 参数说明:

    • async 异步引入的库进行分离(默认);
    • initial 同步引入的库进行分离;
    • all 所有引入的库进行分离
    • 更多参数说明请查看文档
optimization: {
  splitChunks: {
    chunks'all',
    minSize30000,
    maxSize0,
    minChunks1,
    maxAsyncRequests5,
    maxInitialRequests3,
    automaticNameDelimiter'~',
    nametrue,
    cacheGroups: {
      vendors: {
        test/[\\/]node_modules[\\/]/,
        priority-10,
      },
      default: {
        minChunks2,
        priority-20,
        reuseExistingChunktrue,
      },
    },
  },
},
  • tree shaking
    • webpack production 模式下默认支持 js tree shaking;只支持 ES6 模块;有副作用的代码不会被优化(比如在函数的prototype 属性上添加方法,即使方法没有被使用到,也不会被优化);通过以上描述,可以发现, tree shaking 的部分优化,其实在写代码的时候,通过 eslint 等工具也可以规避。会被优化的代码分以下三种情况:

      • 代码不会被执行;
      • 代码执行的结果不会被用到;
      • 代码只写不读;
// foobar.js
let a = 'aaa'// 会被清除掉,因为只定义,未使用
export function foo({}
export function bar({} // 会被清除掉,因为不会被执行

// index.js
import {foo} from './foobar.js';
foo();
  • 清除无用css代码可使用Purgecss

  • scope hosting原理:将所有模块代码按照引用顺序放在同一个函数中,重命名变量防止变量名冲突。production 模式下默认开启,只支持 ES6 模块;如果不开启的话,每一个模块都会被不同的函数包裹,导致打出来的包较大;

加快打包速度

  • 使用最新版本的 Node 和 webpack。优化做了那么多,一顿操作猛如虎,回头一看,提升的速度,可能没有点下升级的大。但需要注意的是,在升级 webpack 的时候,某些插件可能回不兼容。
  • 多进程打包。之前比较流行的Happy Pack,但作者已经没有太大的兴趣继续维护这个库了,并推荐了Thread Loader
module.exports = {
  module: {
    rules: [
      {
        test/\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // your expensive loader (e.g babel-loader)
        ]
      }
    ]
  }
}
module.exports = {
  optimization: {
    minimizetrue,
    minimizer: [
      new TerserPlugin({
        paralleltrue,
      }),
    ],
  },
};
  • 使用HtmlWebpackExternalsPlugin分离基础包,优点是基础包不会被打包进 bundle,但如果基础包增加的话,需要手动添加入口;
new HtmlWebpackExternalsPlugin({
  externals: [
    {
      module'jquery',
      entry'https://unpkg.com/jquery@3.2.1/dist/jquery.min.js',
      global'jQuery',
    },
  ],
})
// webpack.dll.js
// package.json 中加入脚本:"dll": "webpack --config webpack.dll.js"
module.exports = {
  entry: {
    libary: ['react''react-dom']
  },
  output: {
    filename'[name]_[chunkhash].dll.js',
    path: path.join(__dirname, 'build/libary'),
    libary'[name]'
  },
  plugins: [
    new webpack.DllPlugin({
      name'[name]_[hash]',
      path: path.join(__dirname, 'build/libary/[name].json'),
    });
  ]
};
// webpack.prod.js
module.exports = {
  // ...
  plugins: [
    new webpack.DllReferencePlugin({
      manifestrequire('./build/libary/libary.json'),
    });
  ],
}

使用缓存提升二次构建速度

  • babel-loader 缓存避免在每次执行时,可能产生的、高性能消耗的 Babel 重新编译过程: loader: 'babel-loader?cacheDirectory'
  • terser-webpack-plugin 开启压缩缓存:
module.exports = {
  optimization: {
    minimizetrue,
    minimizer: [
      new TerserPlugin({
        paralleltrue,
        cachetrue,
      }),
    ],
  },
};
module.exports = {
  context// ...
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

缩小构建目标

  • 通过使用 excludeinclude 来减少需要构建的模块:
exclude: 'node_modules'
  • 减少文件搜索范围:
    • 优化 resolve.modules 配置(减少模块搜索层级);
    • 优化 resolve.mainFields 配置;
    • 优化 resolve.extensions 配置;
    • 合理使用 resolve.alias;
module.exports = {
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // 直接精准定位,无需搜索
    },
    modules: [path.resolve(__dirname, 'node_modules')], // 只在当前目录下搜索
    extensions: ['.js'], // 扩展名不宜过长,默认会查询 js、json;extensions 越多搜索时间越长;
    mainFields: ['main'], // 入口文件
  }
};

动态引入 polyfill

为了让不同的浏览器都能支持 map、set、promise 等语法,我们在项目中通常需要引入 babel-polyfill;但是完全引入的话,代价有些大;况且对于使用高级浏览器的用户来说,平白无故地耗费流量,肯定也是不开心的。为了极致优化的话,可以使用动态 polyfill 服务。这项服务是通过判断 User-Agent 来返回对应的 polyfill;考虑到国内浏览器环境比较复杂,各种魔改,动态 polyfill 是否可以直接应用,还是要打一个问号的。

  • 动态 import 代码懒加载

这样虽然不能减小包的体积,但是能保证代码只有在被使用的时候才会被加载,实现懒加载的效果。首先,安装babel插件

yarn add --dev  @babel/plugin-syntax-dynamic-import

然后配置 .babelrc

{
  "plugins": [" @babel/plugin-syntax-dynamic-import"]
}

多页面打包

某些情况下,根据业务需求,需要进行多页面打包。其基本思路是每个页面对应一个 entry 和一个 html-webpack-plugin 配置,但这种实现方式,添加页面时需要手动去修改配置。如果做好约定,可以通过 glob 来自动匹配多文件页面:

const glob = require('glob');
const setMPA = () => {
  const entry = {};
  const entryFiles = glob.sync(path.join(__dirname, './src/*/index.{js,jsx}'));
  const htmlWebpackPlugins = [];

  Object.keys(entryFiles).forEach((index) => {
    const entryFile = entryFiles[index];
    // 这里需要约定好,src/*/ 目录下的 index.js 文件作为入口
    const match = entryFile.match(/src\/(.*)\/index\.js/);
    const pageName = match && match[1];
    entry[pageName] = entryFile;
    htmlWebpackPlugins.push(
      new HtmlWebpackPlugin({
        template: path.join(__dirname, `src/${pageName}/index.html`),
        filename`${pageName}.html`,
        chunks: ['vendors', pageName],
        injecttrue,
        minify: {
          html5true,
          collapseWhitespacetrue,
          preserveLineBreaksfalse,
          minifyCSStrue,
          minifyJStrue,
          removeCommentstrue,
        },
      }),
    );
  });
  return {
    entry, // 多入口
    htmlWebpackPlugins, // 多页面
  };
};
const { entry, htmlWebpackPlugins } = setMPA();
module.exports = {
  entry,
  output: {
    path: path.join(__dirname, 'dist'),
    filename'[name]_[chunkhash:8].js',
  },
  plugins:[
    // 其他插件配置
  ].concat(htmlWebpackPlugins),
};