AIGC标识 flatMap is not a function:一次"明明指定了 Node 14"的构建排查

结论先行:报错文件是我自己写的 config/extraCopyList.js,但问题一行都不在它身上。真正的现场是:CI 脚本里写着 NODE_HOME=/usr/install/node-v14.19.0-linux-x64,可实际执行 npm run build 的 Node 是 v10.18.0。原因是脚本用 export PATH=$PATH:$NODE_HOME/bin 把新路径追加到了 PATH 末尾,而基础镜像的 PATH 里早就有一个 node10 排在前面——PATH 是"先到先得",追加等于没写。Array.prototype.flatMap 是 ES2019 的特性,Node 11 才有,Node 10 的 V8 6.8 里它是 undefined,于是调用即崩。

一、现象:一行看不出出处的 TypeError

一个 Vue 2 老项目(@vue/cli-service@4.5.19 + webpack@4.46.0),生产构建挂在第一步:

> my-app@x.x.x build /data/workspace/project
> vue-cli-service build --mode production

 ERROR  Error loading /data/workspace/project/vue.config.js:
 ERROR  TypeError: projectDependencies(...).filter(...).flatMap is not a function
TypeError: projectDependencies(...).filter(...).flatMap is not a function
    at searchExtraCopyList (/data/workspace/project/config/extraCopyList.js:29:10)
    at extraConfig (/data/workspace/project/config/vueConfigSetting.js:86:18)
    at Object.<anonymous> (/data/workspace/project/vue.config.js:70:10)
    at Module._compile (internal/modules/cjs/loader.js:778:30)
    ...

这条报错的迷惑性在于:它指向的每一行都是业务代码。extraCopyList.js 是我自己写的,vue.config.js 也是我自己写的,堆栈一路指向项目目录,看起来就像是"我代码写错了"。

但它偏偏不是。

二、先排除两个方向

面对 XXX is not a function,通常有三种可能。先逐个排掉:

假设 判定 依据
调用它的对象是"假的"(类数组、jQuery 对象、自定义类) ❌ .filter() 已经成功返回了,说明前面是真数组
代码本身写错了(拼错方法名、传错参数) ❌ flatMap 拼写正确,是标准的 ES2019 方法
运行时没有这个 API ✅ 真数组的方法来自 Array.prototype,数组上找不到只可能是原型上没有

关键推理在这一句:

projectDependencies(...).filter(...).flatMap(...)

.filter() 能调通,说明它前面是一个真正的 Array;真数组的 .flatMap 继承自 Array.prototype。一个真数组不可能"没有" flatMap,除非当前运行时的 Array.prototype 上就没有它。

再回头看 projectDependencies 的实现:

function projectDependencies() {
    return Object.keys({
        ...(MANIFEST['dependencies'] || {}),
        ...(MANIFEST['peerDependencies'] || {}),
    });
}

Object.keys() 返回的东西没有任何可疑余地。

那 flatMap 是什么时候进的语言?查 V8 官方文档,Array#{flat, flatMap} 的支持情况写得很清楚:

Chrome 69 / Firefox 62 / Safari 12 / Node.js: supported since version 11

结论出来了:当时跑的 Node 一定低于 11。

可是 CI 脚本里明明白白写着 Node 14 啊。

三、证据就在日志的第三行

sh -xe 会把每条命令展开后的真实值打出来。构建脚本开头三行是:

export NODE_HOME=/usr/install/node-v14.19.0-linux-x64
export PATH=$PATH:$NODE_HOME/bin
export NODE_PATH=$NODE_HOME/lib/node_modules

日志里对应的回显是:

+ export NODE_HOME=/usr/install/node-v14.19.0-linux-x64
+ NODE_HOME=/usr/install/node-v14.19.0-linux-x64
+ export PATH=/usr/install/python3.7/bin:/usr/local/sbin:/usr/local/bin:...:/usr/install/node-v10.18.0-linux-x64/bin:/usr/install/gradle-4.8/bin:...:/root/bin:/usr/install/node-v14.19.0-linux-x64/bin

把这一长串按顺序读一遍,问题就摆在脸上了:

... :/usr/install/node-v10.18.0-linux-x64/bin : ... :/usr/install/node-v14.19.0-linux-x64/bin
                    ↑ 排在前面                                              ↑ 被追加到最后

v10 在前,v14 在最后。

而 vue-cli-service 这个可执行文件的第一行是:

#!/usr/bin/env node

env node 的规则是:沿着 PATH 从左往右找,找到第一个 node 就用它。先撞上的是 /usr/install/node-v10.18.0-linux-x64/bin/node。

于是整条链路串成了这样:

npm run build
  → vue-cli-service 的 shebang 执行 env node
    → 按 PATH 顺序取到第一个 node = /usr/install/node-v10.18.0-linux-x64/bin/node
      → 用 Node 10 加载 vue.config.js
        → 触发 searchExtraCopyList()
          → Array.prototype.flatMap 在 Node 10 里不存在
            → TypeError: ... .flatMap is not a function

