webpack4优化总结

382 阅读11分钟

打包多页面

打包多个页面可以有多种配置方式:

1.html-webpack-plugin:
module.exports = {
    entry: {
        index: "./src/index.js", // 指定打包输出的chunk名为index
        foo: "./src/foo.js" // 指定打包输出的chunk名为foo
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/index.html", // 要打包输出哪个文件,可以使用相对路径
            filename: "index.html", // 打包输出后该html文件的名称
            chunks: ["index"] // 数组元素为chunk名称,即entry属性值为对象的时候指定的名称,index页面只引入index.js
        }),
        new HtmlWebpackPlugin({
            template: "./src/index.html", // 要打包输出哪个文件,可以使用相对路径
            filename: "foo.html", // 打包输出后该html文件的名称
            chunks: ["foo"] // 数组元素为chunk名称,即entry属性值为对象的时候指定的名称,foo页面只引入foo.js
        }),
    ]
}

打包多页面时,关键在于 chunks 属性的配置,因为在没有配置 chunks 属性的情况下,打包输出的 index.html 和 foo.html 都会同时引入 index.js 和 foo.js。

所以必须配置 chunks 属性,来指定打包输出后的 html 文件中要引入的输出模块,数组的元素为 entry 属性值为对象的时候指定的 chunk 名,如上配置,才能实现,index.html 只引入 index.js,foo.html 只引入 foo.js 文件。

2.AutoWebPlugin

上面的方法没添加一个html页面就要多配置一次,为了解决这个问题,所以可以采用本方法。

首先,项目源码目录结构如下:

├── pages
│   ├── index
│   │   ├── index.css // 该页面单独需要的 CSS 样式
│   │   └── index.js // 该页面的入口文件
│   └── login
│       ├── index.css
│       └── index.js
├── common.css // 所有页面都需要的公共 CSS 样式
├── google_analytics.js
├── template.html
└── webpack.config.js
从目录结构中可以看成出下几点要求:

(1)所有单页应用的代码都需要放到一个目录下,例如都放在 pages 目录下; (2)一个单页应用一个单独的文件夹,例如最后生成的 index.html 相关的代码都在 index 目录下,login.html 同理; (3)每个单页应用的目录下都有一个 index.js 文件作为入口执行文件。

const { AutoWebPlugin } = require('web-webpack-plugin');

// 使用本文的主角 AutoWebPlugin,自动寻找 pages 目录下的所有目录,把每一个目录看成一个单页应用
const autoWebPlugin = new AutoWebPlugin('pages', {
  template: './template.html', // HTML 模版文件所在的文件路径
  postEntrys: ['./common.css'],// 所有页面都依赖这份通用的 CSS 样式文件
  // 提取出所有页面公共的代码
  commonsChunk: {
    name: 'common',// 提取出公共代码 Chunk 的名称
  },
});

module.exports = {
  // AutoWebPlugin 会为寻找到的所有单页应用,生成对应的入口配置,
  // autoWebPlugin.entry 方法可以获取到所有由 autoWebPlugin 生成的入口配置
  entry: autoWebPlugin.entry({
    // 这里可以加入你额外需要的 Chunk 入口
  }),
  plugins: [
    autoWebPlugin,
  ],
};

AutoWebPlugin 会找出 pages 目录下的2个文件夹 index 和 login,把这两个文件夹看成两个单页应用。 并且分别为每个单页应用生成一个 Chunk 配置和 WebPlugin 配置。 详细请看:web-webpack-plugin的AutoWebPlugin

配置source-map

source-map 就是源码映射,主要是为了方便代码调试,因为我们打包上线后的代码会被压缩等处理,导致所有代码都被压缩成了一行,如果代码中出现错误,那么浏览器只会提示出错位置在第一行,这样我们无法真正知道出错地方在源码中的具体位置。webpack 提供了一个 devtool 属性来配置源码映射。

devtool 常见的有 6 种配置: 1、source-map: 这种模式会产生一个.map文件,出错了会提示具体的行和列,文件里面保留了打包后的文件与原始文件之间的映射关系,打包输出文件中会指向生成的.map文件,告诉js引擎源码在哪里,由于源码与.map文件分离,所以需要浏览器发送请求去获取.map文件,常用于生产环境.

