做这件事的起点是一个很具体的窘境:用户反馈「页面白了」,我们完全无法复现。他用什么机型、什么浏览器、哪个版本的代码、报了什么错,一概不知。只能让客服要截图,然后猜。
接了一个最简单的 window.onerror 上报之后,问题变成了另一种:拿到的堆栈是压缩后的乱码——a.js:1:52831 t is not a function,完全无法定位。而且很快发现更多缺口:
一是 window.onerror 抓不全。Promise 里的异常不走它(要监听 unhandledrejection)、Vue 组件内的错误被 Vue 自己的错误边界吞了(要挂 app.config.errorHandler)、图片和脚本加载失败压根不是 JS 错误(要在捕获阶段监听 error 事件)。四个来源,缺一个就有一类问题永远看不到。
二是上报把自己搞挂了。某次上线有个循环里的报错,单个用户一分钟上报了几千次,上报接口被打满,其他项目的监控也一起瞎了。监控本身必须是安全的,不能成为故障源。
三是告警按错误总量触发,全是噪音。总量每天几千条(大部分是浏览器插件、爬虫、老旧机型的固有报错),阈值设高了漏报,设低了天天响,最后没人看。
所以四个设计点:五类数据源全覆盖、sourcemap 还原但不泄露源码、上报侧做限流去重、告警按「新增」而非「总量」。
window.onerror 抓同步异常;unhandledrejection 抓未处理的 Promise 拒绝(现在的异步代码里这类占比很高);框架的 errorHandler 抓组件渲染和生命周期里的错误(这些会被框架捕获,不会冒泡到 window);资源加载失败必须在捕获阶段监听,因为资源的 error 事件不冒泡,用 addEventListener('error', fn, true) 的第三个参数为 true 才拿得到。这四个的区别是高频考点。sendBeacon 而不是 fetch。页面卸载时 fetch 可能被浏览器取消,而 sendBeacon 是浏览器保证在后台发送的,且不阻塞页面卸载。「用户看到错误就关页面」是很常见的行为,这时候的上报最容易丢。为什么自建而不直接用现成的监控服务。现成服务(Sentry 之类)功能完善,如果条件允许应该优先用。我们自建的原因是数据合规——业务覆盖多个国家,用户相关的数据出境有限制,把包含用户标识和行为轨迹的数据发到第三方平台需要走合规评审。自建的代价很实在:要自己做数据存储、聚合、查询界面、sourcemap 管理,工作量不小,而且功能远不如成熟产品。面试时要诚实说这个取舍——如果没有合规约束,我会直接用现成的,自建不是为了炫技。
用户标识的采集要脱敏且有边界。需要一个标识来串联同一用户的多条上报(否则无法判断影响了多少人、无法还原完整轨迹),但不能上报手机号、邮箱这类可直接识别个人的信息。做法是用后端下发的匿名 ID。面包屑里的输入内容一律不采集,只记「在某个输入框输入了」这个事件。
踩过的坑:sourcemap 误发布到 CDN。构建配置里开了 sourcemap,CI 直接把整个 dist 目录传上去了,源码在公网上挂了两周才被发现。修法是在 CI 里加一步显式删除并加校验(部署前检查产物里不含 .map 文件,有就中断部署)。这个坑的教训是:安全约束必须做成流水线里的自动检查,靠人记住一定会漏。
踩过的坑二:上报接口自己报错,形成死循环。上报失败时代码里有一个 catch 记录了错误,而这个记录动作又触发了上报,上报又失败,无限循环把浏览器卡死。修法是上报模块内部的所有异常一律静默吞掉,绝不再触发上报。监控代码必须是「绝对不会抛错也绝对不会重入」的,这是它的特殊要求。
没做的部分:没做录屏回放。它对复现问题极有帮助,但存储成本高、隐私风险大(会录到用户输入),需要严格的脱敏和用户授权,当时判断投入产出比不划算。
「错误可定位到源码位置」怎么验证:做一次故意的验证——在预发环境埋一个必然抛错的代码,走完整的构建发布流程,然后在监控平台上检查:能否看到这条错误、堆栈是否还原到了正确的源码文件和行号、上下文(版本、机型、面包屑)是否完整。这个端到端验证比任何数字都有说服力,而且能暴露 sourcemap 版本对不上之类的问题。
上报量:统计单会话的平均上报请求数与最大值。去重限流后控制在每会话数十次以内。要说清这个数字包含性能数据(采样后)和错误数据,只报错误数会显得很低但不完整。
「错误风暴被限流拦住」怎么验证:构造一个死循环报错的场景(在测试页面里故意写),观察实际发出的上报请求数是否被限流上限卡住、页面是否仍可用。这类保护机制必须演练过,说「我做了限流」但没验证过,一问「上限是多少、超了之后怎么处理」就答不上来。
不要报「错误率下降 X%」。建监控这件事本身不会降低错误率——它让你看见错误,短期内统计到的错误数反而会上升(因为以前看不见)。把「看见问题」说成「解决了问题」是很容易被问穿的。可以报的是「从发现问题到定位到具体代码的时间」这类过程指标。
window.onerror 抓同步的 JS 运行时异常。unhandledrejection 抓未被 catch 的 Promise 拒绝——现在的异步代码里这类占比很高,而它压根不会触发 onerror。框架的 errorHandler(Vue 是 app.config.errorHandler)抓组件渲染、生命周期、watcher 里的错误,这些被框架内部捕获了,不会冒泡到 window。资源加载失败(图片、脚本、样式 404)要用 addEventListener('error', fn, true),注意第三个参数必须是 true,因为资源的 error 事件不冒泡,只能在捕获阶段拿到。另外接口错误通常在请求库的拦截器里单独采集,因为它是「业务失败」不是「JS 异常」,需要的字段也不同。
.map 文件,有就中断发布——安全约束必须做成自动检查,靠人记一定会漏。
做这块的直接动因是一次事故:上线后发现严重 bug,回滚花了二十多分钟——因为回滚的方式是「把代码 revert 掉,重新走一遍 CI 构建部署」。这二十分钟里用户一直在用坏的版本。
顺着往下查,发现前端发布链路有一串问题。
一是缓存策略混乱。入口 HTML 被 CDN 缓存了几分钟,导致发布后一部分用户还是旧页面;而 JS 文件名没带哈希,浏览器缓存了旧文件,新旧代码混在一起跑出各种诡异错误。
二是单页应用发版后老页面白屏。用户页面开着没刷新,此时发了新版本,旧的资源块(chunk)在部署时被删掉了。用户点击一个还没加载过的路由,去请求那个已经不存在的 chunk,直接白屏。这类反馈很难复现——因为开发者刷新一下就好了。
三是没有灰度。每次发版都是全量,有问题就是所有用户一起中招。
所以三个设计:产物按内容哈希版本化 + HTML 不缓存(这一条同时解决了缓存混乱和秒级回滚)、旧版本资源保留一段时间 + 版本更新提示(解决白屏)、按比例灰度 + 灰度期指标观察。
核心认知是:前端的「回滚」本质上只是「让用户加载另一个版本的 HTML」。只要各版本的静态资源都还在 CDN 上,回滚就是改一个指针的事,不需要重新构建。想清楚这一点,回滚时间从分钟级降到秒级是必然的。
import() 会 reject,捕获后提示用户刷新页面(刷新就会拿到新的 HTML 和新的资源)。没有这个兜底,用户看到的就是纯白屏加控制台一行报错。CDN 上保留所有历史版本的成本。只增不删意味着文件持续累积。实际算下来前端产物本身不大(每个版本几百 KB 到几 MB),保留两周的量级完全可接受,存储成本远低于「回滚要等二十分钟」的代价。清理策略是定时删除超过保留期的版本,但要先确认这些版本已经没有活跃用户在用(可以从监控上报的版本号分布看)。
灰度粒度选用户而不是请求。按请求灰度(每次请求随机决定版本)实现更简单,但用户会在两个版本间来回跳,本地缓存的状态、已加载的资源、localStorage 里的数据格式都可能不兼容,会造成难以排查的问题。按用户哈希灰度保证一致性,代价是灰度比例的调整粒度较粗。
前端灰度和后端灰度必须协调,这是个容易被忽略的坑。新版本前端往往依赖新版本后端接口。如果前端先灰度而后端没上,灰度用户会调到不存在的接口;反过来后端先上而前端未更新,旧前端可能不兼容新的响应格式。做法是接口先做向后兼容的上线,前端再灰度——后端新接口上线但不影响旧接口,前端灰度切过去,全量后再考虑下线旧接口。这个顺序说清楚会明显加分。
踩过的坑:回滚了 HTML 但用户还是旧的(坏的)版本。因为 CDN 上 HTML 的缓存配置写的是几分钟强缓存,回滚后要等缓存过期。那几分钟里回滚等于没生效,当时非常慌乱。修法是把 HTML 改为不缓存,并且回滚流程里加一步主动刷新 CDN 缓存。教训是:回滚链路必须像发布链路一样被演练过,我们当时是第一次真正回滚才发现这个配置问题。
踩过的坑二:灰度时用随机数分流,用户版本乱跳。用户刷新一次进新版、再刷新回旧版,而新版本在 localStorage 里写了新格式的数据,旧版本读到后解析失败报错。修法是改用用户 ID 哈希取模,保证同一用户稳定命中同一版本。顺带的教训是:涉及本地存储格式变更的发布,必须考虑新旧版本共存期的兼容。
没做的部分:没做自动化的灰度决策。目前灰度比例的推进和异常时的回滚都是人工判断,理想状态是「指标异常自动回滚」。做这个需要对指标的置信度有足够把握,否则误触发的回滚本身就是故障,当时不敢做。
回滚耗时:实际演练——在预发环境走一遍完整回滚,从「决定回滚」到「验证用户拿到旧版本」计时。1 分钟以内。要说清这个时间包含什么:切换 HTML、刷新 CDN 缓存、验证。改造前的十几分钟是「revert 代码 + 等 CI 构建 + 部署」的实际记录(可以从 CI 的历史耗时里取)。
「白屏未再出现」怎么验证:依据是监控里「动态导入失败」这类错误的数量(模块一建的监控派上用场了)。改造前每次发版后这类错误都会出现一批,改造后被版本提示和兜底捕获消化掉。这是两个模块联动的例子,面试时可以主动串起来讲。
灰度的有效性:可以报「灰度期间拦下的问题次数」——即在灰度阶段发现异常并回退、没有影响全量用户的次数。这是个绝对数,口径清晰,而且直接证明灰度有价值。比说「我们做了灰度」有力得多。
不要报「发布成功率 100%」或「零故障发布」。绝对化断言一问就穿。正确的表述是「灰度阶段拦下 N 次问题」「回滚耗时从十几分钟降到 1 分钟以内」——承认问题会发生,但影响面和恢复时间被控制住了,这才是可信的工程叙事。
import() reject,页面白屏。三层解决:一是部署只增不删,旧 chunk 保留一段时间,这一条就消掉了大部分问题;二是版本更新提示——构建时把版本号写进一个小文件,页面定期拉取比对,不一致就提示用户刷新(不要强制刷新,用户可能正在填表单);三是捕获动态导入失败,作为最后兜底,捕获到就提示刷新。另一类问题是本地存储格式不兼容:新版本改了 localStorage 的数据结构,旧版本页面读不了或者反过来,所以涉及存储格式变更的发布要考虑共存期兼容。
起因很朴素:同一个「商品选择器」组件,在三个项目里各写了一遍,行为还不一致——一个支持多选一个不支持,一个有搜索一个没有。运营在不同后台看到的同一个功能长得不一样,来提需求说要统一。
更麻烦的是修 bug 要修三遍,而且总有一处漏掉。这类组件不是通用 UI 组件(那些用现成组件库就行),而是带业务语义的组件——商品选择器、门店选择器、地区级联、金额输入(要处理多币种和精度)、审核状态标签。它们只在自己公司有意义,外部组件库不会有。
第二个问题是规范靠人靠不住。约定了代码风格、约定了不许引入大依赖、约定了 sourcemap 不能发布,但这些都写在文档里,没有强制手段。结果就是每个人都遵守九成,加起来就有一成不遵守,几个月后代码风格五花八门、首包体积悄悄涨了一倍。
所以两件事:把重复的业务组件抽成有版本管理的内部库、把规范从文档变成 CI 门禁。第二件事的认知是关键:任何依赖自觉的规范都会退化,只有自动化检查能长期守住。
组件库会引入版本地狱,这是真实代价。三个项目依赖三个不同版本的组件库,修一个 bug 要判断影响哪些版本、要不要往老版本回合。缓解手段是设「最低支持版本」并主动推进升级——只维护最近的一到两个主版本,更老的不再修。但推进升级会占用业务排期,需要和团队达成共识,这不是技术问题而是协作问题。
门禁会引起团队摩擦,引入方式很重要。突然加一堆硬拦的检查,大家的感受是「被拖慢了」。我们的做法是先以警告模式运行一两周,让大家看到实际会拦下什么、有多少误报,调整规则之后再转成硬拦。而且要先沟通目的——门禁是为了防止大家的劳动被别人悄悄破坏(比如你辛苦优化的体积被下一个人一个依赖加回去),不是为了考核。说明「我怎么推动这件事落地」,比只说「我加了门禁」更能体现工程能力。
踩过的坑:组件库的一个小改动,把三个项目一起搞挂了。在一个「修 bug」的补丁版本里顺手改了某个 prop 的默认值(觉得原来的默认值不合理),发布后三个项目升级都出现了行为变化。教训是「什么算破坏性改动」的判断要保守——改默认值就是破坏性的,即使你觉得新的更合理。正确做法是加一个新的 prop 让业务方显式选择,老行为保持不变,并在文档里标记原行为将在下个主版本废弃。
踩过的坑二:抽得太早,组件参数设计不合理。看到两个项目有相似的表单就抽成了组件,结果第三个项目的需求略有不同,为了兼容加了一堆 props 和条件分支,组件内部变成了一坨 if,最后不得不拆回去重做。这就是为什么标准是「第三个项目要用时才抽」——只有见过三种真实用法,才知道哪些是共性哪些是差异。
没做的部分:没做组件库的可视化文档站(带在线示例和 props 说明的那种)。目前文档是 Markdown 加代码示例,业务方要看效果得自己跑起来。这明显影响接入体验,但搭文档站的工作量不小,一直排在后面。
「减少约 4000 行」:用 git diff --stat 统计抽组件库那几次改动的删除行数减去新增行数,而且必须把组件库自身的代码算进新增里。只报「删了 6000 行」不提「组件库写了 2000 行」是不诚实的,面试官一算就知道你在挑数字。报净减少。
「首包体积未因引入组件库而增长」:用构建分析工具对比接入前后的产物构成,确认只有实际用到的组件进了包。这个验证很重要——按需引入配错的话会把整个库打进去,体积反而暴涨。要说明是 gzip 前还是 gzip 后的体积。
「门禁拦下十余次」:从 CI 的失败记录里统计因体积检查失败的次数。这是个绝对数、口径清晰、可复现,而且直接证明门禁有价值——比说「我加了体积门禁」有说服力得多。可以顺带说出几个典型案例(某次引入了一个日期库导致体积涨了几十 KB,被拦下后改用原生 Intl)。
不要报「开发效率提升 30%」。这个数字算不出来——没有基线、没有可比的任务、受人员和需求复杂度影响。硬报一个效率提升的百分比是最容易被追问穿的一类数字。可以报的是具体的、可核查的事实:几个项目接入了、重复代码净减少多少行、门禁拦下多少次。
没有匹配的内容,换个关键词试试。
项目拆解 · 前端工程效能(Vue)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据