错误处理与异常模式
What — 是什么
错误处理是 JavaScript 运行时对异常状况的捕获、传递与恢复机制。它包含内置 Error 对象体系、try/catch/finally 同步捕获、Promise 异步错误传播、全局错误兜底以及函数式 Result 模式等策略。
Error 对象体系:
| 错误类型 | 触发时机 | 示例 |
|---|---|---|
Error | 通用错误基类 | throw new Error('something') |
SyntaxError | 代码解析阶段语法错误 | JSON.parse('{') |
TypeError | 值的类型不符合预期 | undefined.foo |
ReferenceError | 引用未声明的变量 | console.log(notExist) |
RangeError | 值超出允许范围 | new Array(-1) |
URIError | URI 编解码参数非法 | decodeURI('%') |
Error 实例核心属性:
const err = new Error('出错了');
console.log(err.name); // 'Error'
console.log(err.message); // '出错了'
console.log(err.stack); // 错误栈字符串,包含调用链和行号
自定义错误类:
class BusinessError extends Error {
constructor(code, message, extra = {}) {
super(message);
this.name = 'BusinessError';
this.code = code; // 业务错误码,如 'AUTH_EXPIRED'
this.extra = extra; // 附加信息
this.timestamp = Date.now();
// 修复原型链(ES6 继承 Error 的已知问题)
Object.setPrototypeOf(this, BusinessError.prototype);
}
}
throw new BusinessError('AUTH_EXPIRED', '登录已过期', { userId: 123 });
try/catch/finally 基本结构:
try {
// 可能抛出错误的代码
riskyOperation();
} catch (error) {
// 捕获并处理错误
console.error(error.message);
} finally {
// 无论是否出错都会执行(清理资源等)
cleanup();
}
关键特性:
- JavaScript 错误是”异常”(exception)机制:
throw抛出,catch捕获,未捕获则沿调用栈向上冒泡 - Error.stack 非标准但所有主流引擎都支持,V8/SpiderMonkey/JavaScriptCore 格式各异
- try/catch 只能捕获同步错误,异步错误需用 Promise.catch 或全局事件捕获
- ES2019 起 catch 绑定可省略:
try { ... } catch { ... }
Why — 为什么
适用场景:
- 网络请求失败、接口异常返回
- 用户输入校验不通过
- 第三方库抛出未知异常
- 资源加载失败(图片、脚本、样式)
- 异步任务超时或取消
不处理错误的后果:
- 用户体验差:白屏、功能无响应、无错误提示
- 调试困难:错误被静默吞掉,线上问题无法复现
- 数据丢失:未清理的状态导致后续逻辑混乱
- 安全隐患:错误信息泄露内部实现细节
核心价值:
- 健壮性:防御性编程,保证应用在异常情况下仍能优雅降级
- 可调试性:保留完整的错误栈和上下文信息
- 可观测性:错误上报让线上问题可追踪、可量化
- 可恢复性:重试、熔断、降级策略让系统自愈
对比替代方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| try/catch | 同步代码直观、标准语法 | 不能捕获异步错误、嵌套深 |
| Promise.catch | 异步链式错误处理 | 容易遗忘、链中间可能吞错 |
| Result 模式 | 无异常开销、类型安全 | 需要额外封装、不标准 |
| 全局兜底 | 兜住一切遗漏 | 无法恢复上下文、信息有限 |
How — 怎么用
快速上手
// 1. 同步错误:try/catch
try {
JSON.parse(badJson);
} catch (e) {
console.error('JSON 解析失败:', e.message);
}
// 2. 异步错误:async/await + try/catch
async function fetchData(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.error('请求失败:', e.message);
return null; // 降级返回
}
}
// 3. 全局兜底
window.addEventListener('unhandledrejection', (e) => {
e.preventDefault();
console.error('未处理的 Promise 异常:', e.reason);
});
代码示例
1. try/catch/finally 详解
// ===== 基本用法 =====
function divide(a, b) {
try {
if (b === 0) throw new RangeError('除数不能为零');
return a / b;
} catch (error) {
if (error instanceof RangeError) {
console.warn('范围错误:', error.message);
return Infinity;
}
throw error; // 重新抛出不认识的错误
} finally {
console.log('计算结束'); // 始终执行
}
}
// ===== finally 中的 return 陷阱 =====
function trickyReturn() {
try {
throw new Error('原始错误');
} catch (e) {
return '来自 catch';
} finally {
return '来自 finally'; // 会覆盖 catch 的 return!
}
}
console.log(trickyReturn()); // '来自 finally'
// ===== finally 中 throw 也会覆盖 =====
function trickyThrow() {
try {
throw new Error('原始错误');
} finally {
throw new Error('finally 的错误'); // 覆盖原始错误
}
}
try {
trickyThrow();
} catch (e) {
console.log(e.message); // 'finally 的错误',原始错误丢失!
}
// ===== ES2019 省略 catch 绑定 =====
try {
dangerousOp();
} catch {
// 不需要 error 对象时可以省略参数
console.log('出错了,但不关心具体信息');
}
// ===== 嵌套 try/catch =====
function nestedError() {
try {
try {
throw new Error('内层错误');
} catch (inner) {
console.log('内层捕获:', inner.message);
throw inner; // 重新抛出,外层可继续捕获
}
} catch (outer) {
console.log('外层捕获:', outer.message);
}
}
2. 内置错误类型及触发场景
// ===== SyntaxError:解析阶段 =====
try {
eval('const x ='); // 语法不完整
} catch (e) {
console.log(e instanceof SyntaxError); // true
}
try {
JSON.parse('{"name": "test",}'); // 尾逗号不合法
} catch (e) {
console.log(e.name); // 'SyntaxError'
}
// ===== TypeError:类型操作错误 =====
try {
undefined.foo; // 不能读取 undefined 的属性
} catch (e) {
console.log(e instanceof TypeError); // true
}
try {
null(); // null 不是函数
} catch (e) {
console.log(e.name); // 'TypeError'
}
try {
Object.defineProperty({}, 'x', { writable: false }).x = 1; // 严格模式下
} catch (e) {
console.log(e.name); // 'TypeError'
}
// ===== ReferenceError:引用未声明变量 =====
try {
console.log(notDeclaredVar);
} catch (e) {
console.log(e instanceof ReferenceError); // true
}
// ===== RangeError:值超出范围 =====
try {
new Array(-1);
} catch (e) {
console.log(e.name); // 'RangeError'
}
try {
'abc'.repeat(Infinity);
} catch (e) {
console.log(e.name); // 'RangeError'
}
// ===== URIError:URI 编解码错误 =====
try {
decodeURIComponent('%');
} catch (e) {
console.log(e.name); // 'URIError'
}
// ===== 区分错误类型做不同处理 =====
function safeParse(json) {
try {
return JSON.parse(json);
} catch (e) {
if (e instanceof SyntaxError) {
return { error: 'JSON 格式不合法', raw: json };
}
throw e; // 非预期错误重新抛出
}
}
3. 自定义错误类
// ===== 基础自定义错误 =====
class AppError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'AppError';
this.code = options.code || 'UNKNOWN';
this.statusCode = options.statusCode || 500;
this.isOperational = options.isOperational ?? true; // 是否为可预期的业务错误
Object.setPrototypeOf(this, AppError.prototype);
}
}
// ===== 分层错误体系 =====
class AuthError extends AppError {
constructor(message, options = {}) {
super(message, { ...options, code: options.code || 'AUTH_ERROR', statusCode: 401 });
this.name = 'AuthError';
Object.setPrototypeOf(this, AuthError.prototype);
}
}
class NetworkError extends AppError {
constructor(message, options = {}) {
super(message, { ...options, code: options.code || 'NETWORK_ERROR', statusCode: options.statusCode || 503 });
this.name = 'NetworkError';
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
class ValidationError extends AppError {
constructor(message, fields = {}, options = {}) {
super(message, { ...options, code: options.code || 'VALIDATION_ERROR', statusCode: 400 });
this.name = 'ValidationError';
this.fields = fields; // { username: '用户名不能为空', email: '邮箱格式不正确' }
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
// ===== 使用错误码枚举 =====
const ErrorCode = Object.freeze({
AUTH_EXPIRED: { code: 10001, message: '登录已过期' },
AUTH_FORBIDDEN: { code: 10002, message: '无访问权限' },
USER_NOT_FOUND: { code: 20001, message: '用户不存在' },
USER_DUPLICATE: { code: 20002, message: '用户已存在' },
PARAM_INVALID: { code: 30001, message: '参数校验失败' },
NETWORK_TIMEOUT: { code: 40001, message: '网络请求超时' },
NETWORK_ERROR: { code: 40002, message: '网络请求失败' },
SERVER_ERROR: { code: 50000, message: '服务器内部错误' },
});
function createError(key, overrides = {}) {
const def = ErrorCode[key] || ErrorCode.SERVER_ERROR;
const err = new AppError(overrides.message || def.message, {
code: key,
statusCode: overrides.statusCode,
});
err.errorCode = def.code;
return err;
}
// ===== 统一错误处理中间件(类 Express 场景)=====
function errorHandler(err, req, res, next) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
code: err.code,
message: err.message,
...(err instanceof ValidationError && { fields: err.fields }),
});
}
// 未知错误,不暴露内部信息
console.error('未预期错误:', err);
res.status(500).json({ code: 'INTERNAL_ERROR', message: '服务器内部错误' });
}
4. 异步错误处理
// ===== Promise .catch() =====
fetch('/api/user')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => console.log(data))
.catch(err => {
console.error('请求失败:', err.message);
});
// ===== async/await + try/catch =====
async function getUser(id) {
try {
const res = await fetch(`/api/user/${id}`);
if (!res.ok) throw new NetworkError(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err instanceof NetworkError) {
return { error: true, message: err.message };
}
throw err;
}
}
// ===== 封装安全的 async 包装器 =====
function to(promise) {
return promise.then(
data => [null, data],
err => [err, null]
);
}
// 使用:无需 try/catch
async function safeFetch() {
const [err, data] = await to(fetch('/api/data').then(r => r.json()));
if (err) {
console.error('请求出错:', err);
return null;
}
return data;
}
// ===== Promise 链中 catch 的位置影响 =====
// 错误场景1:catch 在 then 之前,后续 then 仍会执行
Promise.reject(new Error('fail'))
.catch(err => {
console.log('捕获到:', err.message);
return '恢复值'; // catch 返回的值会被后续 then 接收
})
.then(val => {
console.log('继续执行:', val); // '继续执行: 恢复值'
});
// 错误场景2:catch 在最后,兜住所有错误
Promise.resolve()
.then(() => { throw new Error('中间出错'); })
.then(() => console.log('不会执行'))
.catch(err => console.log('兜底:', err.message));
// ===== unhandledrejection 事件 =====
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault(); // 阻止控制台打印默认错误信息
console.error('未处理的 Promise rejection:', event.reason);
// 上报到监控系统
reportError({
type: 'unhandledrejection',
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '',
});
});
// ===== 多个 await 的错误隔离 =====
async function multiOp() {
// 方式1:每个操作独立 try/catch
const [r1, r2, r3] = await Promise.allSettled([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json()),
fetch('/api/c').then(r => r.json()),
]);
const results = [r1, r2, r3].map((r, i) => {
if (r.status === 'fulfilled') return r.value;
console.error(`请求${i}失败:`, r.reason);
return null; // 降级
});
return results;
}
5. 全局错误捕获
// ===== window.onerror(传统方式)=====
// 能捕获:同步错误、异步错误(setTimeout 等)
// 不能捕获:资源加载错误、Promise rejection
window.onerror = (message, source, lineno, colno, error) => {
console.log('message:', message); // 错误信息
console.log('source:', source); // 出错脚本 URL
console.log('lineno:', lineno); // 行号
console.log('colno:', colno); // 列号
console.log('error:', error); // Error 对象(可能为 null)
reportError({ message, source, lineno, colno, stack: error?.stack });
return true; // 返回 true 阻止浏览器默认错误处理
};
// ===== window.addEventListener('error')(推荐)=====
// 比 onerror 更灵活,可添加多个监听器
// 默认在冒泡阶段,无法捕获资源加载错误
window.addEventListener('error', (event) => {
// event 包含完整信息
console.log('全局错误:', event.message);
console.log('错误对象:', event.error);
}, false);
// ===== 捕获阶段监听 — 可捕获资源加载错误 =====
window.addEventListener('error', (event) => {
const target = event.target || event.srcElement;
// 区分 JS 运行时错误 vs 资源加载错误
if (target instanceof HTMLElement) {
// 资源加载错误
const tagName = target.tagName.toLowerCase();
const src = target.src || target.href;
console.error(`资源加载失败: <${tagName}> ${src}`);
reportError({
type: 'resource_error',
tagName,
src,
});
} else {
// JS 运行时错误
console.error('JS 运行时错误:', event.message);
reportError({
type: 'js_error',
message: event.message,
stack: event.error?.stack,
});
}
}, true); // 注意:捕获阶段 = true
// ===== window.addEventListener('unhandledrejection')=====
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的 Promise 异常:', event.reason);
event.preventDefault();
});
// ===== 完整的全局错误监控示例 =====
class ErrorMonitor {
constructor(options = {}) {
this.reportUrl = options.reportUrl || '/api/error';
this.maxQueue = options.maxQueue || 10;
this.queue = [];
this._origOnError = window.onerror;
this._origOnUnhandledRejection = window.onunhandledrejection;
}
install() {
// JS 运行时错误
window.addEventListener('error', (e) => this._handleJsError(e), false);
// 资源加载错误(捕获阶段)
window.addEventListener('error', (e) => this._handleResourceError(e), true);
// Promise 未处理 rejection
window.addEventListener('unhandledrejection', (e) => this._handleRejection(e));
// 框架错误(如 Vue)
if (window.Vue) {
window.Vue.config.errorHandler = (err, vm, info) => {
this._report({
type: 'vue_error',
message: err.message,
stack: err.stack,
componentName: vm?.$options?.name,
info,
});
};
}
}
_handleJsError(event) {
if (event.target && event.target instanceof HTMLElement) return; // 跳过资源错误
this._report({
type: 'js_error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
});
}
_handleResourceError(event) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
this._report({
type: 'resource_error',
tagName: target.tagName,
src: target.src || target.href,
});
}
_handleRejection(event) {
event.preventDefault();
const reason = event.reason;
this._report({
type: 'promise_rejection',
message: reason?.message || String(reason),
stack: reason?.stack,
});
}
_report(data) {
data.timestamp = Date.now();
data.userAgent = navigator.userAgent;
data.url = location.href;
this.queue.push(data);
if (this.queue.length >= this.maxQueue) {
this._flush();
}
}
_flush() {
if (this.queue.length === 0) return;
const batch = this.queue.splice(0);
// 使用 sendBeacon 确保页面卸载时也能上报
navigator.sendBeacon?.(this.reportUrl, JSON.stringify(batch))
|| fetch(this.reportUrl, { method: 'POST', body: JSON.stringify(batch), keepalive: true });
}
}
// 使用
const monitor = new ErrorMonitor({ reportUrl: '/api/errors' });
monitor.install();
6. 错误边界模式
// ===== React ErrorBoundary 类组件 =====
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// 渲染阶段调用,不能有副作用
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 提交阶段调用,可以执行副作用
console.error('ErrorBoundary 捕获:', error, errorInfo);
this.setState({ errorInfo });
reportError({
type: 'react_error_boundary',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="error-fallback">
<h2>组件渲染出错</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
重试
</button>
</div>
);
}
return this.props.children;
}
}
// 使用:隔离风险组件
<ErrorBoundary fallback={<FallbackUI />}>
<RiskyComponent />
</ErrorBoundary>
// ===== 安全区域隔离 — 非框架场景 =====
class SafeZone {
constructor(name, onError) {
this.name = name;
this.onError = onError;
}
run(fn) {
try {
const result = fn();
// 如果返回 Promise,自动追加 .catch
if (result && typeof result.catch === 'function') {
return result.catch(err => {
this.onError(err, this.name);
return null;
});
}
return result;
} catch (err) {
this.onError(err, this.name);
return null;
}
}
async runAsync(fn) {
try {
return await fn();
} catch (err) {
this.onError(err, this.name);
return null;
}
}
}
// 使用
const widgetZone = new SafeZone('widget', (err, zone) => {
console.error(`[${zone}] 出错:`, err.message);
// 降级:隐藏 widget,不影响主页面
document.getElementById('widget-container')?.classList.add('hidden');
});
widgetZone.run(() => renderWidget()); // 同步
await widgetZone.runAsync(() => fetchWidgetData()); // 异步
// ===== 多层错误边界 =====
function App() {
return (
<ErrorBoundary fallback={<AppCrashPage />}>
<Header />
<ErrorBoundary fallback={<SidebarError />}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<MainContentError />}>
<MainContent>
<ErrorBoundary fallback={<WidgetError name="推荐" />}>
<RecommendWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="评论" />}>
<CommentWidget />
</ErrorBoundary>
</MainContent>
</ErrorBoundary>
<Footer />
</ErrorBoundary>
);
}
7. Result 模式(函数式错误处理)
// ===== Result 类型定义 =====
class Result {
constructor(val, err, isSuccess) {
this._val = val;
this._err = err;
this._isSuccess = isSuccess;
}
static ok(value) {
return new Result(value, null, true);
}
static fail(error) {
return new Result(null, error, false);
}
get isOk() { return this._isSuccess; }
get isFail() { return !this._isSuccess; }
get value() {
if (!this._isSuccess) throw new Error('Cannot get value from Fail result');
return this._val;
}
get error() {
if (this._isSuccess) throw new Error('Cannot get error from Ok result');
return this._err;
}
// map:转换成功值
map(fn) {
return this._isSuccess
? Result.ok(fn(this._val))
: this;
}
// mapErr:转换错误
mapErr(fn) {
return this._isSuccess
? this
: Result.fail(fn(this._err));
}
// flatMap / andThen:链式操作,可短路
flatMap(fn) {
return this._isSuccess
? fn(this._val)
: this;
}
// match:模式匹配
match(onOk, onFail) {
return this._isSuccess
? onOk(this._val)
: onFail(this._err);
}
// getOrElse:获取值或默认值
getOrElse(defaultVal) {
return this._isSuccess ? this._val : defaultVal;
}
// orElse:失败时提供备选 Result
orElse(fn) {
return this._isSuccess ? this : fn(this._err);
}
// unwrap:获取值,失败时抛出
unwrap() {
if (this._isSuccess) return this._val;
throw this._err;
}
}
// ===== 使用 Result 替代 throw =====
function divide(a, b) {
if (b === 0) return Result.fail(new RangeError('除数不能为零'));
return Result.ok(a / b);
}
const result = divide(10, 2)
.map(v => v * 100)
.flatMap(v => divide(v, 50));
result.match(
value => console.log('结果:', value), // 结果: 10
error => console.error('出错:', error.message)
);
// ===== 封装可能会抛错的函数 =====
function safeExec(fn) {
try {
return Result.ok(fn());
} catch (e) {
return Result.fail(e);
}
}
async function safeAsync(fn) {
try {
return Result.ok(await fn());
} catch (e) {
return Result.fail(e);
}
}
// 使用
const parsed = safeExec(() => JSON.parse('{"name":"test"}'));
parsed.match(
data => console.log('解析成功:', data),
err => console.error('解析失败:', err.message)
);
// ===== 业务校验链 =====
function validateUser(input) {
return Result.ok(input)
.flatMap(data =>
data.name ? Result.ok(data) : Result.fail(new ValidationError('用户名不能为空'))
)
.flatMap(data =>
data.email?.includes('@') ? Result.ok(data) : Result.fail(new ValidationError('邮箱格式不正确'))
)
.flatMap(data =>
data.age >= 18 ? Result.ok(data) : Result.fail(new ValidationError('年龄必须满18岁'))
);
}
const v1 = validateUser({ name: 'Tom', email: 'tom@test.com', age: 20 });
console.log(v1.isOk); // true
const v2 = validateUser({ name: '', email: 'bad', age: 15 });
console.log(v2.isFail); // true
console.log(v2.error.message); // '用户名不能为空'
8. 重试与熔断模式
// ===== 指数退避重试 =====
async function retry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
factor = 2,
jitter = true, // 抖动:防止所有客户端同时重试
retryableCheck = null, // 判断错误是否可重试
onRetry = null, // 每次重试的回调
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
// 最后一次不重试
if (attempt >= maxRetries) break;
// 检查错误是否可重试
if (retryableCheck && !retryableCheck(err)) break;
// 计算退避延迟
let delay = Math.min(baseDelay * Math.pow(factor, attempt), maxDelay);
if (jitter) {
delay = delay * (0.5 + Math.random() * 0.5); // 0.5x ~ 1x 随机抖动
}
onRetry?.({ attempt: attempt + 1, delay, error: err });
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// 使用
const data = await retry(
() => fetch('/api/data').then(r => {
if (r.status >= 500) throw new NetworkError(`服务器错误: ${r.status}`);
if (!r.ok) throw new AppError(`请求失败: ${r.status}`);
return r.json();
}),
{
maxRetries: 3,
baseDelay: 500,
retryableCheck: (err) => err instanceof NetworkError, // 仅网络错误重试
onRetry: ({ attempt, delay }) => console.log(`第${attempt}次重试,${delay}ms 后执行`),
}
);
// ===== 熔断器模式 =====
class CircuitBreaker {
static STATE = { CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' };
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5; // 连续失败次数阈值
this.resetTimeout = options.resetTimeout || 30000; // 熔断后等待时间(ms)
this.halfOpenMaxCalls = options.halfOpenMaxCalls || 1; // 半开状态最大试探次数
this.state = CircuitBreaker.STATE.CLOSED;
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenCalls = 0;
}
async exec(fn) {
// OPEN 状态:直接拒绝
if (this.state === CircuitBreaker.STATE.OPEN) {
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
this.state = CircuitBreaker.STATE.HALF_OPEN;
this.halfOpenCalls = 0;
} else {
throw new AppError('熔断器开启,请求被拒绝', { code: 'CIRCUIT_OPEN' });
}
}
// HALF_OPEN 状态:限制试探次数
if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
if (this.halfOpenCalls >= this.halfOpenMaxCalls) {
throw new AppError('熔断器半开,试探请求已满', { code: 'CIRCUIT_HALF_OPEN' });
}
this.halfOpenCalls++;
}
try {
const result = await fn();
this._onSuccess();
return result;
} catch (err) {
this._onFailure();
throw err;
}
}
_onSuccess() {
this.failureCount = 0;
if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
this.state = CircuitBreaker.STATE.CLOSED; // 试探成功,恢复
}
}
_onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === CircuitBreaker.STATE.HALF_OPEN) {
this.state = CircuitBreaker.STATE.OPEN; // 试探失败,继续熔断
} else if (this.failureCount >= this.failureThreshold) {
this.state = CircuitBreaker.STATE.OPEN; // 连续失败达到阈值,熔断
}
}
getStats() {
return {
state: this.state,
failureCount: this.failureCount,
lastFailureTime: this.lastFailureTime,
};
}
}
// 使用
const breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 10000 });
async function fetchWithBreaker(url) {
try {
return await breaker.exec(() => fetch(url).then(r => {
if (!r.ok) throw new NetworkError(`HTTP ${r.status}`);
return r.json();
}));
} catch (err) {
if (err.code === 'CIRCUIT_OPEN') {
// 降级:返回缓存数据
return getCachedData(url);
}
throw err;
}
}
9. 错误上报与 Source Map 反解
// ===== 错误栈解析 =====
function parseStack(error) {
if (!error?.stack) return [];
return error.stack
.split('\n')
.slice(1) // 跳过第一行(错误信息)
.map(line => {
// Chrome 格式: " at functionName (http://example.com/bundle.js:1:100)"
// Firefox 格式: "functionName@http://example.com/bundle.js:1:100"
const chromeMatch = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
const firefoxMatch = line.match(/(.+?)@(.+?):(\d+):(\d+)/);
const match = chromeMatch || firefoxMatch;
if (!match) return null;
const fnName = match[1];
const file = match[2];
const line = parseInt(match[3], 10);
const col = parseInt(match[4], 10);
return { fnName, file, line, col };
})
.filter(Boolean);
}
// ===== Source Map 反解 =====
// 注意:source-map 库需要在构建时生成 .map 文件
// npm install source-map
async function resolveOriginalPosition(stackFrame, sourceMapConsumer) {
const original = sourceMapConsumer.originalPositionFor({
line: stackFrame.line,
column: stackFrame.col,
});
return {
source: original.source, // 原始文件路径
line: original.line, // 原始行号
column: original.column, // 原始列号
name: original.name, // 原始函数名
};
}
// 完整流程示例
async function resolveErrorStack(error) {
const frames = parseStack(error);
// 服务端:读取 source map 文件
// const sourceMapContent = fs.readFileSync('./dist/bundle.js.map', 'utf-8');
// const consumer = await new sourceMap.SourceMapConsumer(sourceMapContent);
// const resolved = await Promise.all(
// frames.map(frame => resolveOriginalPosition(frame, consumer))
// );
// return resolved;
// 前端:不上报 source map,只上报行列号,服务端反解
return frames.map(f => ({
file: f.file,
line: f.line,
col: f.col,
fnName: f.fnName,
}));
}
// ===== 上报策略 =====
class ErrorReporter {
constructor(options = {}) {
this.endpoint = options.endpoint || '/api/errors';
this.appVersion = options.appVersion || '1.0.0';
this.batchSize = options.batchSize || 5;
this.batchInterval = options.batchInterval || 5000;
this.queue = [];
this._timer = null;
}
report(errorData) {
const payload = {
...errorData,
appVersion: this.appVersion,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent,
};
this.queue.push(payload);
// 达到批量大小立即上报
if (this.queue.length >= this.batchSize) {
this._flush();
} else if (!this._timer) {
// 定时批量上报
this._timer = setTimeout(() => this._flush(), this.batchInterval);
}
}
_flush() {
clearTimeout(this._timer);
this._timer = null;
if (this.queue.length === 0) return;
const batch = this.queue.splice(0);
// 优先 sendBeacon(页面卸载时也能发送)
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
navigator.sendBeacon(this.endpoint, blob);
} else {
// 降级 fetch + keepalive
fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
keepalive: true,
}).catch(() => {
// 上报本身失败,放回队列下次重试
this.queue.unshift(...batch);
});
}
}
}
10. 资源加载错误
// ===== 方式1:捕获阶段监听 window error 事件 =====
window.addEventListener('error', (event) => {
const target = event.target;
if (!target || !(target instanceof HTMLElement)) return;
const tagName = target.tagName.toLowerCase();
if (['img', 'script', 'link', 'video', 'audio', 'source'].includes(tagName)) {
const src = target.src || target.href;
console.error(`资源加载失败: <${tagName}> ${src}`);
reportError({
type: 'resource_error',
tagName,
src,
id: target.id,
className: target.className,
});
}
}, true); // 必须在捕获阶段!冒泡阶段监听不到
// ===== 方式2:元素 onerror 属性 =====
// <img src="photo.jpg" onerror="this.src='fallback.jpg'; this.onerror=null;">
// 动态创建的图片
function loadImage(url, fallbackUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => {
if (fallbackUrl) {
img.src = fallbackUrl;
img.onerror = null; // 防止 fallback 也失败死循环
resolve(img);
} else {
reject(new Error(`图片加载失败: ${url}`));
}
};
img.src = url;
});
}
// ===== 方式3:动态脚本加载错误 =====
function loadScript(src, options = {}) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
if (options.crossOrigin) script.crossOrigin = 'anonymous';
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`脚本加载失败: ${src}`));
document.head.appendChild(script);
});
}
// 注意:跨域脚本需要设置 crossorigin 属性 + 服务器返回 CORS 头
// 否则 window.onerror 只能得到 'Script error.' 无具体信息
// <script src="https://cdn.example.com/lib.js" crossorigin="anonymous"></script>
// ===== 资源加载重试 =====
async function loadResourceWithRetry(loadFn, src, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
return await loadFn(src);
} catch (err) {
if (i === retries) throw err;
console.warn(`资源加载失败,第${i + 1}次重试: ${src}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
// 使用
await loadResourceWithRetry(loadScript, 'https://cdn.example.com/analytics.js', 2);
await loadResourceWithRetry(loadImage, 'https://cdn.example.com/banner.png', 1);
常见问题与踩坑
1. async 函数中 catch 丢失
// 错误写法:忘记 await,catch 捕不到异步错误
async function bad() {
try {
fetchData(); // 忘记 await!错误不会被 try/catch 捕获
} catch (e) {
console.log('捕获不到');
}
}
// 正确写法
async function good() {
try {
await fetchData(); // 加上 await
} catch (e) {
console.log('捕获到了:', e.message);
}
}
2. Promise 链中 catch 位置导致错误被吞
// 问题:中间的 catch 让后续 then 执行了
Promise.reject(new Error('fail'))
.catch(() => console.log('捕获')) // 捕获后返回 undefined
.then(val => console.log('继续:', val)); // 继续: undefined
// 解决:需要区分"已恢复"和"继续报错"
Promise.reject(new Error('fail'))
.catch(err => {
console.log('记录错误:', err.message);
throw err; // 重新抛出,让后续 catch 处理
})
.then(() => console.log('不会执行'))
.catch(err => console.log('最终处理:', err.message));
3. finally 对返回值的影响
// finally 中的 return 会覆盖 try/catch 的返回值
function foo() {
try {
return 1;
} finally {
return 2; // 覆盖了 try 的 return 1
}
}
console.log(foo()); // 2
// finally 中没有 return 时,不影响返回值
function bar() {
try {
return 1;
} finally {
console.log('finally'); // 执行但不影响返回值
}
}
console.log(bar()); // 1
// finally 中 throw 也会覆盖
function baz() {
try {
throw new Error('原始');
} finally {
throw new Error('finally的'); // 原始错误丢失!
}
}
4. window.onerror vs addEventListener(‘error’)
// 区别1:onerror 只能注册一个,后注册覆盖前面的
window.onerror = handler1;
window.onerror = handler2; // handler1 被覆盖
// addEventListener 可以注册多个
window.addEventListener('error', handler1);
window.addEventListener('error', handler2); // 两个都执行
// 区别2:onerror 参数解构,addEventListener 是 ErrorEvent 对象
window.onerror = (msg, source, line, col, error) => { ... };
window.addEventListener('error', (event) => {
// event.message, event.filename, event.lineno, event.colno, event.error
});
// 区别3:资源加载错误
// onerror 无法捕获资源加载错误
// addEventListener 在捕获阶段可以捕获
5. 跨域脚本的 ‘Script error.’
// 问题:跨域脚本出错,window.onerror 只收到 'Script error.'
// 无文件名、行号等详细信息
// 解决方案:
// 1. 脚本标签加 crossorigin 属性
// <script src="https://other.com/lib.js" crossorigin="anonymous"></script>
// 2. 服务器返回 Access-Control-Allow-Origin 响应头
// 3. 同域部署(最可靠)
// 如果无法修改脚本标签,可用 try/catch 包裹调用
try {
thirdPartyLib.method();
} catch (e) {
// 此处可获取完整错误信息
console.error(e.stack);
}
6. forEach 中的错误不会被外部 try/catch 捕获
// forEach 回调中的 throw 会穿透到外部
try {
[1, 2, 3].forEach(item => {
if (item === 2) throw new Error('找到了');
});
} catch (e) {
console.log('能捕获:', e.message); // '找到了'
}
// 但异步回调中的错误捕获不到
try {
[1, 2, 3].forEach(async item => {
await new Promise(r => setTimeout(r, 100));
if (item === 2) throw new Error('异步错误');
});
} catch (e) {
console.log('捕获不到');
}
// 需要用 for...of + await
最佳实践
- 永远处理 Promise 错误:每个 Promise 链末尾必须有
.catch(),每个async函数调用必须有try/catch或上层捕获 - 自定义错误分层:业务错误(AppError)与系统错误(TypeError/ReferenceError)分开处理,业务错误可展示给用户,系统错误上报即可
- 不要吞掉错误:catch 块里至少要
console.error或上报,空 catch 是隐患 - finally 中避免 return/throw:除非明确要覆盖,否则只在 finally 中做清理操作
- 重试需限定条件:仅对幂等且可能临时失败的操作重试(网络错误、5xx),不要对 4xx 或业务逻辑错误重试
- 全局兜底不可或缺:window.error + unhandledrejection 双重保险,捕获遗漏的异常
- 错误上报用 sendBeacon:确保页面卸载时错误也能上报
- Source Map 不上线:构建产物部署时剥离 .map 文件,服务端保存用于反解,防止源码泄露
- 资源加载加 onerror:关键资源(图片、脚本)加上 onerror 回退或降级逻辑
- 区分可恢复与不可恢复错误:可恢复的用重试/降级,不可恢复的展示错误页面并上报
面试题(8题)
1. try/catch 能否捕获异步错误?为什么?
不能。try/catch 是同步的,执行到 setTimeout/Promise 时,异步回调被放入任务队列,try/catch 块已经执行完毕退出,异步回调抛出的错误不在当前 try/catch 的作用域内。要捕获异步错误,需在回调内部 try/catch,或使用 Promise 的 .catch(),或 async/await + try/catch。
// 捕获不到
try {
setTimeout(() => { throw new Error('异步错误'); }, 0);
} catch (e) {
console.log('捕获不到');
}
// 正确方式1:回调内 try/catch
setTimeout(() => {
try { throw new Error('异步错误'); }
catch (e) { console.log('捕获:', e.message); }
}, 0);
// 正确方式2:async/await
async function foo() {
try {
await delay(0);
throw new Error('异步错误');
} catch (e) {
console.log('捕获:', e.message);
}
}
2. Promise 错误传播机制是怎样的?
Promise 链中,任何一环抛出错误(throw 或 reject),都会跳过后续的 .then(),直接到达最近的 .catch()。.catch() 处理后返回的值会被后续 .then() 当作正常值接收。如果没有任何 .catch() 处理,错误会变成 unhandled rejection。
Promise.resolve()
.then(() => { throw new Error('step1 error'); })
.then(() => console.log('跳过')) // 被跳过
.catch(e => { console.log(e.message); return 'recovered'; })
.then(v => console.log('继续:', v)); // 继续: recovered
3. 前端全局错误捕获有哪些方式?各自能捕获什么?
| 方式 | JS 运行时错误 | 资源加载错误 | Promise rejection |
|---|---|---|---|
window.onerror | 能 | 不能 | 不能 |
addEventListener('error', fn, false) | 能(冒泡) | 不能 | 不能 |
addEventListener('error', fn, true) | 能(捕获) | 能 | 不能 |
addEventListener('unhandledrejection') | 不能 | 不能 | 能 |
资源加载错误只在捕获阶段能监听到,因为资源元素不会向上冒泡 error 事件。要完整覆盖所有错误类型,需要同时注册 addEventListener('error', handler, true) 和 addEventListener('unhandledrejection', handler)。
4. 自定义错误类的关键要点有哪些?
三个要点:
① 继承 Error 并修复原型链:ES6 的 class extends Error 在某些引擎中 instanceof 可能失效,需手动 Object.setPrototypeOf(this, CustomError.prototype)。
② 设置 name 属性:Error 的默认 name 是 'Error',自定义类需要在构造函数中设置 this.name = 'CustomError'。
③ 添加业务属性:如 errorCode、statusCode、isOperational 等,便于上层做分类处理。
class BizError extends Error {
constructor(code, message) {
super(message);
this.name = 'BizError';
this.code = code;
Object.setPrototypeOf(this, BizError.prototype); // 修复原型链
}
}
const e = new BizError('NOT_FOUND', '资源不存在');
console.log(e instanceof BizError); // true(没有 setPrototypeOf 可能是 false)
console.log(e.name); // 'BizError'
console.log(e.code); // 'NOT_FOUND'
5. unhandledrejection 事件的用途和注意事项?
unhandledrejection 在 Promise 被 reject 但没有 .catch() 处理时触发。用途:全局兜底捕获遗漏的 Promise 错误,防止错误静默丢失。
注意事项:
- 事件对象的
event.reason是 reject 的值(通常是 Error 对象) - 调用
event.preventDefault()可阻止控制台打印默认错误 - 注册时机要早,否则可能错过早期 Promise 的 rejection
- 某些环境下
event.reason可能不是 Error(如Promise.reject('string')),需要类型判断
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault();
const reason = event.reason;
const message = reason instanceof Error ? reason.message : String(reason);
const stack = reason instanceof Error ? reason.stack : '';
reportError({ type: 'unhandledrejection', message, stack });
});
6. finally 中 throw 会怎样?会覆盖原错误吗?
会。finally 中的 throw 会覆盖 try 或 catch 中抛出的原始错误,原始错误信息将丢失。同理,finally 中的 return 也会覆盖 try 或 catch 的返回值。这是 JavaScript 的语言规范行为。
function example() {
try {
throw new Error('原始错误A');
} catch (e) {
throw new Error('catch中的错误B');
} finally {
throw new Error('finally的错误C'); // 覆盖错误B!
}
}
try { example(); } catch (e) {
console.log(e.message); // 'finally的错误C',错误B丢失
}
// 最佳实践:finally 中只做清理,不要 return 或 throw
function good() {
let resource;
try {
resource = acquire();
return process(resource);
} finally {
resource?.close(); // 仅清理
}
}
7. 如何捕获图片、脚本等资源加载错误?
三种方式:
① 捕获阶段监听 window error 事件(推荐,可统一捕获所有资源):
window.addEventListener('error', (e) => {
const target = e.target;
if (target instanceof HTMLImageElement || target instanceof HTMLScriptElement) {
console.error(`资源加载失败: ${target.src || target.href}`);
}
}, true); // 必须是捕获阶段
② 元素 onerror 属性(适合少量关键资源):
<img src="photo.jpg" onerror="this.onerror=null;this.src='fallback.jpg'">
③ 动态创建元素时绑定 onerror(适合 JS 动态加载):
const img = new Image();
img.onerror = () => { /* 处理 */ };
img.src = url;
注意:跨域脚本需要加 crossorigin="anonymous" 属性且服务器需返回 CORS 头,否则 error 事件中获取不到详细信息。
8. 错误上报时如何进行 Source Map 反解?
流程:
- 构建时生成 Source Map:Webpack/Vite 配置
devtool: 'source-map',产出.js.map文件 - 部署时剥离 .map 文件:生产环境不上传 .map 到 CDN,保存在构建服务器
- 前端上报原始错误栈:只上报压缩后的行列号和文件名
- 服务端反解:根据文件名找到对应 .map 文件,使用
source-map库还原原始位置
// 服务端反解(Node.js)
const { SourceMapConsumer } = require('source-map');
const fs = require('fs');
async function resolveError(filename, line, column) {
const mapContent = fs.readFileSync(`./maps/${filename}.map`, 'utf-8');
const consumer = await new SourceMapConsumer(mapContent);
const original = consumer.originalPositionFor({ line, column });
// original = { source: 'src/utils/format.js', line: 42, column: 15, name: 'formatDate' }
return original;
}
关键点:
- .map 文件绝不部署到线上,防止源码泄露
- 使用 sendBeacon 上报,确保页面关闭时也能发送
- 批量上报减少请求次数
- 上报信息包含 appVersion,用于匹配对应版本的 .map 文件
相关链接: