前端构建报错排查:从一堆看不懂的压缩堆栈开始
最让人头大的线上报错,不是能直接定位的异常,而是这种一眼看不出是谁写的堆栈:
1at t.default.a (app.7f3c2a1.js:1:88234) 2at e.value (app.7f3c2a1.js:1:91007) 3at t.render (app.7f3c2a1.js:1:44521)
t、e、a 这种名字在源码里根本不存在,它们是压缩混淆之后留下的残影。生产环境关了 devtool,构建产物没有 source map,报错平台拿到的只是一坨已经失去原始语义的代码。更麻烦的是,业务侧已经报上来“点保存没反应”,可你连具体是哪一个页面都说不出来。
这类问题光盯着堆栈很难有进展,必须顺着构建链路一起查:怎么从压缩后的偏移量反推出源文件,为什么线上没有 source map,本地断点为什么和构建产物对不上行。后面就按这条排查链路往下拆。
现场土办法:先把这条报错怼出人话
没有 source map,硬解也不是全无办法。压缩后的代码里,字符串常量、方法名这些不会被混淆,app.7f3c2a1.js:1:88234 里的 88234 是字符偏移量,我把这个文件下载下来,用编辑器打开,跳到这个偏移量附近,扒出上下文里没被混淆的字符串——那次是一个 .a 方法里读了个 row.id,附近有个字符串常量 '确认删除该记录?',全局搜这句文案,一下定位到是订单列表页的删除确认逻辑。
这招能顶用,但纯靠运气:混淆得越狠、字符串越通用,就越难反查。真正靠谱的做法是构建时生成 source map,把它单独存起来,报错平台上传原始堆栈时用它做一次"翻译",直接还原成源码文件名、行列号、变量名。我们项目当时压根没上这套,纯粹是因为一开始怕麻烦,devtool 直接设成了 false。这条报错逼着我把这块补上。
source map 到底是什么,为什么我们生产没有它
source map 是一份映射文件,记录压缩混淆后的代码位置和源码位置的对应关系。浏览器和错误监控平台靠它把一堆乱码堆栈还原回你写的那份 Vue 单文件组件、具体到第几行第几列。
webpack 用 devtool 这一个配置项决定要不要生成、生成什么样的 source map,选项一大堆,光是名字我数了一下有十几种。真正常用的没几个,取舍点主要是两个:构建速度和还原精度,这俩基本是反的,选哪个要看开发还是生产。
开发环境追求的是改一行代码、马上看到效果,还要能对上源码位置方便打断点,vue-cli-service serve 默认给的是 eval-cheap-module-source-map 这类"cheap"系列。cheap 的意思是只映射到行,不映射到列,也不映射 loader 转换前的原始代码(比如 Babel 转译前的 ES6 语法),速度快但精度打了折扣:
1module.exports = { 2 devtool: 'eval-cheap-module-source-map', // 开发环境常用 3}
生产环境完全是另一套考量。我们之前图省事直接设了 devtool: false,构建速度最快、产物体积最小,但代价就是这次这样——报错完全看不懂。年底翻了一圈资料和几个同类项目的配置,生产环境常见的其实是 source-map:
1module.exports = { 2 devtool: 'source-map', // 生产环境,独立 .map 文件 3}
这个选项会为每个 chunk 生成一份完整独立的 .map 文件,精度最高,能还原到源码的每一行每一列。代价是构建慢一截、产物目录里多出一堆 .js.map 文件。这些 .map 文件本身不小,一个几百 KB 的业务 chunk,配套的 map 文件可能比它还大。
一个更要命的问题:这些 map 文件到底能不能扔到 CDN 上
捋清楚该开哪个 devtool 之后,我在群里问了句"那咱们把 .map 文件也一起发布上去?",运维直接否了:source map 里是完整的、未混淆的源码,如果跟 JS 文件一起发到公网 CDN,任何人打开浏览器 DevTools 的 Sources 面板,都能直接看到你没压缩过的原始代码,包括接口地址、字段命名、甚至一些写在注释里的临时逻辑说明。这跟直接把源码仓库公开没什么两样。
正确的做法是"两份产物":构建时正常生成 .js.map,但发布上线的静态资源目录不包含它们,只把 .map 文件单独归档,上传到内部的错误监控平台(我们用的那家支持在报错详情页做 source map 反解析,上传接口专门吃这个)。之后线上报错进来时,监控平台自己拿对应的 map 去反解析,展示给我们的是还原后的源码堆栈;普通用户在浏览器里看到的 JS 文件依然是压缩过的,翻不出 map。
webpack 里有个 SourceMapDevToolPlugin 能更细粒度地控制这件事,比直接写 devtool: 'source-map' 灵活,可以指定 map 文件不出现在最终 bundle 的 //# sourceMappingURL 注释里,避免浏览器顺着这个注释自动去请求 map:
1const webpack = require('webpack') 2 3module.exports = { 4 devtool: false, 5 plugins: [ 6 new webpack.SourceMapDevToolPlugin({ 7 filename: '[file].map', 8 append: false, // 不在产物里追加 sourceMappingURL 注释 9 }), 10 ], 11}
append: false 之后,map 文件照样生成到输出目录,但线上 JS 文件里不会带那行指向它的注释,普通用户的浏览器不会去主动拉取;map 文件走独立的上传流程发给监控平台。这套配置我们当天晚上改完就上线了,第二天再出报错,监控平台上看到的已经是带文件名和行号的干净堆栈。
顺手解决的第二个怪事:本地断点和构建后对不上
改完 source map 那天,实习生凑过来问我另一件事:她在 Chrome DevTools 里对着开发环境打了个断点,调试得好好的,可测试环境是走完整 build 流程部署的,同一处逻辑她在生产模式的产物里打断点,代码却对不上——断点停在了一个完全不相关的位置。
这个问题的根子是开发服务器和真实构建产物根本不是一回事。webpack-dev-server 为了热更新速度,很多东西是拿 eval 包出来直接跑在内存里的,从没真正落盘生成完整文件,devtool 也常是前面说的 cheap 系列,映射比较粗;而 npm run build 出来的是真正压缩混淆过的静态文件,经过了完整的 Babel 转译、UglifyJS 压缩、CSS 抽离等一整套流程。开发环境跑得再顺,也不代表构建产物里各个模块的执行顺序、代码形态是完全一致的——开发服务器的实时预览和最终构建产物之间,永远存在一层"不完全等价",调试生产问题必须用生产的产物去复现,而不是拿开发环境的表现去类推。
给她的建议是:测试环境部署的既然是 build 产物,配的 devtool 只要不是 false,浏览器 Sources 面板里就能看到还原后的源码文件,直接在这份还原出来的源码上打断点,效果跟开发环境基本一致。她那次对不上,是因为测试环境当时用的还是没上 source map 之前的旧配置,Sources 里只有压缩后的 app.js,她对着压缩代码猜的行号,自然和实际执行对不上。配置改完,同一个断点问题在测试环境上一次就复现了。
处理完这条报错,我把构建报错这块的排查顺序整理了一遍
那条 Cannot read property 'a' of undefined 到这儿算是彻底解决了:定位到订单列表删除逻辑里一处没做空值判断,修完发布。但这次折腾也让我想起这一年里攒下的一堆构建报错的坑,趁着年底一起理一遍——跟 source map 这条线不完全是一回事,但都是"构建产物出问题、命令行一片红"这个大类。
先看第一条有效错误。命令行里可能刷很多错误,但最重要的是第一条。比如:
1Module not found: Error: Can't resolve '@/utils/request'
这通常是路径别名或文件路径问题。不要被后面一堆连锁错误带偏,webpack 的报错经常是雪崩式的——一个模块解析失败,依赖它的模块全都跟着报,最后命令行底部那条往往是"X modules failed"这种汇总信息,看着吓人,其实没什么信息量。真正有用的是最上面那条带具体路径或具体语法的错误。终端刷得太快看不全,就重定向到文件慢慢看:
1npm run build > build.log 2>&1
注意要加 2>&1,否则 stderr 的内容不会进文件,而 webpack 很多报错恰恰走的是 stderr。还有一类容易看走眼的:warning 和 error 混在一起,DeprecationWarning、peer dependency 之类的黄字看着烦但不会让构建失败。判断标准很简单——构建退出码非 0 才是真挂了。
确认命令,别拿错误的环境去排错误的问题
先看 package.json 里跑的是哪个 script:
1{ 2 "scripts": { 3 "dev": "vue-cli-service serve", 4 "build": "vue-cli-service build" 5 } 6}
有些项目还分 build:test、build:prod,环境不同、配置不同,不要拿测试环境命令去排生产问题。我见过一个典型乌龙:npm run dev 一直好好的,npm run build 报错,于是怀疑是 webpack 生产配置有问题折腾半天,最后发现是 build 命令里多了个 --mode production,触发了 NODE_ENV=production,项目里有段代码只在生产模式下才会走到,那段代码本身有 bug。dev 正常、build 报错,很多时候不是构建工具的事,是两种模式跑的是不同分支的代码——这跟前面说的"开发环境和构建产物不完全等价"其实是同一类问题的两个面。
另外,vue-cli-service 这类命令装在 node_modules/.bin 里,npm scripts 会自动把这个目录加进 PATH,scripts 里直接写命令名就行;但想在终端手动跑,得用 npx vue-cli-service serve,直接敲命令名多半提示 command not found,这个坑新人几乎都踩过。
依赖版本:升级半个 loader 引发的血案
构建报错经常来自依赖版本不匹配,webpack、webpack-cli、babel-loader、vue-loader、sass-loader 之间都有版本关系。先看最近是否升级过依赖:
1git diff package.json
lockfile 变了也要一起看,团队项目里不要随意删除它,这是保证大家依赖一致的重要文件。我吃过一次亏:本地装依赖没用 npm ci 而是 npm install,npm 顺手把 package-lock.json 里某个间接依赖的版本往上抬了一个小版本,我没注意就提交了,那个小版本里 node-sass 的预编译二进制和 CI 的 Node 版本对不上,CI 直接挂。从那以后我们团队定了规矩:CI 和需要复现问题时一律用 npm ci,它严格按 lockfile 安装,lockfile 和 package.json 对不上就直接报错,不会悄悄改。
判断版本是不是元凶,看依赖之间的兼容矩阵,比如 sass-loader 版本和 webpack、node-sass 的搭配是有讲究的,老项目(webpack 4 时代)不要手贱去升 loader 大版本——这些 loader 的 major 版本经常跟着 webpack major 走,单独升一个,配置项可能整个变了。peerDependencies 警告也别一律忽略:
1npm WARN [email protected] requires a peer of @babel/core@^7.0.0 but none is installed
这说明装了对应 Babel 7 的 babel-loader@8,但项目里可能还留着 Babel 6 的包。Babel 6 和 7 的包名、配置文件、preset 名字全变了,混在一起构建必炸,这种 peer 警告其实是提前给你报警。
loader 报错:先看后缀,别先怀疑业务代码
如果报错里出现 Module build failed,接着看是哪一个 loader。例如 sass 相关:
1Module build failed: Error: Cannot find module 'node-sass'
那不是业务 JS 问题,是 Sass 依赖问题。Babel 报错重点看语法和配置:
1Support for the experimental syntax 'decorators' isn't currently enabled
说明用了装饰器语法,但 Babel 插件没配,得去 .babelrc 或 babel.config.js 加 @babel/plugin-proposal-decorators,而且装饰器和 class properties 两个插件的顺序、legacy 选项还得对得上。
还有一类隐蔽的:第三方库没被转译。默认很多项目 babel-loader 会 exclude: /node_modules/,意思是不编译依赖包,但有些库直接发 ES6+ 源码不发编译好的 ES5,进了 bundle 又被跳过,开发能跑(现代浏览器认 ES6),一到用 UglifyJS 压缩就炸:
1ERROR in app.js from UglifyJs 2Unexpected token: keyword «const» [vendor.js:xxxx]
UglifyJS(webpack 4 默认那套)不认 ES6 语法,遇到 const、箭头函数就报 Unexpected token。解决办法要么把这个库从 exclude 里捞出来单独 babel 一遍:
1{ 2 test: /\.js$/, 3 use: 'babel-loader', 4 exclude: /node_modules\/(?!(some-es6-lib)\/).*/ 5}
要么换成支持 ES6 的压缩器 terser-webpack-plugin,省得维护这个白名单正则。判断到底是哪个 loader 出问题,看报错里的文件后缀和紧跟着的模块路径就行,.scss 找 sass 链,.vue 找 vue-loader,.js 找 babel,别一上来就怀疑业务代码,很多时候业务代码一个字没动,是 loader 这条流水线断了。
路径大小写:macOS 和 Linux 之间的分歧
macOS 文件系统通常大小写不敏感,Linux 服务器可能敏感。本地能跑,CI 报错:
1import UserList from './components/userList'
实际文件是 UserList.vue,大小写不一致,线上就可能失败。这个坑我专门栽过:本地 macOS 默认大小写不敏感(HFS+/APFS),./userList 和 ./UserList.vue 都能 resolve 到同一个文件,开发一路绿灯,CI 跑在 Linux 上文件系统大小写敏感,直接报 Module not found。更坑的是 git 默认 core.ignorecase=true,在 macOS 上把文件改名成大小写不同的名字,git 可能压根没感知到改名,提交上去远程还是旧名字。
后来加了个 case-sensitive-paths-webpack-plugin,让本地 webpack 也强制大小写敏感:
1const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin') 2 3module.exports = { 4 plugins: [new CaseSensitivePathsPlugin()] 5}
加上之后本地构建会在大小写对不上时直接报错,把 CI 才暴露的问题提前到本地,这是性价比最高的一个防护,新项目我都顺手装上。
路径别名解析不到
@/utils/request 这种别名解析失败也很常见,原因通常是构建配置和编辑器/TS 配置两边没对齐。webpack 里得配 resolve.alias:
1const path = require('path') 2 3module.exports = { 4 resolve: { 5 alias: { 6 '@': path.resolve(__dirname, 'src') 7 }, 8 extensions: ['.js', '.vue', '.json'] 9 } 10}
extensions 也要留意:没把 .vue 加进去,import Foo from '@/components/Foo' 不带后缀就 resolve 失败。vue-cli 默认配好了,但手写 webpack 配置的老项目经常漏。还有一种是 IDE 报红但构建正常,那是编辑器读的是 jsconfig.json 的 paths,跟 webpack 的 alias 是两套,得分别配。
环境变量:读到 undefined 十有八九是前缀或没重启
构建时读不到环境变量也会报错,比如 process.env.VUE_APP_API_BASE 拿到 undefined。vue-cli 有个硬规矩:只有 VUE_APP_ 前缀的变量才会被注入到客户端代码里,NODE_ENV 和 BASE_URL 是特例。我见过有人在 .env 里写 API_BASE=xxx,代码里读 process.env.API_BASE,构建出来永远是 undefined,排查半天才想起前缀的事(create-react-app 同理,前缀是 REACT_APP_)。
读到 undefined 往往被拼进 URL 变成 http://undefined/api/...,请求直接挂,这种时候别盯着请求层看,往上倒一步看变量从哪来的。我习惯在构建时打一行:
1console.log('API_BASE =', process.env.VUE_APP_API_BASE)
确认值是不是真的注进来了。CI 上尤其要查,因为本地的 .env.local 通常不提交,CI 环境根本没有这个文件,得靠 CI 平台的环境变量配置或者构建脚本显式注入。还有个容易忽略的点:.env 文件改了之后,dev server 不会热更新这些变量,必须重启 npm run dev,我有好几次改了 .env 死活不生效,最后发现是没重启服务。
顺带一句和这次 source map 事故同一个性质的提醒:前端环境变量进入浏览器后不是秘密,VUE_APP_ 的东西最终都会明文出现在打包后的 JS 里,F12 一搜就有。密钥、内部地址这类一律走后端代理,前端只拿一个对外的接口地址——道理和"map 文件不能扔 CDN"是一回事:构建产物最终交到用户浏览器手里的东西,都要按"完全公开"来对待。
不要盲目重装,重装解决不了配置问题
可以重装依赖,但要知道为什么。合理场景是 node_modules 损坏、切换分支后依赖变化、lockfile 更新、Node 版本切换;不合理场景是路径写错、loader 配错、语法插件缺失、环境变量缺失——这些问题重装解决不了。真要重装,删干净一点:
1rm -rf node_modules 2rm -rf node_modules/.cache 3npm ci
node_modules/.cache 专门拎出来说,因为 babel-loader、cache-loader、hard-source 都会往里写缓存,有时候代码改了但构建结果还是旧的,或者出现一些"不可能"的报错,清掉这个缓存就好了。这种诡异问题我一般先清缓存,再考虑动 node_modules。
Node 版本这个隐形变量
很多构建报错的根源其实是 Node 版本。node-sass 是重灾区,它依赖预编译的二进制,跟 Node 的 ABI 强绑定,换了 Node 大版本原来装好的 node-sass 就用不了:
1Error: Missing binding /path/node_modules/node-sass/vendor/.../binding.node 2Node Sass could not find a binding for your current environment
这时候 npm rebuild node-sass 通常能解决。团队里 Node 版本不统一是个长期隐患,A 用 12、B 用 10,装出来的二进制对不上,互相 pull 完就构建失败。我们在根目录放了个 .nvmrc 写死版本,CI 也锁 Node 版本:
1# .nvmrc 212.13.0
配合 nvm use 就能保证大家跑在同一个 Node 上,这一步看着不起眼,省下的扯皮时间是实打实的。
回到那条报错
年底翻回头看这条 Cannot read property 'a' of undefined,它教会我的东西比修复本身值钱:构建报错和线上报错其实是两类问题,但排查思路是相通的——都得先拿到"人话"版本的错误信息,再一层层往下缩小范围,而不是凭感觉猜。这一年攒下来的固定动作是:读第一条有效错误 → 看是不是自己刚改的东西(git diff、git stash 试一下)→ 判断是路径、语法、loader 还是环境问题 → 实在定位不到再清缓存、查 Node 版本,最后才考虑重装。构建报错这样走,线上压缩报错也照这个思路走,只是第一步从"读命令行"换成了"配好 source map,让错误监控平台帮你翻译"。
把"重装"和"猜"从第一反应挪到最后一步,是我这一年在排查这类问题上最大的长进。年底这最后一篇写完,明年这块估计还会继续踩坑,但至少现在看到一条乱码堆栈,我不会再对着它发十分钟呆了。