从webpack4打包文件说起

一堆的webpack配置教程看腻了?这里有webpack4的打包及加载机制,要不了解一下?而这一切就得从打包文件说起。

相信大家都和我一样,用webpack打完包之后,很少或者极度反感打开bundle.js来看的,里面一坨坨的编译后代码和没完没了的/****/注释,完全不知所云。看起来虽然恶心,但还挺有营养。下面通过打包文件来深入了解下webpack4的模块化处理以及代码拆分加载机制。

使用的webpack配置如下,通过调整entry的内容来观察对比打包文件的异同。

// webpack.config.js
module.exports = {
	mode: 'development', // 不压缩
	entry: {
		chunk1: './src/index.js'
	},
	output: {
		path: './dist',
		filename: '[name]-[chunkhash:8].js' // 为了后面的多入口
	},
	devtool: '' // 去掉sourcemap,模块不会被eval包裹,更直观
};

一、webpack4的模块化处理机制

// index.js
const name = require('./name.js');
console.log(name);

// name.js
module.exports = 'elson';

执行npx webpack后得到chunk1-cfdec98e.js(精简改造后)。

// chunk1-cfdec98e.js

// 模块数据映射表
var modulesData = {
	"./src/commonjs/index.js": function (module, exports, __webpack_require__) {
        const name = __webpack_require__("./src/commonjs/name.js");
        console.log(name);
    },
	"./src/commonjs/name.js": xxx
};

(function (modules) {
	function __webpack_require__() {...}
	return __webpack_require__("./src/commonjs/index.js");
})(modulesData);

首先可以看到,我们的各个模块(文件)都被一个匿名函数包裹着传入module, exports, __webpack_require__webpack三个参数(这就不难理解为什么可以直接在js里使用这几个变量了)。

通过一个自执行函数,将每个模块的路径及“包裹函数”以对象键值对modulesData的方式传给modules,函数体内,webpack自己实现了一个__webpack_require__,以入口文件index.js作为起点开始执行__webpack_require__("./src/commonjs/index.js"),并返回执行结果(剧透一下,返回的正是module.exports)。

下面看下__webpack_require__的实现。

// 每个模块的缓存
var installedModules = {};

function __webpack_require__(moduleId) {

    // 查看是否已缓存,有则直接返回exports对象
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // 无缓存,则新建一个module对象
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };

    // 重点:执行模块文件代码,也就是上面modulesData的数据
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // 标识为已加载
    module.l = true;

    // 返回传说中的module.exports对象
    return module.exports;
}

这里的moduleId就是模块路径,如./src/commonjs/index.js

webpack4中只有optimization.namedModules为true,此时moduleId才会为模块路径,否则是数字id。为了方便开发者调试,在development模式下optimization.namedModules参数默认为true。

其中,module对象中的exports非常关键,对应的模块函数(上面所说的被匿名函数包裹着的模块)modules[moduleId]this会绑定到module.exports后执行,传入modulemodule.exports以及__webpack_require__(也就是我们未编译前的require)。

function (module, exports, __webpack_require__) {
   const name = __webpack_require__("./src/commonjs/name.js");
   console.log(name);
}

最后返回module.exports

1. commonjs:this/exports/module.exports

在commonjs中,exports和module.exports总容易弄混。

从上面可以看出,在模块函数里面,thisexports都指向于module.exports,因此也可以通过this.name = 'elson'exports.name = 'elson'对外输出。但不能this = 'elson'exports = 'elson',原因很简单,这样thisexports不再指向最终返回的module.exports

因此如果想对外输出一个基本数据类型或者函数的话,则只能赋值给module.exports

exports.name = 'elson'; // require('xx').name === 'elson'
module.exports = 'elson'; // require('xx') === 'elson'
module.exports = function() {}; // require('xx')()

整个函数的实现要比想象中简单很多,与node里面的commonjs实现也基本一致。

2. ES6 module: export/export default

对于ES6的模块语法,webpack也同样支持(也就是说如果只用到es6的模块语法,是不需要babel)。

// ./src/esmodules/name.es.js
export let obj =  {a: 1, b: 2};
export function getName() {return 'elson';};
let name;
export default name = 'elson';

// ./src/esmodules/index.es.js
import * as all from './name.es.js';
console.log(all);
window.all = all;

在name.es.js中使用export/export default进行导出,在index.es.js使用import进行导入,看下webpack会打包成什么样子。

