前端异常处理

1. 为什么要异常处理

异常:程序发生了意想不到的情况,这种情况影响到了程序的正确运行。 异常的影响:当异常发生时,最好的情况是用户自己不知道发生了什么,然后再重试;最坏的情况是用户感觉特别厌烦,于是永远不回来了。有一个良好的异常处理策略可以让用户知道到底发生了什么。 举个例子:sql-editor轮询执行历史,异常未中断情况。 image.pngimage.pngimage.pngimage.png

2. 基础概念

2.1 try/catch/finally语句

function getOwner() {
  try {
    // 可能出错的代码
    const owner = "wentang";
    owner = "other";
    return "try";
  } catch (error) {
    // 出错时要做什么
    console.log(error);
    return "catch";
  } finally {
    // 始终执行的的代码
    return "finally";
  }
}
console.log(getOwner());
// TypeError: Assignment to constant variable.
//    at getOwner (D:\iflyItem\demo.js:5:11)
//    ...stack
// finally
  1. 如果try块中的代码运行发生错误,代码会立即退出执行,并跳到catch块中。catch块此时接收到一个对象,该对象包含发生错误的相关信息(通常仅使用message属性);
  2. 如果try块中的代码正常运行完,则接着执行finally块中的代码;如果try块中的代码运行发生错误,则执行catch块中的代码,最后执行finally块中的代码。trycatch块无法阻止finally块执行,包括return语句;
  3. 如果写出finally字句,catch块就成了可选的(它们两者中只有一个是必需的);
  4. 搭配throw操作符,语法上来说可以抛出任何东西,而不仅仅是错误对象,但是最好始终抛出正确的错误对象,这样有助于在代码中进行错误的一致性处理,其他成员可以访问error.messageerror.stack来知道错误的源头;
  5. try-catch只能捕获到同步的运行时错误,对语法和异步错误无能为力,捕获不到。

2.2 错误类型

ECMA-262中定义下了下面9中错误类型:

  • Error:错误的基类型,其他错误类型都继承该类型
  • EvalError:关于eval()函数的错误,不再会被JavaScript抛出
  • InternalError:会在底层JavaScript引擎抛出异常时由浏览器抛出
  • RangeError:会在数值越界时抛出
  • ReferenceError:引用一个不存在的变量时抛出
  • SyntaxError:语法错误
  • TypeError:会在变量不是预期类型或者访问不存在的方法时抛出
  • URIError:会在使用encodeURI()decodeURI()但传入格式错误的URI时抛出
  • AggregateError⌛:包裹了由一个操作产生且需要报告的多个错误

浏览中的中的错误还有:

  • DOMException:调用方法或访问 Web API 属性时发生的异常事件
  • DOMError:已经废弃,不再使用了

3. 异常分类

按照产生异常时程序是否正在运行,我们可以将异常分为编译时异常运行时异常。 编译时异常指的是源代码在编译成可执行代码之前产生的异常,而运行时异常指的是可执行代码被装载到内存中执行之后产生的异常。

3.1 编译时异常

使用ts时,ts最终会编译成js,从而在js运行时中执行,这里的编译过程可能发生异常。

const owner: string = 10086;
​
// demo.ts:1:7 - error TS2322: Type 'number' is not assignable to type 'string'.
​


// 1 const owner: string = 10086;
//         ~~~~~
​
// Found 1 error in demo.ts:1

同样js也有编译时异常,常见的异常就是存在语法错误,导致编译不通过。

console.log("line 1");

await "line 2";
console.log("line 3");

​


// D:\mySourceOfLearning\vue2源码\vue-2.6.14\demo.js:2
// await "line 2";
// ^^^^^
​
// SyntaxError: await is only valid in async function
//     at wrapSafe (internal/modules/cjs/loader.js:1054:16)
//     at Module._compile (internal/modules/cjs/loader.js:1102:27)
//     at Object.Module._extensions..js (internal/modules/cjs/loader.js:1158:10)
//     at Module.load (internal/modules/cjs/loader.js:986:32)
//     at Function.Module._load (internal/modules/cjs/loader.js:879:14)
//     at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
//     at internal/main/run_main_module.js:17:47

