JavaScript 中的 try-catch

缘起

先看一段常见的前端数据请求代码:

const fetchData = async () => {
  // 这里如果发生网络错误,如断网、请求超时、跨域问题或者 HTTP 状态码小于 200、大于 299
  // 会抛出请求异常
  const { data: responseData } = await http.get(`http://localhost/example/get`);
  const { code, msg, data } = responseData;
  if (code !== '000000') {
    // 这里主动抛出业务异常
    throw new Error(msg);
  }
  return data;
};

(async () => {
  try {
    const data = await fetchData();
    // 后续操作
  } catch (error) {
    // 异常处理
  }
})();

最近就一段类似上文数据请求的代码使用 try-catch 是否合适和同事产生不同意见,同事反对使用 try-catch 和抛异常,觉得无论成功失败,都应该返回 response

当然,他不是我遇到的一个抗拒使用 try-catch 和抛异常的人。去年某同事在使用我封装的客户端 API 方法时,为了解决不写 try-catch 会导致浏览器报 unhandled promise rejection 错误,全部用一个 errorCaptured 函数包一遍。该 errorCaptured 函数和用法大致如下:

async function errorCaptured(asyncFunc) {
  try {
    let res = await asyncFunc();
    return [null, res];
  } catch (e) {
    return [e, null];
  }
}

let [err, res] = await errorCaptured(Douya.getLocation);
if (err) {
  // 异常处理
}
// 后续操作

errorCaptured 函数调用后,错误异常被放到了返回值中和正常执行结果一起返回给调用方。 该方案给人一种类似 Node.js error-first callback 风格的既视感,将异常转换为返回值。 这样写可以免去块级代码嵌套,看起来很“精简”。 但是也埋下了隐患,由于出错不会向外抛异常,即使不处理返回值中的 err 对象,程序也会继续往下执行。

溯源

这次好奇心大发,决定寻找该用法的出处。

中文范围内主要就是以上两篇去年发表的文章,都声称该写法更优雅,同时还有一篇更早的 2016 年的英文文章:

作者在发表文章的同时,将该方案开源到 GitHub: await-to-js,并发布为 npm 包

await-to-js 和那两篇中文文章方案的区别在于入参和返回值顺序不同,await-to-js 不会默认执行函数,返回值是 Golang 风格,数据在前,错误在后。

我自己实现了一下:

async function to<T, E = Error>(
  promise: Promise<T> | T,
): Promise<[T, null] | [null, E]> {
  try {
    return [await promise, null];
  } catch (err) {
    return [null, err];
  }
}

也可以不用 async/await,直接基于 Promise

function to<T, E = Error>(
  promise: Promise<T> | T,
): Promise<[T, null] | [null, E]> {
  return Promise.resolve(promise).then(
    (res) => [res, null],
    (err) => [null, err],
  );
}

问题讨论

在两篇中文文章下,除了一些“学到了”之外,有不少人表达了反对意见:

  • 又活生生把node带回到4.x时代,我用async await和promise生成器就是为了不要一堆堆的if err,你倒好,又开始了,错误处理就应该准循错误处理语义,抛出或者向上传递result,哪里该消化result就哪里消化,你这不伦不类的

  • 连go都在讨论引入try,写js的反倒还学别人的糟糠,并且还觉得别人的糟糠真香,这是什么想法,错误应该传播或者捕获,不应该转为正常返回,这样error就没有存在的意义,建议看下rust的错误处理学习一下

  • match不会丢失result语义啊,该传播还是会传播啊,而且错误这样专为普通返回会丢失错误堆栈啊

  • 你需要的是全局的错误处理机制和清晰的异常层次,而不是在代码中“小心翼翼”的处理每个可能的错误。通过异常冒泡的机制在不同层次做相应的处理就好了,这种错误处理方式,放在业务中只会徒增工作量

  • catchCode 绝大部分情况下 绝对不应该 只是 console.error ,有错误就应该往上抛,没处理掉异常接下来的代码就不应该执行,否则你的代码常常容易进入到未定义的状态里,引发更严重的 BUG

有同学更是给出了 Deno 标准库相关的讨论 suggestion: code style about async try catch #525 - denoland/deno_std,看来这个问题不仅是国内圈子会遇到。

回顾历史

既然该方案和 Node.js 的 error-first callback 风格那么像,不如我们重新回顾一下这些年异步编程中异常处理的写法变化。

先定义 3 个 Node.js callback 风格的异步函数:

const fn1 = (callbak) => {
  console.log('fn1');
  setTimeout(() => {
    callbak(null, 1);
  });
};
const fn2 = (callbak) => {
  console.log('fn2');
  setTimeout(() => {
    callbak(null, 2);
  });
};
const fn3 = (callbak) => {
  console.log('fn3');
  setTimeout(() => {
    callbak(new Error('3'), 3);
  });
};

Callback 写法

回调函数的第一个参数为 Errornull