2.eval: 这种模式打包速度最快,不会生成.map文件,会使用eval将模块包裹,在末尾加入sourceURL,常用于开发环境

3.eval-source-map: 每个 module 会通过 eval() 来执行,并且生成一个 DataUrl 形式的 SourceMap (即 base64 编码形式内嵌到 eval 语句末尾), 但是不会生成 .map 文件,可以减少网络请求**,但是打包文件会非常大**。

4.cheap-source-map: 加上 cheap,就只会提示到第几行报错,少了列信息提示,同时不会对引入的库做映射,可以提高打包性能,但是会产生 .map 文件。

5.cheap-module-source-map: 和 cheap-source-map 相比,加上了 module,就会对引入的库做映射,并且也会产生 .map 文件,用于生产环境。

6.cheap-module-eval-source-map: 常用于开发环境,使用 cheap 模式可以大幅提高 souremap 生成的效率,加上 module 同时会对引入的库做映射,eval 提高打包构建速度,并且不会产生 .map 文件减少网络请求。

凡是带 eval 的模式都不能用于生产环境,因为其不会产生 .map 文件,会导致打包后的文件变得非常大。通常我们并不关心列信息,所以都会使用 cheap 模式,但是我们也还是需要对第三方库做映射,以便精准找到错误的位置。

区分环境问题

可以将webpack配置分为:

  1. webpack.config.dev.js
  2. webpack.config.prod.js
  3. webpack.config.base.js base.js主要是通用的配置,是所有环境都会执行的配置。但是有些配置也要区分环境,需要用cross-env 传入的NODE_ENV来区分配置。

将prod与dev以及其他环境的单独配置与通用配置结合时,可以用到webpack-merge插件,将多个配置合并在一起。如下:

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

module.exports = merge(prodWebpackConfig, {
    // 增加 webpack-bundle-analyzer 配置
    plugins: [new BundleAnalyzerPlugin()]
});

image.png

抽离公共代码

在一个项目里,我们会把哥哥页面的公共部分抽离出来作为commonjs,并且将其中所有页面都依赖的公共库文件抽出来作为basejs。

image.png

1 CommonsChunkPlugin

const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 从哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成一个新的 Chunk,这个新 Chunk 的名称
  name: 'common'
})

以上配置就能从网页 A 和网页 B 中抽离出公共部分,放到 common 中。 下面我们需要配置base.js.

//base.js
// 所有页面都依赖的基础库
import 'react';
import 'react-dom';
// 所有页面都使用的样式
import './base.css';

接着再修改 Webpack 配置,在 entry 中加入 base,相关修改如下:

module.exports = {
  entry: {
    base: './base.js'
  },
};

为了从 common 中提取出 base 也包含的部分,还需要配置一个 CommonsChunkPlugin,相关代码如下:

new CommonsChunkPlugin({
  // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
  chunks: ['common', 'base'],
  // 把公共的部分放到 base 中
  name: 'base'
})

由于 common 和 base 公共的部分就是 base 目前已经包含的部分,所以这样配置后 common 将会变小,而 base 将保持不变。 针对 CSS 资源,以上理论和方法同样有效,也就是说你也可以对 CSS 文件做同样的优化。

以上方法可能会出现 common.js 中没有代码的情况,原因是去掉基础运行库外很难再找到所有页面都会用上的模块。 在出现这种情况时,你可以采取以下做法之一:

  • (1) CommonsChunkPlugin 提供一个选项 minChunks,表示文件要被提取出来时需要在指定的 Chunks 中最小出现最小次数。 假如 minChunks=2、chunks=['a','b','c','d'],任何一个文件只要在 ['a','b','c','d'] 中任意两个以上的 Chunk 中都出现过,这个文件就会被提取出来。
  • (2) 根据各个页面之间的相关性选取其中的部分页面用 CommonsChunkPlugin 去提取这部分被选出的页面的公共部分,而不是提取所有页面的公共部分,而且这样的操作可以叠加多次。 这样做的效果会很好,但缺点是配置复杂,你需要根据页面之间的关系去思考如何配置,该方法不通用。但CommonsChunkPlugin 会去将所有 entry 中的公有模块遍历出来再进行编译压缩混淆,这个过程是非常缓慢的。

