⚠️ webpack v4.44.2 版本以下存在的 bug
问题背景 b 同学反馈他负责的项目在测试环境有两个 js 文件 404 了, 一看文件名又很诡异, 即下图的 xx.undefined.chunk.xx.js
查看 webpackConfig 中配置的文件名的生成规则 chunkFilename 字段, 知道了原本应该是长度为 8 的 chunkhash 字符串变成了 undefined 导致了本次问题
1 2 3 4 5 6 7 8 module .exports = { output : { chunkFilename : `static/js/[name].[chunkhash:8].chunk.${release} .js` , }, };
问题排查 可能性1: 生成 chunkhash 的算法出了问题, 返回了 undefined 首先我们找到把 [chunkhash:8] 这个占位符替换为计算后真实值的代码处, 接着拦截一下该函数运算的所有结果, 发现没有一个文件名字符串中包含 undefined, 故排除了该可能性
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 const replacePathVariables = (path, data, assetInfo ) => { const chunk = data.chunk ; const chunkId = chunk && chunk.id ; const chunkName = chunk && (chunk.name || chunk.id ); const chunkHash = chunk && (chunk.renderedHash || chunk.hash ); const chunkHashWithLength = chunk && chunk.hashWithLength ; const contentHashType = data.contentHashType ; const contentHash = (chunk && chunk.contentHash && chunk.contentHash [contentHashType]) || data.contentHash ; return ( path .replace ( REGEXP_HASH , withHashLength (getReplacer (data.hash ), data.hashWithLength , assetInfo) ) .replace ( REGEXP_CHUNKHASH , withHashLength (getReplacer (chunkHash), chunkHashWithLength, assetInfo) ) .replace ( REGEXP_CONTENTHASH , withHashLength ( getReplacer (contentHash), contentHashWithLength, assetInfo ) ) .replace ( REGEXP_MODULEHASH , withHashLength (getReplacer (moduleHash), moduleHashWithLength, assetInfo) ) .replace (REGEXP_ID , getReplacer (chunkId)) .replace (REGEXP_MODULEID , getReplacer (moduleId)) .replace (REGEXP_NAME , getReplacer (chunkName)) .replace (REGEXP_FILE , getReplacer (data.filename )) .replace (REGEXP_FILEBASE , getReplacer (data.basename )) .replace (REGEXP_QUERY , getReplacer (data.query , true )) .replace (REGEXP_URL , getReplacer (data.url )) .replace (/\[\\(\\*[\w:]+\\*)\\\]/gi , "[$1]" ) ); };
其他可能性 本地复现后从错误堆栈发现是如下打包后的代码__webpack_require__.e(10)
调用后产生的错误
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function AsyncCaptcha (props ) { var _a = Object (react__WEBPACK_IMPORTED_MODULE_0__["useState" ])(), Module = _a[0 ], setModule = _a[1 ]; Object (react__WEBPACK_IMPORTED_MODULE_0__["useEffect" ])(function ( ) { if (!props.isBApp ) { Promise .all ([__webpack_require__.e (10 ), __webpack_require__.e (53 )]).then (__webpack_require__.bind (null , 144 )) .then (function (r ) { return setModule (r); }) .catch (function (e ) { return Object (_pmm__WEBPACK_IMPORTED_MODULE_1__[ "c" ])(e); }); } }, [props.isBApp ]); if (Module ) return react__WEBPACK_IMPORTED_MODULE_0___default.a .createElement (Module .default ); return null ; }
__webpack_require__.e
函数是 webpack 对于动态 import 函数的实现, 如上对应的打包前的代码是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import React , { useEffect, useState } from 'react' import { reportError } from '../pmm' export function AsyncCaptcha (props ) { var _a = useState (), Module = _a[0 ], setModule = _a[1 ] useEffect ( function ( ) { if (!props.isBApp ) { import ('@xxxx/captcha-prompt-mobile' ) .then (function (r ) { return setModule (r) }) .catch (function (e ) { return reportError (e) }) } }, [props.isBApp ] ) if (Module ) return React .createElement (Module .default ) return null }
接着我们查看__webpack_require__.e
函数的实现, 知道了传入的参数 10 与 53 是 chunkId, 但是不存在下面这个对象的 key 中, 所以 { 55: 'aec66236', 57: '833f5543', 59: '6b297fa3', 62: 'd7940dbd' }[chunkId]
取值是 undefined, 这就解释了为什么文件名中出现了 undefined, 原来完整的文件名是在这里拼接而成
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 function jsonpScriptSrc (chunkId ) { return ( __webpack_require__.p + 'static/js/' + ({}[chunkId] || chunkId) + '.' + { 55 : 'aec66236' , 57 : '833f5543' , 59 : '6b297fa3' , 62 : 'd7940dbd' }[ chunkId ] + '.chunk.v20230215191953_91080d6e.js' ) } __webpack_require__.e = function requireEnsure (chunkId ) { var script = document .createElement ('script' ) var onScriptComplete script.src = jsonpScriptSrc (chunkId) onScriptComplete = function (event ) { } var timeout = setTimeout (function ( ) { onScriptComplete ({ type : 'timeout' , target : script }) }, 120000 ) script.onerror = script.onload = onScriptComplete document .head .appendChild (script) }
下面我们看看{ 55: 'aec66236', 57: '833f5543', 59: '6b297fa3', 62: 'd7940dbd' }
对象的生成逻辑, 为什么漏掉了 10 这个 key?
从代码可以看出 55, 57, 59, 62 这些 key 值其实是 this.getAllAsyncChunks()
函数返回的 chunk 的 id, 从函数名getAllAsyncChunks
可以知道返回值是该页面所有动态加载的 AsyncChunk, 而 chunk.id 为 10 对应的 '@xxxx/captcha-prompt-mobile'
模块确实也是动态加载的, 那么是什么原因 webpack 漏掉了 import('@xxxx/captcha-prompt-mobile')
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 class Chunk { getChunkMaps (realHash ) { const chunkHashMap = Object .create (null ) const chunkContentHashMap = Object .create (null ) const chunkNameMap = Object .create (null ) for (const chunk of this .getAllAsyncChunks ()) { chunkHashMap[chunk.id ] = realHash ? chunk.hash : chunk.renderedHash for (const key of Object .keys (chunk.contentHash )) { if (!chunkContentHashMap[key]) { chunkContentHashMap[key] = Object .create (null ) } chunkContentHashMap[key][chunk.id ] = chunk.contentHash [key] } if (chunk.name ) { chunkNameMap[chunk.id ] = chunk.name } } return { hash : chunkHashMap, contentHash : chunkContentHashMap, name : chunkNameMap } } }
继续阅读代码后发现 import('@xxxx/captcha-prompt-mobile')
是没有走到第 6 步与父 chunkGroup 连接起来 (文件之间的引用关系可以用数据结构 Graph 来描叙, 没有连接则表示后续父 chunkGroup 通过 Graph 查询不到与之的依赖关系), 所以 getAllAsyncChunks 函数不包含该 chunk。原因是被如下的 filterFn 函数给过滤了, 被过滤的原因是import('@xxxx/captcha-prompt-mobile')
对应的模块出现在了resultingAvailableModules 中, 继续查看依赖关系发现是有一个 npm 包同步import '@xxxx/captcha-prompt-mobile'
, 原来一切是因为还同时存在一个同步的 import 导致 webpack 认为它不是 AsyncChunk
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 const filterFn = (dep ) => { const depChunkGroup = dep.chunkGroup if (blocksWithNestedBlocks.has (dep.block )) return true if (areModulesAvailable (depChunkGroup, resultingAvailableModules)) { return false } return true } for (const [chunkGroup, deps] of chunkDependencies) { if (deps.length === 0 ) continue const info = chunkGroupInfoMap.get (chunkGroup) resultingAvailableModules = info.resultingAvailableModules for (let i = 0 ; i < deps.length ; i++) { const dep = deps[i] if (!filterFn (dep)) { continue } const depChunkGroup = dep.chunkGroup const depBlock = dep.block GraphHelpers .connectDependenciesBlockAndChunkGroup (depBlock, depChunkGroup) GraphHelpers .connectChunkGroupParentAndChild (chunkGroup, depChunkGroup) } }
按理来说同步 import 与动态 import 引用这两者都引用同一个文件虽然少见, 但也不应该造成 bug 。难道是该项目是多页应用还有其他未知的秘密?于是我们删除了其他页面, 仅留当前页面进行测试
最后发现只留下一个页面是正常的, 与多个页面打包后的代码不同的是__webpack_require__.e(10)
变成了 Promise.resolve(/* import() */)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function AsyncCaptcha (props ) { var _a = Object (react__WEBPACK_IMPORTED_MODULE_0__["useState" ])(), Module = _a[0 ], setModule = _a[1 ]; Object (react__WEBPACK_IMPORTED_MODULE_0__["useEffect" ])(function ( ) { if (!props.isBApp ) { Promise .resolve ().then (__webpack_require__.bind (null , 105 )) .then (function (r ) { return setModule (r); }) .catch (function (e ) { return Object (_pmm__WEBPACK_IMPORTED_MODULE_1__[ "c" ])(e); }); } }, [props.isBApp ]); if (Module ) return react__WEBPACK_IMPORTED_MODULE_0___default.a .createElement (Module .default ); return null ; }
于是查看这块的 blockPromise 函数实现发现是第一个 if 条件为 true, 所以返回的是Promise.resolve(/* import() */)
。 此时 block.chunkGroup 为 undefined, 这也对应了上面说的是被 filterFn 函数过滤了, 所以没有走到为 block.chunkGroup 赋值的代码处
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 module .exports = class RuntimeTemplate { blockPromise ({ block, message } ) { if (!block || !block.chunkGroup || block.chunkGroup .chunks .length === 0 ) { const comment = this .comment ({ message }) return `Promise.resolve(${comment.trim()} )` } const chunks = block.chunkGroup .chunks .filter ( (chunk ) => !chunk.hasRuntime () && chunk.id !== null ) const comment = this .comment ({ message, chunkName : block.chunkName , chunkReason : block.chunkReason }) if (chunks.length === 1 ) { const chunkId = JSON .stringify (chunks[0 ].id ) return `__webpack_require__.e(${comment} ${chunkId} )` } else if (chunks.length > 0 ) { const requireChunkId = (chunk ) => `__webpack_require__.e(${JSON .stringify(chunk.id)} )` return `Promise.all(${comment.trim()} [${chunks .map(requireChunkId) .join(', ' )} ])` } else { return `Promise.resolve(${comment.trim()} )` } } }
接下来我们需要排查为啥多页时返回的是__webpack_require__.e
, 按照上面的分析多页的 block.chunkGroup 应该也为 undefined 才对。再次回到 buildChunkGraph 文件代码, 发现是import('@xxxx/captcha-prompt-mobile')
对应的模块被另一个页面仅动态 import 引用了, 所以如下的 for 循环有一次走到了第 6 步连接上了这个页面对应的 chunkGroup, 而第 5 就给该 block 设置了 chunkGroup, 即多页时 block.chunkGroup 是存在的, 所以上面的 blockPromise 函数返回的是 __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 const filterFn = (dep ) => { const depChunkGroup = dep.chunkGroup if (blocksWithNestedBlocks.has (dep.block )) return true if (areModulesAvailable (depChunkGroup, resultingAvailableModules)) { return false } return true } for (const [chunkGroup, deps] of chunkDependencies) { if (deps.length === 0 ) continue const info = chunkGroupInfoMap.get (chunkGroup) resultingAvailableModules = info.resultingAvailableModules for (let i = 0 ; i < deps.length ; i++) { const dep = deps[i] if (!filterFn (dep)) { continue } const depChunkGroup = dep.chunkGroup const depBlock = dep.block GraphHelpers .connectDependenciesBlockAndChunkGroup (depBlock, depChunkGroup) GraphHelpers .connectChunkGroupParentAndChild (chunkGroup, depChunkGroup) } }
问题解决 把当前 webpack 版本 v4.39.0 升级到小版本最新的 v4.46.0 发现问题已经不存在, 于是查看 Release 日志 找到是 v4.44.2 版本进行的修复