fn1((err1, res1) => {
  if (err1) {
    console.error('err1', err1);
    return;
  }

  console.log('fn1 callback', res1);

  fn2((err2, res2) => {
    if (err2) {
      console.error('err2', err2);
      return;
    }

    console.log('fn2 callback', res2);

    fn3((err3, res3) => {
      if (err3) {
        console.error('err3', err3);
        return;
      }

      console.log('fn3 callback', res3);
    });
  });
});

Promise 写法

后来有了 Promise,重构所有 Node.js Callback 风格的函数代价太大,于是我们实现一个 promisify 用来包装已有函数,方便调用。

const promisify = (fn) => (...args) =>
  new Promise((resolve, reject) => {
    fn.call(this, ...args, (err, ...res) => {
      if (err) {
        reject(err);
      } else {
        resolve(res.length > 0 ? res : res[0]);
      }
    });
  });

const fn1Async = promisify(fn1);
const fn2Async = promisify(fn2);
const fn3Async = promisify(fn3);
fn1Async()
  .then((res1) => {
    console.log('fn1 callback', res1);

    fn2Async()
      .then((res2) => {
        console.log('fn2 callback', res2);

        fn3Async()
          .then((res3) => {
            console.log('fn3 callback', res3);
          })
          .catch((err3) => {
            console.error('err3', err3);
          });
      })
      .catch((err2) => {
        console.error('err2', err2);
      });
  })
  .catch((err1) => {
    console.error('err1', err1);
  });

和 Callback 相比,好不到哪里去……如果希望统一处理异常,在 then()return 即可。

fn1Async()
  .then((res1) => {
    console.log('fn1 callback', res1);

    return fn2Async()
      .then((res2) => {
        console.log('fn2 callback', res2);

        return fn3Async()
          .then((res3) => {
            console.log('fn3 callback', res3);
          })
      })
  })
  .catch((err) => {
    console.error('err', err);
  });

代码顿时简洁不少。

Generator 写法

再后来有了 Generator 写法,搭配 Promiseyield 自动迭代执行,便可以用同步的写法编写异步逻辑,异常处理和 Promise 一致。

function* main() {
  const res1 = yield fn1Async();
  console.log('fn1 callback', res1);

  const res2 = yield fn2Async();
  console.log('fn2 callback', res2);

  const res3 = yield fn3Async();
  console.log('fn3 callback', res3);
}

const runGenerator = (generator) => {
  const gen = generator();
  const iterate = (val) => {
    const result = gen.next(val);
    if (!result.done) {
      return Promise.resolve(result.value).then(iterate);
    }
  };
  return iterate();
};

runGenerator(main).catch((err) => {
  console.log('err', err);
});

async/await 写法

async/await 是对 Generator + yield 写法的进化,代码更为精简直观,异常处理和同步写法一样使用 try-catch 语句。

const main = async () => {
  try {
    const res1 = await fn1Async();
    console.log('fn1 callback', res1);

    const res2 = await fn2Async();
    console.log('fn2 callback', res2);

    const res3 = await fn3Async();
    console.log('fn3 callback', res3);
  } catch (err) {
    console.error('err', err);
  }
};

main();

await-to-js 写法

现在一部分人不想写 try-catch,于是我们实现一个 to 方法用来转换。

const to = (promise) =>
  Promise.resolve(promise).then(
    (res) => [res, null],
    (err) => [null, err],
  );
(async () => {
  const [res1, err1] = await to(fn1Async());
  if (err1) {
    console.error('err1', err1);
    return;
  }
  console.log('fn1 callback', res1);

  const [res2, err2] = await to(fn2Async());
  if (err2) {
    console.error('err2', err2);
    return;
  }
  console.log('fn2 callback', res2);

  const [res3, err3] = await to(fn3Async());
  if (err3) {
    console.error('err3', err3);
    return;
  }
  console.log('fn3 callback', res3);
})();

于是我们又回到了熟悉的 Callback 风格常见的 if err 时代(摸着良心说这种异常处理真的优雅吗?)。

异常的几种处理方式

下面再比较下几种异常的处理方式。

返回错误码

返回正常结果或者错误码,不方便携带具体错误信息。

const fnCStyle = () => {
  if (Math.random() > 0.5) {
    // success
    return 0;
  } else {
    // failure
    return -1;
  }
};

返回错误对象

和错误码相比,可以携带携带具体错误信息,但返回值类型不统一。

const fnCStylePlus = () => {
  if (Math.random() > 0.5) {
    // success
    return 0;
  } else {
    // failure
    return new Error("Opps");
  }
};

返回 Result 对象

返回一个自定义的 Result 对象,保证类型统一(类型安全)。

const fnResult = () => {
  const result = {
    value: null,
    error: null,
  };
  if (Math.random() > 0.5) {
    // success
    result.value = 0;
  } else {
    // failure
    result.error = new Error("Opps");
  }

  return result;
};

返回 Either

返回类似函数式编程中 Either 概念的对象,左值为错误,右值为正常结果,类型统一(类型安全)。 这其实就是上面的 errorCaptured 函数和 await-to-js

/**
 * 参考自 {@link https://blog.logrocket.com/elegant-error-handling-with-the-javascript-either-monad-76c7ae4924a1/}
 */
