前端老鸟血泪史:搞不定报错?这套错误处理策略让你少加三天班!
前端老鸟血泪史:搞不定报错?这套错误处理策略让你少加三天班!
开篇先唠两句
别划走,我知道你刚被控制台那一堆红字搞崩了心态。那种看着满屏飘红的绝望,我懂——上周三凌晨两点,我盯着生产环境那个 Cannot read properties of undefined 的错误,手边的咖啡已经凉透,脑子里只有一个念头:这破代码到底是谁写的?哦,是三个月前的我自己。
咱们今天不整那些虚头八脑的理论,就聊聊怎么让那些该死的 Uncaught TypeError 闭嘴,顺便让你的代码健壮得像穿了防弹衣。说实话,错误处理这事儿吧,新手觉得麻烦,老鸟觉得保命。我见过太多项目,功能做得花里胡哨,一报错直接白屏,用户体验瞬间归零,产品经理的脸色比控制台还黑。
所以啊,这篇文章就是把我这些年踩过的坑、流过的泪、加过的班,统统打包成一份"防猝死指南"。你照着做,不一定能让你代码零 bug,但至少能让你睡个安稳觉。
到底啥是前端错误处理
简单说就是给代码买个保险。浏览器不会惯着你,用户更不会。一旦脚本报错,页面白屏或者按钮点了没反应,产品经理能顺着网线过来打你——这话真不是夸张,我亲眼见过隔壁组的老哥因为线上白屏十分钟,被拉去开了三小时的复盘会。
前端这地方,出错的姿势简直千奇百怪。你以为只是自己写的业务逻辑会崩?太天真了。网络抖一下,接口返回个 null,用户手贱按了 F12 删掉某个 DOM 元素,甚至浏览器插件瞎注入脚本,都能让你的页面当场去世。所以咱们得把全局错误、资源加载失败、异步 Promise rejection 这些"定时炸弹"都认全了,别等炸了才想起来拆弹。
先说说全局错误。浏览器提供了一个 window.onerror 这个老古董,虽然 API 设计得有点反人类,但确实是最后一道防线。然后是那些 Promise 里没 catch 住的 rejection,现在满世界都是 async/await,一个 await 忘了包 try-catch,控制台就给你飘红。还有资源加载失败——图片挂了、CSS 丢了、JS 文件 404 了,这些都不会冒泡到 window.onerror,得用 window.addEventListener('error', ...) 专门监听。
最阴间的是那种第三方脚本搞出来的错。你接了个统计 SDK,或者埋了个广告位,他们代码一崩,你的页面跟着陪葬,找谁说理去?所以错误处理不是可选项,是刚需,是底线,是让你能安心下班的护身符。
扒开底层看看怎么抓错
光知道有错不行,得知道怎么逮住它。这节咱们把几个核心 API 和机制掰开了揉碎了讲,代码管够,注释写满,复制粘贴就能用。
window.onerror 这老伙计
说实话,window.onerror 的 API 设计得挺离谱的,参数顺序跟别的 Web API 完全不是一个路数,但谁让人家辈分高呢?这玩意儿能捕获大部分同步错误,是全局监控的基石。
// 基础版:先能跑起来再说
window.onerror = function(message, source, lineno, colno, error) {
console.log('抓到一个错误:', {
message, // 错误信息字符串,比如 "Uncaught TypeError: xxx is not a function"
source, // 出错的文件 URL
lineno, // 行号
colno, // 列号
error // 错误对象,里面堆栈信息最值钱
});
// 返回 true 可以阻止错误冒泡到控制台,但生产环境建议别瞎阻止,不然调试更难
return false;
};
// 进阶版:带点实际功能的
window.onerror = function(msg, url, line, col, error) {
// 过滤掉一些无关痛痒的扩展插件错误,不然日志里全是垃圾
if (url && url.includes('chrome-extension')) {
return true; // 这种错误直接吞了,爱谁谁
}
// 构造错误报告对象
const errorInfo = {
type: 'javascript',
message: msg,
filename: url,
position: `${line}:${col}`,
stack: error?.stack || 'no stack',
// 顺便捞点环境信息,排查问题时候救命用
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
// 如果用了性能监控,可以把当前页面加载时间也带上
timing: performance?.timing ? {
domReady: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
loadTime: performance.timing.loadEventEnd - performance.timing.navigationStart
} : null
};
// 发送到监控系统,这里用 console 模拟一下
console.error('[全局错误捕获]', errorInfo);
// 上报到服务器
reportError(errorInfo).catch(e => {
// 上报失败也得记一下,别死循环
console.warn('错误上报失败:', e);
});
return false; // 让错误继续抛出来,方便开发时看到
};
// 模拟上报函数
async function reportError(info) {
// 实际项目中这里换成你的上报接口
// 比如 Sentry.captureException 或者自研的日志服务
await fetch('/api/log/error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
// 错误上报要用 keepalive,防止页面关闭时请求被掐断
keepalive: true
});
}
注意几个坑:第一,window.onerror 捕获不到 Promise 的错误,那是另一个故事;第二,跨域的脚本错误,如果不配 crossorigin 属性,你拿到的信息只有 Script error,跟没抓一样;第三,返回值 true 能阻止默认处理(就是控制台不飘红了),但生产环境建议别乱用,不然真出问题了你连调试信息都看不到。
unhandledrejection 专门治各种不服
现在谁还不用 Promise 或者 async/await?这玩意儿用爽了,错误处理也更容易翻车。一个 async 函数里 await 了七八个接口,中间哪个抛异常没包住,控制台就给你来个 Uncaught (in promise) Error。
// 基础监听
window.addEventListener('unhandledrejection', function(event) {
// event.reason 就是 rejected 的值,可能是 Error 对象,也可能是个字符串,甚至是个 undefined
console.error('有 Promise 没被 catch:', event.reason);
// 阻止默认行为(控制台飘红),看情况决定要不要阻止
// event.preventDefault();
});
// 生产环境实用版本
window.addEventListener('unhandledrejection', function(event) {
const reason = event.reason;
// 构造错误信息,得兼容各种奇奇怪怪的 rejection 值
let message = 'Unhandled Promise Rejection';
let stack = '';
if (reason instanceof Error) {
message = reason.message;
stack = reason.stack;
} else if (typeof reason === 'string') {
message = reason;
} else {
// 有些人喜欢 reject 个对象,比如 reject({code: 500, msg: 'error'})
try {
message = JSON.stringify(reason);
} catch(e) {
message = String(reason);
}
}
const errorInfo = {
type: 'unhandledrejection',
message: message,
stack: stack,
// 如果能拿到触发错误的源头,记下来
source: event.target?.src || 'unknown',
timestamp: new Date().toISOString()
};
console.error('[未捕获的 Promise 错误]', errorInfo);
reportError(errorInfo);
// 这里建议不要 preventDefault,让错误继续暴露,开发时能及时发现
});
// 还有个对应的 handledrejection,用来监听那些被补 catch 的错误
// 这个一般用得少,主要是做统计或者清理工作
window.addEventListener('rejectionhandled', function(event) {
console.log('刚刚那个错误被 catch 住了:', event.reason);
});
这里有个血泪教训:很多开发者喜欢在 async 函数里一把梭,所有 await 都不包 try-catch,指望全局监听兜底。这想法很危险,因为 unhandledrejection 触发的时候,你的代码执行上下文可能已经丢了,想做错误恢复或者重试都没戏。所以全局监听只能是最后一道防线,不能当主力用。
try-catch 的手动挡模式
有些逻辑你得自己包起来,特别是那些第三方库调用或者复杂的业务计算。别指望浏览器全包圆,它没那个义务。
// 基础用法,地球人都会
try {
const result = JSON.parse(userInput);
processData(result);
} catch (error) {
console.error('JSON 解析失败:', error);
showToast('数据格式不对,请检查输入');
}
// 但是 async/await 里的 try-catch 有点讲究
async function fetchUserData(userId) {
try {
// 这里如果 fetchUser 抛错,会被 catch 住
const user = await fetchUser(userId);
// 如果 fetchOrders 抛错,也会被 catch 住
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (error) {
// 问题来了:这里怎么知道是哪个接口挂了?
// 简单业务还好,复杂业务得做错误分类
if (error.name === 'NetworkError') {
showToast('网络开小差了,戳这里重试');
// 记录网络错误,可能是用户信号不好
logError({ type: 'network', error });
} else if (error.response?.status === 404) {
showToast('用户不存在');
// 业务错误,可能需要跳转 404 页面
router.push('/404');
} else {
// 未知错误,抛给上层或者全局处理
throw error;
}
}
}
// 更优雅的错误处理封装
// 定义一个 Result 类型,类似 Rust 或者 Go 的错误处理风格
class Result {
constructor(ok, data, error) {
this.ok = ok;
this.data = data;
this.error = error;
}
// 成功时返回数据,失败时返回默认值
unwrapOr(defaultValue) {
return this.ok ? this.data : defaultValue;
}
// 链式处理
map(fn) {
return this.ok ? new Result(true, fn(this.data), null) : this;
}
}
// 包装异步操作,让它永不抛错,总是返回 Result
async function safeAsync(promise) {
try {
const data = await promise;
return new Result(true, data, null);
} catch (error) {
return new Result(false, null, error);
}
}
// 使用示例,代码清爽多了
async function loadPageData() {
const userResult = await safeAsync(fetchUser());
if (!userResult.ok) {
console.error('获取用户失败:', userResult.error);
return { error: 'user_fetch_failed' };
}
const ordersResult = await safeAsync(fetchOrders(userResult.data.id));
// 即使 orders 挂了,user 数据还在,可以部分渲染
return {
user: userResult.data,
orders: ordersResult.unwrapOr([]) // 订单挂了显示空数组,别白屏
};
}
Vue 和 React 的专属结界
现代框架都给了组件级的错误边界,这简直是救命稻草。组件挂了不至于全站崩盘,用户至少能看到个"出错了"的友好提示,而不是整页白屏。
Vue 的 errorHandler 配置:
// Vue 2 的全局配置
Vue.config.errorHandler = function(err, vm, info) {
// err: 错误对象
// vm: 出错的组件实例
// info: 错误信息,比如 "render function" 或者 "v-on handler"
console.error('Vue 捕获到错误:', {
error: err.message,
component: vm?.$options?.name || 'anonymous',
info: info,
// 可以拿到组件的 props 和 data,排查问题很有用
props: vm?.$options?.propsData,
data: vm?._data,
// 组件调用栈
trace: vm?.$options?.__file || 'unknown file'
});
// 上报错误
reportError({
type: 'vue',
message: err.message,
stack: err.stack,
component: vm?.$options?.name,
info: info,
timestamp: Date.now()
});
};
// Vue 3 稍微变了一下,但思路一样
import { createApp } from 'vue';
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
// 逻辑和上面差不多,instance 是组件实例
console.error('[Vue3 Error]', err, info);
// 这里可以配合全局状态管理,显示个错误提示组件
// 比如把错误信息塞到 Pinia 或者 Vuex 里
useErrorStore().setError({
message: '页面渲染出错了,刷新试试?',
detail: err.message
});
};
// Vue 还提供了 warnHandler 专门抓警告,开发环境很有用
app.config.warnHandler = (msg, instance, trace) => {
// 警告一般不上报,但开发时可以打印详细点
console.warn('[Vue Warning]', msg, trace);
};
React 的 Error Boundary 稍微麻烦点,得用类组件:
import React, { Component } from 'react';
import PropTypes from 'prop-types';
// 错误边界必须是类组件,React 官方说的,函数组件暂时不行
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
// 静态方法,返回新的 state,用于渲染降级 UI
static getDerivedStateFromError(error) {
// 下次渲染时显示备用 UI
return { hasError: true, error };
}
// 组件DidCatch,用于记录错误信息
componentDidCatch(error, errorInfo) {
// errorInfo 包含 componentStack,能看到是哪个组件树出的问题
console.error('ErrorBoundary 捕获到错误:', {
error: error.toString(),
componentStack: errorInfo.componentStack,
// 可以记录当前的路由信息
pathname: window.location.pathname,
// 用户信息,如果有的话
userId: localStorage.getItem('userId')
});
this.setState({
errorInfo: errorInfo
});
// 上报错误
reportError({
type: 'react',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString()
});
}
// 提供重置功能,让用户能尝试恢复
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
// 可以调用父组件传入的重置回调
this.props.onReset?.();
};
render() {
if (this.state.hasError) {
// 自定义降级 UI
return (
<div className="error-fallback" style={{
padding: '40px',
textAlign: 'center',
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: '8px'
}}>
<h2 style={{ color: '#cf1322' }}>😅 哎呀,页面出错了</h2>
<p style={{ color: '#595959', margin: '16px 0' }}>
别慌,错误已经被记录下来,我们会尽快修复
</p>
{/* 开发环境显示详细错误,生产环境隐藏 */}
{process.env.NODE_ENV === 'development' && (
<details style={{
marginTop: '16px',
padding: '16px',
background: '#fff',
borderRadius: '4px',
textAlign: 'left',
whiteSpace: 'pre-wrap',
fontSize: '12px',
fontFamily: 'monospace'
}}>
<summary>查看错误详情(仅开发环境)</summary>
<p style={{ color: '#cf1322' }}>{this.state.error?.toString()}</p>
<p>{this.state.errorInfo?.componentStack}</p>
</details>
)}
<button
onClick={this.handleReset}
style={{
marginTop: '20px',
padding: '8px 16px',
background: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
点我重试
</button>
</div>
);
}
// 正常渲染子组件
return this.props.children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
onReset: PropTypes.func
};
// 使用方式,把可能出错的组件包起来
function App() {
return (
<ErrorBoundary onReset={() => window.location.reload()}>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user" element={<UserPage />} />
</Routes>
</Router>
</ErrorBoundary>
);
}
// 甚至可以嵌套使用,不同区域独立降级
function Dashboard() {
return (
<div className="dashboard">
<ErrorBoundary>
<UserProfile /> {/* 用户卡片挂了不影响下面 */}
</ErrorBoundary>
<ErrorBoundary>
<DataChart /> {/* 图表挂了不影响其他 */}
</ErrorBoundary>
</div>
);
}
Vue 和 React 的错误边界都不是万能的,它们捕获不到以下错误:事件处理函数里的错误(得自己 try-catch)、异步代码(setTimeout、Promise 等)、服务端渲染的错误、错误边界自身抛出的错误。所以全局的 window.onerror 和 unhandledrejection 还是得留着,双保险。
这套方案到底香在哪又有啥坑
没有银弹,真的。这句话我得说三遍。任何技术方案都是权衡,错误处理也不例外。
爽点
用户体验直接起飞。以前页面一报错就白屏,用户一脸懵逼,刷新也没用,只能关掉页面重进。现在至少能给用户看个友好的"出岔子了"页面,配个可爱的错误插画,加个重试按钮,用户知道发生了什么,也知道能做什么,焦虑感大大降低。
监控平台能收到第一手日志。以前用户来反馈"页面打不开",问半天也问不出个所以然,只能盲猜。现在错误信息、用户操作路径、设备环境自动上报,修 bug 快如闪电。我上次靠这个定位到一个只在 iOS 13 出现的兼容性问题,从收到报警到修复上线只用了 20 分钟。
代码健壮性提升是实打实的。写错误处理的过程,其实也是梳理业务边界的过程。你会发现很多之前没考虑到的异常情况,比如网络超时、数据格式不对、权限不足等等。把这些都处理了,代码质量自然上去。
槽点
代码量肯定得涨。到处都要包 try-catch,看着确实烦。有时候一个简单的函数,业务逻辑就三行,错误处理得写十行。但这就是 trade-off,想睡得安稳就得付出这个代价。
误伤问题很头疼。有些错误你其实希望它抛出来,比如开发时的类型错误,或者一些不该被吞掉的致命错误。但全局监听一股脑全抓了,导致问题更难复现。所以得做好错误分级,致命的直接抛,可恢复的才吞。
性能上嘛,稍微有一丢丢损耗。try-catch 会阻止某些引擎的优化,但在现代浏览器里基本可以忽略不计。除非你在超热点的代码路径里疯狂 try-catch,否则不用太担心。
还有个隐形坑:错误处理代码本身也可能出错。比如上报接口挂了,或者错误处理逻辑里有 bug,那就套娃了。所以错误处理也要尽量简单,别搞太复杂的逻辑。
真实项目里怎么落地
别光说不练,来看看实际场景。这几个例子都是我项目中真实用过的,复制粘贴改改就能用。
接口请求翻车了咋办
axios 拦截器里统一处理 401、500 这些状态码,别让每个组件都写一遍判断逻辑。这活儿我干了太多次了,标准答案如下:
import axios from 'axios';
import { message } from 'antd'; // 或者你用的 UI 库
import { useUserStore } from '@/stores/user'; // Pinia/Vuex 用户状态
// 创建实例
const instance = axios.create({
baseURL: process.env.VUE_APP_API_URL,
timeout: 10000, // 10 秒超时,别太长,用户等不及
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器:加 token,加 loading
instance.interceptors.request.use(
config => {
// 自动带上 token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 可以在这里显示全局 loading
// 但建议只在特定请求里显示,不然太闪了
if (config.showLoading) {
window.$loadingBar?.start();
}
return config;
},
error => {
// 请求发不出去(比如网络断了),直接进这里
console.error('请求发送失败:', error);
return Promise.reject(error);
}
);
// 响应拦截器:错误分类处理
instance.interceptors.response.use(
response => {
// 关闭 loading
if (response.config.showLoading) {
window.$loadingBar?.finish();
}
// 如果后端统一包装了响应,比如 { code: 0, data: xxx, message: 'ok' }
// 这里可以统一处理 code 不为 0 的情况
const { code, data, message: msg } = response.data;
if (code !== 0) {
// 业务错误,比如参数校验失败
message.error(msg || '操作失败');
return Promise.reject(new Error(msg));
}
return data; // 直接返回数据,省得每层都 .data
},
error => {
// 关闭 loading
if (error.config?.showLoading) {
window.$loadingBar?.error();
}
// 这里处理 HTTP 错误和业务错误
if (error.response) {
// 服务器有响应,但状态码不对
const { status, data } = error.response;
const errorMsg = data?.message || '服务器开小差了';
switch (status) {
case 400:
message.error(`请求参数错误: ${errorMsg}`);
break;
case 401:
message.error('登录已过期,请重新登录');
// 清除登录态,跳转登录页
useUserStore().logout();
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
break;
case 403:
message.error('没有权限执行此操作');
// 可以记录一下,看看是不是有人想越权
console.warn('权限不足:', {
url: error.config.url,
method: error.config.method,
user: localStorage.getItem('userId')
});
break;
case 404:
message.error('请求的资源不存在');
break;
case 500:
case 502:
case 503:
message.error('服务器繁忙,请稍后重试');
// 这种错误得上报,可能是后端挂了
reportError({
type: 'server_error',
status: status,
url: error.config.url,
response: data
});
break;
default:
message.error(`未知错误: ${status}`);
}
} else if (error.request) {
// 请求发出去没收到响应,网络问题或者超时
if (error.code === 'ECONNABORTED') {
message.error('请求超时,请检查网络后重试');
} else {
message.error('网络连接失败,请检查网络设置');
}
} else {
// 其他错误,比如请求配置错了
message.error('请求配置错误');
console.error('Axios 配置错误:', error.message);
}
return Promise.reject(error);
}
);
// 封装一下,加上重试机制
async function requestWithRetry(config, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await instance(config);
} catch (error) {
lastError = error;
// 只有特定错误才重试,比如网络错误或者 503
const shouldRetry = !error.response || error.response.status >= 500;
if (!shouldRetry || i === maxRetries - 1) {
throw error;
}
// 指数退避,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
console.log(`第 ${i + 1} 次重试...`);
}
}
throw lastError;
}
export default instance;
export { requestWithRetry };
图片视频加载不出来
给 <img> 标签加个兜底图,别让页面裂开一个大口子,丑死了。这个看似简单,但细节很多:
// React 组件示例
import React, { useState } from 'react';
import PropTypes from 'prop-types';
function SafeImage({ src, alt, fallback, ...props }) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(false);
// 默认兜底图,可以是个 base64 的占位图
const defaultFallback = 'data:image/svg+xml;base64,PHN2Zy...'; // 省略 base64
const handleError = (e) => {
// 防止无限循环:如果兜底图也挂了,就别再触发了
if (e.target.src !== (fallback || defaultFallback)) {
setError(true);
console.warn('图片加载失败,切换到兜底图:', src);
}
};
const handleLoad = () => {
setLoaded(true);
};
return (
<div style={{ position: 'relative', ...props.style }}>
{/* 加载中的骨架屏 */}
{!loaded && !error && (
<div
className="skeleton"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: '#f0f0f0',
animation: 'pulse 1.5s ease-in-out infinite'
}}
/>
)}
<img
src={error ? (fallback || defaultFallback) : src}
alt={alt}
onError={handleError}
onLoad={handleLoad}
style={{
...props.style,
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s',
// 如果图片本身有错误,显示红色边框提示(开发环境)
border: process.env.NODE_ENV === 'development' && error ? '2px solid red' : 'none'
}}
{...props}
/>
{/* 错误提示,可以 hover 显示 */}
{error && process.env.NODE_ENV === 'development' && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(255,0,0,0.8)',
color: 'white',
fontSize: '12px',
padding: '4px',
display: 'none' // hover 时显示
}} className="error-hint">
原图加载失败: {src}
</div>
)}
</div>
);
}
// Vue 版本
export default {
name: 'SafeImage',
props: {
src: String,
alt: String,
fallback: String
},
data() {
return {
error: false,
loaded: false
};
},
computed: {
currentSrc() {
return this.error ? (this.fallback || this.defaultFallback) : this.src;
}
},
methods: {
handleError() {
if (!this.error) {
this.error = true;
this.$emit('error');
}
},
handleLoad() {
this.loaded = true;
this.$emit('load');
}
},
template: `
<div class="safe-image-wrapper">
<img
:src="currentSrc"
:alt="alt"
@error="handleError"
@load="handleLoad"
:class="{ 'image-loaded': loaded, 'image-error': error }"
/>
<div v-if="!loaded && !error" class="image-placeholder">
加载中...
</div>
</div>
`
};
第三方脚本挂了对抗策略
比如统计代码或者广告脚本挂了,绝对不能影响主业务流程。我见过太多项目,因为百度统计或者 Google Analytics 的脚本加载失败,导致页面其他功能也崩了,得不偿失。
// 动态加载脚本,带超时和错误处理
function loadScript(src, options = {}) {
const {
timeout = 5000, // 最多等 5 秒
async = true, // 默认异步加载
crossOrigin = false, // 是否跨域
onError = () => {} // 错误回调
} = options;
return new Promise((resolve, reject) => {
// 检查是否已加载
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
resolve(existing);
return;
}
const script = document.createElement('script');
script.src = src;
script.async = async;
if (crossOrigin) {
script.crossOrigin = 'anonymous';
}
// 超时处理
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Script load timeout: ${src}`));
onError('timeout');
}, timeout);
const cleanup = () => {
clearTimeout(timer);
script.onload = null;
script.onerror = null;
};
script.onload = () => {
cleanup();
resolve(script);
};
script.onerror = () => {
cleanup();
reject(new Error(`Script load failed: ${src}`));
onError('load_error');
};
document.head.appendChild(script);
});
}
// 使用:加载第三方统计脚本,挂了也不影响主业务
async function initAnalytics() {
try {
// 尝试加载,但最多等 3 秒,挂了就算了
await loadScript('https://third-party-analytics.com/script.js', {
timeout: 3000,
onError: (type) => {
console.warn('统计脚本加载失败:', type);
// 记录到监控系统,看看是不是对方服务挂了
reportError({
type: 'third_party_script_error',
script: 'analytics',
errorType: type
});
}
});
// 初始化统计
window.Analytics?.init({ trackingId: 'UA-XXXXX' });
} catch (error) {
// 彻底失败,但主业务继续跑
console.warn('统计功能不可用,跳过');
}
}
// 更保险的方案:用 iframe 隔离(适用于广告等完全独立的第三方内容)
function loadThirdPartyInIframe(containerId, url) {
const container = document.getElementById(containerId);
if (!container) return;
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.sandbox = 'allow-scripts allow-same-origin'; // 限制权限
// iframe 加载错误不会影响到父页面
container.appendChild(iframe);
}
给用户看什么提示
别直接把错误堆栈甩给用户看,那是给开发看的。给用户得是人话,还得带点幽默感缓解尴尬。
// React 错误提示组件
function ErrorMessage({ type, onRetry }) {
const messages = {
network: {
icon: '📡',
title: '网络开小差了',
desc: '可能是您的网络在闹脾气,或者是我们的服务器在喝咖啡休息',
action: '戳我重试'
},
server: {
icon: '🔧',
title: '服务器正在维修',
desc: '我们的工程师已经被叫醒,正在火速赶来修 bug',
action: '刷新看看'
},
unknown: {
icon: '🤔',
title: '遇到了神秘错误',
desc: '这不应该发生...除非您发现了什么不得了的东西',
action: '再试一次'
}
};
const config = messages[type] || messages.unknown;
return (
<div style={{
padding: '40px 20px',
textAlign: 'center',
background: '#fafafa',
borderRadius: '12px',
maxWidth: '400px',
margin: '40px auto'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
{config.icon}
</div>
<h3 style={{
margin: '0 0 8px',
color: '#262626',
fontSize: '18px'
}}>
{config.title}
</h3>
<p style={{
margin: '0 0 24px',
color: '#8c8c8c',
fontSize: '14px',
lineHeight: 1.6
}}>
{config.desc}
</p>
<button
onClick={onRetry}
style={{
padding: '8px 24px',
background: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s'
}}
onMouseEnter={e => e.target.style.background = '#40a9ff'}
onMouseLeave={e => e.target.style.background = '#1890ff'}
>
{config.action}
</button>
{/* 小字提示,可以联系客服 */}
<p style={{
marginTop: '16px',
fontSize: '12px',
color: '#bfbfbf'
}}>
如果一直不行,可以联系客服小姐姐:400-xxx-xxxx
</p>
</div>
);
}
遇到诡异报错怎么顺藤摸瓜
线上报错最头疼的就是复现不了。用户说"点了一下就崩了",问点哪了、用的啥手机、网络好不好,一概不知。这时候日志系统就是你的福尔摩斯放大镜。
日志上报得有讲究
别一股脑全传,把用户操作路径、浏览器版本、网络状态这些关键信息带上:
// 用户行为追踪器,记录操作路径
class UserActionTracker {
constructor() {
this.actions = [];
this.maxLength = 20; // 最多记 20 步,防止内存爆炸
}
// 记录用户操作
track(action, data = {}) {
const record = {
action, // 操作类型:click、input、route-change 等
data, // 相关数据:点了哪个按钮、输入了什么
timestamp: Date.now(),
url: window.location.href,
// 如果是点击,记录点击坐标,用于热力图分析
position: data.event ? {
x: data.event.clientX,
y: data.event.clientY
} : null
};
this.actions.push(record);
// 超出长度就删掉老的
if (this.actions.length > this.maxLength) {
this.actions.shift();
}
}
// 获取最近的操作序列
getRecentActions() {
return this.actions;
}
// 清空
clear() {
this.actions = [];
}
}
// 全局实例
const actionTracker = new UserActionTracker();
// 自动追踪点击事件
document.addEventListener('click', (e) => {
// 找到最近的可交互元素
const target = e.target.closest('[data-track-id]') || e.target;
const id = target.getAttribute('data-track-id') || target.id || target.tagName;
actionTracker.track('click', {
element: id,
text: target.innerText?.slice(0, 50), // 只取前 50 字
event: e
});
});
// 追踪路由变化(React Router 示例)
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteTracker() {
const location = useLocation();
useEffect(() => {
actionTracker.track('route-change', {
path: location.pathname,
search: location.search
});
}, [location]);
return null;
}
// 错误上报时带上这些信息
function reportErrorWithContext(error) {
const errorInfo = {
// 基础错误信息
message: error.message,
stack: error.stack,
type: error.name,
// 环境信息
environment: {
userAgent: navigator.userAgent,
url: window.location.href,
referrer: document.referrer,
screen: `${window.screen.width}x${window.screen.height}`,
viewport: `${window.innerWidth}x${window.innerHeight}`,
language: navigator.language,
// 性能信息
memory: performance?.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize
} : null,
// 网络类型(如果支持)
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType,
downlink: navigator.connection.downlink
} : null
},
// 用户操作路径,排查问题时救命用
userActions: actionTracker.getRecentActions(),
// 时间戳
timestamp: new Date().toISOString(),
// 用户标识(如果有)
userId: localStorage.getItem('userId'),
sessionId: getSessionId() // 自己实现的会话 ID
};
// 发送到服务器
fetch('/api/log/error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorInfo),
keepalive: true // 确保页面关闭也能发出去
});
// 同时存一份到 localStorage,万一网络断了,下次恢复时补报
saveErrorToLocal(errorInfo);
}
// 本地存储错误,用于离线补报
function saveErrorToLocal(errorInfo) {
const key = 'pending_errors';
const pending = JSON.parse(localStorage.getItem(key) || '[]');
pending.push(errorInfo);
// 最多存 10 条,防止占满存储
if (pending.length > 10) {
pending.shift();
}
localStorage.setItem(key, JSON.stringify(pending));
}
// 页面加载时尝试补报
window.addEventListener('load', () => {
const pending = JSON.parse(localStorage.getItem('pending_errors') || '[]');
if (pending.length > 0) {
Promise.all(pending.map(err =>
fetch('/api/log/error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(err)
}).catch(() => null)
)).then(() => {
localStorage.removeItem('pending_errors');
});
}
});
Source Map 是神器
压缩后的代码看不懂?把 Source Map 配好,直接定位到源码行号。但这玩意儿也有讲究,不能无脑上传:
// webpack 配置示例
module.exports = {
// 开发环境生成完整 source map
devtool: process.env.NODE_ENV === 'development' ? 'eval-source-map' : false,
// 生产环境用隐藏 source map,单独生成文件但不打包到 bundle
// 这样用户下载的代码里没有 map,但监控系统可以用
...(process.env.NODE_ENV === 'production' && {
devtool: 'hidden-source-map',
// 或者上传到 Sentry 等第三方服务
plugins: [
new SentryWebpackPlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: 'your-org',
project: 'your-project',
// 只在构建成功后上传
release: process.env.GIT_COMMIT_SHA,
include: './dist',
urlPrefix: '~/'
})
]
})
};
// 如果不用 Sentry,自己搭建 source map 服务
// 需要注意:source map 文件可能很大,而且包含完整源码,要做好权限控制
// 建议内网访问或者加 token 验证,别直接暴露在公网
// 错误上报时,服务端根据 stack 里的行号列号,反查原始位置
// 可以用 mozilla 的 source-map 库
const { SourceMapConsumer } = require('source-map');
async function parseErrorStack(stack, sourceMapContent) {
const consumer = await new SourceMapConsumer(sourceMapContent);
const lines = stack.split('\n');
const parsedStack = lines.map(line => {
// 匹配 (url:line:column) 这种格式
const match = line.match(/\((.+):(\d+):(\d+)\)$/);
if (!match) return line;
const [, url, lineNum, colNum] = match;
const original = consumer.originalPositionFor({
line: parseInt(lineNum),
column: parseInt(colNum)
});
if (original.source) {
return line.replace(
`${url}:${lineNum}:${colNum}`,
`${original.source}:${original.line}:${original.column} (${url})`
);
}
return line;
});
consumer.destroy();
return parsedStack.join('\n');
}
本地重现大法
利用 Chrome 的断点调试和 Mock 数据,模拟弱网、慢速 CPU,主动把错误"造"出来再解决:
// 网络模拟工具
class NetworkSimulator {
// 模拟接口延迟或者失败
static async mockDelay(ms = 2000, shouldFail = false) {
await new Promise(resolve => setTimeout(resolve, ms));
if (shouldFail) {
throw new Error('Simulated network error');
}
}
// 模拟弱网(通过 Chrome DevTools 的 Network throttling 更真实,但代码里也能模拟)
static throttleRequest(promise, minDelay = 1000) {
return Promise.all([
promise,
new Promise(resolve => setTimeout(resolve, minDelay))
]).then(([result]) => result);
}
}
// 在业务代码里埋点,方便调试
async function fetchData() {
// 开发环境可以手动触发错误
if (process.env.NODE_ENV === 'development' && window.__SIMULATE_ERROR__) {
throw new Error('Simulated error for testing');
}
// 真实请求...
}
// Chrome 控制台里执行 window.__SIMULATE_ERROR__ = true 就能测试错误处理逻辑
监控平台怎么看
Sentry 或者自研平台,得学会看错误聚合趋势。是偶发还是必现,是哪个版本引入的,一眼定生死。重点关注这几个指标:
- 错误率趋势:突然飙升说明新版本有问题
- 影响用户数:同样 100 个错误,是 1 个用户触发的 100 次,还是 100 个用户各触发 1 次,严重程度完全不同
- 浏览器分布:是不是只在某个浏览器版本出现,比如 iOS Safari 的某个老版本
- 用户操作路径:复现步骤就靠这个还原
几个让老板眼前一亮的骚操作
全局兜底组件
做一个通用的 ErrorFallback 组件,哪里报错插哪里,样式统一还能自动收集信息。前面 React 的 ErrorBoundary 示例已经展示了,这里再给个 Vue 的组合式 API 版本:
<template>
<div v-if="hasError" class="error-fallback">
<div class="error-content">
<slot name="error" :error="error" :reset="reset">
<!-- 默认错误 UI -->
<h3>😅 这里加载出错了</h3>
<p>别担心,我们已经记录了这个问题</p>
<button @click="reset">重新加载</button>
</slot>
</div>
</div>
<slot v-else></slot>
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue';
const hasError = ref(false);
const error = ref(null);
onErrorCaptured((err, instance, info) => {
error.value = err;
hasError.value = true;
// 上报错误
reportError({
type: 'vue_component',
error: err.message,
component: instance?.$options?.name,
info
});
// 阻止错误继续传播
return false;
});
const reset = () => {
hasError.value = false;
error.value = null;
};
</script>
静默失败与重试机制
非核心业务报错了,悄悄记个日志然后自动重试三次,用户根本感知不到。比如埋点上报、非关键数据预加载这些:
// 静默任务管理器
class SilentTaskRunner {
constructor(options = {}) {
this.maxRetries = options.maxRetries || 3;
this.retryDelay = options.retryDelay || 1000;
this.tasks = [];
}
async run(taskFn, context = {}) {
let lastError;
for (let i = 0; i < this.maxRetries; i++) {
try {
return await taskFn();
} catch (error) {
lastError = error;
// 记录重试
console.warn(`任务失败,第 ${i + 1} 次重试:`, context);
if (i < this.maxRetries - 1) {
await this.delay(this.retryDelay * (i + 1)); // 递增延迟
}
}
}
// 彻底失败,记录到日志但不抛错
console.error('任务最终失败:', context, lastError);
reportError({
type: 'silent_task_failed',
context,
error: lastError.message,
retries: this.maxRetries
});
// 返回默认值或者 null,不影响主流程
return null;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// 使用:埋点上报,挂了也无所谓
const analyticsRunner = new SilentTaskRunner({ maxRetries: 3 });
async function trackEvent(eventName, data) {
await analyticsRunner.run(
() => fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ event: eventName, data }),
keepalive: true
}),
{ event: eventName, data } // 上下文,用于日志
);
}
防御性编程习惯
可选链操作符 ?. 和空值合并 ?? 用起来,少写一堆 if (a && a.b && a.b.c) 这种又臭又长的代码:
// 以前这么写,又丑又容易漏
const userName = user && user.profile && user.profile.name ? user.profile.name : 'Anonymous';
// 现在这么写,清爽
const userName = user?.profile?.name ?? 'Anonymous';
// 函数参数默认值结合空值合并
function createUser(options = {}) {
const name = options.name ?? 'New User';
const age = options.age ?? 18;
const settings = options.settings ?? {};
// 深层默认值
const theme = options.settings?.theme ?? 'light';
const notifications = options.settings?.notifications ?? true;
}
// 数组访问也安全了
const firstItem = array?.[0];
// 函数调用也安全
const result = obj?.callback?.(arg1, arg2);
类型检查不能省
TypeScript 虽然不能杜绝运行时报错,但能帮你挡掉一大半低级错误。特别是接口返回数据的类型定义,一定要写:
// 定义 API 返回类型,别用 any 一把梭
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
interface User {
id: string;
name: string;
email: string;
avatar?: string; // 可选字段
settings: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
// 严格类型检查,防止 undefined 混进来
async function fetchUser(id: string): Promise<User> {
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
if (res.data.code !== 0) {
throw new Error(res.data.message);
}
// 这里 res.data.data 就是 User 类型,有完整类型提示
return res.data.data;
}
// 运行时再加一层校验(比如用 zod 库),防止后端不按套路出牌
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatar: z.string().url().optional(),
settings: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
})
});
// 解析并校验数据
const validatedUser = UserSchema.parse(apiData);
最后碎碎念
行了,套路都教给你了。这套东西不是让你写代码的时候束手束脚,而是让你能睡个安稳觉。我见过太多前端兄弟,白天写功能写得飞起,晚上做梦都在想"那个接口会不会报错"、“那个第三方脚本会不会挂”。
说实话,错误处理这事儿,前期确实要多写点代码,多费点脑子。但等你真正遇到线上问题,靠这些机制快速定位、快速修复、用户几乎无感知的时候,你会感谢现在的自己的。那种从容不迫的感觉,比啥都强。
要是下次上线还因为没做错误处理被测试打回票,或者半夜被报警电话叫醒,那可真不能怪我没提醒过你。赶紧去把项目里的 console.error 都换成正经的处理逻辑吧,毕竟头发是自己的,少熬点夜比啥都强!
哦对了,还有个小建议:定期 review 你的错误监控系统。每周花半小时看看都报了啥错,哪些是已知问题,哪些是新增的,哪些是可以预防的。这习惯养成了,你的代码质量会肉眼可见地提升。别等到错误堆积成山了才想起来看,那时候就晚了。
就这样,去干活吧!💪


513

被折叠的 条评论
为什么被折叠?



