JS 非同步錯誤捕獲二三事

sunyongjian發表於2019-04-25

引入

我們都知道 try catch 無法捕獲 setTimeout 非同步任務中的錯誤,那其中的原因是什麼。以及非同步程式碼在 js 中是特別常見的,我們該怎麼做才比較?

無法捕獲的情況

function main() {
  try {
    setTimeout(() => {
      throw new Error('async error')
    }, 1000)
  } catch(e) {
    console.log(e, 'err')
    console.log('continue...')
  }
}

main();
複製程式碼

這段程式碼中,setTimeout 的回撥函式丟擲一個錯誤,並不會在 catch 中捕獲,會導致程式直接報錯崩掉。

所以說在 js 中 try catch 並不是說寫上一個就可以高枕無憂了。難道每個函式都要寫嗎, 那什麼情況下 try catch 無法捕獲 error 呢?

非同步任務

  • 巨集任務的回撥函式中的錯誤無法捕獲

    上面的栗子稍微改一下,主任務中寫一段 try catch,然後呼叫非同步任務 task,task 會在一秒之後丟擲一個錯誤。

    // 非同步任務
    const task = () => {
      setTimeout(() => {
       throw new Error('async error')
     }, 1000)
    }
    // 主任務
    function main() {
      try {
        task();
      } catch(e) {
        console.log(e, 'err')
        console.log('continue...')
      }
    }
    
    複製程式碼

    這種情況下 main 是無法 catch error 的,這跟瀏覽器的執行機制有關。非同步任務由 eventloop 加入任務佇列,並取出入棧(js 主程式)執行,而當 task 取出執行的時候, main 的棧已經退出了,也就是上下文環境已經改變,所以 main 無法捕獲 task 的錯誤。

    事件回撥,請求回撥同屬 tasks,所以道理是一樣的。eventloop 複習可以看這篇文章

  • 微任務(promise)的回撥

    // 返回一個 promise 物件
    const promiseFetch = () => 
      new Promise((reslove) => {
      reslove();
    })
    
    function main() {
      try {
        // 回撥函式裡丟擲錯誤
        promiseFetch().then(() => {
          throw new Error('err')
        })
      } catch(e) {
        console.log(e, 'eeee');
        console.log('continue');
      }
    }
    複製程式碼

    promise 的任務,也就是 then 裡面的回撥函式,丟擲錯誤同樣也無法 catch。因為微任務佇列是在兩個 task 之間清空的,所以 then 入棧的時候,main 函式也已經出棧了。

並不是回撥函式無法 try catch

很多人可能有一個誤解,因為大部分遇到無法 catch 的情況,都發生在回撥函式,就認為回撥函式不能 catch。

不全對,看一個最普通的栗子。

// 定義一個 fn,引數是函式。
const fn = (cb: () => void) => {
  cb();
};

function main() {
  try {
    // 傳入 callback,fn 執行會呼叫,並丟擲錯誤。
    fn(() => {
      throw new Error('123');
    })
  } catch(e) {
    console.log('error');
  }
}
main();
複製程式碼

結果當然是可以 catch 的。因為 callback 執行的時候,跟 main 還在同一次事件迴圈中,即一個 eventloop tick。所以上下文沒有變化,錯誤是可以 catch 的。 根本原因還是同步程式碼,並沒有遇到非同步任務。

promise 的異常捕獲

建構函式

先看兩段程式碼:

function main1() {
  try {
    new Promise(() => {
      throw new Error('promise1 error')
    })
  } catch(e) {
    console.log(e.message);
  }
}

function main2() {
  try {
    Promise.reject('promise2 error');
  } catch(e) {
    console.log(e.message);
  }
}

複製程式碼

以上兩個 try catch 都不能捕獲到 error,因為 promise 內部的錯誤不會冒泡出來,而是被 promise 吃掉了,只有通過 promise.catch 才可以捕獲,所以用 Promise 一定要寫 catch 啊。

然後我們再來看一下使用 promise.catch 的兩段程式碼:

// reject
const p1 = new Promise((reslove, reject) => {
  if(1) {
    reject();
  }
});
p1.catch((e) => console.log('p1 error'));
複製程式碼
// throw new Error
const p2 = new Promise((reslove, reject) => {
  if(1) {
    throw new Error('p2 error')
  }
});

p2.catch((e) => console.log('p2 error'));
複製程式碼

