性能文章>webpack5优化实战>

webpack5优化实战原创

2年前
638389

引言

z之前讲了如何使用webpack5从0到1搭建项目,没有看过的可以点击链接前往食用。花了几天时间开发完项目,很开心地运行打包命令并准备将其部署到服务器,结果打包完惊呆了,发现包的大小是5.59M,打包时间花了15s,这明显是有问题的,有强迫症的我决定对其进行一个比较正式的优化,优化完成后包的大小是687.11KB,打包时间花了5.21s,优化效果还是很明显的;另外我这个项目是一个小项目,只花了两三天的开发时间,优化效果可能不是很明显,但是对于比较大的项目,应用了以下我总结的可优化的点后,理论上优化效果是很可观的。

接下来我会围绕webpack优化进行讲述,其中也会作某些原理性地解释,尽量做到让大家知道为什么这么做,以及这么做的结果是什么。不同于webpack4及其以前的版本,webpack5自身处理了很多优化性的东西,很多以前在4中被广泛使用的插件在5中已经用不到了,这一点需要特别注意。废话不多说,让我们开始吧。

优化前

这个项目是一个大屏展示,主要实现了可视化展示数据的功能,整体代码量不大,给大家看一下项目效果图,只有一个页面:

打包完成后控制台显示的结果是bundle size: 5.59M(Parsed);打包时间15s ,这时候webpack只有一个配置文件webpack.config.js,并且mode: development,所以打包出来的size会这么大,接下来优化的第一步是先分离配置。

分离配置文件

我们的目标是将一个配置文件分离成三个,分别是webpack.common.js、webpack.dev.js、webpack.prod.js,对应的是通用配置、开发环境配置、生产环境配置。

我们把配置文件都放在src/config目录下,方便进行管理,项目结构如下:

接下来先安装一个库,用来合并webpack的通用配置:

yarn add webpack-merge
复制代码

我的未经优化的配置代码如下:

// webpack.common.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const APP_DIR = path.resolve(__dirname, '../src');
const OUPUT_DIR = path.resolve(__dirname, '../dist');

module.exports = {
  entry: {
    app: APP_DIR,
  },
  output: {
    path: OUPUT_DIR,
    filename: '[name].[contenthash].js',
  },
  resolve: {
    modules: [path.resolve(__dirname, '../node_modules')],
    alias: {
      '@/images': path.resolve(__dirname, '../src/assets/images'),
      '@/utils': path.resolve(__dirname, '../src/utils'),
      '@': path.resolve(__dirname, '../src'),
    },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.(scss|css)$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
        ],
      },
      {
        test: /\.(?:ico|gif|png|jpg|jpeg)$/i,
        type: 'asset/resource',
      },
      {
        test: /\.(woff(2)?|eot|ttf|otf|svg|)$/,
        type: 'asset/inline',
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: '铁木真大屏展示',
      template: path.resolve(__dirname, '../public/index.html'),
      filename: 'index.html',
    }),
  ],
};
复制代码
// webpack.dev.js
const webpack = require('webpack');
const path = require('path');
const { merge } = require('webpack-merge');

const common = require('./webpack.common');

module.exports = merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    historyApiFallback: true,
    contentBase: path.join(__dirname, '../public'),
    open: false,
    hot: true,
    quiet: true,
    port: 8082,
    proxy: {
      '/api': {
        target: 'http://xxx.xxx.com',
        changeOrigin: true,
      },
    },
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ],
});
复制代码
// webpack.prod.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const { merge } = require('webpack-merge');

const common = require('./webpack.common');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
  plugins: [
    new CleanWebpackPlugin(),
  ],
});
复制代码

到目前为止,我们已经分离了开发环境和生产环境,还有公共配置。公共配置不用多说,主要是配置了项目的入口、打包后的文件及输出的位置、解析模块的规则以及模板文件的绑定;生产环境关注的可以自动编译及刷新并支持HMR,所以使用了内置的devServer;生产环境需要对代码进行压缩,在打包前清理旧的文件,引用的是clean-webpack-plugin插件。

打包性能分析

这里推荐两个插件,webpack-bundle-analyzer可以帮助我们分析打包后的依赖包大小,webpackbar提供了友好的编译进度提示,通过以下方式安装:

yarn add webpackbar webpack-bundle-analyzer
复制代码

加入配置文件:

// webpack.prod.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  // ...
  plugins: [
    new BundleAnalyzerPlugin(),
  ]
}
复制代码
// webpack.common.js
const WebpackBar = require('webpackbar');

module.exports = {
  // ...
  plugins: [
    new WebpackBar(),
  ]
}
复制代码

这是我在package.json中scripts的配置:

{
  "scripts": {
    "start": "webpack serve --config config/webpack.dev.js",
    "build": "webpack --config config/webpack.prod.js",
  }
}
复制代码

