详细解析Webpack是怎么运行的
在平时开发中我们经常会用到Webpack这个时下最流行的前端打包工具。它打包开发代码,输出能在各种浏览器运行的代码,提升了开发至发布过程的效率。
我们知道一份Webpack配置文件主要包含入口(entry)、输出文件(output)、模式、加载器(Loader)、插件(Plugin)等几个部分。但如果只需要组织JS文件的话,指定入口和输出文件路径即可完成一个迷你项目的打包。下面我们来通过一个简单的项目来看一下Webpack是怎样运行的。
同步加载
本文使用webpack^4.30.0作示例.为了更好地观察产出的文件,我们将模式设置为development关闭代码压缩,再开启source-map支持原始源代码调试。除此之外。我们还简单的写了一个插件MyPlugin来去除源码中的注释。
新建src/index.js:
console.log('Hellowebpack!');
新建webpack配置文件webpack.config.js
constpath=require('path'); constMyPlugin=require('./src/MyPlugin.js') module.exports={ mode:'development', devtool:'source-map', entry:'./src/index.js', output:{ path:path.resolve(__dirname,'dist') }, plugins:[ newMyPlugin() ] };
新建src/MyPlugin.js。了解webpack插件更多信息
classMyPlugin{ constructor(options){ this.options=options this.externalModules={} } apply(compiler){ varreg=/("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g compiler.hooks.emit.tap('CodeBeautify',(compilation)=>{ Object.keys(compilation.assets).forEach((data)=>{ letcontent=compilation.assets[data].source()//欲处理的文本 content=content.replace(reg,function(word){//去除注释后的文本 return/^\/{2,}/.test(word)||/^\/\*!/.test(word)||/^\/\*{3,}\//.test(word)?"":word; }); compilation.assets[data]={ source(){ returncontent }, size(){ returncontent.length } } }) }) } } module.exports=MyPlugin
现在我们运行命令webpack--configwebpack.config.js,打包完成后会多出一个输出目录dist:dist/main.js。main是webpack默认设置的输出文件名,我们快速瞄一眼这个文件:
(function(modules){ //... })({ "./src/index.js":(function(){ //... }) });
整个文件只含一个立即执行函数(IIFE),我们称它为webpackBootstrap,它仅接收一个对象——未加载的模块集合(modules),这个modules对象的key是一个路径,value是一个函数。你也许会问,这里的模块是什么?它们又是如何加载的呢?
在细看产出代码前,我们先丰富一下源代码:
新文件src/utils/math.js:
exportconstplus=(a,b)=>{ returna+b; };
修改src/index.js:
import{plus}from'./utils/math.js'; console.log('Hellowebpack!'); console.log('1+2:',plus(1,2));
我们按照ES规范的模块化语法写了一个简单的模块src/utils/math.js,给src/index.js引用。Webpack用自己的方式支持了ES6Module规范,前面提到的module就是和ES6module对应的概念。
接下来我们看一下这些模块是如何通ES5代码实现的。再次运行命令webpack--configwebpack.config.js后查看输出文件:
(function(modules){ //... })({ "./src/index.js":(function(){ //... }), "./src/utils/math.js":(function(){ //... }) });
IIFE传入的modules对象里多了一个键值对,对应着新模块src/utils/math.js,这和我们在源代码中拆分的模块互相呼应。然而,有了modules只是第一步,这份文件最终达到的效果应该是让各个模块按开发者编排的顺序运行。
探究webpackBootstrap
接下来看看webpackBootstrap函数中有些什么:
//webpackBootstrap (function(modules){ //缓存__webpack_require__函数加载过的模块 varinstalledModules={}; /** *Webpack加载函数,用来加载webpack定义的模块 *@param{String}moduleId模块ID,一般为模块的源码路径,如"./src/index.js" *@returns{Object}exports导出对象 */ function__webpack_require__(moduleId){ //... } //在__webpack_require__函数对象上挂载一些变量及函数... //传入表达式的值为"./src/index.js" return__webpack_require__(__webpack_require__.s="./src/index.js"); })(/*modules*/);
可以看到其实主要做了两件事:
定义一个模块加载函数__webpack_require__。
使用加载函数加载入口模块"./src/index.js"。
整个webpackBootstrap中只出现了入口模块的影子,那其他模块又是如何加载的呢?我们顺着__webpack_require__("./src/index.js")细看加载函数的内部逻辑:
function__webpack_require__(moduleId){ //重复加载则利用缓存 if(installedModules[moduleId]){ returninstalledModules[moduleId].exports; } //如果是第一次加载,则初始化模块对象,并缓存 varmodule=installedModules[moduleId]={ i:moduleId,//模块ID l:false,//模块加载标识 exports:{}//模块导出对象 }; /** *执行模块 *@parammodule.exports--模块导出对象引用,改变模块包裹函数内部的this指向 *@parammodule--当前模块对象引用 *@parammodule.exports--模块导出对象引用 *@param__webpack_require__--用于在模块中加载其他模块 */ modules[moduleId].call(module.exports,module,module.exports,__webpack_require__); //模块加载标识置为已加载 module.l=true; //返回当前模块的导出对象引用 returnmodule.exports; }
首先,加载函数使用了闭包变量installedModules,用来将已加载过的模块保存在内存中。接着是初始化模块对象,并把它挂载到缓存里。然后是模块的执行过程,加载入口文件时modules[moduleId]其实就是./src/index.js对应的模块函数。执行模块函数前传入了跟模块相关的几个实参,让模块可以导出内容,以及加载其他模块的导出。最后标识该模块加载完成,返回模块的导出内容。
根据__webpack_require__的缓存和导出逻辑,我们得知在整个IIFE运行过程中,加载已缓存的模块时,都会直接返回installedModules[moduleId].exports,换句话说,相同的模块只有在第一次引用的时候才会执行模块本身。
模块执行函数
__webpack_require__中通过modules[moduleId].call()运行了模块执行函数,下面我们就进入到webpackBootstrap的参数部分,看看模块的执行函数。
/***入口模块./src/index.js***/ "./src/index.js":(function(module,__webpack_exports__,__webpack_require__){ "usestrict"; //用于区分ES模块和其他模块规范,不影响理解demo,战略跳过。 __webpack_require__.r(__webpack_exports__); /*harmonyimport*/ //源模块代码中,`import{plus}from'./utils/math.js';`语句被loader解析转化。 //加载"./src/utils/math.js"模块, var_utils_math_js__WEBPACK_IMPORTED_MODULE_0__=__webpack_require__("./src/utils/math.js"); console.log('Hellowebpack!'); console.log('1+2:',Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["plus"])(1,2)); }), "./src/utils/math.js":(function(module,__webpack_exports__,__webpack_require__){ "usestrict"; __webpack_require__.r(__webpack_exports__); /*harmonyexport(binding)*/ //源模块代码中,`export`语句被loader解析转化。 __webpack_require__.d(__webpack_exports__,"plus",function(){ returnplus; }); constplus=(a,b)=>{ returna+b; }; })
执行顺序是:入口模块->工具模块->入口模块。入口模块中首先就通过__webpack_require__("./src/utils/math.js")拿到了工具模块的exports对象。再看工具模块,ES导出语法转化成了__webpack_require__.d(__webpack_exports__,[key],[getter]),而__webpack_require__.d函数的定义在webpackBootstrap内:
//定义exports对象导出的属性。 __webpack_require__.d=function(exports,name,getter){ //如果exports(不含原型链上)没有[name]属性,定义该属性的getter。 if(!__webpack_require__.o(exports,name)){ Object.defineProperty(exports,name,{ enumerable:true, get:getter }); } }; //包装Object.prototype.hasOwnProperty函数。 __webpack_require__.o=function(object,property){ returnObject.prototype.hasOwnProperty.call(object,property); };
可见__webpack_require__.d其实就是Object.defineProperty的简单包装.
引用工具模块导出的变量后,入口模块再执行它剩余的部分。至此,Webpack基本的模块执行过程就结束了。
好了,我们用流程图总结一下Webpack模块的加载思路:
异步加载
有上面的打包我们发现将不同的打包进一个main.js文件。main.js会集中消耗太多网络资源,导致用户需要等待很久才可以开始与网页交互。
一般的解决方式是:根据需求降低首次加载文件的体积,在需要时(如切换前端路由器,交互事件回调)异步加载其他文件并使用其中的模块。
Webpack推荐用ESimport()规范来异步加载模块,我们根据ES规范修改一下入口模块的import方式,让其能够异步加载模块:
src/index.js
console.log('Hellowebpack!'); window.setTimeout(()=>{ import('./utils/math').then(mathUtil=>{ console.log('1+2:'+mathUtil.plus(1,2)); }); },2000);
工具模块(src/utils/math.js)依然不变,在webpack配置里,我们指定一下资源文件的公共资源路径(publicPath),后面的探索过程中会遇到。
constpath=require('path'); constMyPlugin=require('./src/MyPlugin.js') module.exports={ mode:'development', devtool:'source-map', entry:'./src/index.js', output:{ path:path.resolve(__dirname,'dist'), publicPath:'/dist/' }, plugins:[ newMyPlugin() ] };
接着执行一下打包,可以看到除了dist/main.js外,又多了一个dist/0.js./src/utils/math.js。模块从mainchunk迁移到了0chunk中。而与demo1不同的是,mainchunk中添加了一些用于异步加载的代码,我们概览一下:
//webpackBootstrap (function(modules){ //加载其他chunk后的回调函数 functionwebpackJsonpCallback(data){ //... } //... //用于缓存chunk的加载状态,0为已加载 varinstalledChunks={ "main":0 }; //拼接chunk的请求地址 functionjsonpScriptSrc(chunkId){ //... } //同步require函数,内容不变 function__webpack_require__(moduleId){ //... } //异步加载chunk,返回封装加载过程的promise __webpack_require__.e=functionrequireEnsure(chunkId){ //... } //... //defineProperty的包装,内容不变 __webpack_require__.d=function(exports,name,getter){} //... //根据配置文件确定的publicPath __webpack_require__.p="/dist/"; /****JSONP初始化****/ varjsonpArray=window["webpackJsonp"]=window["webpackJsonp"]||[]; varoldJsonpFunction=jsonpArray.push.bind(jsonpArray); jsonpArray.push=webpackJsonpCallback; jsonpArray=jsonpArray.slice(); for(vari=0;i{ __webpack_require__.e(/*!import()*/0).then(__webpack_require__.bind(null,/*!./utils/math*/"./src/utils/math.js")).then(mathUtil=>{ console.log('1+2:'+mathUtil.plus(1,2)); }); },2000); }) })
可以看到webpackBootstrap的函数体部分增加了一些内容,参数部分移除了"./src/utils/math.js"模块。跟着包裹函数的执行顺序,我们先聚焦到「JSONP初始化」部分:
//存储jsonp的数组,首次运行为[] varjsonpArray=window["webpackJsonp"]=window["webpackJsonp"]||[]; //保存jsonpArray的push函数,首次运行为Array.prototype.push varoldJsonpFunction=jsonpArray.push.bind(jsonpArray); //将jsonpArray的push重写为webpackJsonpCallback(加载其他chunk后的回调函数) jsonpArray.push=webpackJsonpCallback; //将jsonpArray重置为正常数组,push重置为Array.prototype.push jsonpArray=jsonpArray.slice(); //由于jsonpArray为[],不做任何事 for(vari=0;i初始化结束后,变化就是window上挂载了一个webpackJsonp数组,它的值为[];此外,这个数组的push被改写为webpackJsonpCallback函数,我们在后面会提到这些准备工作的作用。
接着是__webpack_require__入口模块,由于__webpack_require__函数没有改变,我们继续观察入口模块执行函数有了什么变化。
显然,import('../utils/math.js')被转化为__webpack_require__.e(0).then(__webpack_require__.bind(null,"./src/utils/math.js"))。0是./src/utils/math.js所在chunk的id,「同步加载模块」的逻辑拆分成了「先加载chunk,完成后再加载模块」。
我们翻到__webpack_require__.e的定义位置:
__webpack_require__.e=functionrequireEnsure(chunkId){ varpromises=[]; //installedChunks是在webpackBootstrap中维护的chunk缓存 varinstalledChunkData=installedChunks[chunkId]; //chunk未加载 if(installedChunkData!==0){ //installedChunkData为promise表示chunk加载中 if(installedChunkData){ promises.push(installedChunkData[2]); }else{ /***首次加载chunk:***/ //初始化promise对象 varpromise=newPromise(function(resolve,reject){ installedChunkData=installedChunks[chunkId]=[resolve,reject]; }); promises.push(installedChunkData[2]=promise); //创建script标签加载chunk varhead=document.getElementsByTagName('head')[0]; varscript=document.createElement('script'); varonScriptComplete; //...省略一些script属性设置 //src根据publicPath和chunkId拼接 script.src=jsonpScriptSrc(chunkId); //加载结束回调函数,处理script加载完成、加载超时、加载失败的情况 onScriptComplete=function(event){ script.onerror=script.onload=null;//避免IE内存泄漏问题 clearTimeout(timeout); varchunk=installedChunks[chunkId]; //处理script加载完成,但chunk没有加载完成的情况 if(chunk!==0){ //chunk加载中 if(chunk){ varerrorType=event&&(event.type==='load'?'missing':event.type); varrealSrc=event&&event.target&&event.target.src; varerror=newError('Loadingchunk'+chunkId+'failed.\n('+errorType+':'+realSrc+')'); error.type=errorType; error.request=realSrc; //reject(error) chunk[1](error); } //统一将没有加载的chunk标记为未加载 installedChunks[chunkId]=undefined; } }; //设置12秒超时时间 vartimeout=setTimeout(function(){ onScriptComplete({type:'timeout',target:script}); },120000); script.onerror=script.onload=onScriptComplete; head.appendChild(script); /***首次加载chunk***/ } } returnPromise.all(promises); };看起来有点长,我们一步步剖析,先从第一行和最后一行来看,整个函数将异步加载的过程封装到了promise中,最终导出。
接着从第二行开始,installedChunkData从缓存中取值,显然首次加载chunk时此处是undefined。接下来,installedChunkData的undefined值触发了第一层if语句的判断条件。紧接着进行到第二层if语句,此时根据判断条件走入else块,这里if块里的内容我们先战略跳过,else里主要有两块内容,一是chunk脚本加载过程,这个过程创建了一个script标签,使其请求chunk所在地址并执行chunk内容;二是初始化promise,并用promis控制chunk文件加载过程。
不过,我们只在这段else代码块中找到了reject的使用处,也就是在chunk加载异常时chunk[1](error)的地方,但并没发现更重要的resolve的使用地点,仅仅是把resolve挂在了缓存上(installedChunks[chunkId]=[resolve,reject])。
这里的chunk文件加载下来会发生什么呢?让我们打开dist/0.js一探究竟:
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([[0],{ "./src/utils/math.js": (function(module,__webpack_exports__,__webpack_require__){ "usestrict"; __webpack_require__.r(__webpack_exports__); /*harmonyexport(binding)*/ __webpack_require__.d(__webpack_exports__,"plus",function(){ returnplus; }); constplus=(a,b)=>{ returna+b; }; }) }]);我们发现了:
久违的./src/utils/math.js模块
window["webpackJsonp"]数组的使用地点
这段代码开始执行,把异步加载相关的chunkid与模块传给push函数。而前面已经提到过,window["webpackJsonp"]数组的push函数已被重写为webpackJsonpCallback函数,它的定义位置在webpackBootstrap中:
functionwebpackJsonpCallback(data){ varchunkIds=data[0]; varmoreModules=data[1]; //thenflagall"chunkIds"asloadedandfirecallback varmoduleId,chunkId,i=0,resolves=[]; //将chunk标记为已加载 for(;i走进这个函数中,意味着异步加载的chunk内容已经拿到,这个时候我们要完成两件事,一是让依赖这次异步加载结果的模块继续执行,二是缓存加载结果。
关于第一点,我们回忆一下之前__webpack_require__.e的内容,此时chunk还处于「加载中」的状态,也就是说对应的installedChunks[chunkId]的值此时为[resolve,reject,promise]。而这里,chunk已经加载,但promise还未决议,于是webpackJsonpCallback内部定义了一个resolves变量用来收集installedChunks上的resolve并执行它。`
接下来说到第二点,就要涉及几个层面的缓存了。
首先是chunk层面,这里有两个相关操作,操作一将installedChunks[chunkId]置为0可以让__webpack_require__.e在第二次加载同一chunk时返回一个立即决议的promise(Promise.all([]));操作二将chunkdata添加进window["webpackJsonp"]数组,可以在多入口模式时,方便地拿到已加载过的chunk缓存。通过以下代码实现:
/***缓存执行部分***/ varjsonpArray=window["webpackJsonp"]=window["webpackJsonp"]||[]; //... for(vari=0;i而在modules层面,chunk中的moreModules被合入入口文件的modules中,可供下一个微任务中的__webpack_require__同步加载模块。
({ "./src/index.js": (function(module,exports,__webpack_require__){ console.log('Hellowebpack!'); window.setTimeout(()=>{ __webpack_require__.e(0).then(__webpack_require__.bind(null,"./src/utils/math.js")).then(mathUtil=>{ console.log('1+2:'+mathUtil.plus(1,2)); }); },2000); }) });__webpack_require__.e(0)返回的promise决议后,__webpack_require__.bind(null,"./src/utils/math.js")可以加载到chunk携带的模块,并返回模块作为下一个微任务函数的入参,接下来就是WebpackLoader翻译过的其他业务代码了。
现在让我们把异步流程梳理一下:
更多Webpack的基础知识请点击下面的相关文章
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。