promise 內部的無論是 reject 或者 throw new Error,都可以通過 catch 回撥捕獲。

這裡要跟我們最開始微任務的栗子區分,promise 的微任務指的是 then 的回撥,而此處是 Promise 建構函式傳入的第一個引數,new Promise 是同步執行的。

then

那 then 之後的錯誤如何捕獲呢。

function main3() {
  Promise.resolve(true).then(() => {
    try {
      throw new Error('then');
    } catch(e) {
      return e;
    }
  }).then(e => console.log(e.message));
}
複製程式碼

只能是在回撥函式內部 catch 錯誤,並把錯誤資訊返回,error 會傳遞到下一個 then 的回撥。

用 Promise 捕獲非同步錯誤


const p3 = () =>  new Promise((reslove, reject) => {
  setTimeout(() => {
    reject('async error');
  })
});

function main3() {
  p3().catch(e => console.log(e));
}
main3();
複製程式碼

把非同步操作用 Promise 包裝,通過內部判斷,把錯誤 reject,在外面通過 promise.catch 捕獲。

async/await 的異常捕獲

首先我們模擬一個請求失敗的函式 fetchFailure,fetch 函式通常都是返回一個 promise。

main 函式改成 async,catch 去捕獲 fetchFailure reject 丟擲的錯誤。能不能獲取到呢。

const fetchFailure = () => new Promise((resolve, reject) => {
  setTimeout(() => {// 模擬請求
    if(1) reject('fetch failure...');
  })
})

async function main () {
  try {
    const res = await fetchFailure();
    console.log(res, 'res');
  } catch(e) {
    console.log(e, 'e.message');
  }
}
main();
複製程式碼

async 函式會被編譯成好幾段,根據 await 關鍵字,以及 catch 等,比如 main 函式就是拆成三段。

  1. fetchFailure 2. console.log(res) 3. catch

通過 step 來控制迭代的進度,比如 "next",就是往下走一次,從 1->2,非同步是通過 Promise.then() 控制的,你可以理解為就是一個 Promise 鏈,感興趣的可以去研究一下。 關鍵是生成器也有一個 "throw" 的狀態,當 Promise 的狀態 reject 後,會向上冒泡,直到 step('throw') 執行,然後 catch 裡的程式碼 console.log(e, 'e.message'); 執行。

明顯感覺 async/await 的錯誤處理更優雅一些,當然也是內部配合使用了 Promise。

更進一步

async 函式處理非同步流程是利器,但是它也不會自動去 catch 錯誤,需要我們自己寫 try catch,如果每個函式都寫一個,也挺麻煩的,比較業務中非同步函式會很多。

首先想到的是把 try catch,以及 catch 後的邏輯抽取出來。


const handle = async (fn: any) => {
  try {
    return await fn();
  } catch(e) {
    // do sth
    console.log(e, 'e.messagee');
  }
}

async function main () {
    const res = await handle(fetchFailure);
    console.log(res, 'res');
}
複製程式碼

寫一個高階函式包裹 fetchFailure,高階函式複用邏輯,比如此處的 try catch,然後執行傳入的引數-函式 即可。

然後,加上回撥函式的引數傳遞,以及返回值遵守 first-error,向 node/go 的語法看齊。如下:

const handleTryCatch = (fn: (...args: any[]) => Promise<{}>) => async (...args: any[]) => {
  try {
    return [null, await fn(...args)];
  } catch(e) {
    console.log(e, 'e.messagee');
    return [e];
  }
}

async function main () {
  const [err, res] = await handleTryCatch(fetchFailure)('');
  if(err) {
    console.log(err, 'err');
    return;
  }
  console.log(res, 'res');
}

複製程式碼

但是還有幾個問題,一個是 catch 後的邏輯,這塊還不支援自定義,再就是返回值總要判斷一下,是否有 error,也可以抽象一下。 所以我們可以在高階函式的 catch 處做一下文章,比如加入一些錯誤處理的回撥函式支援不同的邏輯,然後一個專案中錯誤處理可以簡單分幾類,做不同的處理,就可以儘可能的複用程式碼了。

// 1. 三階函式。第一次傳入錯誤處理的 handle,第二次是傳入要修飾的 async 函式,最後返回一個新的 function。