详细请看:提取公共代码

2.splitChunks

splitChunks的默认配置:

module.exports = {
    // ...
    optimization: {
        splitChunks: {
            chunks: 'async', // 三选一: "initial" | "all" | "async" (默认)
            minSize: 30000, // 最小尺寸,30K,development 下是10k,越大那么单个文件越大,chunk 数就会变少(针对于提取公共 chunk 的时候,不管再大也不会把动态加载的模块合并到初始化模块中)当这个值很大的时候就不会做公共部分的抽取了
            maxSize: 0, // 文件的最大尺寸,0为不限制,优先级:maxInitialRequest/maxAsyncRequests < maxSize < minSize
            minChunks: 1, // 默认1,被提取的一个模块至少需要在几个 chunk 中被引用,这个值越大,抽取出来的文件就越小
            maxAsyncRequests: 5, // 在做一次按需加载的时候最多有多少个异步请求,为 1 的时候就不会抽取公共 chunk 了
            maxInitialRequests: 3, // 针对一个 entry 做初始化模块分隔的时候的最大文件数,优先级高于 cacheGroup,所以为 1 的时候就不会抽取 initial common 了
            automaticNameDelimiter: '~', // 打包文件名分隔符
            name: true, // 拆分出来文件的名字,默认为 true,表示自动生成文件名,如果设置为固定的字符串那么所有的 chunk 都会被合并成一个
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/, // 正则规则,如果符合就提取 chunk
                    priority: -10 // 缓存组优先级,当一个模块可能属于多个 chunkGroup,这里是优先级
                },
                default: {
                    minChunks: 2,
                    priority: -20, // 优先级
                    reuseExistingChunk: true // 如果该chunk包含的modules都已经另一个被分割的chunk中存在,那么直接引用已存在的chunk,不会再重新产生一个
                }
            }
        }
    }
};

image.png

上述默认配置是利用import动态加载来打包chunk的。 index.js文件里利用import引入react文件:

import(/* webpackChunkName: "react" */ 'react');

再利用webpack-bundle-analyzer来进行打包依赖分析,如下:

image.png

由此可以知道:

  • 1)index.js打包出来了两个文件react.js和main.js;
  • 2)react.js是被拆分出来的,内容实际是 react;
  • 3)react.js被拆分出来是因为splitChunks默认配置chunks='async'。

实践:

    optimization:{
        splitChunks: {
            cacheGroups: {
                default: false,
                commons: {
                    test: /react|lodash|mobx/,
                    name: 'split-vendor',
                    chunks: 'all'
                }
            }
        },
    },

image.png

image.png

runtimeChunk

优化持久化缓存的, runtime 指的是 webpack 的运行环境(具体作用就是模块解析, 加载) 和 模块信息清单, 模块信息清单在每次有模块变更(hash 变更)时都会变更, 所以我们想把这部分代码单独打包出来, 配合后端缓存策略, 这样就不会因为某个模块的变更导致包含模块信息的模块(通常会被包含在最后一个 bundle 中)缓存失效. optimization.runtimeChunk 就是告诉 webpack 是否要把这部分单独打包出来.

image.png

image.png

按需加载

即在需要使用的时候才打包输出,webpack 提供了 import() 方法,传入要动态加载的模块,来动态加载指定的模块,当 webpack 遇到 import()语句的时候,不会立即去加载该模块,而是在用到该模块的时候,再去加载,也就是说打包的时候会一起打包出来,但是在浏览器中加载的时候并不会立即加载,而是等到用到的时候再去加载,比如,点击按钮后才会加载某个模块,如:

const button = document.createElement("button");
button.innerText = "点我"
button.addEventListener("click", () => { // 点击按钮后加载foo.js
    import("./foo").then((res) => { // import()返回的是一个Promise对象
        console.log(res);
    });
});
document.body.appendChild(button);