忽略掉webpack的runtime代码,我们写的模块会被打包成以下模样:

{
	"./src/esmodules/index.es.js":(
	    function(module, __webpack_exports__, __webpack_require__) {
	
	        "use strict";
	        __webpack_require__.r(__webpack_exports__);
	        /* harmony import */ 
	        var _name_es_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/esmodules/name.es.js");
	        console.log(_name_es_js__WEBPACK_IMPORTED_MODULE_0__);
	        window.all = _name_es_js__WEBPACK_IMPORTED_MODULE_0__;
	    }
	),
	
	"./src/esmodules/name.es.js":(
	    function(module, __webpack_exports__, __webpack_require__) {
	
	        "use strict";
	        __webpack_require__.r(__webpack_exports__);
	        /* harmony export (binding) */ 
	        __webpack_require__.d(__webpack_exports__, "obj", function() { return obj; });
	        /* harmony export (binding) */ 
	        __webpack_require__.d(__webpack_exports__, "getName", function() { return getName; });
	
	        let obj =  {a: 1,b: 2};
	        function getName() {return 'elson';};
	        let name;
	        /* harmony default export */ 
	        __webpack_exports__["default"] = (name = 'elson');
	    }
	)
}

这里用到了挂载在__webpack_require__上的两个函数dr

  • d在exports对象上为某一属性设置getter函数。
  • r在exports对象上设置属性__esModule: true
// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
    if(!__webpack_require__.o(exports, name)) {
        Object.defineProperty(exports, name, { enumerable: true, get: getter });
    }
};

// define __esModule on exports
__webpack_require__.r = function(exports) {
    if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
    }
    Object.defineProperty(exports, '__esModule', { value: true });
};

简单来说,对于ES6模块,webpack会先在module.exports对象上标记这是ES6模块,import进来的模块通过__webpack_require__加载,而export输出的值则通过Object.defineProperty设置响应的getter。

看到这里,可能会产生几个疑问:

  1. 为什么要设置getter?
  2. export default输出的值怎么没有设置getter?

设置getter是为了实现ES6模块的动态绑定,即export的值修改之后能够动态更新到import。但如果export default一个非函数或class,则不会动态绑定。如下:

// name.es.js
export let obj =  {a: 1,b: 2};
export let liveName = 'elson';
export function getName() {return 'elson';};
let deadName;
export default deadName = 'elson'; // default导出

// 3秒后修改导出值
setTimeout(() => {
    liveName = 'peter';  // 会更新
    deadName = 'peter'; // 不会更新
    obj.a = 222; // 会更新
    console.log('changed!!');
}, 3000);

// index.es.js
import * as all from './name.es.js';
console.log(all);
window.all = all;

此外由于ES6模块编译后使用了Object.defineProperty,因此无法兼容IE8-。如果有低版本IE兼容的需要,建议还是用回commonjs进行模块化,否则最后还得为此引入各种polyfill,得不偿失。

export var uses getter via Object.defineProperty that breaks <=IE8 https://github.com/webpack/webpack/issues/2729

3. 混合使用commonjs和ES module

webpack支持上述两者混合使用,我们可以export default导出require()引入,或者module.exports导出import引入。

从上面的打包代码分析可以知道,export default导出的值是挂载在default属性上,这也是为什么在一些混用场景下,需要通过require().default才能取到值。

而对于import引入module.exports导出的模块时,webpack做了如下处理:

编译前:

// a.js
module.exports = {elson: 'elson'};

// index.js
import elson from 'a.js';
console.log(elson);

编译后:

// index.js(部分代码)
var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( "./src/mixed/a.js");

var _a_js__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(_a_js__WEBPACK_IMPORTED_MODULE_0__);

console.log(_a_js__WEBPACK_IMPORTED_MODULE_0___default.a);

通过__webpack_require__获取到{elson: 'elson'}后,传入__webpack_require__.n:生成一个getter函数,getter上定义a属性并指向getter,最后返回getter。而模块中最终通过这个a属性来访问module.exports的值。至于为什么要放到一个a属性上,这点还是不太理解,求各路大神指教。

__webpack_require__.n = function(module) {
    var getter = module && module.__esModule ?
        function getDefault() { return module['default']; } :
        function getModuleExports() { return module; };
    __webpack_require__.d(getter, 'a', getter);
    return getter;
};

二、webpack4的代码拆分加载机制

