微信小程式捕獲async/await函數異常實踐

  • 2019 年 10 月 3 日
  • 筆記

背景

我們的小程式項目的構建是與web項目保持一致的,完全使用webpack的生態來構建,沒有使用小程式自帶的構建功能,那麼就需要我們配置程式碼轉換的babel插件如PromiseProxy等;另外,項目中涉及到非同步的功能我們統一使用async/await來處理。我們知道,小程式的onError 生命周期只能捕獲同步錯誤,而完全不採用小程式自帶構建工具的情況下,開發模式下遇到的問題:

小程式非同步程式碼中的異常onError無法捕獲,開發者工具控制台也沒有拋出異常資訊

這樣在開發過程中頁面展示異常,但是無任何異常資訊輸出,只有程式碼單步調試時走到異常之處才能發現異常發生的地方,這對開發者很不友好。下面就來說說項目在完全用webpack構建情況下如何在小程式項目中捕獲非同步程式碼方面的實踐。

幾個需要知道的知識點

首先,在切入正文之前介紹幾個知識點:

  • 小程式onError只能捕獲同步程式碼錯誤,不能捕獲非同步程式碼錯誤。

    具體原因是因為小程式在內部實現時會對邏輯層的js方法進行try-catch封裝,對於其中的非同步程式碼異常則不能捕獲。

  • try-catch不能捕獲非同步異常,但是可以捕獲async/await函數異常。

    如下面程式碼的異常try-catch可以捕獲:

    function asyncFn() {      try {          await exectionFn()      } catch(err) { // exectionFn函數發生的異常可以及時被catch住          console.error(err)      }  }
  • 小程式項目程式碼中無法訪問window對象,並不意味著其脫離web渲染。

    這一點對自定義的babel轉換配置來說尤其需要注意,小程式無法訪問window對象,即使通過Function('return this')()來訪問全局作用域也不起作用,因為小程式重寫了Function,如下圖源碼;具體可以查看從微信小程式開發者工具源碼看實現原理(一)- – 小程式架構設計這篇文章。

    那麼,就不能通過window訪問該對象上的api,例如window.Promise。這對根據window是否定義過指定api來判斷是否對其轉換的babel插件來說意味著,不管怎樣都會對
    用到的es6新的api進行轉換,即使瀏覽器已經內置了該api的實現。

    例如babel-runtime在轉換Promise時就採用polyfill的實現機制,而不是內置實現機制,帶來的問題是:

    Promise的polyfill實現,程式碼產生的異常在不用Promise.catch或者unhandledrejection事件進行捕獲的情況下也不會向上拋異常(小程式開發者工具控制台無法得到錯誤資訊),而內置的原生實現則會向上拋

    這也是為什麼採用自定義babel程式碼轉換配置時,控制台無法捕獲到非同步程式碼異常資訊的原因。

    順便說一下,有小程式經驗的同學可能會問,用小程式自帶的es6轉es5程式碼轉換構建時,非同步程式碼中的異常是可以在小程式開發者工具控制台捕獲到的啊;這是因為小程式自帶的源碼轉換隻對es6的語法進行轉換,而沒有對像Promise這樣的api進行轉換,所以其使用的是原生的Promise實現。

  • babel在轉換async/await非同步時會有兩層try-catch封裝

    babel是如何轉換async/await的可以看看這篇文章 。下面簡單看一下async/await的程式碼轉換的兩層try-catch封裝。

    例如如下程式碼:

    function test() {      console.log('hello async')  }

    轉換後的程式碼如下圖:

    其中,mark方法返回的函數,調用該函數原型上的方法會被加上try-catch,如下圖:

    另外,wrap方法的參數函數callee$也會被try-catch包裹,如下

    function tryCatch(fn, obj, arg) { // fn為wrap方法的函數參數_callee$      try {        return { type: "normal", arg: fn.call(obj, arg) };      } catch (err) {        return { type: "throw", arg: err };      }    }

    這樣,async/await非同步方法發生異常時首先會被轉換程式碼中的tryCatch捕獲,最終轉換程式碼會通過throw將異常拋出,而其會被上層的try-catch捕獲到,其最終會通過調用Promise的reject方法來處理,程式碼如上圖所示。

小程式捕獲async/await非同步程式碼異常實現

上面提到,try-catch可以捕獲到async/await程式碼中的異常,利用這一點我們可以對async函數添加try-catch封裝來捕獲其中異常錯誤資訊。但是手動的為每個async函數添加try-catch過於機械,並且對已有項目均需要添加。為此我們可以利用webpack loader來對程式碼進行轉換,自動為async函數添加try-catch封裝。例如:

async function test() {   console.log('hello async')  }

轉換為:

async function test(){      try{          console.log('hello async')      }catch(err) {          console.error('async test函數異常:', err)      }  }

具體的轉換規則如下:

  • 只對async函數進行轉換,其他的函數不轉換,若滿足則看第二點

  • async函數整個函數體若有try-catch則不進行轉換,否則進行轉換。

我們寫的源碼其實就是字元串,對源碼進行轉換其實就是對字元串內容進行轉換,可以想到兩種方式來實現:

  • 字元串配合正則

    這種方式需要利用字元串的相關API(如replace、substring等)並配合正則表達式來實現,是一種粗粒度的轉換,並且對正則的要求比較高。

  • 抽象語法樹(AST)

    這種方式將源碼轉換為JSON對象,可以更精細地對源碼進行轉換。例如下面程式碼

    function test() {      console.log('hello async');  }

    經ast轉換後生成的如下JSON內容以tree結構如下圖:

    可以自己嘗試在網站https://astexplorer.net在線查看程式碼轉換結果。具體的ast可以參考babel手冊對其的介紹。

因為我們使用webpack來構建項目,所以利用webpack loader對字元串程式碼進行AST轉換是自然而然的事。webpack loader的原理本文就不做過多介紹,類似文章有很多,不熟悉的可以自行google。

因為小程式項目都是使用Page(object)或者Component(object),因此我們將程式碼變換範圍縮小為Page或者Component方法的對象參數中的async函數。

loader開發

webpack loader接收源碼字元串,要經過三個步驟來完成程式碼轉換,babel6/7分別有對應的npm包來負責處理,例如babel7中:

  • 程式碼解析,將程式碼解析為AST,由@babel/parser負責完成

  • AST轉換,遍歷並操作AST來改變源碼,由@babel/traverse負責遍歷AST,輔助@babel/types負責操作變換

  • 程式碼生成,根據變換後的AST生成程式碼,由@babel/generator負責完成

根據上面提到的,我們只對Page和Component方法中傳入的對象參數中的async函數進行轉換,所以我們對AST的ObjectMethod進行轉換。

const parser = require('@babel/parser');  const traverse = require('@babel/traverse').default;  const generate = require('@babel/generator').default;  const t = require('@babel/types');    module.exports = function(source) {      let ast = parser.parse(source, {sourceType: 'module'}); // 支援es6 module        traverse(ast, {        ObjectMethod(path) {          ...        }      });     return generate(ast).code  }

根據上面程式碼轉換規則,只對整個函數體沒有被try-catch包裹的aysnc函數進行轉換,若有則不進行轉換。

const vistor = {      ObjectMethod(path) {        const isAsyncFun = t.isObjectMethod(path.node, {async: true});        if (isAsyncFun) {          const currentBodyNode = path.get('body');          if (t.isBlockStatement(currentBodyNode)) {            const asyncFunFirstNode = currentBodyNode.node.body;              if (asyncFunFirstNode.length === 0) {              return;            }            if (asyncFunFirstNode.length !== 1 || !t.isTryStatement(asyncFunFirstNode[0])) {              let catchCode = `console.error("async ${path.get('key').node.name}函數異常: ", err)`;              let tryCatchAst = t.tryStatement(                currentBodyNode.node,                t.catchClause(                  t.identifier('err'),                  t.blockStatement(parser.parse(catchCode).program.body)                )              );              currentBodyNode.replaceWithMultiple([tryCatchAst]);            }          }        }      }    };

loader使用

一般loader使用是通過webpack來配置loader適用的匹配規則的,如js文件使用loader配置一樣:

{      test: /.js$/,      use: "babel-loader"  }

但是對於使用滴滴開源的MPX來搭建的小程式項目,其跟vue類似:模板、js、樣式以及頁面配置JSON內容寫在一個後綴為.mpx文件中;其配套提供的@mpxjs/webpack-plugin包自帶loader來處理該後綴文件,其作用與vue-loader類似,將模板、js、css和json內容轉換以loader內聯的方式來進行分別處理。

例如對index.mpx文件經過該loader輸出內容如下圖:

這樣就對不同的內容處理成選擇對應的loader以內聯方式來處理。而我們處理async函數的loader是要對mpx文件中的js內容進行轉換,所以就不能直接像上面配置js文件使用babel-loader來處理一樣;我們需要在babel-loader處理轉換js內容之前添加自定義loader,即在處理js內容的內聯loader字元串中加入自已的loader。

如何加呢?我們可以利用webpack的插件機制,在webpack解析模組時修改內聯loader內容,正好webpack提供了normalModuleFactory鉤子函數:

const path = require('path');  const asyncCatchLoader = path.resolve(__dirname, './mpx-async-catch-loader.js');  class AsyncTryCatchPlugin {    constructor(options) {      this.options = options;    }      apply(compiler) {      compiler.hooks.normalModuleFactory.tap('AsyncTryCatchPlugin', normalModuleFactory => {        normalModuleFactory.hooks.beforeResolve.tapAsync('AsyncTryCatchPlugin', (data, callback) => {          let request = data.request;          if (/!+babel-loader!/.test(request)) {            let elements = request.replace(/^-?!+/, '').replace(/!!+/g, '!').split('!');            let resourcePath = elements.pop();            let resourceQuery = '?';            const queryIdx = resourcePath.indexOf(resourceQuery);            if (queryIdx >= 0) {              resourcePath = resourcePath.substr(0, queryIdx);            }            if (!/node_modules/.test(data.context) && /.mpx$/.test(resourcePath)) {              data.request = data.request.replace(/(babel-loader!)/, `$1${asyncCatchLoader}!`);            }          }          callback(null, data);        });      });    }  }    module.exports = AsyncTryCatchPlugin;

這樣添加該插件後,該loader就會對mpx文件的js內容添加對async函數的轉換;目前該loader插件只用在開發環境,通過console.error方法在控制台列印出錯非同步方法的堆棧資訊,及時發現開發過程遇到的問題,增強開發者的開發體驗。

參考文獻