从中可以看到,import() 返回的是一个 Promise 对象,其主要就是利用 JSONP 实现动态加载,返回的 res 结果不同的 export 方式会有不同,如果使用的 module.exports 输出,那么返回的 res 就是 module.exports 输出的结果;如果使用的是 ES6 模块输出,即 export default 输出,那么返回的 res 结果就是 res.default,如:

{default: "foo", __esModule: true, Symbol(Symbol.toStringTag): "Module"}

实践

方法一:Route+import()异步加载
/**
 * 异步加载组件
 * @param load 组件加载函数,load 函数会返回一个 Promise,在文件加载完成时 resolve
 * @returns {AsyncComponent} 返回一个高阶组件用于封装需要异步加载的组件
 */
function getAsyncComponent(load) {
  return class AsyncComponent extends PureComponent {

    componentDidMount() {
      // 在高阶组件 DidMount 时才去执行网络加载步骤
      load().then(({default: component}) => {
        // 代码加载成功,获取到了代码导出的值,调用 setState 通知高阶组件重新渲染子组件
        this.setState({
          component,
        })
      });
    }

    render() {
      const {component} = this.state || {};
      // component 是 React.Component 类型,需要通过 React.createElement 生产一个组件实例
      return component ? createElement(component) : null;
    }
  }
}

image.png

结果:

image.png

方法二:React.lazy()
const HotList = React.lazy(() => import(/* webpackChunkName: "index-page" */ '../../components/hotList'));

image.png

方法三:异步回调+import()

image.png

结果图:

image.png

并且在onclick事件触发后,才会异步请求执行foo.js。

thread-loader

把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行

在 worker 池(worker pool)中运行的 loader 是受到限制的。例如:

  • 这些 loader 不能产生新的文件。
  • 这些 loader 不能使用定制的 loader API(也就是说,通过插件)。
  • 这些 loader 无法获取 webpack 的选项设置。
  • 每个 worker 都是一个单独的有 600ms 限制的 node.js 进程。同时跨进程的数据交换也会被限制。

请仅在耗时的 loader 上使用

// node-sass 中有个来自 Node.js 线程池的阻塞线程的 bug。 当使用 thread-loader 时,需要设置 workerParallelJobs: 2
// https://webpack.docschina.org/guides/build-performance/#sass
const threadLoader = workerParallelJobs => {
    const options = { workerParallelJobs }
    if (constants.APP_ENV === 'dev') {
        Object.assign(options, { poolTimeout: Infinity })
    }
    return { loader: 'thread-loader', options }
}

module.exports = [
    {
        test: /\.svg$/,
        loader: [cacheLoader, threadLoader(), '@svgr/webpack'],
        include: [resolve('src')]
    }
]

详细配置描述

//配置项
use: [
  {
    loader: "thread-loader",
    // 有同样配置的 loader 会共享一个 worker 池(worker pool)
    options: {
      // 产生的 worker 的数量,默认是 (cpu 核心数 - 1)
      // 或者,在 require('os').cpus() 是 undefined 时回退至 1
      workers: 2,

      // 一个 worker 进程中并行执行工作的数量
      // 默认为 20
      workerParallelJobs: 50,

      // 额外的 Node.js 参数
      workerNodeArgs: ['--max-old-space-size=1024'],

      // Allow to respawn a dead worker pool
      // respawning slows down the entire compilation
      // and should be set to false for development
      poolRespawn: false,

      // 闲置时定时删除 worker 进程
      // 默认为 500ms
      // 可以设置为无穷大, 这样在监视模式(--watch)下可以保持 worker 持续存在
      poolTimeout: 2000,

      // 池(pool)分配给 worker 的工作数量
      // 默认为 200
      // 降低这个数值会降低总体的效率,但是会提升工作分布更均一
      poolParallelJobs: 50,

      // 池(pool)的名称
      // 可以修改名称来创建其余选项都一样的池(pool)
      name: "my-pool"
    }
  },
  // your expensive loader (e.g babel-loader)
]

预热

可以通过预热 worker 池(worker pool)来防止启动 worker 时的高延时。

这会启动池(pool)内最大数量的 worker 并把指定的模块载入 node.js 的模块缓存中。

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',
  'babel-preset-es2015',
  'sass-loader',
]);

cache-loader

npm install --save-dev cache-loader

在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里。

