JS基础系列之 —— 💣 异常处理

1,522 阅读11分钟

前言

  • 代码执行异常是我们经常会遇到的,意外的情况总是层出不穷,抛出的错误,有时甚至会导致页面白屏,遇到异常放任不管很显然不是一个合格的程序员该干的。

  • 程序的异常年年有,常常有,但更重要的还是我们如何去处理那些可能出现的异常。

  • 如果我们可以提前对错误有所准备,将错误捕获做出反应,给用户一定的反馈,从用户体验角度来看,这样可以一定程度上降低用户对我们产品的不满意度。

  • 面向用户、更贴近于用户的应用对于异常全面且正确的处理有着更高的迫切和需求。

  • 当然,尽可能的不让你的代码出现异常是我们最应该做的。

对于异常处理的重要性,举一个最常见的例子来说:
进入页面如果我们的代码执行报错了,此时导致程序被阻塞,页面呈现白屏。但是如果我们展示一个系统异常的错误页,对于用户产生的感受来说,可能会稍微好一点点。

我们可能都遇到过这些问题?

  • 什么时候需要捕获(try-catch)异常, 什么时候需要抛出(throw)异常到上层.

  • 抛出异常后要怎么处理. 怎么返回给页面错误信息

  • 是不是所有情况下都需要对可能存在的异常做捕获和处理呢?哪些情况下异常是可以忽略的?

  • ......

我们先来了解一些关于异常的基础知识,然后再切合业务场景来具体探讨下:

一、什么是异常?

异常就是预料之外的事件,往往影响了程序的正确运行。

实质来说,异常就是一个对象,它存储了异常发生时的相关信息,如错误码、错误堆栈等错误信息。

1.1 错误与异常的关系

值得注意的是错误只有被抛出,才会产生异常,不被抛出的错误不会产生异常。

function test() { 
    console.log("test start");
    new Error();
    console.log("test end"); 
} 
test();

// 输出:
// test start
// test end
// 不会抛出任何异常

上面代码执行后是不会抛出异常的。

二、异常的分类

异常可以分为两类:运行时异常 和 编译时异常。

  • 编译时异常 指的是源代码在编译成可执行代码之前产生的异常。

  • 运行时异常 指的是可执行代码被装载到内存中执行之后产生的异常。

2.1 编译时异常

const s: string = 10001;

Typescript 编译成 JS 代码,从而在 Js runtime 执行。上面的例子,TS经过编译的时候就会报出一个编译异常。(Type '10001' is not assignable to type 'string'.)

JS 虽说是解释性语言,但是还是存在编译环节的。代码执行前还是会经过词法分析和语法分析。

function test() {
  console.log("before")
  await fetch // 语法错误
  console.log("end")
}
test()
// 不会有任何输出打印,直接报错
// Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules

async function test() { // 语法错误
  console.log("before")
  console.log("end")
}
test()
// 不会有任何输出打印,直接报错
// Uncaught SyntaxError: Unexpected token 'function'

编译时异常在我们编译的时候就会被发现,对我们的威胁性不是很大,真正最具威胁性的是运行时异常,同时它也是最常出现的。

2.2 运行时异常

function a() {
  console.log('before');
  throw new Error('运行中...');
  console.log("after");
}
a();

// 运行结果:
// before
// Uncaught Error: 运行中...

通过上面的例子,我们可以直观了解到其作为运行时异常的特性:代码运行过程中抛出的。

三、异常的传播

我们都清楚浏览器事件传播机制,异常的传播同此类似。

  • 异常的传播是作用在函数调用栈。

  • 异常传播不存在捕获机制。

  • JS中异常的传递是自动进行,不需要你一层一层去手动传递。

  • 如果一个异常没有被catch,他会沿着调用栈层层传递,直到栈为空。

  • 当一个异常被 throw 出来的时候,传播就开始了。

  • 如果一个异常没有被 catch ,最终会打印在控制台中。错误类型是 UncaughtError。

  • 如果一个异常没有被 catch ,最终会打印在控制台中。错误类型是 UncaughtError。

  • 实际项目中,此类 UncaughtError 异常是最容易被我们发现的,尽可能不要让你的系统不要出现这种异常。

Guard 的主要作用就是去捕获全局状态下未被 catch 处理的异常,然后报告给开发者。

四、异常被抛出的方式

4.1 手动抛出

throw new Error(`error message`);

4.2 自动抛出

const obj = null;
obj.toString();

【问题】:什么时候应该手动抛出异常呢?
【原则一】:当你预感到你的程序不能正确执行下去的时候,可以主动抛出一个错误。
【案例】:当你提供一个通用服务,需要使用者传入一个 option ,我们通常会去解析 option ,对于不合理的输入,通常需要抛出一个具有明确含义的异常给使用者(自定义的异常),告诉它我们处理不了。

针对输入内容的处理,如果不符合规范,除了抛出异常外,我们还可以使用不同的返回值来表示。

function test(a, b) {
  // ...
  if (!b) {
    return new Error({
      code: 1,
      message: "Invalid params " + b,
    });
  }

  if (Number.isNaN(b)) {
    return new Error({
      code: 2,
      message: "Invalid params" + b,
    });
  }
  // ....
  return ;
}

