【webpack 进阶】Webpack 打包后的代码是怎样的?

webpack 是我们现阶段要掌握的重要的打包工具之一,我们知道 webpack 会递归的构建依赖关系图,其中包含应用程序的每个模块,然后将这些模块打包成一个或者多个 bundle。
那么webpack 打包后的代码是怎样的呢?是怎么将各个 bundle连接在一起的?模块与模块之间的关系是怎么处理的?动态 import() 的时候又是怎样的呢?
本文让我们一步步来揭开 webpack 打包后代码的神秘面纱

准备工作

创建一个文件,并初始化
mkdir learn-webpack-output cd learn-webpack-output npm init -y yarn add webpack webpack-cli -D
根目录中新建一个文件 webpack.config.js,这个是 webpack 默认的配置文件
const path = require(‘path’); module.exports = { mode: ‘development’, // 可以设置为 production // 执行的入口文件 entry: ‘./src/index.js’, output: { // 输出的文件名 filename: ‘bundle.js’, // 输出文件都放在 dist path: path.resolve(_dirname, ‘./dist’) }, // 为了更加方便查看输出 devtool: ‘cheap-source-map’ }
然后我们回到 package.json 文件中,在 npm script 中添加启动 webpack 配置的命令
“scripts”: { “test”: “echo \”Error: no test specified\” && exit 1”, “build”: “webpack” }
新建一个 src文件夹,新增 index.js 文件和 sayHello 文件
// src/index.js import sayHello from ‘./sayHello’; console.log(sayHello, sayHello(‘Gopal’));
// src/sayHello.js_ function sayHello(name) { return Hello ${name}; } export default sayHello;
一切准备完毕,执行 yarn build

分析主流程

看输出文件,这里不放具体的代码,有点占篇幅,可以点击这里查看

其实就是一个 IIFE

莫慌,我们一点点拆分开看,其实总体的文件就是一个 IIFE——立即执行函数。
(function(modules) { // webpackBootstrap // The module cache var installedModules = {}; function webpack_require(moduleId) { // …省略细节 } // 入口文件 return webpack_require(webpack_require.s = “./src/index.js”); }) ({ “./src/index.js”: (function(module, webpack_exports, webpack_require) {}), “./src/sayHello.js”: (function(module, webpack_exports, webpack_require) {}) });
函数的入参 modules 是一个对象,对象的 key 就是每个 js 模块的相对路径,value 就是一个函数(我们下面称之为模块函数)。IIFE 会先 require 入口模块。即上面就是 ./src/index.js:
// 入口文件 return webpack_require(webpack_require.s = “./src/index.js”);
然后入口模块会在执行时 require 其他模块例如 ./src/sayHello.js”以下为简化后的代码,从而不断的加载所依赖的模块,形成依赖树,比如如下的模块函数中就引用了其他的文件 sayHello.js
{ “./src/index.js”: (function(module, webpack_exports, webpack_require) { webpack_require.r(webpack_exports); var sayHelloWEBPACK_IMPORTED_MODULE_0 = webpack_require(“./src/sayHello.js”); console.log(sayHelloWEBPACK_IMPORTED_MODULE_0[“default”], Object(_sayHello__WEBPACK_IMPORTED_MODULE_0[“default”])(‘Gopal’)); }) }

重要的实现机制——webpack_require

这里去 require 其他模块的函数主要是 webpack_require 。接下来主要介绍一下 webpack_require 这个函数
// 缓存模块使用 var installedModules = {}; // The require function // 模拟模块的加载,webpack 实现的 require function webpack_require(moduleId) { // Check if module is in cache // 检查模块是否在缓存中,有则直接从缓存中获取 if(installedModules[moduleId]) { return installedModules[moduleId].exports; } // Create a new module (and put it into the cache) // 没有则创建并放入缓存中,其中 key 值就是模块 Id,也就是上面所说的文件路径 var module = installedModules[moduleId] = { i: moduleId, // Module ID l: false, // 是否已经执行 exports: {} }; // Execute the module function // 执行模块函数,挂载到 module.exports 上。this 指向 module.exports modules[moduleId].call(module.exports, module, module.exports, webpack_require); // Flag the module as loaded // 标记这个 module 已经被加载 module.l = true; // Return the exports of the module // module.exports通过在执行module的时候,作为参数存进去,然后会保存module中暴露给外界的接口,如函数、变量等 return module.exports; }
第一步,webpack 这里做了一层优化,通过对象 installedModules 进行缓存,检查模块是否在缓存中,有则直接从缓存中获取,没有则创建并放入缓存中,其中 key 值就是模块 Id,也就是上面所说的文件路径
第二步,然后执行模块函数,将 module, module.exports, webpack_require 作为参数传递,并把模块的函数调用对象指向 module.exports,保证模块中的 this 指向永远指向当前的模块。
第三步,最后返回加载的模块,调用方直接调用即可。
所以这个webpack_require就是来加载一个模块,并在最后返回模块 module.exports 变量

webpack 是如何支持 ESM 的

可能大家已经发现,我上面的写法是 ESM 的写法,对于模块化的一些方案的了解,可以看看我的另外一篇文章【面试说】Javascript 中的 CJS, AMD, UMD 和 ESM是什么?
我们重新看回模块函数
{ “./src/index.js”: (function(module, webpack_exports, webpack_require) { webpack_require.r(webpack_exports); var sayHelloWEBPACK_IMPORTED_MODULE_0 = webpack_require(“./src/sayHello.js”); console.log(sayHelloWEBPACK_IMPORTED_MODULE_0[“default”], Object(sayHelloWEBPACK_IMPORTED_MODULE_0[“default”])(‘Gopal’)); }) }
我们看看 webpack_require.r 函数
webpack_require.r = function(exports) { object.defineProperty(exports, ‘esModule’, { value: true }); };
就是为
webpackexports_ 添加一个属性 esModule,值为 true
再看一个
webpack_require
.n 的实现
// getDefaultExport function for compatibility with non-harmony modules webpack_require.n = function(module) { var getter = module && module.esModule ? function getDefault() { return module[‘default’]; } : function getModuleExports() { return module; }; webpackrequire.d(getter, ‘a’, getter); return getter; };
webpackrequire.n会判断module是否为es模块,当esModule为 true 的时候,标识 module 为es 模块,默认返回module.default,否则返回module。
最后看 __webpack_require
.d,主要的工作就是将上面的 getter 函数绑定到 exports 中的属性 a 的 getter 上
// define getter function for harmony exports webpack_require.d = function(exports, name, getter) { if(!webpack_require.o(exports, name)) { Object.defineProperty(exports, name, { configurable: false, enumerable: true, get: getter }); } };
我们最后再看会 sayHello.js 打包后的模块函数,可以看到这里的导出是 webpack_exports[“default”] ,实际上就是 webpack_require.n 做了一层包装来实现的,其实也可以看出,实际上 webpack 是可以支持 CommonJS 和 ES Module 一起混用的
“./src/sayHello.js”:
/! exports provided: default / (function(module, webpack_exports, webpack_require) { “use strict”; webpack_require.r(webpack_exports); function sayHello(name) { return Hello ${name}; } / harmony default export / _webpack_exports[“default”] = (sayHello); })
目前为止,我们大致知道了 webpack 打包出来的文件是怎么作用的了,接下来我们分析下代码分离的一种特殊场景——动态导入

动态导入

代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。
常见的代码分割有以下几种方法:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

本文我们主要看看动态导入,我们在 src 下面新建一个文件 another.js
function Another() { return ‘Hi, I am Another Module’; } export { Another };
修改 index.js
import sayHello from ‘./sayHello’; console.log(sayHello, sayHello(‘Gopal’)); // 单纯为了演示,就是有条件的时候才去动态加载 if (true) { import(‘./Another.js’).then(res => console.log(res)) }
我们来看下打包出来的内容,忽略 .map 文件,可以看到多出一个 0.bundle.js 文件,这个我们称它为动态加载的 chunk,bundle.js 我们称为主 chunk
Webpack打包之后的文章 - 图1
输出的代码的话,主 chunk 看这里,动态加载的 chunk 看这里 ,下面是针对这两份代码的分析

主 chunk 分析

我们先来看看主 chunk
内容多了很多,我们来细看一下:
首先我们注意到,我们动态导入的地方编译后变成了以下,这是看起来就像是一个异步加载的函数
if (true) { webpack_require.e(/! import() / 0).then(webpack_require.bind(null, /! ./Another.js / “./src/Another.js”)).then(res => console.log(res)) }
所以我们来看 webpack_require.e 这个函数的实现

webpack_require.e ——使用 JSONP 动态加载

// 已加载的chunk缓存 var installedChunks = { “main”: 0 }; // … webpack_require.e = function requireEnsure(chunkId) { // promises 队列,等待多个异步 chunk 都加载完成才执行回调 var promises = []; // JSONP chunk loading for javascript var installedChunkData = installedChunks[chunkId]; // 0 代表已经 installed if(installedChunkData !== 0) { // 0 means “already installed”. // a Promise means “currently loading”. // 目标chunk正在加载,则将 promise push到 promises 数组 if(installedChunkData) { promises.push(installedChunkData[2]); } else { // setup Promise in chunk cache // 利用Promise去异步加载目标chunk var promise = new Promise(function(resolve, reject) { // 设置 installedChunks[chunkId] installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); // i设置chunk加载的三种状态并缓存在 installedChunks 中,防止chunk重复加载 // nstalledChunks[chunkId] = [resolve, reject, promise] promises.push(installedChunkData[2] = promise); // start chunk loading // 使用 JSONP var head = document.getElementsByTagName(‘head’)[0]; var script = document.createElement(‘script’); script.charset = ‘utf-8’; script.timeout = 120; if (webpack_require.nc) { script.setAttribute(“nonce”, webpack_require.nc); } // 获取目标chunk的地址,webpack_require.p 表示设置的publicPath,默认为空串 script.src = webpack_require.p + “” + chunkId + “.bundle.js”; // 请求超时的时候直接调用方法结束,时间为 120 s var timeout = setTimeout(function(){ onScriptComplete({ type: ‘timeout’, target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; // 设置加载完成或者错误的回调 function onScriptComplete(event) { // avoid mem leaks in IE. // 防止 IE 内存泄露 script.onerror = script.onload = null; clearTimeout(timeout); var chunk = installedChunks[chunkId]; // 如果为 0 则表示已加载,主要逻辑看 webpackJsonpCallback 函数 if(chunk !== 0) { if(chunk) { var errorType = event && (event.type === ‘load’ ? ‘missing’ : event.type); var realSrc = event && event.target && event.target.src; var error = new Error(‘Loading chunk ‘ + chunkId + ‘ failed.\n(‘ + errorType + ‘: ‘ + realSrc + ‘)’); error.type = errorType; error.request = realSrc; chunk1; } installedChunks[chunkId] = undefined; } }; head.appendChild(script); } } return Promise.all(promises); };

  • 可以看出将 import() 转换成模拟 JSONP 去加载动态加载的 chunk 文件
  • 设置 chunk 加载的三种状态并缓存在installedChunks中,防止chunk重复加载。这些状态的改变会在 webpackJsonpCallback 中提到

// 设置 installedChunks[chunkId] installedChunkData = installedChunks[chunkId] = [resolve, reject];

  • installedChunks[chunkId]为0,代表该 chunk 已经加载完毕
  • installedChunks[chunkId]为undefined,代表该 chunk 加载失败、加载超时、从未加载过
  • installedChunks[chunkId]为Promise对象,代表该 chunk 正在加载

看完webpack_require.e,我们知道的是,我们通过 JSONP 去动态引入 chunk 文件,并根据引入的结果状态进行处理,那么我们怎么知道引入之后的状态呢?我们来看异步加载的 chunk 是怎样的

异步 Chunk

// window[“webpackJsonp”] 实际上是一个数组,向中添加一个元素。这个元素也是一个数组,其中数组的第一个元素是chunkId,第二个对象,跟传入到 IIFE 中的参数一样 (window[“webpackJsonp”] = window[“webpackJsonp”] || []).push([[0],{ /*/ “./src/Another.js”: /*/ (function(module, webpack_exports, webpack_require) { “use strict”; webpack_require.r(webpack_exports); / harmony export (binding) / webpack_require.d(webpack_exports, “Another”, function() { return Another; }); function Another() { return ‘Hi, I am Another Module’; } /*/ }) }]); //# sourceMappingURL=0.bundle.js.map
主要做的事情就是往一个数组 window[‘webpackJsonp’] 中塞入一个元素,这个元素也是一个数组,其中数组的第一个元素是 chunkId,第二个对象,跟主 chunk 中 IIFE 传入的参数类似。关键是这个 window[‘webpackJsonp’] 在哪里会用到呢?我们回到主 chunk 中。在 return webpack_require(webpack_require.s = “./src/index.js”); 进入入口之前还有一段
var jsonpArray = window[“webpackJsonp”] = window[“webpackJsonp”] || []; // 保存原始的 Array.prototype.push 方法 var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 将 push 方法的实现修改为 webpackJsonpCallback // 这样我们在异步 chunk 中执行的 window[‘webpackJsonp’].push 其实是 webpackJsonpCallback 函数。 jsonpArray.push = webpackJsonpCallback; jsonpArray = jsonpArray.slice(); // 对已在数组中的元素依次执行webpackJsonpCallback方法 for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); var parentJsonpFunction = oldJsonpFunction;
jsonpArray 就是 window[“webpackJsonp”] ,重点看下面这一句代码,当执行 push 方法的时候,就会执行 webpackJsonpCallback,相当于做了一层劫持,也就是执行完 push 操作的时候就会调用这个函数
jsonpArray.push = webpackJsonpCallback;

webpackJsonpCallback ——加载完动态 chunk 之后的回调

我们再来看看 webpackJsonpCallback 函数,这里的入参就是动态加载的 chunk 的 window[‘webpackJsonp’] push 进去的参数。
var installedChunks = { “main”: 0 }; function webpackJsonpCallback(data) { // window[“webpackJsonp”] 中的第一个参数——即[0] var chunkIds = data[0]; // 对应的模块详细信息,详见打包出来的 chunk 模块中的 push 进 window[“webpackJsonp”] 中的第二个参数 var moreModules = data[1]; // add “moreModules” to the modules object, // then flag all “chunkIds” as loaded and fire callback var moduleId, chunkId, i = 0, resolves = []; for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; // 所以此处是找到那些未加载完的chunk,他们的value还是[resolve, reject, promise] // 这个可以看 webpack_require.e 中设置的状态 // 表示正在执行的chunk,加入到 resolves 数组中 if(installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0]); } // 标记成已经执行完 installedChunks[chunkId] = 0; } // 挨个将异步 chunk 中的 module 加入主 chunk 的 modules 数组中 for(moduleId in moreModules) { if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } } // parentJsonpFunction: 原始的数组 push 方法,将 data 加入 window[“webpackJsonp”] 数组。 if(parentJsonpFunction) parentJsonpFunction(data); // 等到 while 循环结束后,webpack_require.e 的返回值 Promise 得到 resolve // 执行 resolove while(resolves.length) { resolves.shift()(); } };
当我们 JSONP 去加载异步 chunk 完成之后,就会去执行 window[“webpackJsonp”] || []).push,也就是 webpackJsonpCallback。主要有以下几步

  • 遍历要加载的 chunkIds,找到未执行完的 chunk,并加入到 resolves 中

for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; // 所以此处是找到那些未加载完的chunk,他们的value还是[resolve, reject, promise] // 这个可以看 webpack_require.e 中设置的状态 // 表示正在执行的chunk,加入到 resolves 数组中 if(installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0]); } // 标记成已经执行完 installedChunks[chunkId] = 0; }

  • 这里未执行的是非 0 状态,执行完就设置为0
  • installedChunks[chunkId][0] 实际上就是 Promise 构造函数中的 resolve

// webpack_require.e var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; });

  • 挨个将异步 chunk 中的 module 加入主 chunk 的 modules 数组中
  • 原始的数组 push 方法,将 data 加入 window[“webpackJsonp”] 数组
  • 执行各个 resolves 方法,告诉 webpack_require.e 中回调函数的状态

只有当这个方法执行完成的时候,我们才知道 JSONP 成功与否,也就是script.onload/onerror 会在 webpackJsonpCallback 之后执行。所以 onload/onerror 其实是用来检查 webpackJsonpCallback 的完成度:有没有将 installedChunks 中对应的 chunk 值设为 0

动态导入小结

大致的流程如下图所示
Webpack打包之后的文章 - 图2

总结

本篇文章分析了 webpack 打包主流程以及和动态加载情况下输出代码,总结如下