xiaoxiaojx / blog

Project for records problems solved in my work and study.
https://xiaoxiaojx.github.io/
MIT License
252 stars 6 forks source link

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

Open xiaoxiaojx opened 1 year ago

xiaoxiaojx commented 1 year ago

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