const handleTryCatch = (handle: (e: Error) => void = errorHandle) =>
  (fn: (...args: any[]) => Promise<{}>) => async(...args: any[]) => {
    try {
      return [null, await fn(...args)];
    } catch(e) {
      return [handle(e)];
    }
  }
   
// 2. 定義各種各樣的錯誤型別
//    我們可以把錯誤資訊格式化,成為程式碼裡可以處理的樣式,比如包含錯誤碼和錯誤資訊
class DbError extends Error {
  public errmsg: string;
  public errno: number;
  constructor(msg: string, code: number) {
    super(msg);
    this.errmsg = msg || 'db_error_msg';
    this.errno = code || 20010;
  }
}
class ValidatedError extends Error {
  public errmsg: string;
  public errno: number;
  constructor(msg: string, code: number) {
    super(msg);
    this.errmsg = msg || 'validated_error_msg';
    this.errno = code || 20010;
  }
}

// 3. 錯誤處理的邏輯,這可能只是其中一類。通常錯誤處理都是按功能需求來劃分
//    比如請求失敗(200 但是返回值有錯誤資訊),比如 node 中寫 db 失敗等。
const errorHandle = (e: Error) => {
  // do something
  if(e instanceof ValidatedError || e instanceof DbError) {
    // do sth
    return e;
  }
  return {
    code: 101,
    errmsg: 'unKnown'
  };
}   
const usualHandleTryCatch = handleTryCatch(errorHandle);

// 以上的程式碼都是多個模組複用的,那實際的業務程式碼可能只需要這樣。
async function main () {
  const [error, res] = await usualHandleTryCatch(fetchFail)(false);
  if(error) {
    // 因為 catch 已經做了攔截,甚至可以加入一些通用邏輯,這裡甚至不用判斷 if error
    console.log(error, 'error');
    return;
  }
  console.log(res, 'res');
}
複製程式碼

解決了一些錯誤邏輯的複用問題之後,即封裝成不同的錯誤處理器即可。但是這些處理器在使用的時候,因為都是高階函式,可以使用 es6 的裝飾器寫法。

不過裝飾器只能用於類和類的方法,所以如果是函式的形式,就不能使用了。不過在日常開發中,比如 React 的元件,或者 Mobx 的 store,都是以 class 的形式存在的,所以使用場景挺多的。

比如改成類裝飾器:

const asyncErrorWrapper = (errorHandler: (e: Error) => void = errorHandle) => (target: Function) => {
  const props = Object.getOwnPropertyNames(target.prototype);
  props.forEach((prop) => {
      var value = target.prototype[prop];
      if(Object.prototype.toString.call(value) === '[object AsyncFunction]'){
        target.prototype[prop] = async (...args: any[]) => {
          try{
            return await value.apply(this,args);
          }catch(err){
            return errorHandler(err);
          }
        }
      }
  });
}

@asyncErrorWrapper(errorHandle)
class Store {
  async getList (){
    return Promise.reject('類裝飾:失敗了');
  }
}

const store = new Store();

async function main() {
  const o = await store.getList();
}
main();
複製程式碼

這種 class 裝飾器的寫法是看到黃子毅 這麼寫過,感謝靈感。

koa 的錯誤處理

如果對 koa 不熟悉,可以選擇跳過不看。

koa 中當然也可以用上面 async 的做法,不過通常我們用 koa 寫 server 的時候,都是處理請求,一次 http 事務會掉起響應的中介軟體,所以 koa 的錯誤處理很好的利用了中介軟體的特性。

比如我的做法是,第一個中介軟體為捕獲 error,因為洋蔥模型的緣故,第一個中介軟體最後仍會執行,而當某個中介軟體丟擲錯誤後,我期待能在此捕獲並處理。

// 第一個中介軟體
const errorCatch = async(ctx, next) => {
  try {
    await next();
  } catch(e) {
    // 在此捕獲 error 路由,throw 出的 Error
    console.log(e, e.message, 'error');
    ctx.body = 'error';
  }
}

app.use(errorCatch);

// logger
app.use(async (ctx, next) => {
  console.log(ctx.req.body, 'body');
  await next();
})

// router 的某個中介軟體
router.get('/error', async (ctx, next) => {
  if(1) {
    throw new Error('錯誤測試')
  }
  await next();
})


複製程式碼

