缘起
先看一段常见的前端数据请求代码:
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 对象,程序也会继续往下执行。
溯源
这次好奇心大发,决定寻找该用法的出处。
- async/await 优雅的错误处理方法 2019-01-25
- 嘿,不要给 async 函数写那么多 try/catch 了 2019-07-10
中文范围内主要就是以上两篇去年发表的文章,都声称该写法更优雅,同时还有一篇更早的 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 写法
回调函数的第一个参数为 Error 或 null。
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 写法,搭配 Promise 和 yield 自动迭代执行,便可以用同步的写法编写异步逻辑,异常处理和 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 这种会改变程序执行流程的操作(这样很不函数式),转而返回 Result、Either,把异常作为数据处理。
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/await 和 try-catch、throw 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-catch、throw 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) {
    // 异常处理
  }
  // 后续操作
})();