通过在代码构建流程中添加静态代码分析或代码检查器,可以实现预先发现非常多的错误。常用的静态分析工具有eslinttslintstylintvetur,使用TypeScript可以提供编译时的静态类型检查。 总体来说,编译异常可以在代码被编译成可执行代码前被发现,因此对我们的伤害较小。

3.2 运行时异常

console.log("line 1");

console.log(line2);
console.log("line 3");

​


// line 1
// D:\mySourceOfLearning\vue2源码\vue-2.6.14\demo.js:2
// console.log(line2);
//             ^
​

// ReferenceError: line2 is not defined
//     at Object.<anonymous> (D:\mySourceOfLearning\vue2源码\vue-2.6.14\demo.js:2:13)
//     at Module._compile (internal/modules/cjs/loader.js:1138:30)
//     at Object.Module._extensions..js (internal/modules/cjs/loader.js:1158:10)
//     at Module.load (internal/modules/cjs/loader.js:986:32)
//     at Function.Module._load (internal/modules/cjs/loader.js:879:14)
//     at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
//     at internal/main/run_main_module.js:17:47

如上,终端只打印出了”line 1″。这是因为异常是在运行过程中抛出的,因此这个异常属于运行时异常。相当于编译时异常,运行时异常更加难以发现。比如异常只会发生在某一个流程控制块中,例如: image.png 这种运行时异常排查的难度就会很大了,很有可能造成“在我的电脑上好好的,到你的电脑上怎么就不行了”。

4. 异常传播及处理

4.1 异常传播

js中异常传播是自动的,不需要我们手动地一层层传递。如果一个异常没有被 catch,它会沿着函数调用栈一层层传播直到栈空。未被catch的异常会导致程序抛出unCaughtError,打印在控制台上,里面有详细的错误信息、堆栈信息。异常处理的一大目标就是避免unCaughtError,这要求我们要明确自己写的程序可能发生哪些异常。

// lodash  debounce函数
function debounce(func, wait, options) {
    // ...
    if (typeof func !== "function") {
        throw new TypeError("Expected a function");
    }
    // ...
}

​

debounce('22', 1000)()
// Uncaught TypeError: Expected a function
//     at Function.debounce (lodash.js:10385:15)
//     at index.html:12:15

抛出异常时,将暂停当前函数的执行,开始查找匹配的 catch子句。首先检查 throw 本身是否在 try 块内部,如果是,检查与该 try 相关的 catch 子句,看是否可以处理该异常。如果不能处理,就退出当前函数,并且释放当前函数的内存并销毁局部对象,继续到上层的调用函数中查找,直到找到一个可以处理该异常的 catch。这个过程称为栈展开(stack unwinding)。当处理该异常的 catch 结束之后,紧接着该 catch 之后的代码继续执行。

4.2 同步中的异常处理

同步中的异常处理可以使用try/catch/finally,这里不再赘述。

4.3 异步中的异常处理

js本质上是同步的,是一种单线程语言。诸如浏览器引擎之类的宿主环境使用许多Web API,增强了 js以与外部系统进行交互并处理与 I/O 绑定的操作。浏览器中异步操作有:定时器相关的函数、事件、Ajax请求和 Promise等。

4.3.1 定时器的错误处理

function failAfterOneSecond() {
    setTimeout(() => {
        throw Error("发生错误");
    }, 1000);
}

try {
    failAfterOneSecond();
} catch (error) {
    console.error("捕获到的错误:", error.message);
}
// Error: 发生错误
//     at Timeout._onTimeout (D:\iflyItem\demo.js:3:15)
//     ...stack

可以看出来try/catch没有捕获到异常,这是因为try/catch是同步,而setTimeout是异步的。当执行到setTimeout回调时,函数调用栈早就没有try/catch的踪影了,所以异常就无法捕获到。

4.3.2 使用Promise处理异常

// Promise处理定时器的异常
function failAfterOneSecond() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(Error('发生错误'));
        }, 1000);
    });
}