执行yarn run build,可以在运行终端看到打包的进度,并且在浏览器可以看到包的依赖关系图,默认展示地址是http://127.0.0.1:8888/,可以分析出哪些包特别大:

image.png

打包优化

去掉大的库中没有用到的代码

从上图可以分析得出moment占用的大小是个大头,其中locale占了很大一部分,这也是我目前不需要用到的,所以我可以借用moment-locales-webpack-plugin插件移除moment中未用到的代码,先来安装它:

yarn add moment-locales-webpack-plugin
复制代码

以下是配置:

// webpack.common.js
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');

module.exports = {
  // ...
  plugin: [new MomentLocalesPlugin()]
}
复制代码

Google有一个非常好的仓库,列出了通常会遇到的有问题的依赖,并告诉你如何使用webpack去压缩你的库,如果运气好的话,可以从中找到你想要优化的库,就像我从中找到了moment的优化插件。

缩小文件搜索范围

Webpack 启动后会从配置的 Entry 出发,解析出文件中的导入语句,再递归的解析。 在遇到导入语句时 Webpack 会做两件事情:

  1. 根据导入语句去寻找对应的要导入的文件。例如 require('react') 导入语句对应的文件是 ./node_modules/react/react.jsrequire('./util') 对应的文件是 ./util.js
  2. 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用Typescript开发的 JavaScript 文件需要使用 ts-loader 去处理。

以上两件事情虽然对于处理一个文件非常快,但是当项目大了以后文件量会变的非常多,这时候构建速度慢的问题就会暴露出来。 虽然以上两件事情无法避免,但需要尽量减少以上两件事情的发生,以提高速度。

接下来一一介绍可以优化它们的途径。

优化loader配置

由于 Loader 对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。

// webpack.common.js
module.exports = {
  // ...
  module: {
    rules: [
       {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            // 缓存转换出的结果
            cacheDirectory: true,
            // 只对src目录下的文件使用babel-loader处理,可以缩小命中范围
            include: path.resolve(__dirname, '../src'),
            presets: ['@babel/preset-env'],
          },
        },
      },
    ]
  }
}
复制代码

优化resolve.modules配置

resolve.modules 用于配置 Webpack 去哪些目录下寻找第三方模块。

resolve.modules 的默认值是 ['node_modules'],含义是先去当前目录下的 ./node_modules 目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules 中找,再没有就去 ../../node_modules 中找,以此类推,这和 Node.js 的模块寻找机制很相似。

当安装的第三方模块都放在项目根目录下的 ./node_modules 目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:

// webpack.common.js
const path = require('path');

module.exports = {
  // ...
  resolve: {
    // 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前工作目录,也就是项目根目录
    modules: [path.resolve(__dirname, 'node_modules')]
  }
}
复制代码

优化resolve.mainFields配置

resolve.mainFields 用于配置第三方模块使用哪个入口文件。

安装的第三方模块中都会有一个 package.json 文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields 用于配置采用哪个字段作为入口文件的描述。

resolve.mainFields 的默认值和当前的 target 配置有关系,对应关系如下:

  • targetweb 或者 webworker 时,值是 ["browser", "module", "main"]
  • target 为其它情况时,值是 ["module", "main"]

target 等于 web 为例,Webpack 会先采用第三方模块中的 browser 字段去寻找模块的入口文件,如果不存在就采用 module 字段,以此类推。

为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main 字段去描述入口文件的位置,可以这样配置 Webpack:

// webpack.common.js
module.exports = {
  // ...
  resolve: {
    // 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};
复制代码

优化resolve.alias配置

resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径。

在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到 node_modules 目录下的 React 库的目录结构如下:

├── dist
│   ├── react.js
│   └── react.min.js
├── lib
│   ... 还有几十个文件被忽略
│   ├── LinkedStateMixin.js
│   ├── createClass.js
│   └── React.js
├── package.json
└── react.js
复制代码

可以看到发布出去的 React 库中包含两套代码:

  • 一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib 目录下,以 package.json 中指定的入口文件 react.js 为模块的入口。
  • 一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中 dist/react.js 是用于开发环境,里面包含检查和警告的代码。dist/react.min.js 是用于线上环境,被最小化了。

默认情况下 Webpack 会从入口文件 ./node_modules/react/react.js 开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias 可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。

// webpack.prod.js
module.exports = {
  resolve: {
    // 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.production.min.js 文件,
    // 减少耗时的递归解析操作
    alias: {
      'react': path.resolve(
        __dirname,
        '../node_modules/react/umd/react.production.min.js'
      ),
    }
  },
};
复制代码

除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias。

但是对于有些库使用本优化方法后会影响到Tree-Shaking优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。

优化resolve.extensions配置

在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。resolve.extensions 用于配置在尝试过程中用到的后缀列表。默认是:

extensions: ['.js', '.json']
复制代码

也就是说当遇到 require('./data') 这样的导入语句时,Webpack 会先去寻找 ./data.js 文件,如果该文件不存在就去寻找 ./data.json 文件,如果还是找不到就报错。

如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions 时你需要遵守以下几点,以做到尽可能的优化构建性能:

  • 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
  • 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
  • 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json')

相关 Webpack 配置如下:

// webpack.common.js
module.exports = {
  // ...
  resolve: {
    // 尽可能的减少后缀尝试的可能性
    extensions: ['.tsx', '.ts', '.js'],
  },
};
复制代码

优化module.noParse配置

module.noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。

在上面的 优化 resolve.alias 配置 中讲到单独完整的 react.production.min.js 文件就没有采用模块化,让我们来通过配置 module.noParse 忽略对 react.production.min.js 文件的递归解析处理, 相关 Webpack 配置如下:

// webpack.prod.js
const path = require('path');

module.exports = {
  // ...
  module: {
    // 独完整的 `react.production.min.js` 文件就没有采用模块化,忽略对 `react.production.min.js` 文件的递归解析处理
    noParse: /react\.production\.min\.js$/,
  },
};
复制代码

以上就是所有和缩小文件搜索范围相关的构建性能优化了,在根据自己项目的需要去按照以上方法改造后,你的构建速度一定会有所提升。

保持最新的版本

使用最新的webpack版本。webpack官方一直在改进性能。webpack的最新推荐版本是:5.11.0

保持Node.js的最新版本也有助于提高性能。除此之外,让你的包管理器(例如npm或yarn)保持最新状态也会有帮助。更新的版本可以创建更高效的模块树并提高解析速度。

减少辅助程序

尽可能少地使用loader和插件。

Resolving

前文中已经将过如何加快检索速度,即缩小文件搜索范围;另外如果项目中未使用npm link or yarn link,可以设置resolve.symlinks: false

开发环境优化

开启缓存

缓存生成的webpack模块和块,以提高构建速度,推荐在开发环境开启,生产环境关闭:

// webpack.dev.js
module.exports = {
  //...
  cache: {
    type: 'memory',
  },
};
复制代码

测试发现,每次编译的时间都会比上次要少一点,这得益于webpack缓存的配置。

Devtool

注意不同devtool设置之间的性能差异。

  • eval有最好的性能,但是不能帮助我们很好地跟踪代码
  • 如果您能忍受稍微差一点的映射质量,那么cheap-source-map会有更好的性能
  • 增量地构建使用eval-source-map

大多数情况下,eval-cheap-module-source-map是最好的选择。

// webpack.dev.js
module.exports = {
  // ...
  devtool: 'eval-cheap-module-source-map'
}
复制代码

避免使用生产环境下才需要用的工具

某些实用程序、插件和加载器只有在生产环境下构建时才有意义,例如压缩代码、输出随机字符串的文件名等,以下的工具应在开发环境中排除:

  • [fullhash]/[chunkhash]/[contenthash]
  • TerserPlugin

主要改了output的配置,配置如下:

// webpack.dev.js
module.exports = {
  output: {
    filename: '[name].js'
  }
}
复制代码
// webpack.common.js
const OUPUT_DIR = path.resolve(__dirname, '../dist');

module.exports = {
  output: {
    path: OUPUT_DIR,
  }
}
复制代码
// webpack.prod.js
module.exports = {
  output: {
    filename: '[name].[contenhash].js',
  }
}
复制代码

最小的入口chunk

通过保持入口块较小,确保它不容易释放。下面的配置为运行时代码创建了一个额外的块,所以生成的chunk较小:

// webpack.dev.js
module.exports = {
  optimization: {
    runtimeChunk: true
  }
}
复制代码

避免额外的优化步骤

webpack做了额外的算法工作来优化输出的大小和加载性能。这些优化适用于较小的代码库,但在较大的代码库中可能代价高昂,我们将额外的优化给关闭掉:

// webpack.dev.js
module.exports = {
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false
  }
}
复制代码

输出不带路径信息

webpack有能力在输出包中生成路径信息。然而,这会给捆绑数千个模块的项目带来垃圾收集的压力。在options.output中将此选项关闭。pathinfo设置:

// webpack.dev.js
module.exports = {
  output: {
    pathinfo: false,
  }
}
复制代码

TypeScript Loader

在使用ts-loader时,要提高构建时间,请使用transpileOnly加载器选项。这个选项本身关闭了类型检查。再次进行类型检查,使用ForkTsCheckerWebpackPlugin。这可以通过将TypeScript类型检查和ESLint lint移动到单独的进程来加速它们。

安装ForkTsCheckerWebpackPlugin插件:

yarn add --dev fork-ts-checker-webpack-plugin
复制代码
// webpack.common.js
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
          },
        },
        exclude: /node_modules/,
      },
    ]
  },
  plugins: [
    // ...
    new ForkTsCheckerWebpackPlugin()
  ]
}
复制代码

