微信小程式捕獲async/await函式異常實踐
背景
我們的小程式專案的構建是與web專案保持一致的,完全使用webpack的生態來構建,沒有使用小程式自帶的構建功能,那麼就需要我們配置程式碼轉換的babel外掛如Promise
、Proxy
等;另外,專案中涉及到非同步的功能我們統一使用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物件,那麼該物件上的api就無法訪問,例如
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方法在控制檯打印出錯非同步方法的堆疊資訊,及時發現開發過程遇到的問題,增強開發者的開發體驗。
參考文獻
loader API
babel-handbook
深入Babel,這一篇就夠
babel內部機制研究