failAfterOneSecond().catch(
    error => console.log('捕获到的错误:', error.message) // 捕获到的错误: 发生错误
);
​
const promiseSuccess1 = Promise.resolve('resPromiseSuccess1');
const promiseSuccess2 = Promise.resolve('resPromiseSuccess2');
const promiseFaied1 = Promise.reject(Error('resPromiseFaied1'));
const promiseFaied2 = Promise.reject(Error('resPromiseFaied2'));
​
// Promise.all处理错误
Promise.all([promiseSuccess1, promiseSuccess2]).then(res => console.log(res));
// [ 'resPromiseSuccess1', 'resPromiseSuccess2' ]
​
// Promise.any处理错误
Promise.any([promiseFaied1, promiseFaied2]).catch(error => console.log(error));
// AggregateError: All promises were rejected
// error.errors:['resPromiseFaied1', 'resPromiseFaied2']
​
// Promise.race处理错误
Promise.race([promiseSuccess2, promiseSuccess1, promiseFaied2])
    .then(res => console.log(res))
    .catch(error => console.log(error));
// resPromiseSuccess2
​
// Promise.allSettled处理错误
Promise.allSettled([promiseSuccess1, promiseSuccess2, promiseFaied1, promiseFaied2])
    .then(res => console.log(res))
    .catch(error => console.log(error));
// [
//     { status: 'fulfilled', value: 'resPromiseSuccess1' },
//     { status: 'fulfilled', value: 'resPromiseSuccess2' },
//     { status: 'rejected', reason: 'resPromiseFaied1' },
//     { status: 'rejected', reason: 'resPromiseFaied2' }
// ];

5. 异常捕获

5.1 window.onerror

js 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()

window.onerror = function (message, source, lineno, colno, error) {
  /* 
  message:错误信息  Uncaught TypeError: Assignment to constant variable.
  source:发生错误的脚本URL  http://127.0.0.1:5501/error.html
  lineno:发生错误的行号 34 
  colno:发生错误的列号 11
  error:Error对象 
  TypeError: Assignment to constant variable.
      at error.html:34:11
  */
  console.log("捕获到异常:", message, source, lineno, colno, error);
  return true
}

可以捕获同步运行时错误、异步运行时错误,无法捕获语法错误、网络请求错误(静态资源、接口)。另外,window.onerror函数只有在返回true时,异常才不会向上抛出。

5.2 window.addEventListener(‘error’,erorHandler,flag)

当一项资源(如<img><script>)加载失败,加载资源的元素会触发一个Event接口的error事件,并执行该元素上的onerror()处理函数,这些error时间不会冒泡到window。 用法:

  • 第三个参数为true时,捕获状态可以捕获到同步运行时错误,也能捕获静态资源加载错误;
  • 第三次参数为false时,冒泡状态能捕获到同步运行时错误,不能捕获静态资源加载错误。

window.onerror能捕获到错误的函数堆栈信息,存在error.stack中,所以使用window.onerror的方式对js运行时错误进行捕获更合适些,但不是很必要。

5.3 Promise 异常

没有写 catchPromise 中抛出的错误无法被 onerrortry-catch 捕获到,所以我们务必要在 Promise 中不要忘记写 catch处理抛出的异常。 可以在全局增加一个对 unhandledrejection的监听,用来全局监听Uncaught Promise Error

window.addEventListener("unhandledrejection", function (event) {
  event.preventDefault()
  // event:PromiseRejectionEvent对象  .promise属性  .reason属性
  console.log('捕获到异常:', event);
  return true
});

event.preventDefault()可以用来取消控制台的Uncaught (in promise) reason

5.4 addGlobalUncaughtErrorHandler

addGlobalUncaughtErrorHandler是乾坤官网提供的用来添加全局的未捕获异常处理器。 源码变更记录: image.png

function render(props){
  addGlobalUncaughtErrorHandler((event, source, lineno, colno, error) => {
    console.log('捕获到异常:', event, source, lineno, colno, error);
  });
}

5.5 Vue 异常

对于 Vue 的错误上报需要用其提供的 Vue.config.errorhandler 方法,但是错误一旦被这个方法捕获,就不会外抛到在控制台。

Vue.config.errorHandler = (err,vm,info) => {
  // errorHandler(err: Error, vm: Vue, info: string): void
  console.error('通过vue errorHandler捕获的错误');
  console.error(err);
  console.error(vm);
  console.error(info);
}
// info到底是啥,源码整理结果如下:
// 文件				值
// error.js		errorCaptured hook
// render.js	render	renderError 可查官网api了解更多
// state.js		data()	`callback for immediate watcher "${watcher.expression}"`
// watcher.js	`getter for watcher "${this.expression}"`	
//						`callback for watcher "${this.expression}"`
// next-tick.js	nextTick
// directive.js	`directive ${dir.name} ${hook} hook`
// render-component-template.js	@render
// lifecycle.js	`${hook} hook`
// update-listeners.js	`v-on handler`
// evenet.js	`event handler for "${event}"`

