前端老鸟血泪史:搞不定报错?这套错误处理策略让你少加三天班!

前端老鸟血泪史:搞不定报错?这套错误处理策略让你少加三天班!

前端老鸟血泪史:搞不定报错?这套错误处理策略让你少加三天班!

开篇先唠两句

别划走,我知道你刚被控制台那一堆红字搞崩了心态。那种看着满屏飘红的绝望,我懂——上周三凌晨两点,我盯着生产环境那个 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.onerrorunhandledrejection 还是得留着,双保险。

这套方案到底香在哪又有啥坑

没有银弹,真的。这句话我得说三遍。任何技术方案都是权衡,错误处理也不例外。

爽点

用户体验直接起飞。以前页面一报错就白屏,用户一脸懵逼,刷新也没用,只能关掉页面重进。现在至少能给用户看个友好的"出岔子了"页面,配个可爱的错误插画,加个重试按钮,用户知道发生了什么,也知道能做什么,焦虑感大大降低。

监控平台能收到第一手日志。以前用户来反馈"页面打不开",问半天也问不出个所以然,只能盲猜。现在错误信息、用户操作路径、设备环境自动上报,修 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 你的错误监控系统。每周花半小时看看都报了啥错,哪些是已知问题,哪些是新增的,哪些是可以预防的。这习惯养成了,你的代码质量会肉眼可见地提升。别等到错误堆积成山了才想起来看,那时候就晚了。

就这样,去干活吧!💪

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值