在项目中,如果一股脑把所有东西都打包到一个js中,那就只能唱首凉凉给你了。因此对第三方库、公共代码、按需加载的代码、甚至webpack的runtime代码进行拆分是常见的优化手段。下面了解一下如何准确配置拆分点以及运行时webpack是怎样加载被拆分了的代码。

1. 配置拆分点

webpack4使用optimization.splitChunks来配置拆分点,与webpack3的commonChunkPlugin相比,更加易操作、易理解。

(1)默认配置

默认配置中,optimization.splitChunks只拆分通过import()引入的异步加载代码,官方文档的案例可更直观了解在默认配置下的打包结果。

// 默认的optimization.splitChunks配置
// webpack.config.js
module.exports = {
	...
	optimization: {
		splitChunks: {
			chunks: 'async', // 抽离类型:async、initial、all,默认是async
		    minSize: 30000, // 抽离包大小下限,默认超过30kb才会抽离
		    maxSize: 0, // 抽离包大小上限,抽离后大小若超过上限,且包含多个可再拆分的模块时,会再次拆分,保证单个文件不会过大
		    minChunks: 1, // 至少要有1个及以上的chunk共用同一模块才会抽离
		    maxAsyncRequests: 5,
		    maxInitialRequests: 3,
		    automaticNameDelimiter: '~',
		    name: true,
            cacheGroups: { // 可自行配置缓存组,组中的配置会覆盖掉父级的配置
		        vendors: { // 异步chunk中的第三方模块会单独抽离
		          test: /[\\/]node_modules[\\/]/,
		          priority: -10
		        },
		        default: {
		          minChunks: 2,
		          priority: -20,
		          reuseExistingChunk: true
		        }
		    }
        }
	}
};

(2)自定义拆分案例

// chunk1.js
import name from './name.js';
import zepto from 'zepto';
console.log(name);

// chunk2.js
import name from './name.js';
import(/* webpackChunkName: "math" */'./math.js').then(() => {console.log('math loaded!');}); // 通过注释指定异步chunk的名字
console.log(name);

// name.js 与 math.js各自无其他依赖

希望做到:

  1. 抽离webpack的runtime代码
  2. 抽离公共代码name.js
  3. 抽离第三方库zepto为vendor.js
// webpack.config.js
module.exports = {
	entry: {
		chunk1: './src/esmodules/chunk1.js',
		chunk2: './src/esmodules/chunk2.js',
	},
	optimization: {
		runtimeChunk: 'single', // 抽离webpack的runtime代码
		splitChunks: {
			chunks: 'all', // 异步、非异步均纳入抽离范围
			minSize: 0, // 抽离包大小下限不做限制,30k以下的也抽离
			cacheGroups: {
				vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: "vendor" // 抽离后的文件名称
                }
			}
		}
	}
};

打包结果:

  • index.html
  • 入口1:chunk1-559865ad.js
  • 入口2:chunk2-3d2f5b4a.js
  • 入口1~2公共代码(name.js):chunk1~chunk2-6fa130db.js
  • 异步加载的math.js:math-7512974a.js
  • webpack runtime:runtime-dc502348.js
  • 第三方库zepto:vendor-af69430f.js

值得一提的是如果引入了多个第三方库造成vendor.js太大的话,可以配置maxSize,当vendor超过max值时会拆成多个小包。结合http2,效果更佳。

2. 加载拆分代码机制分析

html-webpack-plugin 会将上面的非异步脚本按照依赖顺序注入页面,下面我们看下具体webpack是怎样执行的。

<script type="text/javascript" src="runtime-dc502348.js"></script>
<script type="text/javascript" src="chunk1~chunk2-6fa130db.js"></script>
<script type="text/javascript" src="chunk1-559865ad.js"></script>
<script type="text/javascript" src="vendor-af69430f.js"></script>
<script type="text/javascript" src="chunk2-3d2f5b4a.js"></script>

我们已经把runtime代码单独抽离出来,那么除了runtime.js以外,其他脚本都长得非常类似:

// chunk1~chunk2.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
    ["chunk1~chunk2"],
    {"./src/esmodules/name.js": (function() {})}
]);

// chunk1.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
    ["chunk1"],
    {"./src/esmodules/chunk1.js": (function() {})},
    [["./src/esmodules/chunk1.js","runtime","chunk1~chunk2"]]
]);

// chunk2.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
    ["chunk2"],
    {"./src/esmodules/chunk2.js": (function () {})},
    [["./src/esmodules/chunk2.js","runtime","vendor","chunk1~chunk2"]]
]);

可以看出无论是入口chunk还是非入口chunk,都是将一个数组push进window["webpackJsonp"]。这个数组包括2-3个元素:

  1. 各自的chunk名:如["chunk1~chunk2"]
  2. chunk所包含的模块:如{"./src/esmodules/name.js": (function() {})}
  3. 对于入口chunk来说,还有说明entry文件所依赖哪些chunk的数组:如[["./src/esmodules/chunk2.js","runtime","vendor","chunk1~chunk2"]]

那么,这真的仅仅只是一个数组的push操作吗?下面看下核心——runtime.js还做了些什么。

// runtime.js(部分)
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 将原生的push方法存起来
jsonpArray.push = webpackJsonpCallback; // 暂时劫持原生push方法
// jsonpArray === window.webpackJsonp true
jsonpArray = jsonpArray.slice(); // 复制一份webpackJsonp数组
// jsonpArray === window.webpackJsonp false
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

jsonpArray.push = webpackJsonpCallback;可以看出webpack将数组的原生push方法劫持成了webpackJsonpCallback,因此在所有拆分的代码中执行的window["webpackJsonp"].push()实际上是执行webpackJsonpCallback,而真正的push操作则放在了webpackJsonpCallback里面进行。

function webpackJsonpCallback(data) {
    var chunkIds = data[0]; // ["chunk1"]
    var moreModules = data[1]; // {"./src/esmodules/chunk1.js": (function() {})}
    var executeModules = data[2]; // [["./src/esmodules/chunk1.js","runtime","chunk1~chunk2"]]

    var moduleId, chunkId, i = 0, resolves = [];
    for(;i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        
        // 此分支用于加载异步脚本,如math.js
        // true代表其值为Promise,意思是加载中
        if(installedChunks[chunkId]) {
            // 将promise的resolve函数推入数组,稍后批量执行
            resolves.push(installedChunks[chunkId][0]);
        }
        // 当前chunk置为已加载完成
        installedChunks[chunkId] = 0;
    }
    // 将当前chunk的所有模块都放入modules对象中
    for(moduleId in moreModules) {
        if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }
    // 在这里才真正执行push进数组的操作!window.webpackJsonp.push(data)
    if(parentJsonpFunction) parentJsonpFunction(data);

    // 执行异步脚本(如果有)的resolve,resolve后便会触发then回调
    while(resolves.length) {
        resolves.shift()();
    }

    // executeModules是入口chunk才会传入的参数
    // 将入口文件以及依赖推入deferred队列
    deferredModules.push.apply(deferredModules, executeModules || []);

    // 处理deferred队列
    return checkDeferredModules();
}

webpackJsonpCallback中会处理两种脚本:已注入页面的非异步chunk以及按需加载的异步chunk(如math.js)。

(1)非异步chunk的加载

对于非异步chunk来说,经过webpackJsonpCallback的处理,已经将chunk中的所有模块都存进了modules对象中。这对于非入口chunk(如chunk1~chunk2.js)已经没什么需要处理的了,而对于入口chunk(如chunk1.js)则还需要执行entry模块如./src/esmodules/chunk1.js

而这部分工作在webpackJsonpCallback末尾交给了return checkDeferredModules()处理。

function checkDeferredModules() {
    var result;
    // 以[["./src/esmodules/chunk1.js","runtime","chunk1~chunk2"]]为例
    for(var i = 0; i < deferredModules.length; i++) {
        var deferredModule = deferredModules[i];
        var fulfilled = true;
        // 第0项是入口文件,第1项开始是所依赖的chunk名称:"runtime","chunk1~chunk2"
        for(var j = 1; j < deferredModule.length; j++) {
            var depId = deferredModule[j];
            // 检查所依赖的chunk是否已加载完毕
            if(installedChunks[depId] !== 0) fulfilled = false;
        }
        // 只有加载完毕才会执行入口文件
        if(fulfilled) {
            deferredModules.splice(i--, 1);
            // 执行入口文件./src/esmodules/chunk1.js
            result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
        }
    }
    return result;
}

checkDeferredModules先检查所依赖的chunk是否都加载完毕,是的话才会执行入口文件。

(2)异步chunk的加载