const res = test(2, "1");
if (res.code === 1) {
  return console.log("error1");
}
if (res.code === 2) {
  return console.log("error2");
}

【原则二】:已经被捕获的异常,想要不阻断其传播

【案例】:

  async pageInit() {
    try {
      await this.fetchData();
      await this.calculateValue();
      // ... 其他方法
      this.updateStatus(Types.PageStatus.READY);
    } catch (error) {
      console.error('failed to pageInit', error);
      // 收集各个阶段可能出现的异常,展示一个异常错误页
      this.resolveError(error);
    }
  }
  
  fetchData() {
      try {
        // ....
      } catch(e) {
        // 针对该异常做一部分处理
        // eg: toast 提示
        throw e;
      }
  }

五、异常的处理 & 案例分析

5.1 try catch 回顾

对于异常的捕获和处理我们都是使用 try catch,大家应该都比较熟悉了,下面列几个关键点,一起回顾复习下,加深对它的理解,避免我们以后范一些低级的错误。

  • try catch 处理运行时异常

try catch 处理的代码一定要是无语法错误的,不然 try catch 无法正常工作;而且处理的是运行时异常。

try {
  {{{{{{{{{{{{ // 语法异常
} catch(e) {
  console.log("can't understand this code"); // 不会执行
}

try {
  test();
} catch(err) {
  alert(`Error has occurred!`);
}

function test() {
  console.log('Start');
  balabala(); // Error,未定义!
  console.log('End');
}
// 运行结果:
// Start
// Error has occurred!
  • try catch 只支持同步

try catch 只能处理同步代码,异步产生的错误不能用 try catch 捕获,而要使用回调捕获。原因在于 try catch 的作用仅仅是捕获当前调用栈的错误。

try {
  setTimeout(function() {
    throw new Error('error') // 这里发生的错误将不会被捕获到
  }, 2000);
} catch (e) {
  console.log( "error" ); // 始终不执行
}
  • try catch 可以让出现异常的代码继续执行

try catch 可以让出现异常的代码继续执行,不会阻塞程序的执行。

function pageInit() {
  fetchData();
  console.log('pageInit end')
}

function fetchData() {
  getBaseInfo();
  console.log('fetchData end')
}

function getBaseInfo() {
  try {
    console.log('getBaseInfo');
    throw new Error('error');
  } catch(e) {
    console.log('捕获异常', e)
  }
}

pageInit();
// getBaseInfo
// 捕获异常 Error: error
//    at getBaseInfo (<anonymous>:14:11)
//    at fetchData (<anonymous>:7:3)
//    at pageInit (<anonymous>:2:3)
//    at <anonymous>:20:1
// fetchData end
// pageInit end

不捕获的情况呢?异常抛出如果不被 catch ,会直接阻塞程序的执行。

function pageInit() {
  fetchData();
  console.log('pageInit end')
}

function fetchData() {
  getBaseInfo();
  console.log('fetchData end')
}

function getBaseInfo() {
    console.log('getBaseInfo');
    throw new Error('error');
}

pageInit();
// getBaseInfo
// Uncaught Error: error
//    at getBaseInfo (<anonymous>:14:11)
//    at fetchData (<anonymous>:7:3)
//    at pageInit (<anonymous>:2:3)
//    at <anonymous>:20:1

5.2 调用栈较深情况下的异常处理

以下面案例来说明:

function pageInit() {
  fetchData();
  console.log('pageInit end')
}

function fetchData() {
  getBaseInfo();
  console.log('fetchData end')
}

function getBaseInfo() {
    console.log('getBaseInfo');
    throw new Error('getBaseInfo  error');
}

pageInit();

对 getBaseInfo 来做 catch:

function pageInit() {
  fetchData();
  console.log('pageInit end')
}

function fetchData() {
  getBaseInfo();
  console.log('fetchData end')
}

function getBaseInfo() {
    try {
        console.log('getBaseInfo');
        throw new Error('getBaseInfo  error');
    } catch(e) {
        console.log('getBaseInfo error', e);
    }
}

pageInit();

上面的代码执行:程序正常执行,不会被中途抛出的异常所阻塞。

这里存在一个致命的隐患,好在我们我们还打印了一句 log 。
console.log('getBaseInfo error', e);
如果连这句 log 都没有,一旦 getBaseInfo 执行中发生任何错误,对于开发者来说都无从感知。从开发开始到最终上线,可能这里的隐藏 bug ,我们都没能注意到。

function getBaseInfo() {
    try {
        console.log('getBaseInfo');
        throw new Error('getBaseInfo  error');
    } catch(e) {
        // 其他操作
        this.loadedBaseInfo = false;
        // ...
    }
}

如果你发现了 getBaseInfo 执行可能有问题,但是在被 catch 并且也没有任何输出的情况下,我们在控制台是什么都看不到的。

如上代码不会有任何异常被抛出,它被完全吞没了,这对我们调试问题简直是灾难。因此切记不要吞没你不能处理的异常。不要惧怕异常,把异常抛出更容易我们解决它。
「正确的做法」:只 catch 你可以处理的异常,而将你不能处理的异常 throw 出来。

重写上面的案例:

function pageInit() {
    try {
        fetchData();
        this.pageStatus = 'ready';
        console.log('pageInit end')
    } catch(e) {
        console.error('failed to pageInit', e);
        this.resolveError(e); // 针对异常,给出相应的错误页提示
    }
}

function fetchData() {  
    try {
        getBaseInfo();
        // ... 调接口
        console.log('fetchData end')
    } catch(e) {
        console.error('failed to fetchData', e);
        toast('页面数据初始化失败');
        throw e;
    }
}

function getBaseInfo() {
    try {
        console.log('getBaseInfo');
        throw new Error('getBaseInfo  error');
    } catch(e) {
        console.error('failed to getBaseInfo', e);
        toast('获取基础信息失败');
        throw e;
    }
}

pageInit();

【问题】:嵌套调用的函数一定都要 try catch 吗?
显然不是的,依据实际业务场景来定。但是当你做了 catch 处理,并且针对catch住的异常做了相关的操作。这时候你还不确定你有没有做的全面一些,我们最好的选择仍旧是将其错误再次抛出。

【问题】:上诉案例,之所以这么重写,还有其他考虑吗?
有的,执行 fetchData 和 getBaseInfo,他们都是有明确任务在身的,每个方法对于自己在做什么、要做什么,只有它自己是最清楚的。显然,对于自己可能发生的异常也是最清楚的。
在这两个方法中分别执行自己的 try catch,对于自身的异常是最合适的。如果放在 pageInit 中去统一再去处理异常,那么根据不同的错误去给出更具体的提示和处理是相对更麻烦的。

5.3 其他案例一

async created() {
    await this.initState();
    await this.fetchData();
},
methods: {
    initState() {
        // 初始化 state 等操作
        // 无 try catch
    },
    fetchData() {
        try {
            // 请求数据
            // 通过请求回来的数据,更新state
        } catch(err) {
            this.pageStatus = 'error';
        }
    }
}

上面的例子显然是不大合理的。

页面进入后一定有一个 initPage 的过程,此时我们一定会进行 try catch,请用try catch 包括住你初始化的所有操作。只对部分操作进行 try catch ,写法不是很严谨。

5.4 其他案例二

function getBaseInfo() {
  try {
    console.log('getBaseInfo');
    throw new Error('getBaseInfo  error');
  } catch(e) {
    console.error('failed to getBaseInfo', e);
    toast('获取基础信息失败');

    // 内部可识别的异常类型
    if (isInnerError(error)) {
      if (error.code === '-1') {
        toast('网络异常,请稍后重试')
      } else if (error.code === '-3') {
        toast('系统异常,请稍后再试')
      }
    } else {
      // 其他异常,可能是JS运行时异常
      toast('系统执行异常')
      throw e;
    }
  }
}

对异常捕获后,处理时候要小心。对于内部可识别的错误类型,我们可以直接处理掉,但是对于未知的异常,一定要 throw 出去。

避免 try catch 后导致异常未被暴露出来,Guard 等全局异常捕获机制无法获取到。

5.5 其他案例三

  // 更新奖励配置
  async updateRewardConfig(rewardConfigList) {
    try {
      const res = await BindingAPi.updateRewardConfig(rewardConfigList);
      if (res) {
        this.showToast('error', '更新奖励配置成功')
        return true;
      }
      return false;
    } catch (e) {
      console.error('failed to updateRewardConfig', e, rewardConfigList);
      this.showToast('error', '更新奖励配置失败,请重试')
      throw e;
    }
  }
  
  const result = this.updateRewardConfig(rewardConfigList);
  // 正常情况下,此处在上一个方法出现异常后是不应该继续执行的
  if (result) {
    this.$router.go(-1);
  }

需要依赖于某个方法的返回值,且该方法内进行了 try catch。如果被捕获的异常不抛出去,就无法在出现错误的情况下,阻塞后续代码的执行。

总结:对于异常处理,不可能列出所有的情况,让我们去形成死规则,去套用。

重中之重在于我们要掌握一套适合自己的异常处理的原则,在平时的业务场景中自己不断的去反思和总结,最终形成一套自己的习惯和方法。

让你的代码更健壮一些,是减少异常最有效的途径。正确使用 catch、增加全局异常捕获机制能更早的帮我们发现代码中的问题,尽早解决。让你自己对你的项目能更加有信心。

瞎谈

分享几个,个人总结的,感觉我们平时写代码时候可以反复思考的要点。

  • 尽可能提升我们代码的健壮性,培养好自己的编码素养。

  • 写代码有 “ 洁癖 ”是好事,把你的项目看成是你的一个作品,它的整个执行流程以及它可能在哪些地方存在有瑕疵,自己都应该是清楚的。

  • 动手写代码之前,可以先想一想,再去动手打。想什么呢?(比如:我想要实现什么功能?这样写好不好?有没有更好的写法或者更合理的写法?)