背景:老项目撞上新版 Node
手上有一个 vue-cli2 模板时代的前端项目——Vue 2.5.16、webpack 1.15,构建入口是 node build/build.js。这套技术栈在当年的 Node 8/10 下跑了很多年相安无事,最近把它搬去一台装了新系统的构建机上跑,Node 直接是 17+。结果 npm run build 一上来就连环报错,按下一个又冒出一个,前后踩了七八个坑才把 EXIT=0 的完整 dist 打出来。
事后复盘,真正的根因基本都跟 Node 大版本没有直接关系:老项目的依赖锁的是年代版 npm registry 的解析结果,^ 语义在新装机上一次 npm install 就把版本漂到作者都没见过的远方;而 webpack1 内置的压缩器、缺失的进程退出钩子这些"老栈默认行为",在高版本 Node + 新依赖树的组合下全部现出原形。更麻烦的是,错误信息会严重误导方向——比如第一条报错长得非常像"Node 版本太新不兼容",实际却是 ES6 语法压缩不了。下面按我当时修的优先级逐条记录。
坑一:UglifyJs: Unexpected token: operator (>)
现象
构建刚进压缩阶段就炸:
ERROR in ./~/vxe-table/lib/index.js
UglifyJsPlugin ...
Unexpected token: operator (>)
排查
operator (>) 这个说法很怪。webpack1 内置的 webpack.optimize.UglifyJsPlugin 内嵌的是 uglify-js 1.x/2.x,那代压缩器只认 ES5 语法,遇到 ES6 箭头函数 => 就会把它当成孤立的 > 运算符报语法错误。也就是说报错的不是 Node,而是压缩器读不懂依赖里的新语法。翻 node_modules/vxe-table/lib 确实满眼箭头函数——问题依赖是 [email protected]。
解法
换成 uglifyjs-webpack-plugin@^1.1.2。它基于 uglify-es,支持 ES6,同时兼容 webpack1 的插件 API。在 build/webpack.prod.conf.js 里:
var UglifyJsPlugin = require('uglifyjs-webpack-plugin');
// 把原来的
// new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } })
// 换成:
new UglifyJsPlugin()
这里有个反直觉的坑要专门记一下:别给它传 { compress: { warnings: false } }。我一开始照搬旧配置把 compress 选项带了过去,结果报:
additionalProperties ... 'compress' is an invalid additional property
[email protected] 的 schema 不接受这个属性,直接 new UglifyJsPlugin() 裸用即可。
复盘
"编译器不认新语法"类报错,先定位报错 token 属于哪种语法,再看是哪一层工具链在解析它——压缩器、转译器、还是运行时,三者的升级路径完全不同。
坑二:Module not found: Cannot resolve module '@vxe-ui/core'
现象
换完压缩器,报错变成缺模块:
Module not found: Cannot resolve module '@vxe-ui/core' in .../node_modules/vxe-table/lib
Module not found: Cannot resolve module 'dom-zindex' in ...
排查
项目 package.json 里写的是 vxe-table: ^3.1.1。当年锁这个版本时它还是个无依赖的单包组件库;但 ^ 在新装机的 npm install 里把版本解析到了最新的 3.23.x——新版本改成了拆包架构,运行时依赖 @vxe-ui/* 一整个生态。老 webpack1 项目没有能力解析这套新依赖树,装完就找不到模块。
解法
把范围符去掉,锁死当年真正想要的那个单包版本:
"vxe-table": "3.1.1"
重装前用一行命令确认它是"无依赖单包",避免再踩:
npm view [email protected] dependencies
# 输出为空即确认
复盘
老项目的 ^ 是个时间胶囊:它当年解析到什么版本,团队从未写在 package-lock 之外确认过。迁移构建环境等于把胶囊重新拆开,所有 ^ 都可能漂走。凡是"装完环境后突然找不到模块"的依赖,先对比 npm ls 的实际版本和代码作者当年能写通代码的版本。
坑三:vue-template-compiler 与 vue 版本漂移
现象
模板编译阶段开始出警告,构建产物页面打开直接白屏,控制台是 vue-loader 相关的编译错误。
排查
vue-template-compiler 的产物是要交给 vue 运行时消费的,官方要求两者版本完全一致。检查发现 vue@^2.5.16 在装机后解析到了 2.5.16,而 devDependencies 里的 vue-template-compiler 写的是 ^2.7.16,装成了 2.7.x——两个版本号从写下来那天就不配对,只是老环境里恰好一直没重装过。compiler 编译出的模板 render 函数和运行时对不上,轻则警告重则白屏。
解法
以项目实际的 vue 版本为准,把 compiler 锁到同一个精确版本号:
"vue": "2.5.16",
"vue-template-compiler": "2.5.16"
复盘
vue 和 vue-template-compiler 是"连体"依赖,版本一致是硬约束不是建议。这对组合最好成对出现在 lock 文件里,任何一个单独升 ^ 都会埋雷。
坑四:npm run build 跑完不退出
现象
构建日志已经打印完产物统计,dist 目录也齐了,但 npm run build 永远不返回,挂到超时;在 CI 上表现为构建号一直停在 building 状态。
排查
看 build/build.js,webpack 的回调只做了打印:
webpack(webpackConfig, function (err, stats) {
// ... 只打印 stats,没有退出逻辑
})
webpack1 的编译器会持有长驻的事件循环句柄,回调执行完进程也不会自己退出。老的交互式使用感受不到这个问题,CI 里"命令是否退出"就是成功判定本身。
解法
在回调末尾补一行退出:
webpack(webpackConfig, function (err, stats) {
// ... 原有打印逻辑
process.exit(err ? 1 : 0)
})
复盘
凡是给 CI 用的构建脚本,"能跑完"和"能退出"是两件事。检查脚本里有没有悬挂的 watcher、server、keep-alive 句柄,是移植老项目时的固定动作。
坑五:ENOSPC: no space left on device
现象
某次 npm install 中途直接失败:
npm ERR! code ENOSPC
npm ERR! syscall write
npm ERR! errno -28
npm ERR! Error: ENOSPC: no space left on device
排查
第一反应是 node_modules 太大,其实跟依赖没关系——先查盘:
df -h /
构建机根分区已经满。空间被历史残留吃掉:maven 缓存、CI 工作区的旧任务残留、以及一轮轮发布攒下来的 .bak.* 备份包。
解法
分类清理。缓存和旧工作区按保留策略删,备份包只留最新 1–2 个:
ls -1t *.bak.* | tail -n +3 | xargs -I{} rm -f {}
清不出来就换构建节点,别硬调 npm 参数。
复盘
"install 写不进磁盘"的报错,根因九成在文件系统不在包管理器。排这类错先看 df -h,再决定要不要动构建工具。
坑六:devDependencies 整体失踪
现象
换机后 npm run build 起手就报找不到 shelljs / ora / webpack——这些明明都在 package.json 里。
排查
npm ls webpack 显示没装。对比环境发现装机脚本里 export 了 NODE_ENV=production,而 npm 在该模式下默认跳过 devDependencies,npm install 只装 prod deps。构建链本身全在 devDependencies 里,等于整条流水线被环境变量卸载了。
解法
构建前显式清掉环境变量并强制带上 dev 依赖:
unset NODE_ENV
npm install --include=dev --legacy-peer-deps
加 --legacy-peer-deps 是因为老项目依赖树按新 npm 的 peer 校验动不动就 ERESOLVE,老栈项目直接走 legacy 解析最省事。
复盘
同一份 package.json,在不同 NODE_ENV 下装出来的 node_modules 可以完全不同。构建环境里的全局环境变量要当作构建输入来审查。
坑七:npm 源失效,依赖拉不动
现象
npm install 长时间挂起后报连接失败,大量依赖拉不下来。
排查
npm config get registry 一查,项目残留的还是早已废弃的 registry.npm.taobao.org;新装机的 ~/.npmrc 又没指到当前生效的镜像。
解法
把源统一到当前可用的镜像并写入 ~/.npmrc:
npm config set registry https://registry.npmmirror.com/
复盘
老仓库里沉着的 registry 地址是"配置层面的技术债",环境迁移时必须重新核对一遍源地址,它不出现在 git diff 里,却能让整个 install 失败。
收尾:Node 版本本身怎么选
七个坑修完,我再回答"到底该用哪个 Node"这个问题:这类 webpack1 老项目,优先直接用 Node 16。它不是修坑的一部分,而是把坑整体变少——Node 17+ 引入 OpenSSL 3 后还会带来 ERR_OSSL 一类与业务代码完全无关的兼容报错。虽然 uglifyjs-webpack-plugin 解决了 ES6 压缩,但 Node 16 仍是老栈最稳的位置。服务器上的做法是保留多版本并让 PATH 精确指向:
ls -d /usr/local/node16 /usr/local/n/versions/node/*
# 构建环境 PATH 指向 /usr/local/node16/bin/node
最终验证:npm run build 在 Node 16 下 EXIT=0,产出 index.html + static/js/app.*.js / vendor.*.js + static/css 的完整 dist。
总结
回头给这类老项目立一张排查清单:
- 压缩器先换代:内置 UglifyJs 不认 ES6,换 [email protected],且不传
compress选项; - 依赖去
^:vxe-table 这类会拆包的库锁到当年真正在用的精确版本,npm view <pkg>@<ver> dependencies验明架构; - vue 与 vue-template-compiler 版本必须逐位一致;
- build.js 补
process.exit(err ? 1 : 0),CI 才有干净的退出码; - ENOSPC 先看
df -h,清缓存和旧备份,必要时换节点; - 构建前
unset NODE_ENV,防止 devDependencies 被整体跳过; - 重新核对 registry,废弃源直接指到当前镜像;
- 能选 Node 版本就选 16,别和 OpenSSL 3 的死磕。
整场排障里最值得记住的一条经验是:老项目依赖锁的是年代版 registry 的解析结果,用错 Node 大版本或换一台新装机时,错误信息会严重误导方向——它报"语法错误"你以为是 Node 不兼容,报"找不到模块"你以为是 webpack 配置坏了。先把报错归类到"语法解析、版本漂移、进程生命周期、环境状态"这四层里,再决定动哪个开关,连环报错就能一层一层剥掉。
评论