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


背景

我們的小程序項目的構建是與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方法在控制台打印出錯異步方法的堆棧信息,及時發現開發過程遇到的問題,增強開發者的開發體驗。

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM