利用Decorator和SourceMap优化JavaScript错误堆栈
配合源码阅读体验更佳。
最近收到用户吐槽 @cloudbase/js-sdk(云开发Cloudbase的JavaScript SDK)的报错信息不够清晰,比如下面这条报错:
这属于业务型报错,对于熟悉云开发能力细节的用户一眼就能看出错误的症结出在安全规则配置上,但是对于刚接触云开发的新用户或者之前没有遇到类似问题的用户来说,看到这样简短的错误信息肯定会一头雾水,分不清楚到底是业务报错还是代码写的不对。所以大部分人的第一反应是按照Error的堆栈信息进行debug,试图找到抛出Error的具体代码。然后就会遇到另一个让人头疼的问题:Error堆栈太深了,要想找到是哪一行代码引起的报错并不是一件很容易的事。
虽然云开发是一款toB的产品,相对来说B端开发者的容忍度会「略」高于C端用户,但是糟糕的开发体验肯定是会拉低开发者对产品的好感和认可度。所以优化报错信息成了一件必须要做的事情。
在详述优化方案之前,先看一下最终的优化效果:
图中打印的错误跟第一张图是同一个,代表当前的登录类型受到函数的安全规则限制,导致没有调用函数的权限。错误信息分为两部分:
- 上半部分的黑色字体提示包含了后端 API 返回的错误信息以及针对此类问题的一些解决方案建议;
- 下半部分的红色字体是经优化后的错误堆栈,第一条直接定位到 SDK 源码(
index.ts
),第二条直接定位到调用报错 API 的业务源码(App.callFn
)。
看到index.ts
这样的信息估计大部分人都明白这里用到了SourceMap。确实SourceMap是支撑这套优化方案的必备要素,借助SourceMap可以定位到SDK的源码。但只有SourceMap是不够的,优化的核心点在于:如何把原始错误冗长的堆栈中直接定位到关键代码行?
这就是优化的目标。
有了目标之后的第一步要做的不是立即去扣实现细节,而是设计整体方案,包括两部分:
- 第一是确定优化的对象。
是不是所有的类型的报错堆栈都需要优化?答案是否定的。优化的对象应该是业务报错,具体到代码就是SDK的public API。其他类型的错误(比如SDK自身的语法错误)是应该在发布SDK之前开发团队自测解决的,不应该被带给用户。只针对业务报错这一前提给优化方案一个基调:所有的错误信息格式是固定的(如果做不到这一点就说明SDK不合格)。 - 第二是确定接入方式。
优化的目的是改善体验,必须做到一点:不侵入SDK的原本逻辑。这个前提堵死了一条最容易也是最笨的路:直接改SDK的API代码,对所有的关键代码块加一层try-catch
。所以接入的方式必然是一种类似插件的机制,并且成本低、可定制。
除了以上两点之外,还有一个重要的问题需要提前确定:Error应该在SDK代码的什么位置抛出?举个例子,当业务代码调用SDK提供的callFunction
API后,SDK内部再发起网络请求之前有一些前序逻辑,比如判断入参是否正确、获取本地登录态信息等等。如果不做任何处理的话,当发生错误时抛出的Error堆栈是最内层的代码行,如下图:
但是用户关心的只是callFunction
成功还是失败,不会在意这个API内部是如何工作的,内层的Error堆栈对于用户来说没有任何帮助甚至由于加深了堆栈层级反而加重了debug难度。所以期望最佳的效果是由callFunction
所在的代码行抛出Error,最笨的实现方案就是为callFunction
的逻辑块整体包一层try-catch
统一抛出Error,但可惜这条路已经被堵死了。
那么剩下的唯一办法就是精简由内层逻辑抛出的Error的堆栈,把内层逻辑的堆栈全部剔除,只保留到最外层的callFunction
。
梳理一下上面的内容可以得出优化方案的关键信息:
选项 | 说明 |
---|---|
优化对象 | 只针对业务型逻辑报错,错误格式固定 |
接入方式 | 不侵入SDK原本逻辑,使用类似插件的机制 |
预期目标 | 精简Error堆栈,剔除无用条目直接定位到 SDK 的 API代码行 |
精简Error堆栈的基本思路是在SDK的API代码块内捕获内层逻辑抛出的Error,然后重新new一个Error对象抛出,这种方式可以将内层逻辑的堆栈全部消除。实现方式也很简单,在API代码块内用try-catch
包装内存逻辑即可,但这样会涉及修改API原本逻辑,而且工作量也不小,所以行不通。
即不侵入API原本逻辑,又能够影响API的表现,首先想到的便是装饰器Decorator。
Decorator
Decorator的优势有两点:
- 不侵入SDK原本逻辑,接入成本很低,只需要几行代码;
- TypeScript将Decorator编译为ES5语法之后有固定的格式,可以方便地在Error堆栈中找出对应的代码行,为精简Error堆栈提供便利。
写到这里其实大体的思路就定型了,步骤如下:
- 给API添加Decorator;
- 在Decorator内将API重新赋值,保持原本逻辑的前提下,为原本逻辑包装
try-catch
。
大致代码如下:
function catchErrorsDecorator(options){
return function(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<Function>
){
const fn = descriptor.value;
// 重新被装饰的API原本逻辑
descriptor.value = function(...args:any[]) {
try {
return fn.apply(this, args);
} catch (err) {
throw err;
}
}
}
}
然后为API添加装饰器:
class Cloudbase {
@catchErrorsDecorator({
// ...options
})
public init(){
// ...
}
}
这样修改后调用API的行为方式被修改为执行Decorator的逻辑。但是在Decorator的catch
代码块中抛出的Error对象没有经过任何处理,仍然是API抛出的Error对象,也就是说同样携带着API内层逻辑的堆栈信息。接下来的工作就是想办法把堆栈信息精简。
精简Error堆栈
首先缕一下当附加Decorator的API被调用时的堆栈顺序,同样是以上文提到的callFunction
为例,当外层业务逻辑调用这个API时整体的链路如下图所示:
这只是源码的链路,实际上使用TypeScript或ES6语法编写的源码需要经过语法转换或者引入polyfill才能在浏览器中运行,所以实际上的链路长度远远大于上图,尤其是async
函数(因为目前的语法转译通常会把async/await
转化为generator)。这也是造成错误堆栈层次太深的主要原因之一。
上文提到的catchErrorsDecorator
的工作分两步:
- 第一步是Decorator自身的逻辑,也就是复写API原本逻辑的代码块,这一步是给API添加Decorator之后立即执行的;
- 第二步是当外层逻辑调用
callFunction
之后,执行descriptor.value
内部逻辑。
这两个步骤并不是连续的,而是分属于两条链路,第一条发生在SDK初始化时,第二条发生在外层逻辑调用API时。
在SDK初始化的链路内,Decorator的第一步逻辑的前序环节是初始化被装饰的API,所以在这里可以拿到原API的源码行,可以借助Error.stack取到,如下:
/**
* decorate在stack中一般都特定的规范
*/
const REG_STACK_DECORATE = isFirefox ?
/(\.js\/)?__decorate(\$\d+)?<@.*\d$/ :
/(\/\w+\.js\.)?__decorate(\$\d+)?\s*\(.*\)$/;
const REG_STACK_LINK = /https?\:\/\/.+\:\d*\/.*\.js\:\d+\:\d+/;
function catchErrorsDecorator(options){
return function(
target: any,
methodName: string,
descriptor: TypedPropertyDescriptor<Function>
){
let sourceLink = '';
const outterErrStacks = (new Error()).stack.split('\n');
const indexOfDecorator = outterErrStacks.findIndex(str=>REG_STACK_DECORATE.test(str));
if(indexOfDecorator!==-1){
const match = REG_STACK_LINK.exec(outterErrStacks[indexOfDecorator+1]||'');
sourceLink = match?match[0]:'';
}
const fn = descriptor.value;
// 重新被装饰的API原本逻辑
descriptor.value = function(...args:any[]) {
const innerErr = getRewritedError({
err: new Error(),
className,
methodName: fnName,
sourceLink
})
try {
return fn.apply(this, args);
} catch (err) {
throw err;
}
}
}
}
之所以把获取原API代码行的逻辑放在Decorator的第一步,是由于此时距离原API的堆栈层数比较浅,而如果放到第二步(即descriptor.value
内部)获取,则有可能由于堆栈太深取不到。
这里需要说明的一点,获取原API代码行是通过匹配Error.stack信息。调用throw Error或console.error后在浏览器的控制台打印的堆栈是完整的,但是浏览器在返回Error.stack信息时并不是将全部的堆栈返回,而是只返回最前列的几条,一般是5-10条。这也是为何将获取原API代码行的逻辑放在descriptor.value
外执行的主要原因。
另外在上述代码中添加了如下一段逻辑:
const innerErr = getRewritedError({
err: new Error(),
className,
methodName: fnName,
sourceLink
})
其中工具函数getRewritedError
的作用是在Error.stack中找到执行descriptor.value
的前一条信息,这条信息便是外层逻辑调用callFunction
时执行被复写的callFunction
API的代码行,而这条信息之前(Error堆栈是倒序排列)的所有堆栈都是callFunction
的内层逻辑,是要被剔除的无用信息。
getRewritedError
函数的代码比较长就不写了,感兴趣的可以去看源码。
接下来的工作就简单了,从Error.stack中过滤无用的信息,然后把descriptor.value
条目的链接替换为先前拿到的原API代码行,最后new一个Error对象将其stack替换为处理之后的在抛出即可。
边角料工作
截止到这里,优化工作的核心内容就已经完成了,剩下的就是完善一下逻辑支持更丰富的场景,比如:
- 支持同步和异步两种模式;
- 用
console.group
打印错误信息和解决方案建议; - 兼容多种构建工具(Webpack和Rollup,不同的构建工具混淆后的Decorator堆栈有略微差异);
- 兼容多种浏览器(不同浏览器内核的堆栈格式有差异)
等等。这些小事就不写了,感兴趣的可以去阅读源码。
最终的接入方式就是import这个Decorator,然后为API添加装饰器,如下:
class Cloudbase {
@catchErrorsDecorator({
//同步模式
mode: 'sync',
// title和message是错误提示信息,可定制
title: 'Cloudbase 初始化失败',
messages: [
'请确认以下各项:',
' 1 - 调用 cloudbase.init() 的语法或参数是否正确',
' 2 - 如果是非浏览器环境,是否配置了安全应用来源(https://docs.cloudbase.net/api-reference/webv2/adapter.html#jie-ru-liu-cheng)',
`如果问题依然存在,建议到官方问答社区提问或寻找帮助:${COMMUNITY_SITE_URL}`
]
})
public init(options){
// ...
}
}
最后值得一提的是,这种优化方案只支持在开发环境下使用,一是因为逻辑比较繁琐,带入到生产环境中会产生不必要的资源消耗;二是由于生产环境的js通常是所有模块打包到一起并且经过混淆,造成堆栈信息难以定位。
利用Decorator和SourceMap优化JavaScript错误堆栈的更多相关文章
- 利用Image对象,建立Javascript前台错误日志记录
手记:摘自Javascript高级程序设计(第三版),利用Image对象发送请求,确实有很多优点,有时候这也许就是一个创意点,再次做个笔记供自己和大家参考. 原文: 开发 Web 应用程序过程中的一种 ...
- 利用模板将HTML从JavaScript中抽离
利用模板将HTML从JavaScript中抽离 一.当需要注入大段的HTML标签到页面中时,应该使用服务器渲染(从服务器加载HTML标签) 该方法将模板放置于服务器中使用XMLHttpRequest对 ...
- JavaScript错误/异常处理
JavaScript Try...Catch 语句 介绍:JavaScript中的try...carch语句的作用和C#中的try...catch语句的作用一样, 都是捕获并处理异常. 语法: try ...
- javascript错误处理与调试(转)
JavaScript 在错误处理调试上一直是它的软肋,如果脚本出错,给出的提示经常也让人摸不着头脑. ECMAScript 第 3 版为了解决这个问题引入了 try...catch 和 throw 语 ...
- JavaScript错误处理
JavaScript 错误 - Throw.Try 和 Catch JavaScript 测试和捕捉 try 语句允许我们定义在执行时进行错误测试的代码块. catch 语句允许我们定义当 try 代 ...
- 第一百二十三节,JavaScript错误处理与调试
JavaScript错误处理与调试 学习要点: 1.浏览器错误报告 2.错误处理 3.错误事件 4.错误处理策略 5.调试技术 6.调试工具 JavaScript在错误处理调试上一直是它的软肋,如果脚 ...
- 【转】Javascript错误处理——try…catch
无论我们编程多么精通,脚本错误怎是难免.可能是我们的错误造成,或异常输入,错误的服务器端响应以及无数个其他原因. 通常,当发送错误时脚本会立刻停止,打印至控制台. 但try...catch语法结构可以 ...
- [置顶] 利用Global.asax的Application_Error实现错误记录,错误日志
利用Global.asax的Application_Error实现错误记录 错误日志 void Application_Error(object sender, EventArgs e) { // 在 ...
- 工作经验:Java 系统记录调用日志,并且记录错误堆栈
前言:现在有一个系统,主要是为了给其他系统提供数据查询接口的,这个系统上线不会轻易更新,更不会跟随业务系统的更新而更新(这也是有一个数据查询接口系统的原因,解耦).这时,这个系统就需要有一定的方便的线 ...
随机推荐
- luogu P3830 [SHOI2012]随机树 期望 dp
LINK:随机树 非常经典的期望dp. 考虑第一问:设f[i]表示前i个叶子节点的期望平均深度. 因为期望具有线性性 所以可以由每个叶子节点的期望平均深度得到总体的. \(f[i]=(f[i-1]\c ...
- source命令用法:source FileName
转自https://zhidao.baidu.com/question/59790034.html 写得很清楚,就直接搬过来了备忘 作用:在当前bash环境下读取并执行FileName中的命令. 注 ...
- 关于随机数 C++
void test() { srand();//这里设置了 说明又得从头开始循环一次了 //如果没有设置 它还是基于main函数里的srand(1) for(int i=;i<;i++) { c ...
- 自定制格式化方式format
自定制格式化方式format # x='{0}{0}{0}'.format('dog') # # print(x) # class Date: # def __init__(self,year,mon ...
- Linux输出缓存你知道多大吗?
今天看到这个代码很简单,就是验证一下Linux系统的输出缓存大小.当 猜一下这个代码的输出: #include <stdio.h> #include <string.h> #i ...
- Python嫌多(线程/进程)太慢? 嫌Scrapy太麻烦?没事,异步高调走起!——瓜子二手车
基本概念了解: 很多人学习python,不知道从何学起.很多人学习python,掌握了基本语法过后,不知道在哪里寻找案例上手.很多已经做案例的人,却不知道如何去学习更加高深的知识.那么针对这三类人,我 ...
- 18、Java中的 数据结构
Java2中引入了新的数据结构 集合框架 Collection,下一节再谈论(非常重要,面试也常问). 1.枚举 (Enumeration) 1.1 Enumeration 源码: public in ...
- 静态集成腾讯TBS X5内核WebView,从微信提取新版30M浏览器内核打包进apk
目录 前情提要 第一步:下载老版本SDK得到jar 获取SDK 集成SDK 步骤二.下载提取最新TBS X5内核 方法1:从微信中提取 方法2:App内内访问tbs调试页安装新内核 步骤三.集成内核到 ...
- java基础之字符串
以下内容摘自<java编程思想>第十三章. 1. 不可变 String String 对象是不可变对象,String 类中每一个看起来会修改 String 值的方法,实际上都是创建了一个全 ...
- java循环语句for与无限循环
一 for循环 for循环语句是最常用的循环语句,一般用在循环次数已知的情况下. 格式: for(初始化表达式; 循环条件; 操作表达式){ 执行语句 ……… } 循环流程: for(① ; ② ; ...