webpack 打包后的文件名中包含 undefined 排查

⚠️ webpack v4.44.2 版本以下存在的 bug

问题背景

b 同学反馈他负责的项目在测试环境有两个 js 文件 404 了, 一看文件名又很诡异, 即下图的 xx.undefined.chunk.xx.js

image

查看 webpackConfig 中配置的文件名的生成规则 chunkFilename 字段, 知道了原本应该是长度为 8 的 chunkhash 字符串变成了 undefined 导致了本次问题

1
2
3
4
5
6
7
8
module.exports = {
// webpack.config.js

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
// webpack/lib/TemplatedPathPlugin.js

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))
// query is optional, it's OK if it's in a path but there's nothing to replace it with
.replace(REGEXP_QUERY, getReplacer(data.query, true))
// only available in sourceMappingURLComment
.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) {
// state 里不能存放 fn,React 无法区分是否需要调用
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(/* import() */[__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__[/* reportError */ "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) {
// state 里不能存放 fn,React 无法区分是否需要调用
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
// webpack/lib/Chunk.js

class Chunk {
getChunkMaps(realHash) {
/** @type {Record<string|number, string>} */
const chunkHashMap = Object.create(null)
/** @type {Record<string|number, Record<string, string>>} */
const chunkContentHashMap = Object.create(null)
/** @type {Record<string|number, string>} */
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
// webpack/lib/buildChunkGraph.js

const filterFn = (dep) => {
const depChunkGroup = dep.chunkGroup
// TODO is this needed?
if (blocksWithNestedBlocks.has(dep.block)) return true
if (areModulesAvailable(depChunkGroup, resultingAvailableModules)) {
return false // break all modules are already available
}
return true
}

// For all deps, check if chunk groups need to be connected
for (const [chunkGroup, deps] of chunkDependencies) {
if (deps.length === 0) continue

// 1. Get info from chunk group info map
const info = chunkGroupInfoMap.get(chunkGroup)
resultingAvailableModules = info.resultingAvailableModules

// 2. Foreach edge
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]

// Filter inline, rather than creating a new array from `.filter()`
// TODO check if inlining filterFn makes sense here
if (!filterFn(dep)) {
continue
}
const depChunkGroup = dep.chunkGroup
const depBlock = dep.block

// 5. Connect block with chunk
GraphHelpers.connectDependenciesBlockAndChunkGroup(depBlock, depChunkGroup)

// 6. Connect chunk with parent
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) {
// state 里不能存放 fn,React 无法区分是否需要调用
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(/* import() */).then(__webpack_require__.bind(null, 105))
.then(function (r) { return setModule(r); })
.catch(function (e) { return Object(_pmm__WEBPACK_IMPORTED_MODULE_1__[/* reportError */ "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
// webpack/lib/RuntimeTemplate.js

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
// webpack/lib/buildChunkGraph.js

const filterFn = (dep) => {
const depChunkGroup = dep.chunkGroup
// TODO is this needed?
if (blocksWithNestedBlocks.has(dep.block)) return true
if (areModulesAvailable(depChunkGroup, resultingAvailableModules)) {
return false // break all modules are already available
}
return true
}

// For all deps, check if chunk groups need to be connected
for (const [chunkGroup, deps] of chunkDependencies) {
if (deps.length === 0) continue

// 1. Get info from chunk group info map
const info = chunkGroupInfoMap.get(chunkGroup)
resultingAvailableModules = info.resultingAvailableModules

// 2. Foreach edge
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]

// Filter inline, rather than creating a new array from `.filter()`
// TODO check if inlining filterFn makes sense here
if (!filterFn(dep)) {
continue
}
const depChunkGroup = dep.chunkGroup
const depBlock = dep.block

// 5. Connect block with chunk
GraphHelpers.connectDependenciesBlockAndChunkGroup(depBlock, depChunkGroup)

// 6. Connect chunk with parent
GraphHelpers.connectChunkGroupParentAndChild(chunkGroup, depChunkGroup)
}
}

问题解决

把当前 webpack 版本 v4.39.0 升级到小版本最新的 v4.46.0 发现问题已经不存在, 于是查看 Release 日志找到是 v4.44.2 版本进行的修复