关于webpack的公共代码提取和运行

1,291 阅读9分钟

CommonsChunkPlugin

CommonsChunkPlugin 插件,是一个可选的用于建立一个独立文件(又称作 chunk)的功能,这个文件包括多个入口 chunk 的公共模块。通过将公共模块拆出来,最终合成的文件能够在最开始的时候加载一次,便存起来到缓存中供后续使用。这个带来速度上的提升,因为浏览器会迅速将公共的代码从缓存中取出来,而不是每次访问一个新页面时,再去加载一个更大的文件。

基本配置

{
  name: string,
  names: string[],
  filename: string,
  minChunks: number|Infinity|function(module, count) => boolean,
  // 在传入  公共chunk(commons chunk) 之前所需要包含的最少数量的 chunks 。
  // 数量必须大于等于2,或者少于等于 chunks的数量
  // 传入 `Infinity` 会马上生成 公共chunk,但里面没有模块。
  // 你可以传入一个 `function` ,以添加定制的逻辑(默认是 chunk 的数量)

  chunks: string[],
  children: boolean,
  deepChildren: boolean,
  // 如果设置为 `true`,所有公共 chunk 的后代模块都会被选择

  async: boolean|string,
  minSize: number,
  // 在 公共chunk 被创建立之前,所有 公共模块 (common module) 的最少大小。
}

name

  1. 可以是已经存在的chunk(一般指入口文件)对应的name,那么就会把公共模块代码合并到这个chunk上;否则,会创建名字为name的commons chunk进行合并
module.exports = {
entry: {
  main: './main.js',
  vendor: ['vue']
},
...
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity,
  }),
]
}
以上会打包出main和vendor两个文件,其中vendor提取了vue代码
module.exports = {
entry: {
  main: './main.js',
},
...
plugins: [
  new webpack.optimize.CommonsChunkPlugin({  
          name: 'vendor',
          minChunks: function(module) {
            return (
              module.resource &&
              /\.js$/.test(module.resource) &&
              module.resource.indexOf(
                path.join(__dirname, '../node_modules')
              ) === 0
            )
          },
      }),
]
}
以上会打包出main和vendor两个文件,其中vendor提取了公共包

2. 如果一个数组被传入,这相当于插件针对每个 chunk 名被多次调用

plugins: [
      new webpack.optimize.CommonsChunkPlugin({
          name: ['vendor','runtime'],
          filename: '[name].js'
      }),
  ]

等同于

plugins: [
      new webpack.optimize.CommonsChunkPlugin({
          name: 'vendor',
          filename: '[name].js'
      }),
      new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          filename: '[name].js',
          chunks: ['vendor']
      }),
  ]
  1. 如果该选项被忽略,同时 options.async 或者 options.children 被设置,所有的 chunk 都会被使用,否则 options.filename 会用于作为 chunk 名。

    chunks: 通过 chunk name 去选择 chunks 的来源。chunk 必须是公共chunk 的子模块。如果被忽略,所有的入口chunk (entry chunk) 都会被选择。

module.exports = {
entry: {
  main: './main.js',
  vendor: ['vue']
},
...
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
          name: 'vendor',
          minChunks: Infinity,
      }),
  new webpack.optimize.CommonsChunkPlugin({
          name: 'common',
          chunks: ['main'],
          minChunks: function(module) {
            return (
              module.resource &&
              /\.js$/.test(module.resource) &&
              module.resource.indexOf(
                path.join(__dirname, '../node_modules')
              ) === 0
            )
          },
      }),
    new webpack.optimize.CommonsChunkPlugin({
          name: 'manifest',
          chunks: ['vendor', 'common', 'main']
      }),
]
}
以上会打包出main,vendor,common和manifest四个文件,其中`vendor`提取了vue代码,`common`包含了一些公共包(除去vue),`manifest`就可以简单理解为模块映射关系的集合

理解vue-cliwebpack默认配置中对于CommonsChunkPlugin的配置:

// webpack.base.conf.js ???
new webpack.optimize.CommonsChunkPlugin('common.js')

// webpack.prod.conf.js
module.exports = {
  entry: {
    app: './src/main.js'
  },
  output: {
    path: config.build.assetsRoot,
    filename: utils.assetsPath('js/[name].[chunkhash].js'),
    // 第四步
    // `chunkFilename`: 用来指定异步加载的模块名字,异步加载模块中的共同引用到的模块就会被合并到async中指定名字。
    chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
  },
  plugins: [
    // 第三步
    new webpack.HashedModuleIdsPlugin(),
    // 第一步
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks (module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
        )
      }
    }),
    // 第二步
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity
    }),
    // 第四步
    new webpack.optimize.CommonsChunkPlugin({
      async: 'vendor-async',
      children: true
    }),
  ]
}

