全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2023-02-24_微前端接入Sentry的不完美但已尽力的实践总结

您的位置:首页 >> 新闻 >> 行业资讯

微前端接入Sentry的不完美但已尽力的实践总结 前言这是一篇由浅入深地讲述如何对用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", //Calledformessageanderrorevents beforeSend(event){ //Modifyordroptheeventhere if(event.user){ //Don'tsenduser'semailaddress deleteevent.user.email; } returnevent; }, //Calledfortransactionevents beforeSendTransaction(event){ //Modifyordroptheeventhere if(event.transaction==="/unimportant/route"){ //Don'tsendtheeventtoSentry returnnull; } returnevent; }, }); 从上面例子可知,我们可以在钩子函数中修改event的属性。我们再来通过console.log看一下error event和transaction event是长什么样的: error event image.pngtransaction event image.png可以观察到,两种event都带有release属性。当event发送到服务端时,服务端会根据release把event分到不同的版本下。 那么我们可以有这样的设计思路: 在Sentry的后台管理平台上建立一个主应用,一个主应用有多个release。多个release分别对应子应用和主应用的不同版本。如下所示: image.png在钩子函数中通过设计代码逻辑判断出event来自哪个应用,然后修改event.release。让event在服务端分配到对应的release上。 对于上述思路中,有人会提出疑问:为什么不把主应用和子应用放在不同的Project(项目)里,而是放在同一Project的不同Release(版本)里? 在目前方案一中,我还不能成功实现让event存放到不同Project里。曾经试过按照使用 Sentry 做异常监控 - 如何优雅的解决 Qiankun 下 Sentry 异常上报无法自动区分项目的问题 ?这篇文章的思路在实现,但可能是因为版本不一致,导致我在发送event到对应的dsn时,子应用都响应失败,说是JSON格式不正确,如下所示: image.png因此就搁置了这种方式。如果读者觉得遗憾,可以去看本文中的方案二(??),在方案二中成功实现把主应用和子应用都拆分到不同Project里,且Issue和Transaction都可以放到所属应用的Project里。 代码实现纸上得来终觉浅,绝知此事要躬行。接下来通过代码来展示如何实现方案一的思路,整个实现过程可以总结成以下三步: 让被接入主应用的sentry也能监听子应用的错误 这里主要针对以vue为框架的子应用,vue中涉及到组件的错误,例如: 上面这些错误都是由Vue.config.errorHandler来进行捕获处理的,且处理后是不会被window.onerror再次捕获的。我们也可以从以下Vue@2.7.10:src/core/util/error.ts中的源码看出: exportfunctionhandleError(err:Error,vm:any,info:string){ //Deactivatedepstrackingwhileprocessingerrorhandlertoavoidpossibleinfiniterendering. //See:https://github.com/vuejs/vuex/issues/1505 pushTarget(); try{ if(vm){ //以下while操作中,会通过$parent获取组件到根组件的所有errorCaptured钩子函数。然后把依次把捕获到的error作为形参传入执行。 //直至其中有errorCaptured返回true或最终由globalHandleError执行。 letcur= while((cur=cur.$parent)){ consthooks=cur.$options.errorCaptured; if(hooks){ for(leti=0;ihooks.length;i++){ try{ constcapture=hooks[i].call(cur,err,vm,info)===false; if(capture)return; }catch(e:any){ globalHandleError(e,cur,"errorCapturedhook"); } } } } } globalHandleError(err,vm,info); }finally{ popTarget(); } } //globalHandleError内部其实就是调用了Vue.config.errorHandler,因为有try~catch包裹,因此错误不会被window.onerror捕获。 //即使没有定义Vue.config.errorHandler,由于handleError在执行errorCaptured链是也是用try~catch包裹,因此错误也不会被window.onerror捕获。 functionglobalHandleError(err,vm,info){ if(config.errorHandler){ try{ returnconfig.errorHandler.call(null,err,vm,info); }catch(e:any){ //iftheuserintentionallythrowstheoriginalerrorinthehandler, //donotlogittwice 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的判断逻辑不一样,具体逻辑会放在下文去展示。 1. 让被接入主应用的Sentry也能监听子应用的错误在上面 ?? 的分析中知道,我们要我们需要获取子应用的Vue.config.errorHandler,然后交给sentry进行处理。其实我们可以在主应用中创建一个Sentry初始化vue子应用的函数sentryInitForVueSubApp,然后把该方法传给子应用,让子应用在初次加载时调用,如下所示: //在主应用中的逻辑 loadMicroApp({ name:"vue3app", entry:`//${location.host}/vue3-app/`, container:"#app-vue3", activeRule:"/app-vue3/index", props:{ //在props中把该初始化方法传给子应用 sentryInit:sentryInitForVueSubApp, }, }); //在Vue2子应用的逻辑 //在子应用的mount钩子函数中调用 exportasyncfunctionmount(props){ props.sentryInit?.(Vue,{ tracesSampleRate:1.0, logErrors:true, attachProps:true, instance=newVue({ //... }).$mount(props.container?props.container.querySelector("#app"):"#app"); } //在Vue3子应用的逻辑 //在子应用的mount钩子函数中调用 exportasyncfunctionmount(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配置 */ functionsentryInitForVueSubApp(app,options){ //attachErrorHandler中,sentry会获取app.config.errorHandler进行处理 attachErrorHandler(app,options); if("tracesSampleRate"inoptions||"tracesSampler"inoptions){ app.mixin( //createTracingMixins用于在event中追加关于vue的信息,例如从抛出错误的组件到根组件形成的组件轨迹等 //即使一个页面用了多个相同的组件,这种信息也能让我们快速定位错误抛自哪个组件实例上 createTracingMixins({ ...options, ...options.tracingOptions, }) } } 其实sentryInitForVueSubApp中的代码大多取自于sentry源码中的vueInit方法,大家也可以阅读这部分源码。 2. 获取子应用的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"]里 exportasyncfunctionbootstrap(){ //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只会捕获到一级属性的增删改,不能捕获到二级以上属性的变动,我们可以通过下图的控制台操作得出此结论: image.png因此,当在子应用中执行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的同名属性里的。3. 逻辑判断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:[ newBrowserTracing({ //Sentry.vueRouterInstrumentation作用在于优化transactionevent的来源数据 //transactionevent有一个属性transaction记录事件源自哪个页面,其值通常是页面的url,如果用了Sentry.vueRouterInstrumentation,则会把url换成在vue-router中注册的对应路由的name //此处使用Sentry.vueRouterInstrumentation会便于我们判断transactionevent的源自哪个应用 routingInstrumentation:Sentry.vueRouterInstrumentation(router), tracePropagationTargets:["localhost","my-site-url.com",/^\//], }), ], //判断errorevent的逻辑 beforeSend(event,hint){ //hint是一个对象,其中有三个属性:event_id,originalException,syntheticException //originalException指原始错误对象,syntheticException指sentry捕获原始错误对象后包装形成的错误对象 const{originalException}=hint; //originalException带有平时我们从控制台看到的错误栈信息,当我们输出originalException.stacks.split('\n')后会有以下结果: //0:"Error:1" //1:"atonClick(http://localhost:3001/static/js/bundle.js:829:17)" //2:"atHTMLUnknownElement.callCallback(http://localhost:3001/static/js/bundle.js:16911:18)" //3:"atHTMLUnknownElement.sentryWrapped(http://localhost:3000/static/js/chunk-vendors.js:2664:17)" //4:"atObject.invokeGuardedCallbackDev(http://localhost:3001/static/js/bundle.js:16955:20)" //5:"atinvokeGuardedCallback(http://localhost:3001/static/js/bundle.js:17012:35)" //我们可以通过判断错误栈中头部的错误信息发生在哪个应用的文件里,从而知道错误出自哪个应用,如下代码所示: conststacks=originalException.stack?.split("\n"); let //unhandledrejectionevent事件的错误(例如Promise.reject没被处理引起而的报错)是没有stacks的,因此需要做判断处理。也因为如此,方案一判断不了子应用里的unhandledrejection的错误出自哪个应用。只能都放在主应用的release里 if(stacks?.[0]){ //开发环境和生产环境的子应用资源路径不同,需要区分判断 if(process.env.NODE_ENV==="production"){ if(stacks[0].includes("react-app")){ app="react-ts-app"; }elseif(stacks[0].includes("vue-app")){ app="vue-app"; }elseif(stacks[0].includes("vue3-app")){ app="vue3-ts-app"; }else{ app="master-app"; } }else{ if(stacks[0].includes("localhost:3001")){ app="react-ts-app"; }elseif(stacks[0].includes("localhost:3002")){ app="vue-app"; }elseif(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]; } returnevent; }, //判断transactionevent的逻辑 beforeSendTransaction(event){ constreleaseMap={ AppReact:"react-ts-app", AppVue:"vue-app", AppVue3:"vue3-ts-app", //根据transactionevent的transaction属性判断其源自哪个应用 const{transaction}=event; constapp=releaseMap[transaction]; if(window["$micro_app_release"][app]){ event.release=window["$micro_app_release"][app]; } returnevent; }, 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"; constclient=newBrowserClient({ dsn:"https://examplePublicKey@o0.ingest.sentry.io/0", transport:makeFetchTransport, stackParser:defaultStackParser, integrations:defaultIntegrations, }); client.captureException(newError("example")); **Scope(作用域)**:Scope是一个存储event信息的集合。例如event中的 contexts上下文(包含Tags、User、Level等用户浏览器信息和应用信息) 和 breadcrumbs面包屑信息都存放在Scope实例里。 Sentry的默认插件就自动帮我们创建、发送和销毁Scope实例。除此之外,开发者也可以手动编辑其中的信息,如下所示: //全局Scope修改,上报任何event之前都会执行以下逻辑 Sentry.configureScope(function(scope){ scope.setTag("my-tag","myvalue"); scope.setUser({ id:42, email:"john.doe@example.com", }); //局部Scope修改 Sentry.withScope(function(scope){ scope.setTag("my-tag","myvalue"); scope.setLevel("warning"); //上述信息仅在这次captureException中上报,其余event在上报时不会带上 Sentry.captureException(newError("myerror")); }); 最终这些信息会发送给服务端,而我们可以在Sentry管理平台中看到,如下所示: image.pngimage.png**Hub(中心)**:Hub用于管理Scope实例栈。Client实例自身是缺乏上述configureScope和withScope方法的,在这种情况下,我们需要创建Hub实例然后绑定Client实例,才能够管理Scope信息,如下所示: import{ BrowserClient, defaultStackParser, defaultIntegrations, makeFetchTransport, }from"@sentry/browser"; constclient=newBrowserClient({ dsn:"https://examplePublicKey@o0.ingest.sentry.io/0", transport:makeFetchTransport, stackParser:defaultStackParser, integrations:defaultIntegrations, }); consthub=newHub(client); hub.configureScope(function(scope){ scope.setTag("a","b"); }); hub.addBreadcrumb({message:"crumb1" hub.captureMessage("test"); try{ a= }catch(e){ hub.captureException(e); } hub.withScope(function(scope){ hub.addBreadcrumb({message:"crumb2" hub.captureMessage("test2"); }); 当我们在主应用中通过Sentry.init去初始化Sentry实例时,其实其内部也做了 创建Client实例和Hub实例,把Hub实例关联到window上(存在多个Hub实例时,仅能有一个与window关联然后负责执行收集上报信息),最后把Client实例绑定到Hub实例上。 大家如果对此过程的源码有兴趣可以直接从这里看起,重点留意initAndBind方法。 思路分析从上面的概念介绍中,我们知道可以独立创建Client实例,且从参数中可知,每个Client实例都可以带不同的dsn进行上报,那么,我们有以下设计思路: 对主应用和每个子应用都创建一个Client实例和Hub实例。对路由进行监听,当路由变化时,根据页面URL判断是否加载了子应用,若是则切换到含子应用的页面,则更改当前关联window的Hub实例。因为同一时间只能有一个Hub实例执行上报工作,这种思路只适合单实例场景(同一时间只会渲染一个微应用),且比较适合那种除了菜单就只是子应用的页面,如下所示。因为大多数情况下,菜单这类页面基础组件都不会报错,所以我们可以放心把此路由下所有的报错都归类为对应的子应用。 image.png代码实现接下来依旧通过代码来展示如何实现方案而的思路,整个实现过程总结成以下三步: 1. 编写用于切换和创建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实例的名字 letcurrentHubName; //用于存放主应用和子应用的对象,key值是应用的名称 consthubMap= /** *type:应用所使用的框架,目前只支持'vue'和'react' *name:应用的名称 *settings:初始化client实例时需要的配置 */ exportfunctionusingSentryHub(type,name,settings){ if(name===currentHubName)return; if(hubMap[name]){ //makeMain用于切换绑定window的Hub实例 makeMain(hubMap[name]); currentHubName=name; //如果hubMap[name]不存在且settings不为空,则根据type调用不同的函数创建新的Hub实例 }elseif(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实例 functioninitVueSentryHub({Vue,router,options,VueOptions}){ constintegrations=[...defaultIntegrations]; if(router){ integrations.push( newBrowserTracing({ routingInstrumentation:vueRouterInstrumentation(router), tracingOrigins:["localhost",/^\//], }) } constultimateOptions={ environment:process.env.NODE_ENV, transport:makeFetchTransport, stackParser:defaultStackParser, integrations, tracesSampleRate:1.0, ...options, constclient=newBrowserClient(ultimateOptions); constultimateVueOptions={ //显示错误来源组件的props参数 attachProps:true, //控制台输出错误 logErrors:true, tracesSampleRate:1.0, ...VueOptions, attachErrorHandler(Vue,ultimateVueOptions); if( "tracesSampleRate"inultimateVueOptions|| "tracesSampler"inultimateVueOptions ){ Vue.mixin( createTracingMixins({ ...ultimateVueOptions, ...ultimateVueOptions.tracingOptions, }) } returnnewHub(client); } //用于创建应用框架为react的Hub实例 functioninitReactSentryHub({options}){ constultimateOptions={ environment:process.env.NODE_ENV, transport:makeFetchTransport, stackParser:defaultStackParser, integrations:[...defaultIntegrations], tracesSampleRate:1.0, ...options, constclient=newBrowserClient(ultimateOptions); returnnewHub(client); } 2. 在路由变化的回调函数中调用usingSentryHub//在主应用的VueRouter.prototype.beforeEach里调用changeSentryHubWithRouter函数 router.beforeEach(async(to,from,next)={ changeSentryHubWithRouter(to); //...下面的代码省略 }); functionchangeSentryHubWithRouter(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, }, } } 3. 首次加载子应用时,创建子应用的Hub实例子应用Client实例中的上报dsn和release以及别的配置都是存放在子应用中的,而不是在存放在主应用代码里的,因此在首次加载子应用时,需要子应用通过通信把创建Client实例需要的数据上传给主应用。如下所示: //vue2子应用逻辑 //在mount钩子函数中创建。BrowserClient初始化需要在Vue实例mounted之前进行,因此要在mounted中实现 exportasyncfunctionmount(props){ const{container,basepath}=props; //获取vue-router实例 constrouter=getRouter(basepath); //在本文的微前端项目中,子应用-主应用的通信是通过dispatchEvent+CustomEvent的方式实现的 window.dispatchEvent( newCustomEvent("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=newVue({ name:"VueApp", router:router, store, render:(h)=h(App), mixins:container?[devtoolEnhanceMixin,uploadRoutesMixin]:undefined, }).$mount(container?container.querySelector("#app"):"#app"); } //react子应用 //在bootstrap钩子函数中创建 exportasyncfunctionbootstrap(){ window.dispatchEvent( newCustomEvent("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); consthandleMicroAppDispatchEvent=(e)={ const{detail:action}= switch(action.type){ case"SET_MICRO_APP_HUB": //eslint-disable-next-lineno-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分以下三步来解决: 1. webpack配置中开启source-map首先要在项目的脚手架配置文件中配置开启source-map,如下所示: constisProd=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"; returnconfig; }, }; 目前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去学习 2. webpack中添加SentryWebpackPlugin插件SentryWebpackPlugin插件用于上传打包后的代码,方便Sentry平台做source-map错误映射。其配置如下: constisProd=process.env.NODE_ENV==="production"; constSentryWebpackPlugin=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( newSentryWebpackPlugin({ 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( newSentryCliPlugin({ include:'./build', ignore:['node_modules','nginx'], release:process.env.REACT_APP_RELEASE, urlPrefix:'~/react-app', }), config, } returnconfig; } } 上传后的文件如下所示: image.png所有资源文件都会加上urlPrefix中指定的前缀。 3. 在beforeSend中做错误定位偏移处理首先要说一下为什么要做这一步处理: qiankun加载子应用页面资源时,会有以下处理: 首先根据entry请求获取html页面,然后解析html代码拿到其中所有的script元素和link元素 解析所有的link元素取出其中的外链href,然后逐个请求外链拿到对应的css文件,然后把文件中的css代码以内联样式(stylexxx/style)插入到html代码上。然后把html代码插入到container指定的容器基座里。 解析所有的script元素,如果是外部脚本,则取出外链src,然后逐个请求外链拿到对应的js脚本代码;如果是内联脚本则先不处理。然后用getExecutableScript函数对这些脚本内容进行包裹处理,如下所示: /** *scriptSrc:如果script是内联脚本,则直接是script标签字符串;如果是外链脚本,则是script.src,即脚本外链地址 *scriptText:脚本代码内容 *proxy:js沙箱,就是我们在前面说到的用于隔离window的沙箱 *strictGlobal:布尔量,如果开启基于Proxy实现的沙箱则为true,否则为false。如果所处浏览器不支持Proxy,则会用不基于Proxy实现的沙箱,此时strictGlobal为false */ functiongetExecutableScript(scriptSrc,scriptText,proxy,strictGlobal){ constsourceUrl=isInlineCode(scriptSrc) ?"" :`//#sourceURL=${scriptSrc}\n`; //通过这种方式获取全局window,因为script也是在全局作用域下运行的,所以我们通过window.proxy绑定时也必须确保绑定到全局window上 //否则在嵌套场景下,window.proxy设置的是内层应用的window,而代码其实是在全局作用域运行的,会导致闭包里的window.proxy取的是最外层的微应用的proxy constglobalWindow=(0,eval)("window"); globalWindow.proxy=proxy; //TODO通过strictGlobal方式切换with闭包,待with方式坑趟平后再合并 returnstrictGlobal ?`;(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处理后的代码,如下所示: exportfunctionevalCode(scriptSrc,code){ constkey=scriptSrc; if(!evalCache[key]){ constfunctionWrappedCode=`window.__TEMP_EVAL_FUNC__=function(){${code}}`; //通过eval执行`with`处理后的代码 (0,eval)(functionWrappedCode); evalCache[key]=window.__TEMP_EVAL_FUNC__; deletewindow.__TEMP_EVAL_FUNC__; } constevalFunc=evalCache[key]; evalFunc.call(window); } 因此,实际执行的代码,会比我们在打包后生成的代码中多处一层with包裹。我们也可以看下图,当点击查看子应用中抛出错误的运行代码时,可以看到打包代码被包裹着的样子: image.pngimage.png正因为有这层包裹,当子应用有错误抛出时,指定错误出现位置的colno(列位置)就对应不到打包代码中的代码位置。因此我们要手动对错误的定位进行矫正,这里在Sentry提供的beforeSend钩子函数中做处理,代码如下所示: beforeSend(event){ event.exception.values=event.exception.values.map((item)={ //unhandledrejectionevent不存在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, }, } returnitem; returnevent; }, 注意:getExecutableScript和evalCode两个函数都是在import-html-entry这个库中实现的,qiankun引用了import-html-entry这个库对加载资源做处理。而不同版本的import-html-entry的getExecutableScript和evalCode的代码不同,因此偏移量也不同。因此当我们做这层处理时,可以打开浏览器的调试工具,如前面的图中去查看Source来看看运行代码中多了多少偏移量。 最终可以在Sentry后台中看到子应用的错误所出自的源码,如下所示: image.png目前存在的疑惑:目前在vue子应用中需要做偏移处理才能在Sentry后台中看到错误的精准定位,但react子应用却不需要做这层处理也可以看到错误的精准定位,自己也很好奇为什么react子应用不需要处理?有空研究一下create-react-app脚手架的源码后再继续更新这部分。 后记这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 ??????。 阅读原文

上一篇:2022-10-19_「转」重做和平替,今年消费行业的两大流量密码 下一篇:2024-12-07_Claude 水浒版小说 | 智取AI山

TAG标签:

18
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价