class Left {
  static of(value) {
    return new Left(value);
  }

  constructor(value) {
    this._value = value;
  }

  map() {
    return this;
  }

  toString() {
    return `Left(${this._value.toString()})`;
  }
}

class Right {
  static of(value) {
    return new Right(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return Right.of(this._value);
  }

  toString() {
    return `Right(${this._value.toString()})`;
  }
}

const fnEither = () => {
  if (Math.random() > 0.5) {
    // success
    return Right.of(0);
  } else {
    // failure
    return Left.of(new Error("Opps"));
  }
};

返回类似 Either 的元组(Tuple)

切换到完全函数式编程的代价太大,我们可以只返回类似 Either 的元组,左值为错误,右值为正常结果。这就更像上面的 errorCaptured 函数和 await-to-js 了。

const fnEitherTuple = () => {
  if (Math.random() > 0.5) {
    // success
    return [null, 0];
  } else {
    // failure
    return [new Error("Opps"), null];
  }
};

throw Error

出错直接 throw 错误,支持 Stack trace,没有返回值类型不统一的问题。

const fnThrow = () => {
  if (Math.random() > 0.5) {
    // success
    return 0;
  } else {
    // failure
    throw new Error("Opps");
  }
};

个人看法

从我个人角度看来 throw Error 最佳,其次是返回 Result 对象或者 Either,其他在高级语言中基本不用考虑(与其 return new Error 还不如 throw new Error)。如果是函数式编程,应该会比较反对使用 throw Error 这种会改变程序执行流程的操作(这样很不函数式),转而返回 ResultEither,把异常作为数据处理。

return 和 throw 风格的进一步对比

至于 throw Error 为何比 return Result 有优势,让我们再看下两种写法的比较。

return 风格

const fnResult1 = () => {
  if (Math.random() > 0.5) {
    return {
      value: 0,
      error: null,
    };
  }

  return {
    value: null,
    error: new Error('Opps fnResult1'),
  };
};

const fnResult2 = () => {
  const res1 = fnResult1();
  if (res1.error) {
    return res1;
  }

  if (Math.random() > 0.5) {
    return {
      value: 0,
      error: null,
    };
  }

  return {
    value: null,
    error: new Error('Opps fnResult2'),
  };
};

const fnResult3 = () => {
  const res2 = fnResult2();
  if (res2.error) {
    return res2;
  }

  if (Math.random() > 0.5) {
    return {
      value: 0,
      error: null,
    };
  }

  return {
    value: null,
    error: new Error('Opps fnResult3'),
  };
};

const runFnResult3 = () => {
  const res3 = fnResult3();
  if (res3.error) {
    console.error('runFnResult3 error', res3.error);
    return;
  }

  console.log('runFnResult3 value', res3.value);
};

runFnResult3();

throw 风格

const fnThrow1 = () => {
  if (Math.random() > 0.5) {
    return 0;
  } else {
    throw new Error("Opps fnThrow1");
  }
};

const fnThrow2 = () => {
  fnThrow1();

  if (Math.random() > 0.5) {
    return 0;
  } else {
    throw new Error("Opps fnThrow2");
  }
};

const fnThrow3 = () => {
  fnThrow2();

  if (Math.random() > 0.5) {
    return 0;
  } else {
    throw new Error("Opps fnThrow3");
  }
};

const runFnThrow3 = () => {
  try {
    const value3 = fnThrow3();
    console.log('runFnThrow3 value', value3);
  } catch (error) {
    console.error("runFnThrow3 error", error);
  }
}

runFnThrow3();

比较结果

  • 通过 return 处理错误,如果在嵌套结构中,想在外层统一处理,需要内部一层一层返回。
  • 通过 throw 处理错误,可以方便在外层捕获,需要搭配 try-catch 语句。

总结

对比几种异步编程和异常处理风格,很容易发现 async/awaittry-catchthrow error 方式的优越性。在封装底层 API 时,如果返回 Promise,应该尊重语义,只在成功时 resolve, 失败时请 reject。做好类型判断和边界处理,处理不了的错该抛异常抛异常,最终在上层进行捕获处理,拒绝在内部隐藏吞掉错误。

同时,我们也对文章开头的 errorCaptured 函数有了更细致的认知,或许重新换个命名更符合它的含义:

function toEither<T, E = Error>(
  promise: Promise<T> | T
): Promise<[null, T] | [E, null]> {
  return Promise.resolve(promise).then(
    res => [null, res],
    err => [err, null]
  )
}

开头的数据请求去除 try-catchthrow error 后可以重写为:

const fetchData = async () => {
  const either = await toEither(http.get(`http://localhost/example/get`));
  if (either[0]) {
    return either;
  }
  const { data: responseData } = either[1];
  const { code, msg, data } = responseData;
  return code === '000000' ? [null, data] : [new Error(msg), null];
};

(async () => {
  const [err, data] = await fetchData();
  if (err) {
    // 异常处理
  }
  // 后续操作
})();

相关链接