NODE_HOME 写对了,NODE_PATH 也写对了,但那两个变量都不决定"用哪个 node 执行"。只有 PATH 的顺序决定。

四、根因:$PATH: 在前还是在后,是两件完全不同的事

把这两行放在一起看:

export PATH=$PATH:$NODE_HOME/bin      # 追加:新路径排最后
export PATH=$NODE_HOME/bin:$PATH      # 前置:新路径排最前

很多脚本习惯性写第一种,因为它的"目标"通常只是"让系统能找到这个命令"。在干净环境里,这没问题。

但 CI 的 slave 机器不是干净环境。它的 PATH 是从父进程继承的,基础镜像里预装的东西(各种语言、各种构建工具的多个版本)全都在里面。你追加一个路径,只是让它在"所有已有路径之后"被搜索——只要同名的命令已经在更靠前的位置存在,你追加的那份永远不会被用到。

这类问题最恶劣的地方在于:它不报错,也不提示。export PATH 永远成功,npm install 也永远成功(npm 6 在 Node 10 上跑得好好的),一切看起来都很正常,直到某个文件用到了一条新语法。

五、为什么这个坑特别难发现

复盘一下,有三个因素叠加,把一个简单问题拖了很久:

因素 表现
脚本没做版本自检 全文没有一句 node -v,所以从来没在日志里看到真实版本
报错指向业务代码 堆栈每一行都是项目文件,人是被"自己写的代码"吸引走的
失败得足够早 崩在 vue.config.js 加载阶段,webpack 一个模块都还没处理,日志里没有任何"像构建"的信息

其中第一条是可以彻底消除的。任何指定了 Node 版本的构建脚本,都应该在装依赖之前把版本打出来:

export NODE_HOME=/usr/install/node-v14.19.0-linux-x64
export PATH=$NODE_HOME/bin:$PATH
export NODE_PATH=$NODE_HOME/lib/node_modules

node -v && npm -v && command -v node

三行输出,一眼就能看出"到底用的是哪一个"。这次的排查过程里,如果日志开头就有 v10.18.0,整件事大概十分钟就结束了。

六、路线一:改代码,但要改满三处

如果暂时动不了 CI,只能从代码侧兼容。这里有个很容易踩的连环坑。

extraCopyList.js:29 是第一个炸点:

function searchExtraCopyList() {
    return projectDependencies()
        .filter(filterDependencies)
        .flatMap(extraCopyListOfDependency);      // ← 这里先崩
}

改掉它之后,构建不会马上好,而是换个地方再崩一次:

// config/common.js:30  posixPath()
const processed = path.posix.join(...segments.flatMap(segment =>
    segment.split(/[\\/]/g).filter(Boolean)));

因为 flatMap 的回调 extraCopyListOfDependency 内部会调用 posixPath()。第一次异常把后面的执行全挡住了,所以只看到第一个。

全工程构建期一共三处,缺一不可:

# 文件 位置 所属函数
1 config/extraCopyList.js searchExtraCopyList 内 收集依赖的 copy 配置
2 config/common.js 第 30 行,posixPath 内 POSIX 路径拼接
3 config/common.js 第 47 行,systemPath 内 系统路径拼接

改法统一用 reduce + concat 替代,语义完全等价:

// 1. extraCopyList.js
function searchExtraCopyList() {
    return projectDependencies()
        .filter(filterDependencies)
        .reduce((list, dependency) => list.concat(extraCopyListOfDependency(dependency)), []);
}
// 2. common.js - posixPath
const processed = path.posix.join(...segments.reduce((list, segment) =>
    list.concat(segment.split(/[\\/]/g).filter(Boolean)), []));
// 3. common.js - systemPath
const processed = path.join(...segments.reduce((list, segment) =>
    list.concat(segment.split(/[\\/]/g).filter(Boolean)), []));

改完用一条命令确认没有漏网的,期望零输出:

grep -rn "\.flatMap(\|\.flat(" config/ build/ vue.config.js babel.config.js postcss.config.js

七、路线二:给运行时打补丁

不想动业务代码,就给 Node 10 补上 ES2019 的那两个方法。新建 config/node-polyfill.js:

'use strict';

function flattenInto(target, source, depth) {
    for (let i = 0; i < source.length; i++) {
        // 跳过稀疏数组的空洞,与原生 flat/flatMap 行为一致
        if (!(i in source)) {
            continue;
        }
        const value = source[i];
        if (depth > 0 && Array.isArray(value)) {
            flattenInto(target, value, depth - 1);
        } else {
            target.push(value);
        }
    }
    return target;
}

if (typeof Array.prototype.flat !== 'function') {
    Object.defineProperty(Array.prototype, 'flat', {
        configurable: true,
        writable: true,
        enumerable: false,
        value: function flat(depth) {
            const level = depth === undefined ? 1 : Math.floor(Number(depth)) || 0;
            return flattenInto([], this, level > 0 ? level : 0);
        }
    });
}

