引言 在参考了[webpack 进阶]前端运行时的模块化设计与实现 这篇文章后, 打算对模块加载后发生的一系列过程进行总结, 与 Node读取模块路径->编译->执行 不同, 出于用户体验考虑, Webpack 运行时采用暂存/注册模块代码->读取所需模块代码->执行 的方法;
当我们已经获取了模块内容后(但模块还未执行),我们就将其暂存在 modules 对象中,键就是 webpack 的 moduleId;等到需要使用webpack_require 引用模块时,发现缓存中没有,则从 modules 对象中取出暂存的模块并执行。
测试代码 下面是这次要用到的测试代码, 包含了动态导入的内容:
1 2 3 import "./css/main.sass" ;import ( "./module.js" ).then(({ sub } ) => sub());
1 2 3 4 export const sub = function ( ) { console .log("Sub module has been loaded" ); };
通过设置runtimeChunk: 'single'
将运行时代码专门分离打包成一份; 打包结果如下:
其中 vendor 文件主要是style-loader
与css-loader
的内容, main 文件是入口代码, module 文件则是需动态导入的模块代码;大致关系可以理解为: main ->> [runtime, vendor, module]
build 代码分析 打包后的 main 和 vendor 代码, 大概精简一下之后就是这样(删去了关于css-loader
的部分);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 (window ["webpackJsonp" ] = window ["webpackJsonp" ] || []).push( [ ["main" ], { "e6Wu" : (function (module, __webpack_exports__, __webpack_require__ ) { eval (...}}), "hVTH" : (function (module, exports, __webpack_require__ ) {eval (...)}) }, [["e6Wu" ,"runtime" ,"vendors" ]] ]); (window ["webpackJsonp" ] = window ["webpackJsonp" ] || []).push( [ ["vendor" ], { '782b' : (function (module, __webpack_exports__, __webpack_require__ ) { eval (...}}), }, [] ]);
可以看到顶层属性window["webpackJsonp"]
调用了push
方法, 接受了一个数组作为参数, 其中:
数组第一个元素是数组, 表明当前文件包含的 chunkId;
数组第二个参数是一个对象, 其中 key 表明当前导入的 moduleId, 值为对应的代码;
数组第三个参数也是一个数组, 每个数组的第一个元素表明要执行的模块, 剩余的元素为需要提前执行的 chunk;
通过开头提到的文章, 可以知道window["webpackJsonp"].push
其实就是 runtime 中的webpackJsonpCallback
函数,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 function webpackJsonpCallback (data ) { var chunkIds = data[0 ]; var moreModules = data[1 ]; var executeModules = data[2 ]; 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 ; } for (moduleId in moreModules) { if (Object .prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } } while (resolves.length) { resolves.shift()(); } deferredModules.push.apply(deferredModules, executeModules || []); return checkDeferredModules(); }
再来看看构建完后的 html 会如何请求脚本(暂且不管动态导入的 module 文件), 使用了HtmlWebpackPlugin
之后产生的 html 文件如下:
1 2 3 4 5 6 <body > <img src ="1a957d8e755177e4857610261a056c23.jpg" alt ="test jpg" /> <script type ="text/javascript" src ="runtime-1a97347743150b987131.js" > </script > <script type ="text/javascript" src ="vendors-76f45934ea5383e55a5b.js" > </script > <script type ="text/javascript" src ="main-d1583fe8f9ff73e7a318.js" > </script > </body >
具体流程 从上文可以看出脚本是按照 runtime -> vendor -> main 这样的先后次序请求的, 也就是说, 在正式执行 main 脚本前, vendor 脚本已经注册完毕, 对应的 chunk 也已经加载完毕, 即:
1 2 3 4 5 6 7 installedChunks[Object object] { 'runtime' : 0 'vendor' : 0 , } modules[Object object] { }
那么正式执行 main 函数时是怎样的流程呢? 首先注意到之前webpackJsonpCallback
函数中暂存模块的代码:
1 2 3 4 5 6 for (moduleId in moreModules) { if (Object .prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } }
这里的 moreModules 中即之前谈到的数组中的第二个元素, main 将其导入的模块全部注册到 modules 中暂存, 然后执行了:
1 2 3 4 5 deferredModules.push.apply(deferredModules, executeModules || []); return checkDeferredModules();
这里便是关键, checkDeferredModules
函数会检查每个入口模块依赖的 chunk 是否已经载入完毕, 如果载入完毕, 则直接执行__webpack_require__(当前入口模块)
;
由于 main 依赖的runtime
, vendor
chunk 在之前就已经加载完毕, 最后会直接 require 入口模块e6Wu
;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function checkDeferredModules ( ) { var result; for (var i = 0 ; i < deferredModules.length; i++) { var deferredModule = deferredModules[i]; var fulfilled = true ; for (var j = 1 ; j < deferredModule.length; j++) { var depId = deferredModule[j]; if (installedChunks[depId] !== 0 ) fulfilled = false ; } if (fulfilled) { deferredModules.splice(i--, 1 ); result = __webpack_require__( (__webpack_require__.s = deferredModule[0 ]) ); } } return result; }
再来看看__webpack_require__
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 function __webpack_require__ (moduleId ) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = (installedModules[moduleId] = { i: moduleId, l: false , exports: {} }); modules[moduleId].call( module .exports, module , module .exports, __webpack_require__ ); module .l = true ; return module .exports; }
思路很清晰, 首先查看缓存中是否有对应 moduleId 的模块, 有则直接返回该模块的 exports
, 否则执行:
向缓存installedModules
中添加新模块;
执行暂存区modules
中的代码;
返回模块的exports
;
而在第二步执行的代码中, 同样会调用__webpack_require__
函数 require 其它已有模块, 其中包含 vendor 中注册的模块以及动态加载的 module;
动态加载 代码转换 main 文件中, 关于import
的代码, 则被转换为:
1 2 3 4 5 6 7 __webpack_require__.e("module" ) .then(__webpack_require__.bind(null , "oY72" )) .then(function (_ref ) { var sub = _ref.sub; return sub(); });
可以看到__webpack_require__.e
会返回一个 Promise, 第一个 then 方法是加载对应的模块, 第二个方法则是执行真正的源代码; module 文件在被打包后, 结构与 main, vendor 类似:
1 2 3 4 5 6 (window ["webpackJsonp" ] = window ["webpackJsonp" ] || []).push([ ["module" ], { "oY72" :(function (module, __webpack_exports__, __webpack_require__ ) {eval ("" );}) } ]);
代码加载 打开 chrome 的网络选项, 可以看到 module 文件是在 main.js 执行的过程中请求加载的:
这一功能是由 runtime 中的requireEnsure
函数实现(即上文中的__webpack_require__.e
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 function requireEnsure (chunkId ) { var promises = []; var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0 ) { if (installedChunkData) { promises.push(installedChunkData[2 ]); } else { var promise = new Promise (function (resolve, reject ) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push((installedChunkData[2 ] = promise)); var script = document .createElement("script" ); var onScriptComplete; script.charset = "utf-8" ; script.timeout = 120 ; if (__webpack_require__.nc) { script.setAttribute("nonce" , __webpack_require__.nc); } 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; document .head.appendChild(script); } } return Promise .all(promises); };
该函数会先判断当前的 chunk 是否已经被加载至installedChunk
中, 如果没有, 则实例化一个 Promise, 以[resolve, reject, promiseInstance]
的形式加入installedChunk
中, 随后通过jsonpScriptSrc
函数找到 chunkId 对应的文件路径, 往 DOM 中动态添加<script>
标签来加载脚本;
解析 那么加载了对应的 Promise, 会在什么时候 resolve 呢? 按照逻辑, 当然是在 module 脚本载入完成后再进行 resolve, 而module 脚本中又调用了webpackJsonpCallback
, 其中有一段重要代码:1 2 3 4 5 6 7 8 9 if (installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0 ]); } while (resolves.length) { resolves.shift()(); }
于是, 动态导入的模块就顺利完成了加载, 之后再通过then
方法取出(require)暂存在modules
中的 module 代码即可;