最后来看下异步按需加载的chunk是如何加载的。

// chunk1.js
__webpack_require__.e("math")
	.then(__webpack_require__.bind(null, "./src/esmodules/math.js"))
	.then(() => {console.log('math loaded!')});

以math.js为例,我们在源码中通过import('math.js')标识其为需要按需加载的chunk。而webpack则是交由__webpack_require__.e函数,通过动态插入script来实现异步加载。

// 存储chunk加载状态
// undefined = chunk not loaded, 
// null = chunk preloaded/prefetched
// Promise = chunk loading
// 0 = chunk loaded
var installedChunks = {
 	"runtime": 0
};

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    var installedChunkData = installedChunks[chunkId];

    /*部分省略*/

    var promise = new Promise(function(resolve, reject) {
        // 存储promise函数 即标识为加载中
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    promises.push(installedChunkData[2] = promise);

    // 动态插入script实现异步加载
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    var onScriptComplete;

    /*部分省略*/
    script.src = jsonpScriptSrc(chunkId);
    onScriptComplete = function (event) {/*超时及错误处理*/};
    script.onerror = script.onload = onScriptComplete;
    head.appendChild(script);
    
    return Promise.all(promises);
};

installedChunks[chunkid]的值只有为true的时候才表示正在加载中。

整个math.js的异步加载过程需要结合webpackJsonpCallback进行理解。

  1. 首先__webpack_require__.e("math")执行过程中会生成一个promise,将相应的resolve和reject函数,闭包存储在installedChunks['math'],此时值为true,表示加载中(第4步会用来做判断条件);
  2. 动态插入script,加载math.js,并返回promise;
  3. 加载完毕后执行math.js:window["webpackJsonp"].push(),也就是执行webpackJsonpCallback();
  4. 判断installedChunks['math']是否为true(promise),取出之前存储起来的resolve执行。
  5. promise在resolved后自动执行then方法:console.log('math loaded!')

参考

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏分布式系统进阶

Kafka集群Metadata管理Kafka源码分析-汇总

可以看到是调用了ReplicaManager.maybeUpdateMetadataCache方法, 里面又会调用到MetadataCache.updateCa...

2512
来自专栏向治洪

多线程断点下载

多线程断点下载 多线程下载 public class MultiThreadDownloader { private URL url; // 目...

1955
来自专栏Java编程技术

基于Java注解和模块化生成树形业务文档的实践

一个新人快速掌握一个新系统业务逻辑的最好的工具是什么,是看代码?是debug?是看uc?是看demo?答案应该都不是,因为看代码和debug一来太耗时,二来系统...

1151
来自专栏服务端技术杂谈

JAVA NIO内存泄漏

前言 写NIO程序时,经常使用ByteBuffer来读取写入数据,那使用ByteBuffer.allocate()还是ByteBuffer.allocateDi...

3428
来自专栏积累沉淀

Java批处理

批处理 JDBC对批处理的操作,首先简单说一下JDBC操作sql语句的简单机制。 JDBC执行数据库操作语句,首先需要将sql语句打包成为网络字...

4465
来自专栏Java编程技术

使用数据库悲观锁实现不可重入的分布式锁

在同一个jvm进程中时,可以使用JUC提供的一些锁来解决多个线程竞争同一个共享资源时候的线程安全问题,但是当多个不同机器上的不同jvm进程共同竞争同一个共享资源...

751
来自专栏安恒网络空间安全讲武堂

从零基础到成功解题之0ctf-ezdoor

2144
来自专栏用户2442861的专栏

Java NIO使用及原理分析 (一)

最近由于工作关系要做一些Java方面的开发,其中最重要的一块就是Java NIO(New I/O),尽管很早以前了解过一些,但并没有认真去看过它的实现原理,...

862
来自专栏大史住在大前端

webpack4.0各个击破(5)—— Module篇

使用webpack对脚本进行合并是非常方便的,因为webpack实现了对各种不同模块规范的兼容处理,对前端开发者来说,理解这种实现方式比学习如何配置webpac...

1282
来自专栏程序猿DD

程序员你为什么这么累【续】:编写简陋的接口调用框架 - 动态代理学习

导读: 程序员你为什么这么累? 我的编码习惯 - 接口定义 我的编码习惯 - Controller规范 我的编码习惯 - 日志建议 我的编码习惯 - 异常处理 ...

4317

扫码关注云+社区

领取腾讯云代金券