if (typeof Array.prototype.flatMap !== 'function') {
    Object.defineProperty(Array.prototype, 'flatMap', {
        configurable: true,
        writable: true,
        enumerable: false,
        value: function flatMap(callback, thisArg) {
            if (typeof callback !== 'function') {
                throw new TypeError(callback + ' is not a function');
            }
            const result = [];
            for (let i = 0; i < this.length; i++) {
                if (!(i in this)) {
                    continue;
                }
                const mapped = callback.call(thisArg, this[i], i, this);
                if (Array.isArray(mapped)) {
                    flattenInto(result, mapped, 0);   // flatMap 只展开一层
                } else {
                    result.push(mapped);
                }
            }
            return result;
        }
    });
}

然后在 vue.config.js 里插一行。原始开头是:

'use strict';
require('events').EventEmitter.defaultMaxListeners = 0;

改成:

'use strict';
require('./config/node-polyfill');      // ← 必须在 'use strict' 之后
require('events').EventEmitter.defaultMaxListeners = 0;

位置有两个讲究,都不是可有可无的:

⚠️ 必须在 'use strict'; 之后。 如果插到第一行,'use strict' 就不再是指令序言(directive prologue)的首条语句,会静默失效,整个文件退化为非严格模式。这种失效不报错、不警告,很难事后发现。

⚠️ 必须在文件顶部。 因为配置函数是在 module.exports 的对象字面量里立即求值的(...extraConfig() 那一行),构建一开始就会执行,晚一步就来不及。

这个补丁本身有三个特性:

特性 说明
幂等 用 typeof ... !== 'function' 判断,重复 require 无副作用
不覆盖原生 Node 11+ 已有原生实现,直接跳过
不污染遍历 用 Object.defineProperty 设为不可枚举,for...in 行为与原生一致

也就是说,等 Node 版本修好之后,这个文件可以原样留在仓库里,不需要回滚。

八、在本地怎么证明它真的能用

本机装的是 Node 22,原生就有 flatMap,直接跑根本复现不了。所以得手动把原型上的方法删掉,模拟 Node 10:

# 最小验证:不需要 node_modules,只需要 fs / path
node -e "delete Array.prototype.flatMap; require('./config/node-polyfill'); \
const {posixPath}=require('./config/common'); \
console.log(posixPath('my-lib','extra-copy.config.js'));"

期望输出 my-lib/extra-copy.config.js,不报错。

要验证完整链路(需要完整的 node_modules):

node -e "delete Array.prototype.flat; delete Array.prototype.flatMap; \
require('./vue.config.js'); console.log('✅ vue.config.js 加载成功');"

这条命令会真的去执行 searchExtraCopyList(),把整个调用链跑一遍。能打印成功,说明补丁覆盖到位。

补丁的语义正确性我也做过对照:把原生实现存一份,删掉原型方法后加载补丁,用同一批用例跑两遍,比对结果。55 个用例(含稀疏数组空洞、thisArg、Infinity 深度、空数组、回调返回非数组)全部一致。

九、两条路线怎么选

维度 改代码 打补丁
改动量 3 处、约 9 行 新增约 60 行 + 1 行 require
影响面 只影响三个路径处理函数 全局补齐 Array 原型
后续 Node 升级 改动可保留 自动空转,可保留
适合场景 想把这些函数写成不依赖新 API 的形式 时间紧、同类报错可能不止一处

赶时间先上补丁,一处生效、覆盖面大;有空再把 reduce 版本换回去,那是最干净的写法。两者别同时上,否则出问题不好分清是谁的功劳。

十、小结

  • XXX is not a function 遇到真数组时,先怀疑运行时版本,不要怀疑自己的链式调用。
  • export PATH=$PATH:$NEW 只保证"能找到",不保证"优先找到"。要让版本生效必须写成 export PATH=$NEW:$PATH。
  • 任何指定了 Node 版本的 CI 脚本,都应该有一句 node -v && command -v node。没有这行输出,"我指定了版本"就只是一句声明。
  • 报错栈指向业务代码,不代表问题在业务代码。这次三行堆栈全在项目目录里,真凶却在一个没人看的 export 上。
  • 多炸点要一次改完。flatMap 崩在 extraCopyList.js,改完立刻在 common.js 再崩一次——前一个异常把后面的执行全挡住了。
  • 补丁能救急,但治不了本。Node 10 和 14 的差距不止这两个方法:?. 和 ?? 在 Node 10 里是语法错误(不是运行时错误,直接 parse fail),Object.fromEntries、String.prototype.matchAll、Promise.allSettled 也都是 Node 12 才有的。补丁只补上了当前这一处,下次有人往 config/ 里写一个 ?.,照样挂。

这次踩下来,最值钱的不是那两个改法,而是那句 node -v。环境类问题排查到最后,往往都归结为一句"你以为的和实际的不是一回事"——而唯一的办法,是让脚本把实际值打出来。

posted on 2026-11-11 19:20  fox_charon  阅读(9)  评论(0)    收藏  举报

导航