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) 收藏 举报
浙公网安备 33010602011771号