errorCaptured在捕获一个来自后代组件的错误时被调用,message-dialog组件发生错误会被父组件及祖先组件的errorCaptured捕获到。

<template>
  <div>
    <message-dialog></message-dialog>
  </div>
</template>
<script>
  export default {
    errorCaptured(err, vm, info) {
      // (err: Error, vm: Component, info: string) => ?boolean
      console.log(err, vm, info);
    }    
  }

</script>

错误传播规则:

  • 默认情况下,如果全局的 config.errorHandler 被定义,所有的错误仍会发送它,因此这些错误仍然会向单一的分析服务的地方进行汇报;
  • 如果一个组件的 inheritance chain (继承链)或 parent chain (父链)中存在多个 errorCaptured 钩子,则它们将会被相同的错误逐个唤起;
  • 如果此 errorCaptured 钩子自身抛出了一个错误,则这个新错误和原本被捕获的错误都会发送给全局的 config.errorHandler
  • 一个errorCaptured钩子能够返回 false 以阻止错误继续向上传播。本质上是说“这个错误已经被搞定了且应该被忽略”。它会阻止其它任何会被这个错误唤起的 errorCaptured 钩子和全局的 config.errorHandler

源码版本v2.6.14,位于./src/core/util/error.js。handleErrorinvokeWithErrorHandlingglobalHandleErrorlogError四个方法。

export function handleError(err: Error, vm: any, info: string) {
  // 避免无限渲染
  // See: https://github.com/vuejs/vuex/issues/1505
  pushTarget();
  try {
    if (vm) {
      let cur = vm;
      // 向上查找$parent,直到不存在
      // 注意了!一上来 cur 就赋值给 cur.$parent,
      // 所以 errorCaptured 不会在当前组件的错误捕获中执行
      while ((cur = cur.$parent)) {
        // 获取钩子errorCaptured
        const hooks = cur.$options.errorCaptured;
        if (hooks) {
          for (let i = 0; i < hooks.length; i++) {
            try {
              // 执行errorCaptured
              const capture = hooks[i].call(cur, err, vm, info) === false;
              // errorCaptured返回false,直接return,外层的globalHandleError不会执行
              if (capture) return;
            } catch (e) {
              // 如果在执行errorCaptured的时候捕获到错误,会执行globalHandleError,
              // 此时的info为:errorCaptured hook
              globalHandleError(e, cur, "errorCaptured hook");
            }
          }
        }
      }
    }
    // 外层,全局捕获,只要上面不return掉,就会执行
    globalHandleError(err, vm, info);
  } finally {
    popTarget();
  }
}

export function invokeWithErrorHandling(
    handler: Function,
    context: any,
    args: null | any[],
    vm: any,
    info: string
  ) {
    let res;
    try {
      // 处理handle的参数并调用
      res = args ? handler.apply(context, args) : handler.call(context);
      // 判断返回是否为Promise 且 未被catch(!res._handled)
      if (res && !res._isVue && isPromise(res) && !res._handled) {
        res.catch((e) => handleError(e, vm, info + ` (Promise/async)`));
        // issue #9511
        // avoid catch triggering multiple times when nested calls
        // _handled标志置为true,避免嵌套调用时多次触发catch
        res._handled = true;
      }
    } catch (e) {
      // 捕获错误后调用 handleError
      handleError(e, vm, info);
    }
    return res;
  }

  function globalHandleError(err, vm, info) {
    if (config.errorHandler) {
      try {
        // 调用全局的 errorHandler 并return
        return config.errorHandler.call(null, err, vm, info);
      } catch (e) {
        // if the user intentionally throws the original error in the handler,
        // do not log it twice
        // 如果用户故意在处理程序中抛出原始错误,不要记录两次
        if (e !== err) {
          // 对在 globalHandleError 中的错误进行捕获,通过 logError 输出
          logError(e, null, "config.errorHandler");
        }
      }
    }
    // 如果没有 errorHandler 全局捕获,则执行到这里,用 logError 错误
    logError(err, vm, info);
  }

  function logError(err, vm, info) {
    if (process.env.NODE_ENV !== "production") {
      // 开发环境中使用 warn 对错误进行输出
      warn(`Error in ${info}: "${err.toString()}"`, vm);
    }
    /* istanbul ignore else */
    if ((inBrowser || inWeex) && typeof console !== "undefined") {
      // 直接用 console.error 打印错误信息
      console.error(err);
    } else {
      throw err;
    }
  }

