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