分步理解:

  1. 第一步:提取公共包 优化1

  2. 第二步:提取公共包映射关系 manifest: webpack打包默认模块,通过 manifest,webpack 能够对「你的模块映射到输出 bundle 的过程」保持追踪。

当编译器(compiler)开始执行、解析和映射应用程序时,它会保留所有模块的详细要点。这个数据集合称为 "Manifest",当完成打包并发送到浏览器时,会在运行时通过 Manifest 来解析和加载模块。无论你选择哪种模块语法,那些 import 或 require 语句现在都已经转换为 webpack_require 方法,此方法指向模块标识符(module identifier)。通过使用 manifest 中的数据,runtime 将能够查询模块标识符,检索出背后对应的模块。

优化2前 优化2后

  1. 第三步:引入HashedModuleIdsPlugin固定模块id

为了便于看出效果,我们可以尝试将vue和其他node包分开打包,使用配置如下:

module.exports = {
   entry: {
       main: './main.js',
       // 新增的
       vue: ['vue']
   },
   output: {
     filename: '[name][chunkhash].js',
     path: path.resolve(__dirname, '../dist'),
     publicPath: './',
   },
    plugins: [
        ...// 省略了一些配置
        // 新增的
        new webpack.optimize.CommonsChunkPlugin({
          name: 'vue',
          minChunks: Infinity,
        }),
        new webpack.HashedModuleIdsPlugin(),

        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function(module) {
              return (
                module.resource &&
                /\.js$/.test(module.resource) &&
                module.resource.indexOf(
                  path.join(__dirname, '../node_modules')
                ) === 0
              )
            },
            // 新增的
            chunks: ['main'],
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest',
            chunks: ['vendor', 'common', 'main', 'vue']
        }),
    ],
    
  }

main.js中对依赖包jquery分别进行移除操作,未添加HashedModuleIdsPlugin优化3前

添加了HashedModuleIdsPlugin 优化3后

因为未添加HashedModuleIdsPlugin时,模块id是根据webpack的解析顺序增量的,如果变换解析顺序,那模块id也会随之改变,所以需要使用HashedModuleIdsPlugin,它是根据模块相对路径生成模块标识,如果模块没有改变,那模块标识也不会改变。

  1. 第四步:使用async属性处理,异步加载时提取公共代码:

当我们使用异步加载代码时,而且test1test2同时使用了第三方包,如jquery如下:

import Vue from 'vue';
const a = 1 + 1;
new Vue({
    el: '#app',
    data: {
      vue_test: 'vue is loaded!'
    }
})
import('./test1').then(foo => {
});
import('./test2').then(foo => {
});

此时,如果没有使用async属性,打包结果如下: 优化4前

使用async属性,打包结果如下: 优化4后


Webpack4之SplitChunksPlugin

webpack官网:

The CommonsChunkPlugin 已经从 webpack v4 legato 中移除。想要了解在最新版本中如何处理 chunk,请查看 SplitChunksPlugin。

现有CommonsChunkPlugin的问题:CommonsChunkPlugin的思路是基于父子关系的,使得其只能统一抽取到父chunk,造成父chunk过大,不可避免的存在重复引入,引入多余代码。

例如:我们打包多页面时候:

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

module.exports = {
    entry: {
        main1: './main.js',
        main4: './main4.js',
    },
    output: {
        filename: '[name][chunkhash].js',
        path: path.resolve(__dirname, '../dist'),
        publicPath: './',
    },
    plugins: [
        new CleanWebpackPlugin(['dist'], { root: process.cwd() }),
        new HtmlWebpackPlugin({
            filename: 'pagea.html',
            template: 'index.html',
            minify: {
                removeComments: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true
            },
            chunks: ['manifest', 'main1', 'vendor'],
        }),
        new HtmlWebpackPlugin({
            filename: 'pageb.html',
            template: 'index.html',
            minify: {
                removeComments: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true
            },
            chunks: ['manifest', 'main4', 'vendor'],
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function(module) {
              return (
                module.resource &&
                /\.js$/.test(module.resource) &&
                module.resource.indexOf(
                  path.join(__dirname, '../node_modules')
                ) === 0
              )
            },
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest',
        }),
    ],
}

