Open alienzhou opened 5 years ago
你真的了解前端模块化么?
webpack是一个强大而复杂的前端自动化工具。其中一个特点就是配置复杂,这也使得「webpack配置工程师」这种戏谑的称呼开始流行🤷但是,难道你真的只满足于玩转webpack配置么?
显然不是。在学习如何使用webpack之外,我们更需要深入webpack内部,探索各部分的设计与实现。万变不离其宗,即使有一天webpack“过气”了,但它的某些设计与实现却仍会有学习价值与借鉴意义。因此,在学习webpack过程中,我会总结一系列【webpack进阶】的文章和大家分享。
欢迎感兴趣的同学多多交流与关注!
下面进入正题。一直以来,在前端领域,开发人员日益增长的语言能力需求和落后的JavaScript规范形成了一大矛盾。例如,我们会用babel来进行ES6到ES5的语法转换,会使用各种polyfill来兼容老式上的新特性……而我们本文的主角 —— 模块化也是如此。
由于JavaScript在设计之初就没有考虑这一点,加之模块化规范的迟到,导致社区中涌现出一系列前端运行时的模块化方案,例如RequireJS、seaJS等。以及与之对应的编译期模块依赖解决方案,例如browserify、rollup和本文的主角webpack。
但是我们要知道,<script type="module">还存在一定的兼容性与使用问题。
<script type="module">
在更通用的范围内来讲,浏览器原生实际是不支持所谓的CommonJS或ESM模块化规范的。那么webpack是如何在打包出的代码中实现模块化的呢?
在探究webpack打包后代码的模块化实现前,我们先来看一下Node中的模块化。
NodeJS(以下简称为Node)在模块化上基本是遵循的CommonJS规范,而webpack打包出来的代码所实现模块化的方式,也类似于CommonJS。因此,我们先以熟悉的Node(这里主要参考Node v10)作为引子,简单介绍它的模块化实现,帮助我们接下来理解webpack的实现。
Node中的模块引入会经历下面几个步骤:
在Node中,模块以文件维度存在,并且在编译后缓存于内存中,通过require.cache可以查看模块缓存情况。在模块中添加console.log(require.cache)查看输出如下:
require.cache
console.log(require.cache)
{ '/Users/alienzhou/programming/gitrepo/test.js': Module { id: '.', exports: {}, parent: null, filename: '/Users/alienzhou/programming/gitrepo/test.js', loaded: false, children: [], paths: [ '/Users/alienzhou/programming/gitrepo/node_modules', '/Users/alienzhou/programming/node_modules', '/Users/alienzhou/node_modules', '/Users/node_modules', '/node_modules' ] } }
上面就是模块对象的数据结构,也可以在Node源码中找到Module类的构造方法。其中exports属性非常重要,它就是模块的导出对象。因此,下面这行语句
exports
var test = require('./test.js');
其实就是把test.js模块的exports属性赋值给test变量。
test.js
test
也许你还会好奇,当我们写一个Node(JavaScript)模块时,模块里的module、require、__filename等这些变量是哪来的?如果你看过Node loader.js 部分源码,应该就大致能理解:
module
require
__filename
Module.wrap = function(script) { return Module.wrapper[0] + script + Module.wrapper[1]; }; Module.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ];
Node会自动将每个模块进行包装(wrap),将其变为一个function。例如模块test.js原本为:
console.log(require.cache); module.exports = 'test';
包装后大致会变为:
(function (exports, require, module, __filename, __dirname) { console.log(require.cache); module.exports = 'test'; });
这下你应该明白module、require、__filename这些变量都是哪来的了吧 —— 它们会被作为function的参数在模块编译执行时注入进来。以一个扩展名为.js的模块为例,当你require它时,一个完整的方法调用大致包括下面几个过程:
.js
st=>start: require()引入模块 op1=>operation: 调用._load()加载模块 op2=>operation: new Module(filename, parent)创建模块对象 op3=>operation: 将模块对象存入缓存 op4=>operation: 根据文件类型调用Module._extensions op5=>operation: 调用.compile()编译执行js模块 cond=>condition: Module._cache是否无缓存 e=>end: 返回module.exports结果 st->op1->cond cond(yes)->op2->op3->op4->op5->e cond(no)->e
在Node源码中能看到,模块执行时,包装定义的几个变量被注入了:
if (inspectorWrapper) { result = inspectorWrapper(compiledWrapper, this.exports, this.exports, require, this, filename, dirname); } else { result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); }
题外话,从这里你也可以看出,在模块内使用module.exports与exports的区别
module.exports
之所以在介绍「webpack是如何在打包出的代码中实现模块化」之前,先用一定篇幅介绍了Node中的模块化,是因为两者在同步依赖的设计与实现上有异曲同工之处。理解Node的模块化对学习webpack很有帮助。当然,由于运行环境的不同(webpack打包出的代码运行在客户端,而Node是在服务端),实现上也有一定的差异。
下面就来看一下,webpack是如何在打包出的代码中实现前端(客户端)模块化的。
和Node的模块化实现类似,在webpack打包出的代码中,每个模块也有一个对应的模块对象。在__webpack_require__()方法中,有这么一段代码:
__webpack_require__()
function __webpack_require__(moduleId) { // …… other code var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {}, parents: null, children: [] }; // …… other code }
类似于Node,在webpack中各个模块的也有对应的模块对象,其数据结构基本遵循CommonJS规范;其中installedModules则是模块缓存对象,类似于Node中的require.cache/Module._cache。
installedModules
Module._cache
__webpack_require__
__webpack_require__是webpack前端运行时模块化中非常重要的一个方法,相当于CommonJS规范中的require。
根据第一部分的流程图:在Node中,当我们require一个模块时,会先判断该模块是否在缓存之中,如果存在则直接返回该模块的exports属性;否则会加载并执行该模块。webpack中的实现也类似:
function __webpack_require__(moduleId) { // 1.首先会检查模块缓存 if(installedModules[moduleId]) { return installedModules[moduleId].exports; } // 2. 缓存不存在时,创建并缓存一个新的模块对象,类似Node中的new Module操作 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {}, children: [] }; // 3. 执行模块,类似于Node中的: // result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; // 4. 返回该module的输出 return module.exports; }
如果你仔细对比webpack与Node,你会发现在__webpack_require__中有一个重要的区别:
在webpack中不存在像Node一样调用._compile()这种方法的过程。即不会像Node那样,对一个未载入缓存的模块,通过「读取模块路径 -> 编译模块代码 -> 执行模块」来载入模块。为什么呢?
._compile()
这是因为,Node作为服务端语言,模块都是本地文件,加载时延低,可同步阻塞进行模块文件寻址、读取、编译和执行,这些过程在模块require的时候再“按需”执行即可;而webpack运行在客户端(浏览器),显然不能在需要时(即执行__webpack_require__时)再通过网络加载js文件,并同步地等待加载完成后再返回__webpack_require__。这种网络时延,显然不能满足“同步依赖”的要求。
那么webpack是如何解决这个问题的呢?
我们还是回来看下Node:
Node(v10)中加载、编译与执行(js)模块的代码主要集中在Module._extensions['.js']和Module.prototype._compile中。首先会通过fs.readFileSync读取文件内容,然后通过vm.runInThisContext来编译和执行JavaScript代码。
Module._extensions['.js']
Module.prototype._compile
fs.readFileSync
vm.runInThisContext
The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts.
但是,根据上面的分析,在前端runtime中肯定不能通过网络去同步获取JavaScript脚本文件;那么就需要我们换一个思路:有没有什么地方能够预先放置我们“之后”可能会需要的模块,让我们能够在require时不需要同步等待过长的时间(当然,这里的“之后”可能是几秒、几分钟后,也可能是这次事件循环task的下几行代码)。
内存就是一个不错的选择。我们可以把同步依赖的模块先“注册”到内存中(模块暂存),等到require时,再执行该模块、缓存模块对象、返回对应的exports。而webpack中,这个所谓的内存就是modules对象。
modules
注意这里指的模块暂存和模块缓存概念完全不同。暂存可以粗略类比为将编译好的模块代码先放到内存中,实际并没有引入该模块。基于这个目的,我们也可以把“模块暂存”理解为“模块注册”,因此后文中“模块暂存”与“模块注册”具有相等的概念。
所以,过程大致是这样的:
当我们已经获取了模块内容后(但模块还未执行),我们就将其暂存在modules对象中,键就是webpack的moduleId;等到需要使用__webpack_require__引用模块时,发现缓存中没有,则从modules对象中取出暂存的模块并执行。
思路已经清晰了,那么我们就来看看,webpack是如何将模块“暂存”在modules对象上的。在实际上,webpack打包出来的代码可以简单分为两类:
为了便于学习与代码阅读,建议你可以在webpack(v4)配置中加入optimization:{runtimeChunk: {name: 'runtime'}},这样会让webpack将runtime与模块注册代码分开打包。
optimization:{runtimeChunk: {name: 'runtime'}}
// webpack module chunk (window["webpackJsonp"] = window["webpackJsonp"] || []).push([["home-0"],{ /***/ "module-home-0": /***/ (function(module, exports, __webpack_require__) { const myalert = __webpack_require__("module-home-1"); myalert('test'); /***/ }), /***/ "module-home-1": /***/ (function(module, exports) { module.exports = function (a) { alert('hi:' + a); }; /***/ }) },[["module-home-0","home-1"]]]);
上面这是一个不包含runtime的chunk,我们不妨将其称为module chunk(下面会沿用这个叫法)。简化一下这部分代码,大致结构如下:
// webpack module chunk window["webpackJsonp"].push([ ["home-0"], // chunkIds { "module-home-0": (function(module, exports, __webpack_require__){ /* some logic */ }), "module-home-1": (function(module, exports, __webpack_require__){ /* some logic */ }) }, [["module-home-0","home-1"]] ])
这里,.push()方法参数为一个数组,包含三个元素:
.push()
["home-0"]
来看下参数数组的第二个元素 —— 包含模块代码的对象,你会发现这里方法签名是不是很像Node中的通过Module.wrap()进行的模块代码包装?没错,webpack源码中也有类似,会像Node那样,将每个模块的代码用一个function包装起来。
Module.wrap()
而当webpack配置了runtime分离后,打包出的文件中会出现一个“纯净”的、不包含任何模块代码的runtime,其主要是一个自执行方法,其中暴露了一个全局变量webpackJsonp:
webpackJsonp
// webpack runtime chunk var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); jsonpArray.push = webpackJsonpCallback;
webpackJsonp变量名可以通过output.jsonpFunction进行配置
output.jsonpFunction
可以看到,window["webpackJsonp"]上的.push()方法已经被修改为了webpackJsonpCallback()方法。该方法如下:
window["webpackJsonp"]
webpackJsonpCallback()
// webpack runtime chunk function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var executeModules = data[2]; var moduleId, chunkId, i = 0, resolves = []; // webpack会在installChunks中存储chunk的载入状态,据此判断chunk是否加载完毕 for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; if(installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0]); } installedChunks[chunkId] = 0; } // 注意,这里会进行“注册”,将模块暂存入内存中 // 将module chunk中第二个数组元素包含的 module 方法注册到 modules 对象里 for(moduleId in moreModules) { if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } } if(parentJsonpFunction) parentJsonpFunction(data); while(resolves.length) { resolves.shift()(); } deferredModules.push.apply(deferredModules, executeModules || []); return checkDeferredModules(); };
注意以上方法的这几行,就是我们之前所说的「将模块“暂存”在modules对象上」
// webpackJsonpCallback for(moduleId in moreModules) { if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } }
配合__webpack_require__()中下面这一行代码,就实现了在需要引入模块时,同步地将模块从暂存区取出来执行,避免使用网络请求导致过长的同步等待时间。
// __webpack_require__ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
到目前为止,对于webpack的同步依赖实现已经介绍的差不多了,但还遗留一个小问题:webpack中的所有js源文件都是模块,但如果都是不会自动执行的模块,那我们只是在前端引入了一堆“死”代码,怎么让代码“活”起来呢?
很多时候,我们引入一个script标签加载脚本文件,至少希望其中一个模块的代码会自动执行,而不仅仅是注册在modules对象上。一般来说,这就是webpack中所谓的入口模块。
webpack是如何让这些入口模块自动执行的呢?不知道你是否还记得module chunk中那个按下不表的第三个参数:这个参数是一个数组,而数组里面每个元素又是一个数组
[["module-home-0","home-1"], ["module-home-2","home-3","home-5"]]
对照上面这个例子,我们可以具体解释下参数的含义。第一个元素["module-home-0","home-1"]表示,我希望自动执行moduleId为module-home-0的这个模块,但是该模块需要chunkId为home-1的chunk已经加载后才能执行;同理,["module-home-2","home-3","home-5"]表示自动执行module-home-2模块,但是需要检查chunkhome-3和home-5已经加载。
["module-home-0","home-1"]
module-home-0
home-1
["module-home-2","home-3","home-5"]
module-home-2
home-3
home-5
执行某些模块需要保证一些chunk已经加载是因为,该模块所依赖的其他模块可能并不在当前chunk中,而webpack在编译期会通过依赖分析自动将依赖模块的所属chunkId注入到此处。
这个模块“自动”执行的功能在runtime chunk的代码中主要是由checkDeferredModules()方法实现:
checkDeferredModules()
function checkDeferredModules() { var result; for(var i = 0; i < deferredModules.length; i++) { var deferredModule = deferredModules[i]; var fulfilled = true; // 第一个元素是模块id,后面是其所需的chunk for(var j = 1; j < deferredModule.length; j++) { var depId = deferredModule[j]; // 这里会首先判断模块所需chunk是否已经加载完毕 if(installedChunks[depId] !== 0) fulfilled = false; } // 只有模块所需的chunk都加载完毕,该模块才会被执行(__webpack_require__) if(fulfilled) { deferredModules.splice(i--, 1); result = __webpack_require__(__webpack_require__.s = deferredModule[0]); } } return result; }
如果你只是想学习webpack前端runtime中同步依赖的设计与实现,那么到这里主要内容基本已经结束了。不过我们知道,webpack支持使用动态模块引入的语法(代码拆分),例如:dynamic import和早期的require.ensure,这种方式与使用CommonJS的require和ESM的import最重要的区别在于,该类方法会异步(或者说按需)加载依赖。
dynamic import
require.ensure
import
就像在源码中使用require会在webpack打包时被替换为__webpack_require__一样,在源码中使用的异步依赖语法也会被webpack修改。以dynamic import为例,下面的代码
import('./test.js').then(mod => { console.log(mod); });
在产出后会被转换为
__webpack_require__.e(/* import() */ "home-1") .then(__webpack_require__.bind(null, "module-home-3")) .then(mod => { console.log(mod); });
上面代码是什么意思呢?我们知道,webpack打包后会将一些module合并为一个chunk,因此上面的"home-1"就表示:包含./test.js模块的chunk的chunkId为"home-1"。
"home-1"
./test.js
webpack首先通过__webpack_require__.e加载指定chunk的script文件(module chunk),该方法返回一个promise,当script加载并执行完成后resolve该promise。webpack打包时会保证异步依赖的所有模块都已包含在该module chunk或当前上下文中。
__webpack_require__.e
既然module chunk已经执行,那么表明异步依赖已经就绪,于是在then方法中执行__webpack_require__引用test.js模块(webpack编译后moduleId为module-home-3)并返回。这样在第二个then方法中就可以正常使用该模块了。
异步依赖的核心方法就是__webpack_require__.e。下面来分析一下该方法:
__webpack_require__.e = function requireEnsure(chunkId) { var promises = []; var installedChunkData = installedChunks[chunkId]; // 判断该chunk是否已经被加载,0表示已加载。installChunk中的状态: // undefined:chunk未进行加载, // null:chunk preloaded/prefetched // Promise:chunk正在加载中 // 0:chunk加载完毕 if(installedChunkData !== 0) { // chunk不为null和undefined,则为Promise,表示加载中,继续等待 if(installedChunkData) { promises.push(installedChunkData[2]); } else { // 注意这里installChunk的数据格式 // 从左到右三个元素分别为resolve、reject、promise var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push(installedChunkData[2] = promise); // 下面代码主要是根据chunkId加载对应的script脚本 var head = document.getElementsByTagName('head')[0]; var script = document.createElement('script'); var onScriptComplete; script.charset = 'utf-8'; script.timeout = 120; if (__webpack_require__.nc) { script.setAttribute("nonce", __webpack_require__.nc); } // jsonpScriptSrc方法会根据传入的chunkId返回对应的文件路径 script.src = jsonpScriptSrc(chunkId); onScriptComplete = function (event) { script.onerror = script.onload = null; clearTimeout(timeout); var chunk = installedChunks[chunkId]; 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; chunk[1](error); } installedChunks[chunkId] = undefined; } }; var timeout = setTimeout(function(){ onScriptComplete({ type: 'timeout', target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; head.appendChild(script); } } return Promise.all(promises); };
该方法首先会根据chunkId在installChunks中判断该chunk是否正在加载或已经被加载;如果没有则会创建一个promise,将其保存在installChunks中,并通过jsonpScriptSrc()方法获取文件路径,通过sciript标签加载,最后返回该promise。
jsonpScriptSrc()
jsonpScriptSrc()则可以理解为一个包含chunk map的方法,例如这个例子中:
function jsonpScriptSrc(chunkId) { return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"home-1":"0b49ae3b"}[chunkId] + ".js" }
其中包含一个map —— {"home-1":"0b49ae3b"},会根据home-1这个chunkId返回home-1.0b49ae3b.js这个文件名。
{"home-1":"0b49ae3b"}
最后,你会发现,在onload中,并没有调用promise的resolve方法。那么是何时resolve的呢?
你还记得在介绍同步require时用于注册module的webpackJsonpCallback()方法么?我们之前说过,该方法参数数组中的第一个元素是一个chunkId的数组,代表了该脚本所包含的chunk。
p.s. 当一个普通的脚本被浏览器下载完毕后,会先执行该脚本,然后触发onload事件。
因此,在webpackJsonpCallback()方法中,有一段代码就是根据chunkIds的数组,检查并更新chunk的加载状态:
// webpackJsonpCallback() var moduleId, chunkId, i = 0, resolves = []; for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; if(installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0]); } installedChunks[chunkId] = 0; } // …… while(resolves.length) { resolves.shift()(); }
上面的代码先根据模块注册时的chunkId,取出installedChunks对应的所有loading中的chunk,最后将这些chunk的promise进行resolve操作。
至此,对于「webpack打包后是如何实现前端模块化」这个问题就差不多结束了。本文通过Node中的模块化为引子,介绍了webpack中的同步与异步模块加载的设计与实现。
为了方便大家对照文中内容查看webpack运行时源码,我把基础的webpack runtime chunk和module chunk放在了这里,有兴趣的朋友可以对照着看。
最后还是欢迎对webpack感兴趣的朋友能够相互交流,关注我的系列文章。
告别「webpack配置工程师」
webpack是一个强大而复杂的前端自动化工具。其中一个特点就是配置复杂,这也使得「webpack配置工程师」这种戏谑的称呼开始流行🤷但是,难道你真的只满足于玩转webpack配置么?
显然不是。在学习如何使用webpack之外,我们更需要深入webpack内部,探索各部分的设计与实现。万变不离其宗,即使有一天webpack“过气”了,但它的某些设计与实现却仍会有学习价值与借鉴意义。因此,在学习webpack过程中,我会总结一系列【webpack进阶】的文章和大家分享。
欢迎感兴趣的同学多多交流与关注!
1. 引言
下面进入正题。一直以来,在前端领域,开发人员日益增长的语言能力需求和落后的JavaScript规范形成了一大矛盾。例如,我们会用babel来进行ES6到ES5的语法转换,会使用各种polyfill来兼容老式上的新特性……而我们本文的主角 —— 模块化也是如此。
由于JavaScript在设计之初就没有考虑这一点,加之模块化规范的迟到,导致社区中涌现出一系列前端运行时的模块化方案,例如RequireJS、seaJS等。以及与之对应的编译期模块依赖解决方案,例如browserify、rollup和本文的主角webpack。
但是我们要知道,
<script type="module">
还存在一定的兼容性与使用问题。在更通用的范围内来讲,浏览器原生实际是不支持所谓的CommonJS或ESM模块化规范的。那么webpack是如何在打包出的代码中实现模块化的呢?
2. NodeJS中的模块化
在探究webpack打包后代码的模块化实现前,我们先来看一下Node中的模块化。
NodeJS(以下简称为Node)在模块化上基本是遵循的CommonJS规范,而webpack打包出来的代码所实现模块化的方式,也类似于CommonJS。因此,我们先以熟悉的Node(这里主要参考Node v10)作为引子,简单介绍它的模块化实现,帮助我们接下来理解webpack的实现。
Node中的模块引入会经历下面几个步骤:
在Node中,模块以文件维度存在,并且在编译后缓存于内存中,通过
require.cache
可以查看模块缓存情况。在模块中添加console.log(require.cache)
查看输出如下:上面就是模块对象的数据结构,也可以在Node源码中找到Module类的构造方法。其中
exports
属性非常重要,它就是模块的导出对象。因此,下面这行语句其实就是把
test.js
模块的exports
属性赋值给test
变量。也许你还会好奇,当我们写一个Node(JavaScript)模块时,模块里的
module
、require
、__filename
等这些变量是哪来的?如果你看过Node loader.js 部分源码,应该就大致能理解:Node会自动将每个模块进行包装(wrap),将其变为一个function。例如模块test.js原本为:
包装后大致会变为:
这下你应该明白
module
、require
、__filename
这些变量都是哪来的了吧 —— 它们会被作为function的参数在模块编译执行时注入进来。以一个扩展名为.js
的模块为例,当你require
它时,一个完整的方法调用大致包括下面几个过程:在Node源码中能看到,模块执行时,包装定义的几个变量被注入了:
3. webpack实现的前端模块化
之所以在介绍「webpack是如何在打包出的代码中实现模块化」之前,先用一定篇幅介绍了Node中的模块化,是因为两者在同步依赖的设计与实现上有异曲同工之处。理解Node的模块化对学习webpack很有帮助。当然,由于运行环境的不同(webpack打包出的代码运行在客户端,而Node是在服务端),实现上也有一定的差异。
下面就来看一下,webpack是如何在打包出的代码中实现前端(客户端)模块化的。
3.1. 模块对象
和Node的模块化实现类似,在webpack打包出的代码中,每个模块也有一个对应的模块对象。在
__webpack_require__()
方法中,有这么一段代码:类似于Node,在webpack中各个模块的也有对应的模块对象,其数据结构基本遵循CommonJS规范;其中
installedModules
则是模块缓存对象,类似于Node中的require.cache
/Module._cache
。2.2. 模块的require:
__webpack_require__
__webpack_require__
是webpack前端运行时模块化中非常重要的一个方法,相当于CommonJS规范中的require
。根据第一部分的流程图:在Node中,当我们
require
一个模块时,会先判断该模块是否在缓存之中,如果存在则直接返回该模块的exports
属性;否则会加载并执行该模块。webpack中的实现也类似:如果你仔细对比webpack与Node,你会发现在
__webpack_require__
中有一个重要的区别:在webpack中不存在像Node一样调用
._compile()
这种方法的过程。即不会像Node那样,对一个未载入缓存的模块,通过「读取模块路径 -> 编译模块代码 -> 执行模块」来载入模块。为什么呢?这是因为,Node作为服务端语言,模块都是本地文件,加载时延低,可同步阻塞进行模块文件寻址、读取、编译和执行,这些过程在模块require的时候再“按需”执行即可;而webpack运行在客户端(浏览器),显然不能在需要时(即执行
__webpack_require__
时)再通过网络加载js文件,并同步地等待加载完成后再返回__webpack_require__
。这种网络时延,显然不能满足“同步依赖”的要求。那么webpack是如何解决这个问题的呢?
3.2. 如何解决前端的同步依赖
我们还是回来看下Node:
Node(v10)中加载、编译与执行(js)模块的代码主要集中在
Module._extensions['.js']
和Module.prototype._compile
中。首先会通过fs.readFileSync
读取文件内容,然后通过vm.runInThisContext
来编译和执行JavaScript代码。但是,根据上面的分析,在前端runtime中肯定不能通过网络去同步获取JavaScript脚本文件;那么就需要我们换一个思路:有没有什么地方能够预先放置我们“之后”可能会需要的模块,让我们能够在require时不需要同步等待过长的时间(当然,这里的“之后”可能是几秒、几分钟后,也可能是这次事件循环task的下几行代码)。
内存就是一个不错的选择。我们可以把同步依赖的模块先“注册”到内存中(模块暂存),等到require时,再执行该模块、缓存模块对象、返回对应的
exports
。而webpack中,这个所谓的内存就是modules
对象。所以,过程大致是这样的:
当我们已经获取了模块内容后(但模块还未执行),我们就将其暂存在
modules
对象中,键就是webpack的moduleId;等到需要使用__webpack_require__
引用模块时,发现缓存中没有,则从modules
对象中取出暂存的模块并执行。3.3. 如何”暂存“模块
思路已经清晰了,那么我们就来看看,webpack是如何将模块“暂存”在
modules
对象上的。在实际上,webpack打包出来的代码可以简单分为两类:__webpack_require__
这样的require方法等。上面这是一个不包含runtime的chunk,我们不妨将其称为module chunk(下面会沿用这个叫法)。简化一下这部分代码,大致结构如下:
这里,
.push()
方法参数为一个数组,包含三个元素:["home-0"]
表示该js文件所包含的所有chunk的id(可以粗略理解为,webpack中module组成chunk,chunk又组成file);来看下参数数组的第二个元素 —— 包含模块代码的对象,你会发现这里方法签名是不是很像Node中的通过
Module.wrap()
进行的模块代码包装?没错,webpack源码中也有类似,会像Node那样,将每个模块的代码用一个function包装起来。而当webpack配置了runtime分离后,打包出的文件中会出现一个“纯净”的、不包含任何模块代码的runtime,其主要是一个自执行方法,其中暴露了一个全局变量
webpackJsonp
:可以看到,
window["webpackJsonp"]
上的.push()
方法已经被修改为了webpackJsonpCallback()
方法。该方法如下:注意以上方法的这几行,就是我们之前所说的「将模块“暂存”在modules对象上」
配合
__webpack_require__()
中下面这一行代码,就实现了在需要引入模块时,同步地将模块从暂存区取出来执行,避免使用网络请求导致过长的同步等待时间。3.4. 模块的自动执行
到目前为止,对于webpack的同步依赖实现已经介绍的差不多了,但还遗留一个小问题:webpack中的所有js源文件都是模块,但如果都是不会自动执行的模块,那我们只是在前端引入了一堆“死”代码,怎么让代码“活”起来呢?
很多时候,我们引入一个script标签加载脚本文件,至少希望其中一个模块的代码会自动执行,而不仅仅是注册在
modules
对象上。一般来说,这就是webpack中所谓的入口模块。webpack是如何让这些入口模块自动执行的呢?不知道你是否还记得module chunk中那个按下不表的第三个参数:这个参数是一个数组,而数组里面每个元素又是一个数组
对照上面这个例子,我们可以具体解释下参数的含义。第一个元素
["module-home-0","home-1"]
表示,我希望自动执行moduleId为module-home-0
的这个模块,但是该模块需要chunkId为home-1
的chunk已经加载后才能执行;同理,["module-home-2","home-3","home-5"]
表示自动执行module-home-2
模块,但是需要检查chunkhome-3
和home-5
已经加载。执行某些模块需要保证一些chunk已经加载是因为,该模块所依赖的其他模块可能并不在当前chunk中,而webpack在编译期会通过依赖分析自动将依赖模块的所属chunkId注入到此处。
这个模块“自动”执行的功能在runtime chunk的代码中主要是由
checkDeferredModules()
方法实现:4. 异步依赖
如果你只是想学习webpack前端runtime中同步依赖的设计与实现,那么到这里主要内容基本已经结束了。不过我们知道,webpack支持使用动态模块引入的语法(代码拆分),例如:
dynamic import
和早期的require.ensure
,这种方式与使用CommonJS的require
和ESM的import
最重要的区别在于,该类方法会异步(或者说按需)加载依赖。4.1. 代码转换
就像在源码中使用
require
会在webpack打包时被替换为__webpack_require__
一样,在源码中使用的异步依赖语法也会被webpack修改。以dynamic import
为例,下面的代码在产出后会被转换为
上面代码是什么意思呢?我们知道,webpack打包后会将一些module合并为一个chunk,因此上面的
"home-1"
就表示:包含./test.js
模块的chunk的chunkId为"home-1"
。webpack首先通过
__webpack_require__.e
加载指定chunk的script文件(module chunk),该方法返回一个promise,当script加载并执行完成后resolve该promise。webpack打包时会保证异步依赖的所有模块都已包含在该module chunk或当前上下文中。既然module chunk已经执行,那么表明异步依赖已经就绪,于是在then方法中执行
__webpack_require__
引用test.js
模块(webpack编译后moduleId为module-home-3)并返回。这样在第二个then方法中就可以正常使用该模块了。4.2.
__webpack_require__.e
异步依赖的核心方法就是
__webpack_require__.e
。下面来分析一下该方法:该方法首先会根据chunkId在installChunks中判断该chunk是否正在加载或已经被加载;如果没有则会创建一个promise,将其保存在installChunks中,并通过
jsonpScriptSrc()
方法获取文件路径,通过sciript标签加载,最后返回该promise。jsonpScriptSrc()
则可以理解为一个包含chunk map的方法,例如这个例子中:其中包含一个map ——
{"home-1":"0b49ae3b"}
,会根据home-1这个chunkId返回home-1.0b49ae3b.js这个文件名。4.3. 更新chunk加载状态
最后,你会发现,在onload中,并没有调用promise的resolve方法。那么是何时resolve的呢?
你还记得在介绍同步require时用于注册module的
webpackJsonpCallback()
方法么?我们之前说过,该方法参数数组中的第一个元素是一个chunkId的数组,代表了该脚本所包含的chunk。因此,在
webpackJsonpCallback()
方法中,有一段代码就是根据chunkIds的数组,检查并更新chunk的加载状态:上面的代码先根据模块注册时的chunkId,取出installedChunks对应的所有loading中的chunk,最后将这些chunk的promise进行resolve操作。
5. 写在最后
至此,对于「webpack打包后是如何实现前端模块化」这个问题就差不多结束了。本文通过Node中的模块化为引子,介绍了webpack中的同步与异步模块加载的设计与实现。
为了方便大家对照文中内容查看webpack运行时源码,我把基础的webpack runtime chunk和module chunk放在了这里,有兴趣的朋友可以对照着看。
最后还是欢迎对webpack感兴趣的朋友能够相互交流,关注我的系列文章。
参考资料