这是一篇由浅入深地讲述如何对用qiankun
实现的微前端项目接入Sentry
的文章。在这篇文章中,我会列举描述两个接入方案,然后再细致地分析方案中涉及到的原理。
通过这篇文章,你将学会:
两种给自己的微前端项目接入Sentry
的方案以及这两种方案的优缺点
学习如何上传sourcemap
和处理以让Sentry
后台能精准定位错误
了解qiankun
的部分深入知识点
了解Sentry
的部分深入知识点
本文所使用的Sentry
的客户端版本为7.29.0
,服务端版本为23.1.0
,qiankun
版本为2.7.5
。
如果不了解微前端,可看我之前的一篇金选文章给 vue-element-admin 接入 qiankun 的微前端开发实践总结 。
对于为前端的监控系统,我们希望他能做到以下基本点:
对于第 1 点,目前我探索出的两种方案都不能完美做到,都留有着瑕疵。大家看看下面的方案分析来判断这点瑕疵是否对自己的项目影响大,从而决定是否使用以下方案。
对于第 2 点,会在本文的 如何处理sourcemap
章节中分析如何实现,因为两个方案中的处理方式都一致,而且这个不是本文的重点。
Sentry
所报的Error(错误)
和Transaction(性能)
需要根据其所属的应用进行区分上报。
查看Error
时,可根据错误栈和sourcemap
映射得知Error
是所发生在哪个应用的哪段源码上。
本文两个接入方案中,只在主应用里接入sentry
依赖,子应用不作接入处理(因为主应用和子应用同时接入sentry
依赖会报错,原因可看此处)。
本文项目中的主应用和子应用都带各自的Release(版本)
,且Release
不是固定的,而是随着迭代变化的。
接下来开始依次展示两个方案
在每个Issue
和Transaction
上报之前,都分别会触发Sentry
中的beforeSend
和beforeSendTransaction
钩子函数。我们来看一下官方对这两个钩子的代码示例:
Sentry.init({
dsn: "xxx",
// Called for message and error events
beforeSend(event) {
// Modify or drop the event here
if (event.user) {
// Don't send user's email address
delete event.user.email;
}
return event;
},
// Called for transaction events
beforeSendTransaction(event) {
// Modify or drop the event here
if (event.transaction === "/unimportant/route") {
// Don't send the event to Sentry
return null;
}
return event;
},
});
从上面例子可知,我们可以在钩子函数中修改event
的属性。我们再来通过console.log
看一下error event
和transaction event
是长什么样的:
error event
image.pngtransaction event
image.png可以观察到,两种event
都带有release
属性。当event
发送到服务端时,服务端会根据release
把event
分到不同的版本下。
那么我们可以有这样的设计思路:
在Sentry
的后台管理平台上建立一个主应用,一个主应用有多个release
。多个release
分别对应子应用和主应用的不同版本。如下所示:
在钩子函数中通过设计代码逻辑判断出event
来自哪个应用,然后修改event.release
。让event
在服务端分配到对应的release
上。
对于上述思路中,有人会提出疑问:为什么不把主应用和子应用放在不同的Project(项目)
里,而是放在同一Project
的不同Release(版本)
里?
在目前方案一中,我还不能成功实现让event
存放到不同Project
里。曾经试过按照使用 Sentry 做异常监控 - 如何优雅的解决 Qiankun 下 Sentry 异常上报无法自动区分项目的问题 ?这篇文章的思路在实现,但可能是因为版本不一致,导致我在发送event
到对应的dsn
时,子应用都响应失败,说是JSON
格式不正确,如下所示:
因此就搁置了这种方式。如果读者觉得遗憾,可以去看本文中的方案二(),在方案二中成功实现把主应用和子应用都拆分到不同Project
里,且Issue
和Transaction
都可以放到所属应用的Project
里。
纸上得来终觉浅,绝知此事要躬行。接下来通过代码来展示如何实现方案一的思路,整个实现过程可以总结成以下三步:
让被接入主应用的sentry
也能监听子应用的错误
这里主要针对以vue
为框架的子应用,vue
中涉及到组件的错误,例如:
上面这些错误都是由Vue.config.errorHandler
来进行捕获处理的,且处理后是不会被window.onerror
再次捕获的。我们也可以从以下[email protected]:src/core/util/error.ts
中的源码看出:
export function handleError(err: Error, vm: any, info: string) {
// Deactivate deps tracking while processing error handler to avoid possible infinite rendering.
// See: https://github.com/vuejs/vuex/issues/1505
pushTarget();
try {
if (vm) {
// 以下while操作中,会通过$parent获取组件到根组件的所有errorCaptured钩子函数。然后把依次把捕获到的error作为形参传入执行。
// 直至其中有errorCaptured返回true或最终由globalHandleError执行。
let cur = vm;
while ((cur = cur.$parent)) {
const hooks = cur.$options.errorCaptured;
if (hooks) {
for (let i = 0; i < hooks.length; i++) {
try {
const capture = hooks[i].call(cur, err, vm, info) === false;
if (capture) return;
} catch (e: any) {
globalHandleError(e, cur, "errorCaptured hook");
}
}
}
}
}
globalHandleError(err, vm, info);
} finally {
popTarget();
}
}
// globalHandleError内部其实就是调用了Vue.config.errorHandler,因为有try~catch包裹,因此错误不会被window.onerror捕获。
// 即使没有定义Vue.config.errorHandler,由于handleError在执行errorCaptured链是也是用try~catch包裹,因此错误也不会被window.onerror捕获。
function globalHandleError(err, vm, info) {
if (config.errorHandler) {
try {
return config.errorHandler.call(null, err, vm, info);
} catch (e: any) {
// if the user intentionally throws the original error in the handler,
// do not log it twice
if (e !== err) {
logError(e, null, "config.errorHandler");
}
}
}
logError(err, vm, info);
}
sentry
在初始化过程中,针对以vue
为框架的应用是会使用Vue.config.errorHandler
进行错误捕捉的。但由于在我们的方案中,sentry
依赖只在主应用中被接入,子应用不会被接入,从而导致以vue
为框架的子应用中,子应用的Vue.config.errorHandler
没有被sentry
使用,从而导致无法捕获和上传到这些子应用的vue
方面的错误。
想要捕获和上传子应用的错误,我们需要获取子应用的Vue.config.errorHandler
,然后交给sentry
进行处理。
生命周期钩子里的错误、
自定义事件处理函数内部的错误,例如在子组件中$emit('xxx')
触发父组件的事件
v-on
DOM 监听器内部抛出的错误
在method
中的处理函数抛出的错误或者返回的Promise
链中的错误
获取子应用的release
在加载子应用之前,主应用是不能预先知道子应用的release
的。因为子应用的release
是随着其迭代发版而变化的,其release
只能在主应用加载该子应用时,子应用把release
存放在window
的某个属性中,主应用在读取window
这个属性时得知。
在beforeSend
和beforeSendTransaction
中通过逻辑判断event
来自哪个应用,并修改其release
属性为子应用的release
这一步主要在于如何设计判断逻辑,error event
和transaction event
的判断逻辑不一样,具体逻辑会放在下文去展示。
Sentry
也能监听子应用的错误在上面 的分析中知道,我们要我们需要获取子应用的Vue.config.errorHandler
,然后交给sentry
进行处理。其实我们可以在主应用中创建一个Sentry
初始化vue
子应用的函数sentryInitForVueSubApp
,然后把该方法传给子应用,让子应用在初次加载时调用,如下所示:
// 在主应用中的逻辑
loadMicroApp({
name: "vue3 app",
entry: `//${location.host}/vue3-app/`,
container: "#app-vue3",
activeRule: "/app-vue3/index",
props: {
// 在props中把该初始化方法传给子应用
sentryInit: sentryInitForVueSubApp,
},
});
// 在Vue2子应用的逻辑
// 在子应用的mount钩子函数中调用
export async function mount(props) {
props.sentryInit?.(Vue, {
tracesSampleRate: 1.0,
logErrors: true,
attachProps: true,
});
instance = new Vue({
//...
}).$mount(props.container ? props.container.querySelector("#app") : "#app");
}
// 在Vue3子应用的逻辑
// 在子应用的mount钩子函数中调用
export async function mount(props: any) {
const { container, sentryInit } = props;
instance = createApp(App).use(pinia);
sentryInit?.(instance, {
tracesSampleRate: 1.0,
logErrors: true,
attachProps: true,
});
instance.mount(container ? container.querySelector("#app") : "#app");
}
子应用调用sentryInitForVueSubApp
时,其实就是调用在主应用中的sentry
来给子应用的Vue
进行监听处理。接下来我们来看看sentryInitForVueSubApp
的函数要怎么编写:
import { attachErrorHandler, createTracingMixins } from "@sentry/vue";
/**
* app用于传入Vue或者Vue示例
* options用于传入子应用的sentry配置
*/
function sentryInitForVueSubApp(app, options) {
// attachErrorHandler中,sentry会获取app.config.errorHandler进行处理
attachErrorHandler(app, options);
if ("tracesSampleRate" in options || "tracesSampler" in options) {
app.mixin(
// createTracingMixins用于在event中追加关于vue的信息,例如从抛出错误的组件到根组件形成的组件轨迹等
// 即使一个页面用了多个相同的组件,这种信息也能让我们快速定位错误抛自哪个组件实例上
createTracingMixins({
...options,
...options.tracingOptions,
})
);
}
}
其实sentryInitForVueSubApp
中的代码大多取自于sentry
源码中的vueInit
方法,大家也可以阅读这部分源码。
release
本文对应用中release
的值和格式没有硬性规定。release
可以是在CI
过程中生成,也可以是自己在代码中写死。本文采用是写死的方式:每个应用的release
都是取自package.json
里的name
和version
,以{name}@{version}
的形式生成。然后以process.env.VUE_APP_RELEASE
或process.env.REACT_APP_RELEASE
注入到子应用中
在vue-cli
和create-react-app
生成的项目中,分别只有以 VUE_APP_
和REACT*APP*
开头的变量才会被 webpack.DefinePlugin
静态嵌入到客户端侧的包中,具体可看vue-cli#在客户端侧代码中使用环境变量和CRA#Adding Custom Environment Variables。
当子应用被主应用加载时,通过通信方式把自身的release
发送给主应用,主应用接收后存放到window
的属性中,如下所示:
// 主应用逻辑
// 事先声明window["$micro_app_release"],值为一个空对象
window["$micro_app_release"] = {};
// 子应用逻辑
// 在bootstrap钩子函数中把release放到window["$micro_app_release"]里
export async function bootstrap() {
// process.env.VUE_APP_NAME为package.json里的name,此处把微应用的名称作为key值
window["$micro_app_release"][process.env.VUE_APP_NAME] =
process.env.VUE_APP_RELEASE;
}
疑问解答:
子应用是否可以直接通过window[app_name]=release
把release
存放在window
的一级属性里呢?
结论是不可以,必须放在二级属性里。在没有指定sandbox
参数或sandbox
不为false
的情况下,qiankun
会为每个子应用生成一个沙箱,这个沙箱作用在于修改每个子应用的js
作用域中的window
的指向。当子应用运行在处于支持ES6-Proxy
的浏览器环境下,其window
不像以往一样指向作用域链头部的全局对象Window
,而是指向一个Proxy
实例。
这个Proxy
实例的目标对象(即new Proxy(target,handler)
中的target
)是一个带有全局对象Window
所有属性及其值的对象,名为fakeWindow
。当我们在子应用中通过window['a']=1
新增或修改属性时,会触发Proxy
实例的handler.set
方法执行,此时他会做以下操作:
我们再来一段代码来理解一下沙箱的效果:
// 1. 主应用加载时定义window.app
window.app = "masterapp";
// 2. 子应用读取window.app的值
console.log(window.app); // 显示'master-app'
// 3. 子应用A更改window.app的值,然后在子应用A中读取是'micro-a',但如果在主应用中读取依旧是'master-app'
window.app = "micro-a";
console.log(window.app); // 'micro-a'
// 3. 切换到子应用B且读取window.app的值时,此时子应用A已销毁,window.app会从'micro-a'撤回为'masterapp'
console.log(window.user); // 'master-app'
qiankun
设置这种js
沙箱是为了隔离子应用和主应用的window
。但这种沙箱只能隔离Window
的一级属性。因为Proxy
只会捕获到一级属性的增删改,不能捕获到二级以上属性的变动,我们可以通过下图的控制台操作得出此结论:
因此,当在子应用中执行window[app_name]=release
时,只会修改fakeWindow[app_name]=release
,因此不会影响到全局对象Window
。但当子应用中执行window["$micro_app_release"][app_name] = release
时,因为window["$micro_app_release"]
,即fakeWindow["$micro_app_release"]
是从Window["$micro_app_release"]
复制过来的,两者都指向同一个引用对象,因此改动其属性会同步到全局对象Window
上。
关于沙箱的源码分析,可看最近写的文章不懂 qiankun 原理?这篇文章五张图片带你迅速通晓。
关于qiankun
是如何把将子应用的window
指向从全局对象Window
换到Proxy
实例的解答可以看本文下面的篇章 ## 3. 在beforeSend
中做错误定位偏移处理。
修改fakeWindow
的"a"
属性为 1
如果是修改System
等一些在白名单属性里的值,则会先把Window
中的['xx']的目前值备份一下,然后在把新的值覆盖到Window
的属性中(当子应用销毁时,会把备份的值重置到原本属性中)。但大多数属性包括"a"
都不在白名单属性中,因此是不会修改到Window
的同名属性里的。
event
来自哪个应用,并修改其release
属性为子应用的release
下面展示error event
和transaction event
的判断逻辑:
Sentry.init({
dsn: "xx",
environment: process.env.NODE_ENV,
release: process.env.VUE_APP_RELEASE,
attachStacktrace: true,
integrations: [
new BrowserTracing({
// Sentry.vueRouterInstrumentation作用在于优化transaction event的来源数据
// transaction event有一个属性transaction记录事件源自哪个页面,其值通常是页面的url,如果用了Sentry.vueRouterInstrumentation,则会把url换成在vue-router中注册的对应路由的name
// 此处使用Sentry.vueRouterInstrumentation会便于我们判断transaction event的源自哪个应用
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracePropagationTargets: ["localhost", "my-site-url.com", /^\//],
}),
],
// 判断error event的逻辑
beforeSend(event, hint) {
// hint是一个对象,其中有三个属性:event_id,originalException,syntheticException
// originalException指原始错误对象,syntheticException指sentry捕获原始错误对象后包装形成的错误对象
const { originalException } = hint;
// originalException带有平时我们从控制台看到的错误栈信息,当我们输出originalException.stacks.split('\n')后会有以下结果:
// 0: "Error: 1"
// 1: " at onClick (http://localhost:3001/static/js/bundle.js:829:17)"
// 2: " at HTMLUnknownElement.callCallback (http://localhost:3001/static/js/bundle.js:16911:18)"
// 3: " at HTMLUnknownElement.sentryWrapped (http://localhost:3000/static/js/chunk-vendors.js:2664:17)"
// 4: " at Object.invokeGuardedCallbackDev (http://localhost:3001/static/js/bundle.js:16955:20)"
// 5: " at invokeGuardedCallback (http://localhost:3001/static/js/bundle.js:17012:35)"
// 我们可以通过判断错误栈中头部的错误信息发生在哪个应用的文件里,从而知道错误出自哪个应用,如下代码所示:
const stacks = originalException.stack?.split("\n");
let app;
// unhandledrejection event事件的错误(例如Promise.reject没被处理引起而的报错)是没有stacks的,因此需要做判断处理。也因为如此,方案一判断不了子应用里的unhandledrejection的错误出自哪个应用。只能都放在主应用的release里
if (stacks?.[0]) {
// 开发环境和生产环境的子应用资源路径不同,需要区分判断
if (process.env.NODE_ENV === "production") {
if (stacks[0].includes("react-app")) {
app = "react-ts-app";
} else if (stacks[0].includes("vue-app")) {
app = "vue-app";
} else if (stacks[0].includes("vue3-app")) {
app = "vue3-ts-app";
} else {
app = "master-app";
}
} else {
if (stacks[0].includes("localhost:3001")) {
app = "react-ts-app";
} else if (stacks[0].includes("localhost:3002")) {
app = "vue-app";
} else if (stacks[0].includes("localhost:3004")) {
app = "vue3-ts-app";
} else {
app = "master-app";
}
}
}
if (window["$micro_app_release"][app]) {
event.release = window["$micro_app_release"][app];
}
return event;
},
// 判断transaction event的逻辑
beforeSendTransaction(event) {
const releaseMap = {
AppReact: "react-ts-app",
AppVue: "vue-app",
AppVue3: "vue3-ts-app",
};
// 根据transaction event的transaction属性判断其源自哪个应用
const { transaction } = event;
const app = releaseMap[transaction];
if (window["$micro_app_release"][app]) {
event.release = window["$micro_app_release"][app];
}
return event;
},
tracesSampleRate: 1.0,
});
至此,方案一代码实现完毕。
疑问解答:
为什么unhandledrejection event
是没有错误栈的?
在js
中,Error
实例才带有stacks
属性。通常Error
可以通过window.onerror
去捕获。
但未被catch
处理的Promise.reject
并不会生成Error
实例,它只会生成PromiseRejectionEvent
事件且触发window.onunhandledrejection
监听函数的执行。我们也可以看看window.onerror
和window.onunhandledrejection
的接口区别:
window.onerror = (
event, // 触发onerror的ErrorEvent实例
source, // string,显示错误源自哪个文件
lineno // number,显示错误源自上述文件的哪一行
colno,// number,显示错误源自上述文件的哪一列
error // Error实例,其中我们可以通过Error.prototype.stacks查看错误栈
)=>{}
window.onunhandledrejection = (
event // 触发onunhandledrejection的PromiseRejectionEvent实例,继承于Event类,有promises和reason两个只读属性
)=>{}
总结来说,普通的错误在生成Error
实例同时还会生成ErrorEvent
实例,ErrorEvent
实例会触发window.onerror
的监听函数的执行。而未被catch
处理的Promise.reject
只会生成PromiseRejectionEvent
实例,不会生成Error
实例,因此是不存在错误栈信息的。
至此,整个方案一的接入过程已经讲述完。接下来我们分析一下方案一目前存在什么缺点。
目前方案一存在的缺点有:
主应用和子应用只能通过release
来区分,当子应用因迭代发布版本多起来的时候,会显得很混乱。
对于unhandledrejection event
不能区分源自哪个应用。
Sentry
部分概念介绍在介绍方案二前,我们要先了解Sentry
中的三个概念,如下所示:
**Client(用户端)
**:我们可以理解成放在应用里收集和上传event
的实例。在项目中我们可以创建单独创建Client
实例去进行信息收集和上报,如下代码所示:
import {
BrowserClient,
defaultStackParser,
defaultIntegrations,
makeFetchTransport,
} from "@sentry/browser";
const client = new BrowserClient({
dsn: "https://[email protected]/0",
transport: makeFetchTransport,
stackParser: defaultStackParser,
integrations: defaultIntegrations,
});
client.captureException(new Error("example"));
**Scope(作用域)
**:Scope
是一个存储event
信息的集合。例如event
中的 contexts
上下文(包含Tags
、User
、Level
等用户浏览器信息和应用信息) 和 breadcrumbs
面包屑信息都存放在Scope
实例里。
Sentry
的默认插件就自动帮我们创建、发送和销毁Scope
实例。除此之外,开发者也可以手动编辑其中的信息,如下所示:
// 全局Scope修改,上报任何event之前都会执行以下逻辑
Sentry.configureScope(function (scope) {
scope.setTag("my-tag", "my value");
scope.setUser({
id: 42,
email: "[email protected]",
});
});
// 局部Scope修改
Sentry.withScope(function (scope) {
scope.setTag("my-tag", "my value");
scope.setLevel("warning");
// 上述信息仅在这次captureException中上报,其余event在上报时不会带上
Sentry.captureException(new Error("my error"));
});
最终这些信息会发送给服务端,而我们可以在Sentry
管理平台中看到,如下所示:
**Hub(中心)
**:Hub
用于管理Scope
实例栈。Client
实例自身是缺乏上述configureScope
和withScope
方法的,在这种情况下,我们需要创建Hub
实例然后绑定Client
实例,才能够管理Scope
信息,如下所示:
import {
BrowserClient,
defaultStackParser,
defaultIntegrations,
makeFetchTransport,
} from "@sentry/browser";
const client = new BrowserClient({
dsn: "https://[email protected]/0",
transport: makeFetchTransport,
stackParser: defaultStackParser,
integrations: defaultIntegrations,
});
const hub = new Hub(client);
hub.configureScope(function (scope) {
scope.setTag("a", "b");
});
hub.addBreadcrumb({ message: "crumb 1" });
hub.captureMessage("test");
try {
a = b;
} catch (e) {
hub.captureException(e);
}
hub.withScope(function (scope) {
hub.addBreadcrumb({ message: "crumb 2" });
hub.captureMessage("test2");
});
当我们在主应用中通过Sentry.init
去初始化Sentry
实例时,其实其内部也做了 创建Client
实例和Hub
实例,把Hub
实例关联到window
上(存在多个Hub
实例时,仅能有一个与window
关联然后负责执行收集上报信息),最后把Client
实例绑定到Hub
实例上。 大家如果对此过程的源码有兴趣可以直接从这里看起,重点留意initAndBind
方法。
从上面的概念介绍中,我们知道可以独立创建Client
实例,且从参数中可知,每个Client
实例都可以带不同的dsn
进行上报,那么,我们有以下设计思路:
对主应用和每个子应用都创建一个Client
实例和Hub
实例。
对路由进行监听,当路由变化时,根据页面URL
判断是否加载了子应用,若是则切换到含子应用的页面,则更改当前关联window
的Hub
实例。
因为同一时间只能有一个Hub
实例执行上报工作,这种思路只适合单实例场景(同一时间只会渲染一个微应用),且比较适合那种除了菜单就只是子应用的页面,如下所示。因为大多数情况下,菜单这类页面基础组件都不会报错,所以我们可以放心把此路由下所有的报错都归类为对应的子应用。
接下来依旧通过代码来展示如何实现方案而的思路,整个实现过程总结成以下三步:
Hub
实例的函数我们把这个函数命名为usingSentryHub
,代码逻辑如下所示:
import { BrowserTracing } from "@sentry/tracing";
import {
attachErrorHandler,
createTracingMixins,
vueRouterInstrumentation,
} from "@sentry/vue";
import {
makeFetchTransport,
makeMain,
defaultStackParser,
defaultIntegrations,
Hub,
BrowserClient,
} from "@sentry/browser";
// 当前关联window的Hub实例的名字
let currentHubName;
// 用于存放主应用和子应用的对象,key值是应用的名称
const hubMap = {};
/**
* type: 应用所使用的框架,目前只支持'vue'和'react'
* name: 应用的名称
* settings: 初始化client实例时需要的配置
*/
export function usingSentryHub(type, name, settings) {
if (name === currentHubName) return;
if (hubMap[name]) {
// makeMain用于切换绑定window的Hub实例
makeMain(hubMap[name]);
currentHubName = name;
// 如果hubMap[name]不存在且settings不为空,则根据type调用不同的函数创建新的Hub实例
} else if (settings) {
switch (type) {
case "vue":
hubMap[name] = initVueSentryHub(settings);
break;
case "react":
hubMap[name] = initReactSentryHub(settings);
break;
default:
break;
}
makeMain(hubMap[name]);
currentHubName = name;
}
}
// 用于创建应用框架为vue的Hub实例
function initVueSentryHub({ Vue, router, options, VueOptions }) {
const integrations = [...defaultIntegrations];
if (router) {
integrations.push(
new BrowserTracing({
routingInstrumentation: vueRouterInstrumentation(router),
tracingOrigins: ["localhost", /^\//],
})
);
}
const ultimateOptions = {
environment: process.env.NODE_ENV,
transport: makeFetchTransport,
stackParser: defaultStackParser,
integrations,
tracesSampleRate: 1.0,
...options,
};
const client = new BrowserClient(ultimateOptions);
const ultimateVueOptions = {
// 显示错误来源组件的props参数
attachProps: true,
// 控制台输出错误
logErrors: true,
tracesSampleRate: 1.0,
...VueOptions,
};
attachErrorHandler(Vue, ultimateVueOptions);
if (
"tracesSampleRate" in ultimateVueOptions ||
"tracesSampler" in ultimateVueOptions
) {
Vue.mixin(
createTracingMixins({
...ultimateVueOptions,
...ultimateVueOptions.tracingOptions,
})
);
}
return new Hub(client);
}
// 用于创建应用框架为react的Hub实例
function initReactSentryHub({ options }) {
const ultimateOptions = {
environment: process.env.NODE_ENV,
transport: makeFetchTransport,
stackParser: defaultStackParser,
integrations: [...defaultIntegrations],
tracesSampleRate: 1.0,
...options,
};
const client = new BrowserClient(ultimateOptions);
return new Hub(client);
}
usingSentryHub
// 在主应用的VueRouter.prototype.beforeEach里调用changeSentryHubWithRouter函数
router.beforeEach(async (to, from, next) => {
changeSentryHubWithRouter(to);
//...下面的代码省略
});
function changeSentryHubWithRouter(to) {
// 通过meta知道当前路由是否含子应用,若有则直接切换到name对应的子应用Hub实例
// 如果此时Hub实例还没创建,则不会切换
if (to.meta?.microApp?.name) {
usingSentryHub(undefined, to.meta.microApp.name);
} else {
// 如果路由不含子应用,则切换为主应用Hub实例,如果主应用的Hub实例还没创建,则根据第三形参进行创建
usingSentryHub("vue", process.env.VUE_APP_NAME, {
Vue,
router,
options: {
dsn: "xxx", // 主应用dsn
release: process.env.VUE_APP_RELEASE,
},
});
}
}
Hub
实例子应用Client
实例中的上报dsn
和release
以及别的配置都是存放在子应用中的,而不是在存放在主应用代码里的,因此在首次加载子应用时,需要子应用通过通信把创建Client
实例需要的数据上传给主应用。如下所示:
// vue2子应用逻辑
// 在mount钩子函数中创建。BrowserClient初始化需要在Vue实例mounted之前进行,因此要在mounted中实现
export async function mount(props) {
const { container, basepath } = props;
// 获取vue-router实例
const router = getRouter(basepath);
// 在本文的微前端项目中,子应用->主应用的通信是通过dispatchEvent+CustomEvent的方式实现的
window.dispatchEvent(
new CustomEvent("micro-app-dispatch", {
detail: {
type: "SET_MICRO_APP_HUB",
payload: {
type: "vue",
name: process.env.VUE_APP_NAME,
settings: {
Vue, // 如果是Vue3应用,则先创建instance,然后把instance放在该形参上,然后最后才执行instance.$mount
router,
options: {
dsn: "xxx", // 子应用的dsn
release: process.env.VUE_APP_RELEASE,
},
},
},
},
})
);
instance = new Vue({
name: "VueApp",
router: router,
store,
render: (h) => h(App),
mixins: container ? [devtoolEnhanceMixin, uploadRoutesMixin] : undefined,
}).$mount(container ? container.querySelector("#app") : "#app");
}
// react子应用
// 在bootstrap钩子函数中创建
export async function bootstrap() {
window.dispatchEvent(
new CustomEvent("micro-app-dispatch", {
detail: {
type: "SET_MICRO_APP_HUB",
payload: {
type: "react",
name: process.env.REACT_APP_NAME,
settings: {
options: {
dsn: "xxx",
release: process.env.REACT_APP_RELEASE,
},
},
},
},
})
);
}
主应用在收到子应用通信发来的数据后,调用usingSentryHub
函数去创建切换Hub
实例,如下所示:
window.addEventListener("micro-app-dispatch", handleMicroAppDispatchEvent);
const handleMicroAppDispatchEvent = (e) => {
const { detail: action } = e;
switch (action.type) {
case "SET_MICRO_APP_HUB":
// eslint-disable-next-line no-case-declarations
const { type, name, settings } = action.payload;
usingSentryHub(type, name, settings);
break;
// ..其余通信省略
default:
break;
}
};
至此,方案二代码实现完毕。
疑问解答:
为什么要使用dispatchEvent+CustomEvent作为通信方式?不可以直接用qiankun
提供的action.onGlobalStateChange
通信
原因有两个:
image.png本文微前端项目中的通信分主应用->子应用和子应用->主应用两个方向,主应用->子应用方向才用qiankun
提供的initGlobalState
等API
;子应用->主应用方向才用原生js
提供的dispatchEvent+CustomEvent
。分开两个通信方向用不同的技术实现是为了防止数据流迷乱,在出 bug 后不利于排查。
目前qiankun
把globalState
标记为下一个版本弃用,在控制台中我们可以看到下图的警告信息。所以为了升级兼容,我们尽量避免过多依赖globalState
。
方案二可以弥补方案一中的两个缺点,但自身存在以下缺点:
只适用于单实例场景。
如果在子应用页面中,主应用出现报错,则这个错误会归属到子应用中。
sourcemap
分以下三步来解决:
webpack
配置中开启source-map
首先要在项目的脚手架配置文件中配置开启source-map
,如下所示:
const isProd = process.env.NODE_ENV === "production";
// vue-cli中
module.exports = {
// productionSourceMap属性默认为true,千万不要手动把这个属性设为false
productionSourceMap: true,
configureWebpack: {
devtool: isProd ? "hidden-source-map" : "eval-cheap-source-map",
},
};
// @rescript/cli中
module.exports = {
webpack: (config) => {
config.devtool = isProd ? "hidden-source-map" : "eval-cheap-source-map";
return config;
},
};
目前sentry
只支持source-map
和hidden-source-map
两种类型的 map 文件。在开发环境中,我们选择用eval-cheap-source-map
,这样子便于我们准确定位错误,且初次生成和重构的速度快。在生产环境中,当错误出现在浏览器控制台时,我们不像让它能映射出错误所在的源码的位置,但又希望在Sentry
平台中查看错误时能知道错误所在的源码的位置,此时要使用hidden-source-map
。
hidden-source-map
模式下,打包时会生成source-map
类型的 map 文件,但在打包的js
文件尾部是不会加上//# sourceMappingURL=(map文件名称)
的,由此导致加载页面的时候是不会请求 map 文件的,因此在报错时,错误只能定位在打包后执行的代码里,不能定位到源码上。而当错误上报到Sentry
平台时,平台会内部调用source-map
库的API
去做映射,然后在错误栈中显示错误所出现的源码。
关于source-map
类型的选择可查看webpack
官网devtool
说明或我以前写过的文章[webpack 学习]梳理一下 sourcemap 的知识点。
关于Sentry
平台会内部如何调用source-map
库的API
去做映射的原理从这个库@sugarat/source-map-cli去学习
webpack
中添加SentryWebpackPlugin
插件SentryWebpackPlugin
插件用于上传打包后的代码,方便Sentry
平台做source-map
错误映射。其配置如下:
const isProd = process.env.NODE_ENV === "production";
const SentryWebpackPlugin = require("@sentry/webpack-plugin");
// 在vue-cli中
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
// 要把parallel手动置为false,原因可查看:https://github.com/getsentry/sentry-webpack-plugin/issues/272
parallel: false,
chainWebpack(config) {
// 生产环境中才上传打包文件
config.when(isProd, (config) => {
config.plugin("sentry-webpack-plugin").use(
new SentryWebpackPlugin({
include: "./dist",
ignore: ["node_modules", "nginx"],
// 指定本次版本
release: process.env.VUE_APP_RELEASE,
// urlPrefix要与publicPath保持一致
urlPrefix: "~/vue-app",
})
);
});
},
})
// 在@rescript/cli中
const { appendWebpackPlugin } = require('@rescripts/utilities');
module.exports = {
webpack: (config) => {
if (isProd) {
config = appendWebpackPlugin(
new SentryCliPlugin({
include: './build',
ignore: ['node_modules', 'nginx'],
release: process.env.REACT_APP_RELEASE,
urlPrefix: '~/react-app',
}),
config,
);
}
return config;
}
}
上传后的文件如下所示:
image.png所有资源文件都会加上urlPrefix
中指定的前缀。
beforeSend
中做错误定位偏移处理首先要说一下为什么要做这一步处理:
qiankun
加载子应用页面资源时,会有以下处理:
首先根据entry
请求获取html
页面,然后解析html
代码拿到其中所有的script
元素和link
元素
解析所有的link
元素取出其中的外链href
,然后逐个请求外链拿到对应的css
文件,然后把文件中的css
代码以内联样式()插入到
html
代码上。然后把html
代码插入到container
指定的容器基座里。
解析所有的script
元素,如果是外部脚本,则取出外链src
,然后逐个请求外链拿到对应的js
脚本代码;如果是内联脚本则先不处理。然后用getExecutableScript
函数对这些脚本内容进行包裹处理,如下所示:
/**
* scriptSrc: 如果script是内联脚本,则直接是script标签字符串;如果是外链脚本,则是script.src,即脚本外链地址
* scriptText: 脚本代码内容
* proxy: js沙箱,就是我们在前面说到的用于隔离window的沙箱
* strictGlobal: 布尔量,如果开启基于Proxy实现的沙箱则为true,否则为false。如果所处浏览器不支持Proxy,则会用不基于Proxy实现的沙箱,此时strictGlobal为false
*/
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
const sourceUrl = isInlineCode(scriptSrc)
? ""
: `//# sourceURL=${scriptSrc}\n`;
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)("window");
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
经过用with
可以把指定参数放到脚本的作用域链的顶部。如果脚本代码中调用与指定参数同名的变量,即使原本作用域链中已存在同名的属性,则该变量依旧会指向指定参数。因此,通过上面的操作会把window
的指向从全局对象Window
换成js
沙箱。
然后最后会通过evalCode
函数来执行上述with
处理后的代码,如下所示:
export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
// 通过eval执行`with`处理后的代码
(0, eval)(functionWrappedCode);
evalCache[key] = window.__TEMP_EVAL_FUNC__;
delete window.__TEMP_EVAL_FUNC__;
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
因此,实际执行的代码,会比我们在打包后生成的代码中多处一层with
包裹。我们也可以看下图,当点击查看子应用中抛出错误的运行代码时,可以看到打包代码被包裹着的样子:
正因为有这层包裹,当子应用有错误抛出时,指定错误出现位置的colno(列位置)
就对应不到打包代码中的代码位置。因此我们要手动对错误的定位进行矫正,这里在Sentry
提供的beforeSend
钩子函数中做处理,代码如下所示:
beforeSend(event) {
event.exception.values = event.exception.values.map((item) => {
// unhandledrejection event不存在stacktrace,因此要做判断处理
if (item.stacktrace) {
const {
stacktrace: { frames },
...rest
} = item;
// FIXME: 主应用加载时,qiankun 加载当前js资源会在首行添加 window.__TEMP_EVAL_FUNC__ = function(){;(function(window, self, globalThis){with(window){;
// https://github.com/kuitos/import-html-entry/blob/master/src/index.js#L62
frames[frames.length - 1].colno -=
"window.__TEMP_EVAL_FUNC__ = function(){;(function(window, self, globalThis){with(window){;".length;
return {
...rest,
stacktrace: {
frames,
},
};
}
return item;
});
return event;
},
注意:getExecutableScript
和evalCode
两个函数都是在import-html-entry
这个库中实现的,qiankun
引用了import-html-entry
这个库对加载资源做处理。而不同版本的import-html-entry
的getExecutableScript
和evalCode
的代码不同,因此偏移量也不同。因此当我们做这层处理时,可以打开浏览器的调试工具,如前面的图中去查看Source
来看看运行代码中多了多少偏移量。
最终可以在Sentry
后台中看到子应用的错误所出自的源码,如下所示:
目前存在的疑惑:目前在vue
子应用中需要做偏移处理才能在Sentry
后台中看到错误的精准定位,但react
子应用却不需要做这层处理也可以看到错误的精准定位,自己也很好奇为什么react
子应用不需要处理?有空研究一下create-react-app
脚手架的源码后再继续更新这部分。
这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 。