其中main.js引入了vuejquery,而main4.js只引入了vue,打包后的结果:

split4

打包后,vender包含了vuejquery,也就是说当我们在加载pageb的时候,多加载不需要的jquery代码。

SplitChunksPlugin的思路: 引入chunkGroup的概念,在入口chunk和异步chunk中发现被重复使用的模块,将重复模块以vendor-chunk的形式分离出来,也就是vendor-chunk可能有多个,不再受限于所有chunk中都共同存在的模块。

区别

升级了webpack4之后,production模式下,SplitChunksPlugin插件是默认被启用的,默认配置如下:

splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    	default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

也就是说,默认情况下,webpack会根据下述条件自动进行代码块分割:

  • 新代码块可以被共享引用,或者这些模块都是来自node_modules文件夹里面
  • 新代码块大于30kb(min+gziped之前的体积)
  • 按需加载的代码块,并行请求最大数量应该小于或者等于5
  • 初始加载的代码块,并行请求最大数量应该小于或等于3

关于SplitChunksPlugin不再做多的介绍,有需要可以查看没有了CommonsChunkPlugin,咱拿什么来分包(译)

打包后文件分析

为了方便查看,我们用最简单的配置去打包,以下为打包配置:

const webpack = require('webpack')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
    entry: {
        main: './main.js',
    },
    output: {
        filename: '[name][chunkhash].js',
        path: path.resolve(__dirname, '../dist'),
        publicPath: './',
    },
    plugins: [
        new CleanWebpackPlugin(['dist'], { root: process.cwd() }),
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: 'index.html',
            minify: {
                removeComments: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true
            },
        }),
        new BundleAnalyzerPlugin(),
        new webpack.HashedModuleIdsPlugin(),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
    ],
}

一般情况

从 main.js 开始看代码
<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title></title></head><body><div id=app></div><div id=jq></div><script type=text/javascript src=./manifestce30c729b8270ce6a88d.js></script><script type=text/javascript src=./main189de2d1b19ec3394c54.js></script></body></html>

可以看到,打包后 js 文件的加载顺序是先manifest.js,之后才是main.js,这里先看看 main.js 的内容:

// main.js
webpackJsonp([0],{

/***/ "I6/Z":
/***/ (function(module, exports) {
module.exports = 1;
/***/ }),
/***/ "eitI":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__test2__ = __webpack_require__("I6/Z");
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__test2___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__test2__);
const a = 1 + 1;
console.log(__WEBPACK_IMPORTED_MODULE_0__test2___default.a)
/***/ })

},["eitI"]);

删去一些无关代码,首先关注到的是webpackJsonp这个函数,可以看见是不在任何命名空间下的,也就是manifest.js应该定义了一个挂在window下的全局函数main.js往这个函数传入三个参数并调用。

这里先记住两个点:

  1. 第三个参数的值是eitI,与参数2中的某个方法的键是一致的。
  2. 一个exports的函数__webpack_exports__和一个类似require的函数__webpack_require__,这两个应该是模块化的关键。

带着以上疑问,我们去看manifest.js

manifest.js 代码阅读
(function(modules) {
  var parentJsonpFunction = window["webpackJsonp"];
  window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
  	var moduleId, chunkId, i = 0, resolves = [], result;
  	for(moduleId in moreModules) {
  		if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
  			modules[moduleId] = moreModules[moduleId];
  		}
  	}
  	if(executeModules) {
  		for(i=0; i < executeModules.length; i++) {
  			result = __webpack_require__(executeModules[i]);
  		}
  	}
  	return result;
  };

  var installedModules = {};

  // The require function
  function __webpack_require__(moduleId) {
  	if(installedModules[moduleId]) {
  		return installedModules[moduleId].exports;
  	}
  	// Create a new module (and put it into the cache)
  	var module = installedModules[moduleId] = {
  		i: moduleId,
  		l: false,
  		exports: {}
  	};

  	// Execute the module function
  	modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  	// Flag the module as loaded
  	module.l = true;

  	// Return the exports of the module
  	return module.exports;
  }
 })
