凌晨两点零六分手机在床头柜上震得桌子都在响。电话那头领导的声音有点哑“线上首页挂了用户截图都发到群里了。”我一边摸黑开电脑一边在心里默念别慌先看报错。那一刻的感觉做过前端的都懂——测试环境明明跑得好好的为什么一上线就炸这种问题不是一次两次这些年我经手过的前端报错加起来没有上百也有几十类。今天这篇我就把那些让人掉头发的前端报错按我的经验重新梳理一遍包括它们怎么分类、怎么定位、怎么修以及怎么从源头让线上不要再动不动就白屏。不管是刚入门的新人还是被线上问题追着跑的负责人这中间一定有你能直接拿去用的东西。1. 先给报错分个类五大门类每一种的应对姿势都不同很多人一看到页面出问题就慌其实前端报错翻来覆去就那么几类。我习惯把这些报错按“死法”分成五类运行时错误、构建与依赖错误、环境差异错误、数据链路错误、性能资源类问题。分类的目的不是做学问是为了确定下一步往哪个方向查——方向错了再猛敲代码也白搭。1.1 运行时错误最熟悉的“白屏制造机”运行时错误是用户真正在浏览器里碰到的那类特征是脚本执行到一半突然崩溃。最常见的几种ReferenceError: xxx is not defined——变量没定义就拿来用低级但频发。TypeError: Cannot read properties of undefined (reading list)——对象还没拿到就取属性接口数据没回来时最容易触发。TypeError: xxx is not a function——调了一个不存在的方法常见于第三方SDK还没加载完成就被调用。这类报错的特点是控制台会给出文件名和行号但注意很多时候报错的位置并不是根因位置。比如报错在渲染函数里真正的问题可能是数据源在更早的环节就断了。所以看到运行时错误第一反应是顺着调用栈往上翻而不是盯着报错那一行改。1.2 构建与依赖类错误还没上线就让人崩溃这一类发生在打包构建阶段用户看不到但前端自己躲不掉。典型场景包括Module not found: Cant resolve xxx、webpack或vite的loader配置报错、依赖包版本冲突导致构建产物异常。还有一种比较隐蔽——本地构建正常但CI持续集成环境构建失败多半是环境变量或者依赖锁定文件lockfile没同步。这类报错我最大的体会是依赖锁定文件必须提交到代码仓库。不锁版本今天build好明天某个依赖发了新版本就可能把你的构建炸掉。这不是危言耸听我踩过不止一次。1.3 环境差异类错误本地跑得好好的线上就炸这类最邪门也是“血泪史”含量最高的。本地一切正常测试环境一切正常一上生产就白屏。原因往往集中在几个地方接口域名本地代理配的是dev环境线上请求打到不存在或鉴权不一致的域名。环境变量VITE_API_BASE_URL这类变量在不同环境下没配对或者构建时被覆盖。浏览器版本用户用的是老版本浏览器不支持可选链?.或者某个新的APIJS直接解析失败。时区与地区日期格式化、货币符号在不同地区表现不同严重时直接触发异常。这类问题我给的土办法是本地环境尽量模拟线上配置不要把localhost的代理习惯带到生产验证里。同时构建产物里打一个环境信息水印版本号、构建时间、环境标识线上出问题时你能很快判断用户访问的是哪个版本。1.4 数据链路错误接口一变更页面跟着崩数据链路错误表面上也是运行时错误根因却在前后端的数据契约上。最经典的场景后端说好返回{ code: 0, data: { list: [...] } }结果某个异常分支返回了{ code: 500, data: null }前端没做兜底直接res.data.list.map(...)白屏。还有一种更隐蔽字段类型变了原来id是字符串某天后端改成数字前端拿来做字符串拼接拼出来全是[object Object]。这种问题光靠前端防御是不够的我后面会单独讲怎么从协议层面减少这类事故。1.5 性能资源类问题看着不像报错其实最致命这类最容易被忽视因为控制台通常没有红色报错但用户体感就是“卡死”“白屏”“点了没反应”。常见的有内存泄漏定时器没清理、全局事件监听没解绑、闭包引用堆叠最后页面越来越卡。主线程阻塞大数组循环、大规模DOM操作、同步执行耗时任务直接卡死渲染。资源加载超时图片、脚本、字体长时间不返回页面看着就像挂了。我处理过一个案例页面每隔几秒卡顿一次查了半天发现是一个隐藏的setInterval每500ms执行一次高开销计算用户不操作它也跑。这种问题不报错但比报错更伤用户信任。为了更直观我把上面的分类整理成一张表方便大家对照报错类别典型特征出现时机首要排查方向运行时错误控制台红色报错有具体文件和行号页面初始化、用户交互调用栈、数据是否就绪构建依赖错误构建过程中断依赖找不到CI或本地打包依赖版本、锁定文件、环境变量环境差异错误本地正常、线上崩溃特定环境或特定浏览器环境配置、浏览器兼容性数据链路错误运行时崩溃但根因在数据格式接口返回异常时接口契约、兜底逻辑性能资源问题无报错但卡顿/白屏持续运行或特定操作主线程任务、资源加载、泄漏2. 排查报错的底层逻辑先复现再定位最后才动手改很多人拿到报错第一件事就是改代码这是最大的误区。我的原则是复现不了的问题不配被修改。连报错都稳定复现不了你改完代码也无法验证是否真的修好了那和闭着眼睛修车有什么区别。2.1 能在本地复现这个问题就已经解决了80%复现有三层思路。第一层看报错信息里的URL和用户操作路径在本地用同样的数据、同样的步骤走一遍。第二层如果涉及接口数据直接在Network面板里找到那次请求的响应把它存成JSON文件本地mock出来再走页面流程。第三层实在复现不了就把用户浏览器版本、操作系统、网络环境、登录状态全部列出来一个变量一个变量地排除。我见过最惨烈的案例是某个用户反馈“上传图片就报错”开发本地怎么传都没事。后来拿到用户的图片文件才发现那张图的分辨率是12000x8000前端的压缩逻辑只处理了文件大小没处理分辨率Canvas处理超大图时直接内存溢出。像这种问题不复现到像素级根本猜不到根因。2.2 console、network、sourcemap三件套定位用的黄金组合本地复现以后定位就靠浏览器开发者工具的三件套。Console面板看报错信息、堆栈、自己打的日志。注意用console.error标记关键分支比console.log更容易在日志海里找到。Network面板筛出红色的失败请求看状态码、响应体、请求头。接口报错时响应体里往往有后端给的错误信息那是定位的第一手资料。Sources面板 sourcemap这是线上问题定位的神器。线上代码是压缩混淆过的没sourcemap的话报错堆栈就是一团乱码。开启sourcemap后浏览器会自动把压缩代码映射回源码你就能看到原始TS/Vue文件里的出错位置。2.3 偶现问题的“现场重建术”把用户环境搬回本地最麻烦的是偶现问题——十次里面出现一两次本地怎么点都没事。我常用的现场重建方法是用DevTools的Network面板的“离线”模式或自定义网络限速模拟弱网环境。用chrome.throttling模拟CPU降速重现低端机上的卡顿和超时。用Chrome的“设备模拟”切到用户的实际机型再看表现。如果是时间相关的bug直接改系统时间很多时间边界问题立刻现出原形。有一个案例印象很深用户反馈“偶尔提交订单页面卡死”我们复现了整整两天最后发现是用户手机时间比真实时间快了12分钟前端的倒计时组件算出负值走了异常分支直接死循环。这种问题没有重建现场的思路根本不可能定位到。2.4 sourcemap让线上压缩代码“说人话”很多同学不知道线上报错的堆栈里出现VM1074或者a.b.c is not a function这种完全看不懂的信息是因为代码被压缩混淆了。解决方法是构建时生成sourcemap文件并单独上传到监控平台或保留在服务器上注意不要把sourcemap文件放到公开目录否则源码直接暴露。构建配置里以Vite为例// vite.config.js export default defineConfig({ build: { sourcemap: process.env.NODE_ENV production ? hidden : true } })hidden模式的意思是生成sourcemap但不为代码文件添加注释引用这样浏览器默认不加载只有监控平台拉取时才可用。配合Sentry这类工具线上报错的堆栈能直接映射回源码定位时间从小时级压缩到分钟级。3. 拆四个高频到让人头秃的报错——从根因到修复这一节我们把四个出现频率高、杀伤力大的报错完整拆开看看它们背后的真实嘴脸。3.1 “xxx is not a function”的三张脸这个报错可以说是前端界的“熟客”但它背后至少有三种完全不同的病因每种的处理方式都不同。第一张脸第三方SDK还没加载完就调用场景页面接入某个地图SDK或埋点SDK在script标签异步加载后业务代码立即调用window.xxx.init()但脚本还没下载完于是报window.xxx is not a function。解法是先确认SDK加载完成再调用或者在入口处做全局函数检测而不是用setTimeout硬等——setTimeout的时长在弱网下根本不可控。第二张脸对象里根本没有这个方法常见于后端返回的数据结构和前端预期不一致。比如后端返回的对象是{ data: { rows: [...] } }前端却拿res.data.list.filter(...)list不存在filter自然也是undefined。解法分两层前端判断数据存在后再调用更根本的是和后端明确接口契约字段增减必须提前同步。第三张脸打包后的this指向问题这个更阴。用class定义的方法里用到了this但在事件回调或定时器里把它直接传出去调用this就丢了。比如class ApiClient { fetchData() { return request(/api/data) } } // 某处调用 const fetch apiClient.fetchData // this丢了 fetch() // 报 TypeError: this.xxx is not a function解法是绑定this要么用箭头函数要么用bind要么在调用处保持对象方法调用的写法。3.2 接口返回null首页直接白屏这是数据链路错误的重灾区。页面初始化时请求用户信息接口正常时返回大头像、昵称、等级可一旦用户未登录或后端异常返回的data就是null。前端代码写的是user.avatar于是整个页面崩溃。我见过一个非常典型的订单详情页事故接口在某种边界条件下返回了一个不存在的字段组合前端三处链式调用每一处都假设数据一定存在结果一处崩溃整个页面都没了。防御方案并不复杂// 安全取值 const avatar user?.avatar ?? defaultAvatar const list res?.data?.list ?? []关键是要形成习惯所有来自接口的数据默认都是可能为空的。这个认知一旦建立白屏率能降一半。更进一步的方案是在请求封装层统一拦截非预期结构比如function request(url, options) { return fetch(url, options).then(res { if (!res.ok) throw new Error(HTTP ${res.status}) const data await res.json() if (data.code ! 0) throw new Error(data.message || 业务错误) return data.data }) }这样业务代码拿到的永远是纯净的data为空时走错误分支统一处理而不是等到属性读取时报错。3.3 竞态问题请求回来晚一步正确数据被覆盖前端竞态是很多人容易忽略、杀伤力又极大的问题。最典型的是搜索场景用户在搜索框里输入“苹果”然后改成“香蕉”但“苹果”的请求比“香蕉”的请求返回得慢最后页面展示的是“苹果”的结果而搜索框里写的是“香蕉”。用户体验极其割裂。还有一个更严重的场景tab切换加载不同列表前一个tab的请求后返回把后一个tab的数据覆盖了。这种问题一旦发生几乎是不可自愈的用户只能刷新页面。我推荐的方案有两个。一是用AbortController取消过期请求const controller new AbortController() fetch(/api/search?keyword keyword, { signal: controller.signal }) .then(...) .catch(err { if (err.name AbortError) return // 忽略主动取消 }) // 切换关键词时 controller.abort()二是用一个请求序号标记只接受最新一次请求的结果let requestSeq 0 async function search(keyword) { const seq requestSeq const res await fetch(/api/search?keyword keyword) const data await res.json() if (seq requestSeq) { renderList(data) } }实测下来AbortController能省流量请求序号方案更简单易懂两者可以结合用。另外搜索框本身也要做防抖300ms就够既减少请求量也降低竞态概率。3.4 Vue computed报错容易被忽略的依赖陷阱Vue的computed报错不太常见但一旦出现就很费解。最常见的是两句话Computed property xxx was assigned to but it has no setter和Maximum recursive updates exceeded。第一个报错是你给一个只有getter的computed赋了值。比如computed: { fullName() { return this.firstName this.lastName } } // 某处写成 this.fullName 张三computed默认是只读的你要么改依赖的源数据要么显式定义setter。这个报错本身不难解难的是你往往在不知道哪行代码给它赋值的状态下排查这时候用vue-devtools的computed面板看它的依赖和变更记录是最快的路径。第二个报错Maximum recursive updates exceeded本质是computed的依赖链出了问题比如一个computed A依赖了另一个computed BB又反向依赖A形成循环更新或者computed内部直接修改了它依赖的数据源触发了无限递归。处理方式很简单把computed里对数据的赋值操作挪到methods或watcher中保证computed是纯函数式的。**记住一条原则computed只做计算不产生副作用。**这条做到了90%的computed诡异报错都能避免。4. 从“能跑”到“稳如老狗”线上容错与监控的实战思路把报错修好只是底线让线上长期稳定才是目标。这一节讲的是工程化手段核心是四件事错误边界、日志上报、缓存版本控制、灰度与回滚。4.1 错误边界别让一个小报错拖垮整个页面JS运行时错误有个特点一个未被捕获的异常可能让整个页面停止渲染。尤其在React或Vue这种组件化框架里一个小组件的渲染出错如果没有边界处理整棵树都会挂掉。React有官方提供的ErrorBoundary错误边界class ErrorBoundary extends React.Component { state { hasError: false } static getDerivedStateFromError() { return { hasError: true } } componentDidCatch(error, info) { reportError(error, info.componentStack) } render() { if (this.state.hasError) return FallbackUI / return this.props.children } }Vue则提供了errorHandler和errorCaptured钩子。我强烈建议至少给核心模块详情页、支付页加错误边界这样即使某个组件出问题页面整体框架还在用户至少能看到其他信息而不是直接白屏。另外错误边界内部的内容要设计得体——一个友好的占位图、一段说明文字、一个刷新按钮都比裸白屏强一百倍。4.2 日志上报用户在骂你之前先让系统告诉你用户永远比你先发现报错这是最被动的局面。要让系统比用户更早知道线上出了问题就必须做前端日志上报。推荐使用开源的Sentry或自建轻量上报通道。上报的核心信息包括报错信息和堆栈含sourcemap映射后的源码位置用户操作路径哪个页面、哪个按钮、经过哪些路由浏览器版本、操作系统、屏幕分辨率关键业务上下文比如订单号、商品ID发生时间点往前数30秒的console日志这里有个细节容易被忽略不要上报所有报错要有采样率和去重策略。否则某个低端机上的一个循环报错一天能把你的存储费用刷爆。先聚合后上报每类错误每秒最多上报一条既能定位问题又省钱。4.3 线上缓存与版本发布用户为什么就是看不到新版本前端上线最容易被骂“没生效”的场景就是用户浏览器缓存了旧资源。改了半天代码上线用户还是白屏或旧页面。问题根源通常是index.html被浏览器或CDN缓存用户拿到的还是旧入口引用的还是旧JS。新版HTML引用了新的JS文件名但CDN节点还没同步完部分用户拿到新HTML配旧资源直接404或版本不匹配。我采用的稳妥方案是HTML文件设置Cache-Control: no-cache保证每次请求都回源验证。静态资源JS/CSS/图片文件名带hash比如app-8f3a2b.js内容变化hash就变天然平滑更新。CDN的TTL设置合理别设成7天。通常JS/CSS设24小时到72小时配合hash文件名用户最多晚一小时拿到新版资源。还有一类缓存坑是localStorage和sessionStorage——业务版本升级后旧缓存数据格式与新版代码不兼容也会触发运行时报错。上线的同时要清理或迁移旧存储结构这个很多人会漏。4.4 灰度、监控、回滚线上稳定的最后三道防线再怎么测试也不代表线上100%没问题。这时候靠的是三道防线。第一道是灰度发布。新版本只发给5%的流量观察半小时如果没有报错峰值、没有核心接口失败率上升再逐步放大比例。灰度能天然帮你挡掉大多数“线上才会出现”的环境差异问题。第二道是监控告警。建议监控三类指标页面JS错误率、接口失败率、白屏率。白屏率用页面根节点的DOM数量来做近似判断少于阈值就是白屏。告警不能只发到群里要有值班机制否则警报响了没人看等于没报。第三道是回滚预案。每次上线的发布单必须带着回滚方案——是切旧版本镜像还是回退CDN资源版本尽量一键完成。很多团队到出问题时才发现回滚不等于把代码切换回旧版本还要处理数据库迁移、缓存刷新、第三方依赖联动结果回滚比上线还慢。预案一定要提前演练。5. 几个土办法没有很高深但关键时刻能救命最后聊几个我这些年沉淀下来的“土办法”不加高大上的包装但关键时刻真能救命。**第一个保留现场比修复更重要。**线上出问题很多人第一反应是赶紧清缓存、刷新页面结果现场被破坏了报错也消失了后面无从查起。正确的做法是先收集现场截图、控制台报错、Network请求记录、用户操作步骤把这些固定下来再清理。**第二个别单打独斗要拉上后端一起看。**很多前端报错看似是前端代码问题根子在接口返回结构变了、字段语义变了、状态码不对。遇到前端报错而数据可疑时我从来都是直接找后端同学把请求报文和响应报文甩过去问一句“这里为什么返回这个”。十次里有八次后端看一眼就能定位。**第三个把复杂case写成回归测试。**每解决完一个线上事故不要急着翻下一篇。花十分钟把那类问题写成一个最小复现case固化成测试用例。电竞赛场有句话叫“胜败乃兵家常事但复盘是进步的根源”前端也一样。我现在的项目里有一半的测试用例是拿历史事故改的效果比任何理论说教都好。**第四个简化依赖能不用就不用。**很多报错的来源是第三方库的兼容问题。新项目里我尽量少引依赖尤其是那些很久不更新的、维护者不活跃的库。少一个依赖等于少一类报错来源。有时候自己写一个简单的工具函数比引一个300KB的库稳得多性能也更好。前端报错这东西说到底躲不掉但可以靠认知和方法降低它的破坏力。希望这篇血泪史里记录的弯路和教训能帮你少秃几根头发。