//文件webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
    ],
  },
};

⚠️ 请注意,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader

具体实践:

const cacheLoader = {
    loader: 'cache-loader',
    options: {
        // provide a cache directory where cache items should be stored
        cacheDirectory: resolve('.cache-loader')
    }
}

image.png

thread-loader+cache-loader实践结果

image.png

image.png

HappyPack

HappyPack 允许 Webpack 使用 Node 多线程进行构建来提升构建的速度。 编译jsx文件配置:

new HappyPack({
  id: 'jsx',
  threads: 4,
  loaders: ['babel-loader?presets[]=react,presets[]=latest&compact=false'],
})

其中,threads 指明 HappyPack 使用多少子进程来进行编译,一般设置为 4 为最佳。

编译 .scss 文件的 loader 这样写:

new HappyPack({
  id: 'scss',
  threads: 4,
  loaders: [
    'style-loader',
    'css-loader',
    'postcss-loader',
    'sass-loader',
  ],
})//其中,需要注意的一点就是,在使用 HappyPack 的情况下,我们需要单独创建一个 postcss.config.js 文件,不然,在编译的时候,就会报错。
//postcss.config.js
module.exports = {
  autoprefixer: {
    browsers: ['last 3 versions'],
  }
};

image.png

但是单独使用效果不佳,搭配dll plugin,如下小节。

实践:项目newcanary

image.png

DllPlugin

首先.dll在window系统中称为动态链接库,动态链接库包含的是,可以在其他模块中进行调用的函数和数据。 动态链接库提供了将应用模块化的方式,应用的功能可以在此基础上更容易被复用。将各个模块中公用的部分给打包成为一个公用的模块。这个模块就包含了我们的其他模块中需要的函数和数据。

使用 DllPlugin 的时候,会生成一个 manifest.json 这个文件,所存储的就是各个模块和所需公用模块的对应关系。

image.png

我们需要一个文件,这个文件包含所有的第三方或者公用的模块和库,我们在此将其命名为 vendor.js,文件的内容如下:

import 'react';
import 'react-dom';

配置webpack.config.vendor.js文件:

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

module.exports = {
  entry: {
    vendor: [path.join(__dirname, 'src', 'vendor.js')],
  },

  output: {
    path: path.join(__dirname, 'dist-[hash]'),
    filename: '[name].js',
    library: '[name]',
  },

  plugins: [
    new webpack.DllPlugin({
      path: path.join(__dirname, 'dll', '[name]-manifest.json'),
      filename: '[name].js',
      name: '[name]',
    }),
  ]
};
//package.json
"build:dll": "cross-env NODE_ENV=production webpack --config webpack.dll.config.js"

这时候,我们就需要用到 DllReferencePlugin 了。

在我们的主要配置文件中,加入以下的配置:

const DLL_PATH = './../dll' 

// ... 其他完美的配置

plugins: [
  new webpack.DllReferencePlugin({
    manifest: require(`${DLL_PATH}/vendor.manifest.json`)
  }),
   new AddAssetHtmlPlugin({//Add a JavaScript or CSS asset to the HTML generated by html-webpack-plugin html文件里就会引入dll文件
        filepath: path.resolve(__dirname, `${DLL_PATH}/**/*.js`),
        includeSourcemap: false
    }),
],

这样就完成了DllPlugin配置

效果:

项目:ts-react-webpack(newcanary项目效果忒不明显)

image.png

image.png

Tree Shaking

image.png

tree-shaking就是摇晃树木将无用部分摇掉。也就是代码中引入却未用的部分打包时将被去掉。如上图所示。

详细可参考文章:cloud.tencent.com/developer/a…

命中缓存问题

serviceWorker webpack是使用:serviceworker-webpack-plugin插件

scope hosting

scope hosting(作用域提升)

image.png

webpack工具

1 打包依赖plugin:webpack-bundle-analyzer

image.png

效果图

image.png

2.时间分析plugin:

//webpack.js
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap({config})

结果

image.png

参考文献:

深入浅出webpack4:webpack.wuhaolin.cn 使用 HappyPack 和 DllPlugin 来提升你的 Webpack 构建速度:segmentfault.com/a/119000001…