优化到这一步,开发环境的编译时间降到了3.26s,生产环境的打包时间降到了5.38s,并且支持Typescript的类型检查,当然是以另一个进程的方式在运行,当有未通过类型检查的代码时,控制台就可以给出错误提示:

生产环境优化

并行构建

如果是多页应用,即有多个入口,可以使用parallel-webpack 进行并行构建,它充分利用了CPU的多核特性。

由于我这个项目只有一个入口,不需要并行构建,所以暂时没有进行相关配置,有需要的可以点击上面链接查看parallel-webpack插件的使用方法。

Source Maps

Source Maps的开发非常昂贵,请确认是否真的需要它?我在生产环境使用的是source-map,测试发现对实际的编译速度及bundle size的影响不大,大家可以根据需要自行选择:

// webpack.prod.js
module.exports = {
  devtool: 'source-map'
}
复制代码

Tree Shaking

Tree Shaking 可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过 importexport 导入导出。

为了更直观的理解它,来看一个具体的例子。假如有一个文件 util.js 里存放了很多工具函数和常量,在 main.js 中会导入和使用 util.js,代码如下:

util.js 源码:

export function funcA() {
}

export function funB() {
}

export const a = 'a';
复制代码

main.js 源码:

import {funcA} from './util.js';
funcA();
复制代码

Tree Shaking 后的 util.js

export function funcA() {
}
复制代码

由于只用到了 util.js 中的 funcA,所以剩下的都被 Tree Shaking 当作死代码给剔除了。

需要注意的是要让 Tree Shaking 正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些 export 的被 import 过了。 如果你采用 ES5 中的模块化,例如 module.export={...}require(x+y)if(x){require('./util')},Webpack 无法分析出哪些代码可以剔除。

上面讲了 Tree Shaking 是做什么的,接下来一步步教你如何配置 Webpack 让 Tree Shaking 生效。

  1. 首先,为了把采用 ES6 模块化的代码交给 Webpack,需要配置 Babel 让其保留 ES6 模块化语句,修改babel.config.json
{
  "presets": [
    "@babel/preset-env",
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}
复制代码

其中 "modules": false 的含义是关闭 Babel 的模块转换功能,保留原本的 ES6 模块化语法。

  1. package.json中添加sideEffects属性:

    一些导入的文件都会被tree shaking,这意味着如果使用像css-loader这样的loader来解析CSS,需要将该后缀名的文件加入到sideEffects中,避免被当成无用的代码删除了。

{
  "name": "xxx",
  "sideEffects": [
    "*.css"
  ],
}
复制代码
  1. production中使用,即生产环境,webpack在生产环境下默认启用了压缩及tree shaking

压缩CSS

安装css-minimizer-webpack-plugin

yarn add css-minimizer-webpack-plugin
复制代码

配置:

// webpack.prod.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  // ...
  optimization: {
     // ...
     minimizer: [new CssMinimizerPlugin()],
  }
}
复制代码

CSS压缩只在生产环境下生效,如果想要在开发环境下使用,需要设置optimization.minimize的值为true

提取CSS

使用optimization.splitChunks.cacheGroups将CSS提取到一个单独的文件中,还需要用到mini-css-extract-plugin插件,先安装依赖yarn add mini-css-extract-plugin ,以下是完整配置:

// webpack.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: 'styles',
          test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  module: {
     rules: [
      {
        test: /\.(scss|css)$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: './',
            },
          },
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
            },
          },
          'postcss-loader',
        ],
      },
    ],
  },
  plugins: [
     new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
      chunkFilename: '[id].[contenthash].css',
      ignoreOrder: false,
    }),
  ]
}
复制代码

其它优化

图片优化

关于使用图片的格式选择,如果是小图标,需要透明底的,建议使用.pngg格式,而颜色丰富的大图建议使用.jpg,可以以较小的空间保证图片的质量。

推荐一个软件ImageOptim,可以帮助我们几乎无所压缩图片,可以节省20~80%的空间,通过ImageOptim优化完成之后,大大地降低打包的时间。

总结

到这里我们的优化工作就告一段落,截至目前,在开发环境的编译时间是3.37s

生产环境的编译时间是5.21sbundle size: 687.11KB

总体下来还是取得了一定成果,相信大家根据以上步骤选择适合自己项目的优化项,也能取得一个不错的结果,另外我将这个项目制作成了一个脚手架,可以点击此处查看源码,感觉还不错的话可以给个star鼓励一下。

TODO

  • 将脚手架做成一个npm cli部署到npm上
点赞收藏
分类:标签:
Ywhoo

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。

请先登录,查看8条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
9
8
Lv2
Ywhoo

徽章

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。