([]);
  1. manifest.js内部是一个IIFE。这个函数会接受一个空数组作为参数,该数组被命名为 modules。果然在 window 上挂了一个名为webpackJsonp的函数。它接受的三个参数,分别名为chunkIds, moreModules, executeModules。对应了 main.js 中调用webpackJsonp时传入的三个参数。

    看下webpackJsonp:webpackJsonp 先是for遍历了一次moreModules,将 moreModules内的所有方法都存在modules, 也就是自执行函数执行时传入的数组。

    然后判断executeModules, 也就是第三个参数是否存在,如存在即执行 __webpack_require__ 方法。

  2. installedModules 是一个缓存的容器,如果缓存中有对应的 moduleId,那么直接返回它的 exports,不然就定义并赋值一个吧。

  3. __webpack_require__ 最后的返回值是module.exports

    webpack 就是将每一个 js 文件封装成一个函数,每个文件中的 require 方法对应的就是 __webpack_require____webpack_require__ 会根据传入的 moduleId 再去加载对应的代码。

以上面的例子总结,梳理一下打包后代码执行的流程:

  1. 首先执行manifest.js,在里面定义了一个webpackJsonp方法;
  2. 执行main.js:执行webpackJsonp函数,将所有的 moreModules, 也就是每一个依赖的文件存起来。
  3. 因为传入了第三个参数,所以eitI作为参数去执行__webpack_require__,__webpack_require__执行了eitI值定义的方法,这个方法中有以I6/Z作为参数去执行__webpack_require__(可以理解为加载了依赖文件test2.js),将依赖文件导出的值作为__webpack_require_函数返回值后供eitI内使用。
  4. 因为eitI值定义的方法没有导出值,__webpack_require__返回空对象,然后webpackJsonp方法执行完毕。

总结:

  • 首先manifest.js会定义一个webpackJsonp方法,待其他打包后的文件(也可称为 chunk)调用。

  • 当调用 chunk 时,会先将该 chunk 中所有的 moreModules, 也就是每一个依赖的文件也可称为 module存起来。

  • 之后通过executeModules判断这个文件是不是入口文件,决定是否执行第一次 __webpack_require__。而 __webpack_require__ 的作用,就是根据这个 module 所 require 的东西,不断递归调用 __webpack_require____webpack_require_函数返回值后供 require 使用。

  • 当然,模块是不会重复加载的,因为installedModules记录着 module 调用后的 exports 的值,只要命中缓存,就返回对应的值而不会再次调用 module。webpack 打包后的文件,就是通过一个个函数隔离 module 的作用域,以达到不互相污染的目的。

异步加载

这里是demo的代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title></title>
  </head>
  <body>
      <div id="app"></div>
      <div id="jq"></div>
      <button class="btn">load</button>
  </body>
</html>
// main.js
const p = document.querySelector('#jq');
const btn = document.querySelector('.btn');
btn.addEventListener('click', function() {
  //懒加载 test3.js
  require.ensure([], function() {
    const data = require('./test4');
    p.innerHTML = data;
  })
})
// test4.js
const data = 'success!';
module.exports = data;

从上面代码看出,test4是懒加载的,只有在点击了按钮才会加载这部分js代码,看一下打包后的运行效果:

异步

可以看出浏览器一开始只加载了main.jsmanifest.js,在点击按钮后,才加载了05d9040b8834b81d1aeb6.js。说明代码是被分割了的,只要当对应的条件触发时,浏览器才会去加载指定的资源。而无论之后我们点击多少次,05d9040b8834b81d1aeb6.js 文件都不会重复加载。所以这里先留下一个问题:如何做到不重复加载?

从 main.js 开始看代码
webpackJsonp([1],{

/***/ "Pmdr":
/***/ (function(module, exports, __webpack_require__) {

const p = document.querySelector('#jq');
const btn = document.querySelector('.btn');
btn.addEventListener('click', function() {
  //懒加载 test3.js
  __webpack_require__.e/* require.ensure */(0).then((function() {
    const data = __webpack_require__("O5aC");
    p.innerHTML = data;
  }).bind(null, __webpack_require__)).catch(__webpack_require__.oe)
})

/***/ })

},["Pmdr"]);

和上文的一般情况相比,问我们注意到__webpack_require__.e这个方法,传入一个数值之后返回一个promise。这方法当promise决议成功后执行切换文本的逻辑,失败则执行__webpack_require__.oe

综上,我们希望在manifest.js找到这三个问题的答案:

  • 如何做到不重复加载?
  • __webpack_require__.e方法的逻辑
  • __webpack_require__.oe方法的逻辑
manifest.js 代码阅读

