Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
xiaoxiaojx opened this issue Feb 15, 2023 · 0 comments
Open

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

xiaoxiaojx opened this issue Feb 15, 2023 · 0 comments
Labels
webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a

Comments

@xiaoxiaojx
Copy link
Owner

xiaoxiaojx commented Feb 15, 2023

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

问题背景

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

image

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

module.exports = {
// webpack.config.js
  
  output: {
    //...
    chunkFilename: `static/js/[name].[chunkhash:8].chunk.${release}.js`,
  },
};

问题排查

可能性1: 生成 chunkhash 的算法出了问题, 返回了 undefined

首先我们找到把 [chunkhash:8] 这个占位符替换为计算后真实值的代码处, 接着拦截一下该函数运算的所有结果, 发现没有一个文件名字符串中包含 undefined, 故排除了该可能性

// 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) 调用后产生的错误

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 函数的实现, 如上对应的打包前的代码是

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, 原来完整的文件名是在这里拼接而成

/******/ 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')

// 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

// 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() */)

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 赋值的代码处

// 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

// 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 版本进行的修复

@xiaoxiaojx xiaoxiaojx added the webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a label Feb 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting a
Projects
None yet
Development

No branches or pull requests

1 participant