為什麼在第一個中介軟體寫上 try catch,就可以捕獲前面中介軟體 throw 出的錯誤呢。首先我們前面 async/await 的地方解釋過,async 中await handle(),handle 函式內部的 throw new Error 或者 Promise.reject() 是可以被 async 的 catch 捕獲的。所以只需要 next 函式能夠拿到錯誤,並丟擲就可以了,那看看 next 函式。

// compose 是傳入中介軟體的陣列,最終形成中介軟體鏈的,next 控制遊標。
 compose(middlewares) {
    return (context) => {
      let index = 0;
      // 為了每個中介軟體都可以是非同步呼叫,即 `await next()` 這種寫法,每個 next 都要返回一個 promise 物件

      function next(index) {
        const func = middlewares[index];
        try {
          // 在此處寫 try catch,因為是寫到 Promise 構造體中的,所以丟擲的錯誤能被 catch
          return new Promise((resolve, reject) => {
            if (index >= middlewares.length) return reject('next is inexistence');
            resolve(func(context, () => next(index + 1)));
          });
        } catch(err) {
          // 捕獲到錯誤,返回錯誤
          return Promise.reject(err);
        }
      }
      return next(index);
    }
  }
複製程式碼

next 函式根據 index,取出當前的中介軟體執行。中介軟體函式如果是 async 函式,同樣的轉化為 generator 執行,內部的非同步程式碼順序由它自己控制,而我們知道 async 函式的錯誤是可以通過 try catch 捕獲的,所以在 next 函式中加上 try catch 捕獲中介軟體函式的錯誤,再 return 丟擲去即可。所以我們才可以在第一個中介軟體捕獲。詳細程式碼可以看下簡版 koa

然後 koa 還提供了 ctx.throw 和全域性的 app.on 來捕獲錯誤。 如果你沒有寫錯誤處理的中介軟體,那可以使用 ctx.throw 返回前端,不至於讓程式碼錯誤。 但是 throw new Error 也是有優勢的,因為某個中介軟體的程式碼邏輯中,一旦出現我們不想讓後面的中介軟體執行,直接給前端返回,直接丟擲錯誤即可,讓通用的中介軟體處理,反正都是錯誤資訊。

// 定義不同的錯誤型別,在此可以捕獲,並處理。
const errorCatch = async(ctx, next) => {
  try {
    await next();
 } catch (err) {
    const { errmsg, errno, status = 500, redirect } = err;
    
    if (err instanceof ValidatedError || err instanceof DbError || err instanceof AuthError || err instanceof RequestError) {
      ctx.status = 200;
      ctx.body = {
        errmsg,
        errno,
      };
      return;
    }
    ctx.status = status;
    if (status === 302 && redirect) {
      console.log(redirect);
      ctx.redirect(redirect);
    }
    if (status === 500) {
      ctx.body = {
        errmsg: err.message,
        errno: 90001,
      };
      ctx.app.emit('error', err, ctx);
    }
  }
}

app.use(errorCatch);

// logger
app.use(async (ctx, next) => {
  console.log(ctx.req.body, 'body');
  await next();
})

// 通過 ctx.throw
app.use(async (ctx, next) => {
  //will NOT log the error and will return `Error Message` as the response body with status 400
  ctx.throw(400,'Error Message');
}); 

// router 的某個中介軟體
router.get('/error', async (ctx, next) => {
  if(1) {
    throw new Error('錯誤測試')
  }
  await next();
})

// 最後的兜底
app.on('error', (err, ctx) => {
  /* centralized error handling:
   *   console.log error
   *   write error to log file
   *   save error and request information to database if ctx.request match condition
   *   ...
  */
});

複製程式碼

最後

本文的程式碼都存放於

總的來說,目前 async 結合 promise 去處理 js 的非同步錯誤會是比較方便的。另外,成熟的框架(react、koa)對於錯誤處理都有不錯的方式,儘可能去看一下官方是如何處理的。

這只是我對 js 中處理非同步錯誤的一些理解。不過前端的需要捕獲異常的地方有很多,比如前端的程式碼錯誤,cors 跨域錯誤,iframe 的錯誤,甚至 react 和 vue 的錯誤我們都需要處理,以及異常的監控和上報,以幫助我們及時的解決問題以及分析穩定性。採取多種方案應用到我們的專案中,讓我們不擔心頁面掛了,或者又報 bug 了,才能安安穩穩的去度假休息?

最後的最後,blog地址: github.com/sunyongjian…

相關文章