部分截取__webpack_require__.e代码:

	var installedModules = {};
	// objects to store loaded and loading chunks
	var installedChunks = {
		2: 0
	};
	// This file contains only the entry chunk.
	// The chunk loading function for additional chunks
	__webpack_require__.e = function requireEnsure(chunkId) {
		var installedChunkData = installedChunks[chunkId]; // 1.尚未加载对应模块,undefined
    
    // 10.关于如何做到不重复加载?
    // 当再次请求同一文件时,由于对应的 module 已经被加载,因而直接返回一个成功的 promise
		if(installedChunkData === 0) {
			return new Promise(function(resolve) { resolve(); });
		}
		// a Promise means "currently loading".
    // 3.答案:假设网络很差的情况下,我们疯狂点击按钮,为避免浏览器发出若干个请求,通过条件判断都返回同一个 promise,当它决议后,所有挂载在它之上的 then 方法都能得到结果运行下去,相当于构造了一个队列,返回结果后按顺序执行对应方法
		if(installedChunkData) {
			return installedChunkData[2];
		}
		// setup Promise in chunk cache
    // 2. installedChunkData 与 installedChunks[chunkId] 被重新赋值为一个数组,存放着返回值 promise 的 resolve 与 reject
		var promise = new Promise(function(resolve, reject) {
			installedChunkData = installedChunks[chunkId] = [resolve, reject];
		});
    // 问题:为何将数组的第三项赋值为这个 promise呢?
		installedChunkData[2] = promise;
		// start chunk loading
    // 4. 创造一个 script 标签插入头部,加载指定的 js
		var head = document.getElementsByTagName('head')[0];
		var script = document.createElement('script');
		script.type = 'text/javascript';
		script.charset = 'utf-8';
		script.async = true;
		script.timeout = 120000;
		if (__webpack_require__.nc) {
			script.setAttribute("nonce", __webpack_require__.nc);
		}
		script.src = __webpack_require__.p + "" + chunkId + "" + {"0":"5d9040b8834b81d1aeb6","1":"5f81f82fbaa45b7a1fdd"}[chunkId] + ".js";
		var timeout = setTimeout(onScriptComplete, 120000);
    // 5.js 文件下载成功之后,先执行内容,再执行 onload 方法
		script.onerror = script.onload = onScriptComplete;
		function onScriptComplete() {
			// avoid mem leaks in IE.
			script.onerror = script.onload = null;
			clearTimeout(timeout);
			var chunk = installedChunks[chunkId];
      // 9. 下载失败,未被赋值0,执行reject
			if(chunk !== 0) {
				if(chunk) {
					chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
				}
				installedChunks[chunkId] = undefined;
			}
		};
		head.appendChild(script);
		return promise;
	};

总的来说,就是该方法中接受一个名为 chunkId 的参数,返回一个 promise,解释见注释,按数字顺序阅读。

// 6.执行5f81f82fbaa45b7a1fdd.js内容
webpackJsonp([0],{
/***/ "O5aC":
/***/ (function(module, exports) {
const data = 'success!';
module.exports = data;
/***/ })
});
// 截取部分 webpackJsonp 方法
var resolves = [];

for (; i < chunkIds.length; i++) {
  chunkId = chunkIds[i];
  if (installedChunks[chunkId]) { // 查看步骤2,installedChunks[0]就是[resolve, reject]
    resolves.push(installedChunks[chunkId][0]);
  }
  // 7. installedChunks[0]被重新赋值为0
  installedChunks[chunkId] = 0;
}

while (resolves.length) {
  // 8. 执行resolve
  resolves.shift()();
}

__webpack_require__.oe方法

__webpack_require__.oe = function(err) { console.error(err); throw err; };

流程:当异步请求文件发起时,先判断该 chunk 是否已被加载,是的话直接返回一个成功的 promise,让 then 执行的函数 require 对应的 module 即可。不然则构造一个 script 标签加载对应的 chunk,下载成功后挂载该 chunk 内所有的 module。下载失败则打印错误。

作者:Luin

【参考】

CommonsChunkPlugin

知多一点 webpack 的 CommonsChunkPlugin

webpack增量打包

详解CommonsChunkPlugin的配置和用法

简单易懂的 webpack 打包后 JS 的运行过程

简单易懂的 webpack 打包后 JS 的运行过程(二)

webpack CommonsChunkPlugin 和 SplitChunksPlugin 思路

Webpack4之SplitChunksPlugin