总结:

  1. handleError:统一的错误捕获函数。实现子组件到顶层组件错误捕获后对 errorCaptured hook 的冒泡调用,执行完全部的 errorCaptured 钩子后最终执行全局错误捕获函数 globalHandleError
  2. invokeWithErrorHandling:包装函数,通过高阶函数的编程私思路,通过接收一个函数参数,并在内部使用try-catch包裹后执行传入的函数;还提供更好的异步错误处理,当执行函数返回了一个Promise对象,会在此对其实现进行错误捕获,最后也是通知到 handleError 中(如果我们未自己对返回的Promise进行catch操作);
  3. globalHandleError:调用全局配置的 errorHandler 函数,如果在调用的过程中捕获到错误,则通过 logError 打印所捕获的错误,以 ‘config.errorHandler’ 结尾;
  4. logError:实现对未捕获的错误信息进行打印输出,开发环境会打印2种错误信息。

5.6 请求异常

axios是一个基于promise的网络请求库,支持拦截请求和响应,可以使用interceptors处理响应错误。 一般接口 401就代表用户未登录,需要跳转到登录页,让用户进行重新登录,但如果每个请求方法都需要写一遍跳转登录页的逻辑就很麻烦了,这时候就会考虑使用 axios的拦截器来做统一处理,同理能统一处理的异常也可以在放在拦截器里处理。

// 主应用
service.interceptors.response.use(
  response => {
    return Promise.resolve(response.data);
  },
  error => {
    let { response } = error;
    if (response?.status === 401) {
      store.commit('user/logoutClear');
      routerInstance?.push({ name: 'login' });
    }
    return Promise.reject(error);
  }
);
export const requestErrorInterceptor = error => {
    console.log('error:', error);
    if (!error) return;
    let { response } = error;
    // 401处理
    if (response?.status === 401) return Message.warning('登录失效,请重新登录!');
    // 普通报错处理
    let msg = response?.data?.message || response?.data?.msg || error.message;
    msg && Message.error(msg);
};
async submitForm(formName) {
  try {
    this.buttonLoading = true
    const res = this.isAddMode
      ? await addRoute(reqBody)
      : await editRoute(this.currentRoute.id, reqBody);
    if (SUCCESS_CODE.includes(res?.code)) {
      this.$message.success('操作成功');
      return;
    }
    this.$message.error(res.message || '操作失败');
  }

  catch (error) {
    requestErrorInterceptor(error);
  } finally {
    this.buttonLoading = false;
  }
}

async submitForm(formName) {
  this.buttonLoading = true;
  const res = this.isAddMode
    ? await addRoute(reqBody)
    .catch(error => requestErrorInterceptor(error))
    .finally(() => (this.buttonLoading = false))
    : await editRoute(this.currentRoute.id, reqBody)
    .catch(error => requestErrorInterceptor(error))
    .finally(() => (this.buttonLoading = false));
  if (SUCCESS_CODE.includes(res?.code)) {
    this.$message.success('操作成功');
    return;
  }
  this.$message.error(res.message || '操作失败');
}

Promise.all([this.getServices(), this.getRoutes()])
  .then(res => {
    this.serviceList = res[0];
    this.routeList = res[1];
    this.getAllTableData();
  })
  .finally(() => (this.loading = false));

6. 总结

  • 使用静态分析工具eslinttslintstylintvetur
  • 使用typescript
  • 可疑区域增加 try-catch
  • 全局监控 JS 异常 window.onerror
  • 全局监控静态资源异常window.addEventListener
  • 捕获没有 catchPromise 异常用 unhandledrejection
  • Vue异常使用Vue.config.errorHandlererrorCaptured
  • Axios 请求统一异常处理用拦截器interceptors

7. 引用

  1. MDN标准内置对象Error
  2. 前端异常的捕获与处理(政采云)
  3. 纯干货!你不知道的Vue错误处理机制
  4. 如何优雅处理前端异常?

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY2KZh2Z' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片