Vue2.0中app.config.errorhandler在Sentry是如何抓取异常的
喜欢我,欢迎关注我ヽ(✿゚▽゚)ノ
上一篇讲了Vue中的应用配置,其中errorhandler比较常用,并且在sentry中也有应用,接下来我们就分析一下在sentry中是怎么使用errorhandler抓取vue在线上环境中的报错
项目准备以及环境配置
我们到https://github.com/getsentry/sentry-javascript的中,将项目git clone下来。进入项目后打开CONTRIBUTING.md文件,这里面会详细解析,项目要如何运行,如何打包,如何开发。
首先我们需要全局安装lerna和yarn这两个工具npm i -g lerna yarn
,因为sentry-javascript就是依赖这两个工具构建的。然后依次执行如下命令,这样之后整个项目的初始化就完成了,接下来就可以运行所有命令来开发了。
$ yarn
$ yarn lerna bootstrap
$ yarn build
接下来,我们进入packages/vue文件夹打开package.json文件,这里我们将集中在build:bundle:watch
这条命令即可,这个命令会将文件实时打包成支持浏览器能访问的模式。又因为sentry-javascript是lerna的开发模式,我们想执行packages/vue下的script命令,就必须要通过lerna命令 。
我们执行lerna run build:bundle:watch --scope=@sentry/vue
命令后,lerna会去找到@sentry/vue
包名下的build:bundle:watch
命令去执行。这时我们修改packages/vue/src下的任意文件,都会被编译到packages/vue/build文件下,编译成浏览器可执行的文件。
$ lerna run build:bundle:watch --scope=@sentry/vue
info cli using local version of lerna
lerna notice cli v3.13.4
lerna info filter [ '@sentry/vue' ]
lerna info Executing command in 1 package: "yarn run build:bundle:watch"
接下来packages/vue/build下新建一个html文件,我们引入在其中引入当前目录下的bundle.vue.js和在vue官网提供的cdn引入方式vue@next文件。并且在sentry官网找到基于vue3.0的配置demo复制到其中。并且开启debug模式,方面我们调试。
<head>
<script ></script>
<script ></script>
</head>
<body>
<div id="app">
<button @click="throwError">Throw error</button>
</div>
</body>
<script>
const { createApp } = Vue;
const app = createApp({
methods: {
throwError() {
throw new Error('Sentry Error');
}
}
});
app.mount('#app')
Sentry.init({
dsn: "https://[email protected]/0",
app,
debug: true,
});
</script>
</html>
如果你根据我的方法一步一步配置下来,最后只需要在chrome中打开刚刚添加的index.html文件。你就能得到一个sentry+vue3.0最小的demo形式。并且你还可以调试sentry源码进行测试。
正戏开始
找到入口方法和入口文件
Sentry.init({
dns:"xxx",
app,
})
这里我们引入的sentry的vue版本,那么我们进入项目文件查找,找到init方法,看做了什么。打开项目中的packages/vue/package.json文件,核心观察script命令集合,我们前面执行的是$ lerna run build:bundle:watch --scope=@sentry/vue
命令,那么我们就根据命令build:bundle:watch
查看项目究竟执行了什么,我们的最终目的是找到入口文件。
{
"scripts": {
"build:bundle:watch": "rollup --config --watch",
}
}
这里发现,执行build:bundle:watch
也就是执行了rollup --config --watch
,这里我们得知,sentry使用了rollup打包,并且使用了独立配置rollup.config.js
文件。那么我们就找到了项目打包文件了。接下来我们在项目中打开rollup.config.js
文件。
这里我们优先观察export default
导出了什么哪些文件,并且我们在inedx.html文件中引入的是什么文件,找到对应文件之后,我们就可以得出bundle.vue.js
文件的入口文件是那个了。
export default [
// ES5 Browser Tracing Bundle
{
...bundleConfig,
input: 'src/index.bundle.ts',
output: {
...bundleConfig.output,
file: 'build/bundle.vue.js',
},
plugins: bundleConfig.plugins,
},
// ...
];
这里我们关注input参数和output.file参数即可,output.file指打包后的输出文件。和我们html文件中引入的文件对上之后,看input参数,这里的是src/index.bundle.ts,那么我们就可以确定入口文件是index.bundle.ts文件。接下来我们只需要打开src/index.bundle.ts文件,找寻init方法。其他我们都不需要关注,这里就明确了,init方法,是从./sdk文件导入的。接下来我们直接打开./sdk.ts文件即可。
// ...
export { init } from './sdk';
//...
这里我们确定了入口文件src/index.bundle.ts,以及init方法的导出文件src/sdk.ts文件
init方法做了什么
这里我们核心关注options常量、vueInit方法、attachErrorHandler方法
// 这里我简化了一下,完全代码可以看源码
const DEFAULT_CONFIG: Options = {/* ... */};
export function init(config): void {
const options = {
...DEFAULT_CONFIG,
...config,
};
//...
if (options.app) {
const apps = Array.isArray(options.app) ? options.app : [options.app];
apps.forEach(app => vueInit(app, options));
} else if (options.Vue) {
vueInit(options.Vue, options);
}
}
const vueInit = (app: Vue, options: Options): void => {
attachErrorHandler(app, options);
// ...
};
这里options是初始化配置,它是由将DEFAULT_CONFIG默认配置和config用户传入配置合并而成的。接下来会执行一个判断判断app参数是否存在,以及是否是数组,然后去一一执行vueInit方法,vueInit方法就简单了执行,将vue的app实例和options丢入attachErrorHandler方法中执行,这就是init方法的核心执行。那么我们可以得知init方法只是一个单纯的初始化方法,核心执行的方法是attachErrorHandler。
attachErrorHandler方法的由来,以及执行了什么
根据sdk.ts文件中的import { attachErrorHandler } from'./errorhandler';
我们可以得知,attachErrorHandler方法是errorhandler.ts中导出的,那么我们打开errorhandler.ts文件看看核心执行了什么。
export const attachErrorHandler = (app: Vue, options: Options): void => {
const { errorHandler, warnHandler, silent } = app.config;
app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => {
const componentName = formatComponentName(vm, false);
const trace = vm ? generateComponentTrace(vm) : '';
const metadata: Record<string, unknown> = {
componentName,
lifecycleHook,
trace,
};
if (vm && options.attachProps) {
// Vue2 - $options.propsData
// Vue3 - $props
metadata.propsData = vm.$options.propsData || vm.$props;
}
// Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time.
setTimeout(() => {
getCurrentHub().withScope(scope => {
scope.setContext('vue', metadata);
getCurrentHub().captureException(error);
});
});
if (typeof errorHandler === 'function') {
(errorHandler as UnknownFunc).call(app, error, vm, lifecycleHook);
}
// ...
};
};
我们来拆解一下attachErrorHandler方法,它首先取出了app.config的errorHandler方法,然后获取componentName组件名称,trace组件树,组合成metadata变量。然后判断attachProps是否存在,如果存在将在metadata中扩展propsData属性,然后通过一个定时器执行getCurrentHub系列方法,将metadata和error丢入sentry的数据总线,等sentry处理好数据之后,通过fetch或xhr发送到服务器。if (typeof errorHandler === 'function')
判断是防止如果用户已经定义了errorHandler时,通过call的方式,执行方法,不破坏用户定义的方法。
就这样完了
到这里就我们观察sentry/vue就结束了,其中核心与vue相关的方法就是attachErrorHandler,其中利用app.config.errorHandler去上报vue中异常报错,并且利用seTimeout将事件上报维护为一个event loop任务中,保证了事件上报的有序性。最后通过判断errorHandler的类型,判断用户是否定义过,然后利用call来处理用户定义代码,避免和用户定义发生冲突。
有意思的方法
formatComponentName
获取当前vm实例的name参数,vm不存在,返回<Anonymous>,vm为最顶级返回<Root>
const formatComponentName = (vm, includeFile) => {
const ROOT_COMPONENT_NAME = '<Root>';
const ANONYMOUS_COMPONENT_NAME = '<Anonymous>';
const classifyRE = /(?:^|[-_])(\w)/g;
const classify = (str) => str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '');
if (!vm) {
return ANONYMOUS_COMPONENT_NAME;
}
if (vm.$root === vm) {
return ROOT_COMPONENT_NAME;
}
const options = vm.$options;
let name = options.name || options._componentTag;
const file = options.__file;
if (!name && file) {
const match = file.match(/([^/\\]+)\.vue$/);
if (match) {
name = match[1];
}
}
return (
(name ? `<${classify(name)}>` : ANONYMOUS_COMPONENT_NAME) + (file && includeFile !== false ? ` at ${file}` : ``)
);
};
generateComponentTrace
这是Vue2.0中的获取当前组件的全部上级,在Vue3.0中这段被重构了暂且不表,只以这段代码为例。其中if ((vm?._isVue || vm?.__isVue) && vm?.$parent)
首先判断当前vm是不是一个Vue实例,并且利用vm?.$parent
判断有没有上级,然后创建tree和currentRecursiveSequence两个变量,tree用来存储所有vm.$parent,currentRecursiveSequence用来记录层级。其中if (last.constructor === vm.constructor)
用来判断当前vm是不是一个全局组件,如果是currentRecursiveSequence就加一。然后将当前vm推入tree中,并且利用vm.$parent
api来获取上级一vm。当whle遍历完时,tree也就获取了当前vm的全部组件vm,然后通过map,其中调用repeat、formatComponentName方法,组成信息返回。
const generateComponentTrace = (vm) => {
if ((vm?._isVue || vm?.__isVue) && vm?.$parent) {
const tree = [];
let currentRecursiveSequence = 0;
while (vm) {
if (tree.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const last = tree[tree.length - 1];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (last.constructor === vm.constructor) {
currentRecursiveSequence += 1;
vm = vm.$parent; // eslint-disable-line no-param-reassign
continue;
} else if (currentRecursiveSequence > 0) {
tree[tree.length - 1] = [last, currentRecursiveSequence];
currentRecursiveSequence = 0;
}
}
tree.push(vm);
vm = vm.$parent; // eslint-disable-line no-param-reassign
}
const formattedTree = tree
.map(
(vm, i) =>
`${(i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) +
(Array.isArray(vm)
? `${formatComponentName(vm[0])}... (${vm[1]} recursive calls)`
: formatComponentName(vm))
}`,
)
.join('\n');
return `\n\nfound in\n\n${formattedTree}`;
}
return `\n\n(found in ${formatComponentName(vm)})`;
};
function repeat(str, n) {
let res = '';
while (n) {
if (n % 2 === 1) {
res += str;
}
if (n > 1) {
str += str;
}
n >>= 1;
}
return res;
};
总结
这里我们从一个项目的分析寻找入口开始,然后逐步拆解到具体代码。当我们拿到一个新项目时,我们要做的第一件事情应该是阅读package.json文件,看看这个项目使用了什么工具,scripts命令有哪些,打包产出目录是什么,main属性的配置是什么,当我们找到入口之后,再去查看项目的具体代码,形成一个总线式的路径,方便我们阅读项目。
快点动动你的小手指,关注一下我吧。φ(≧ω≦*)♪