当前位置: 首页 > 科技观察

大型前端项目的断点调试共享化和复用化实践

时间:2023-03-16 13:55:27 科技观察

大型前端项目断点调试的分享与复用实践每个大模块中都有N个小模块,每个大模块下的每个小模块的主要负责人都在跟进模块issue。这会导致一个很大的问题,就是在大多数情况下,模块的负责人只会关注自己模块的问题,而对其他人手上的模块的具体问题了解不多。收费。例如:当我们有用户反馈使用复制粘贴有问题时,如果我们想快速定位问题,只能找到复制粘贴对应的模块负责人。如果复制粘贴模块的负责人请假,那么其他负责人在处理这个问题时,解决的成本会非常高,因为其他负责人可能根本不熟悉这个模块.再比如:当我们有几个新同学,想让他们快速查看用户反馈的问题,我们只能把调试这个模块的经验传授给他,把我们熟悉的坑告诉他,或者整理一下.把对应的iwiki给他看就好了(一般效率很低,没人会看!),让他慢慢定位问题,让每个新同学都熟悉模块,学习和维护的成本就变得越大越大,项目越大,这种情况就会越严重!所以我们想了很多关于如何解决这些问题,至少让模块的维护成本更低,更好的维护和定位问题。解决方案因为上面的问题确实很蛋疼,所以我们在爬坡和翻滚的过程中逐渐摸索出了一套解决方案。我们称它为基于断点调试的实用解决方案,以实现共享和可重用性。这里有一个关键词:breakPoint,相比之下每个开发者都不陌生,在我们的前端,当模块定位出现问题的时候,我们必须使用断点来停止代码运行的一些关键地方。下面是一个例子:classCopyPaste{//内部粘贴pasteFromInter(){...}//外部粘贴pasteFromOuter(){debugger;...}//外部图形粘贴isShapePasteFromOuter(){...}//外部图像粘贴isImgPasteFromOuter(){...}//外部文本粘贴isTextFromOuter(){...}}以上代码是用户反馈复制粘贴问题时,熟悉模块的负责人根据用户的feedback,知道用户外部粘贴有问题,由于熟悉模块,会快速破解浏览器控制台,或者手动在源码中注入debugger关键字一步步定位用户问题,他会先检查内部粘贴pasteFromOuter是否被触发,然后检查函数isShapePasteFromOuter是否运行成功,输出和输入参数是否正确,代码是否错误,进入isImgPasteFromOuter。然后在排查和修复问题后,长长地舒了一口气,遇到下一个问题时,清理浏览器或代码中当前的调试痕迹,然后一遍又一遍地重复以上这一系列动作,相信大多数的学生每天都重复以上类似的动作来排查问题,甚至提出要求。是否可以考虑将这些宝贵的调试痕迹保存下来,当我们或者其他同学遇到类似的模块问题时,可以将这些凝结着我们血泪的心路历程自动重现一遍?代码片段记录调试器位置pasteFromInter2行4列isShapePasteFromOuter256行89列isImgPasteFromOuter867行12列对于大型项目,每一个小bug的调试环节时间成本极其巨大,难以重现和重现。我们能做的就是再次遇到类似问题时,复用类似的调试经验。受伤有痕迹,有经历,再次遇到问题时,我们应该更加自信和冷静。于是我们的首要任务其实变成了留住那宝贵的调试环节,也就是留住无数个日日夜夜,每一个刺痛我们内心深处的断点。Plug-in在实践的过程中,我们尝试了无数的方法。第一种解决方案是基于浏览器插件实现断点保留。基于GoogleChrome插件开发提供的接口chrome.debugger,是Chrome远程调试协议的一部分。消息传输方式。chrome.debugger可以附加到一个或多个选项卡以调试JavaScript。并使用调试对象进行基于sendCommand和onEvent的插件通信。它允许我们调试插件中的页面。许多插件和工具都基于此协议与浏览器控制台进行通信。该方案只能实现一个远程调试面板,类似于浏览器本身的调试。界面可以加载代码然后记录断点,最后共享这些断点。这种方案的体验会更差。首先,插件本身实现的调试面板不如谷歌浏览器。其次,插件需要主动开发安装。共享的前提是双方需要安装相应的插件,进行开发和推广。成本比较高,所以个人不推荐,但这并不代表这个方案不行,因为还有另外一种基于插件的实现方式,就是下面的debug功能方案。debug函数具体使用函数断点debug(functionName)和undebug(functionName)方法,其中functionName为要调试的函数。我们可以将debug()插入到代码中(此方法类似于console.log()语句),或者我们可以从DevTools控制台调用它。debug()相当于在函数的第一行设置一个代码行断点。一般在控制台中使用。这种方式使用插件会有更好的体验,因为插件使用的是chrome。发送断点函数。chrome.devtools.inspectedWindow.eval(`debug(window.xxxApi);`,(value)=>{callback&&callback(value);});但是细心的同学发现我用debug函数监控一个全局函数窗口。xxxApi,所以这里总结一下经验。这种方法的缺陷是,如果你在控制台中使用它,它会在你的上下文中寻找函数,所以它只能用于全局函数点。如果需要打点的函数不在上下文中,还需要手动断点到目标函数作用域,然后使用函数触发。如果是闭包函数,就没办法了,但破绽不掩饰。这种方法可以帮助我们快速定位任何一个全局函数,即使代码很混乱。是的,它仍然可以快速读取并为您添加函数断点,所以我建议将此解决方案作为替代方案,它在某些情况下可以创造奇迹!AST注入经历了上面的各种坑之后,下面简单介绍一下我们实现的一套方案:我们的方案其实是在之前的函数调用链方案的基础上进行的改进。既然我们可以在代码中输入debugger关键字,就可以在任何地方破解代码,我们为什么不把这个工作交给工具呢?首先,我们可以通过状态机告诉工具我们需要分配的点在什么位置,类似于我们常用的哨子配置表:Module'CopyPaste'index.ts-fpasteFromInter-s!(()=>{console.log(window.Worker)})()index.ts-fpasteFromOuter-sconsole.log('success')-checkmessagecenter1index.ts-fisShapePasteFromOuterEndModuleModule<--state-->EndModule这里描述的是一个状态,是一个行为distributebreakpoints,用于监控模块的类型,例如:复制粘贴模块,数据层模块或数据层模块-ffunctionname-scode这里可以描述这个状态的具体行为特征,例如:在Distributebreakpoints在pasteFromInter函数中并注入调试器代码。在webpack中,我们可以在loader或者plugin的过程中解析这个配置文件。这里也可以使用第三方库或者正则表达式来解析上面的状态文本。我在加载器中解析这个状态表。我在全局目录或者本地模块定义一个.debug.json来写入上面的状态,然后解析出一个map对象:args=argument({"--class":String,//class"--function":String,//function"--code":String,//function"-c":"--class",//转义替换"-f":"--function","-s":"--code",},{argv:debugConfigValue,});如果不想用状态机写配置文件,其实可以用copy一个debug.json文件来描述断点的位置。这种方法比较简单。解析json文件的开销比状态机的配置文件要低很多。这里json文件涉及的主要字段是要检测的代码的路径,方便。该工具找到文件,然后找到需要检测的类或函数的名称。这个方便的工具定位代码的位置,还有检测的项目名称和要检测的代码,还有一个key键值:{"MessageCenter":{"function":[{"path":"src/core/network/message-center/SendMessageCenter.ts","name":"_sendUserChanges","title":"数据层断点测试2","code":"__console.log('数据层断点测试2')","key":"MessageCenter|function|1"}]}}这里的key值涉及到可以定义的明确点,比如MessageCenter|function|1表示最重要的是管理某个function在MessageCenter模块的文件,以后可以继续完善,写成MessageCenter|class|1:12,表示某个类在MessageCenter模块的文件中的具体位置。如果key的语义更丰富,后续的分发会更准确,定位问题也会更高效。这个可以根据业务场景来确定。定义classCopyPaste{//内部粘贴pasteFromInter(){debugger...}}当我们有了配置文件的时候,我们就不得不思考如何在不侵入的情况下,在代码中添加调试和检测代码。我们更喜欢通过AST来注入,它可以帮助我们把代码中的关键部分整理成一棵树,比如擦掉冒号、括号、分号等,这样我们就可以专注于重要的节点。解析上面的代码后,我们会得到如下的AST语法树:{"program":{"type":"Program","body":[{"type":"ClassDeclaration","id":{{"type":"Identifier","identifierName":"CopyPaste"},"name":"CopyPaste"},"body":{"type":"ClassBody","body":[{"type":"ClassMethod","key":{"type":"Identifier","name":"pasteFromInter"},"body":{"type":"BlockStatement","body":[{"type":"DebuggerStatement"}]},"leadingComments":[{"type":"CommentLine","value":"InternalPaste"}],}]}}}]}}具体步骤如下:解析MessageCenter|function|1这个段参数配置字符串得到函数名、模块名、位置信息等,然后扫码进行词法语法分析,根据得到的函数名、模块名、位置信息得到AST语法树现在。匹配AST树节点,在上面添加我们的调试和测试代码,最后输出处理后的代码。上面的原理我们都明白了,具体怎么实现,我们可以使用webpack工具中的plugins来实现,在plugins中我们经常使用visitor模式,也就是访问某条路径时进行匹配,然后修改这个节点,这样如上面的pasteFromInter函数,它是一个ClassMethod,插件会生成代码的AST树供访问,访问者可以匹配任何对应的词法特征,我们可以在这里匹配所有的ClassMethod,然后根据path,比如函数名,函数参数和函数位置等,得到这些关键信息,我们可以对这个函数节点进行处理,即注入我们的调试检测代码或者直接注入一个调试器来断点。plugins={//visitorVisitor={'ClassMethod'(path){//checkpointpath.node}}}当然注入检测代码也需要构造成类似ClassMethod的结构,这样我们就可以配合@babel/types快速注入一段代码的工具,例如,最简单的是注入调试器:types.expressionStatement(types.identifier(`debugger`))这会将调试器放在你匹配路径的特定位置,而你的代码源文件本身其实是没有变化的,只是一段代码通过AST树和配置文件成功集成到了指定位置。当然实际情况会比想象的更复杂,因为分布的位置可能不是函数中的某个位置,可能是类函数中的某个位置,闭包函数中的某个位置,所以要为了兼容各种语法结构,需要在AST中匹配这些函数的所有特性才能准确交付代码。我们以函数为例。列出一些需要考虑的情况:FunctionExpression需要满足这两种写法,否则调试器会发送错误的位置。this.xxx=function(){debugger}constxxx=function(){debugger}ClassMethod一般可以通过下面的方式定位,但是如果想要更精确一些,比如私有函数等,就需要这样写更精确的访问设备。classxxx{xxx:(){debugger}}FunctionDeclaration除了处理上面函数表达式的写法外,别忘了函数也是有声明和定义的,所以这个也要全。functionxxx(){debugger}ArrowFunctionExpression最后要考虑下箭头函数的写法constxxx=()=>{debugger}this.xxx=()=>{debugger}classxxx{xxx=()=>{debugger}}虽然大一些情况下,匹配函数向项目下发的调试代码可以覆盖大部分场景,但漏网之鱼总会有的。比如有些同学想在类定义之前注入检测代码,那么就需要继续写相应的accessor获取Path,然后将相应的检测代码分发到该位置,所以需要熟悉各种语法和相应的访问器类型以顺利实现它。经过上面的改造,我们会在最终的代码中得到新的代码(所有的检测代码都被注入了),但是这样会触发新的代码。当我们运行这段新代码时,上面所有的检测代码都会再次运行。这样很多其他模块的负责人不想断开的代码区域都会被屏蔽掉,所以在实际情况中我们需要下发一个带有开关的检测代码。当然这个开关的参与可以很简单,如下://模块中基于AST的分布式调试开关if(require('@tencent/vdebugger').call(this,key)){debugger}//或者this,虽然看起来更好,但是调试器无法获取闭包中的上下文require('@tencent/vdebugger').call(this,key)||(()=>{debugger})()//注意下面这种写法是不行的↓require('@tencent/vdebugger')||debugger我们可以用require('@tencent/vdebugger')封装一个函数,这个函数可以设计成读取全局变量或者localstorage等里面的配置,然后返回一个布尔值来判断是否在这个时候执行debuggerlocation,这里为了调试方便,有几个小细节需要注意。关键字debugger有一个独立的范围,所以你不能写这样的东西false||debugger,在这个函数中读取require('@tencent/vdebugger')配置完成后,可以包含一个eval方法来执行检测代码,这样就可以使用call来代理当前作用域,更方便调试。当然,实际情况可能比想象的要复杂。我举个简单的例子:因为distributedswitch可能会注入一些代码打包到worker中。worker在大型项目中用的比较多,但是worker看不懂。document、window等对象可以使用navigator、location、XMLHttpRequest等对象,但无法通过从localstorage读取配置的方式来控制调试开关,需要考虑是否需要将调试开关下发给worker代码.如何与相应的交换机通信等问题。最简单粗暴的方式就是在打包worker代码的时候进行过滤。!isWorker&&newDebuggerPlugin({debugConfig:path.resolve(dirName,'../debug.json'),}),当然如果需要分发的switch在worker中生效,还需要实现一个通信方法用于读取开关配置。常见的方式是使用基于postMessage的通信方式让require('@tencent/vdebugger')函数,即switch模块接受主线程的配置下发命令是否执行检测代码,将断点启动到worker的运行代码。myWorker.postMessage(xx);myWorker.onmessage=()=>{console.log('Messagereceivedfromworker');}经过思考实现上面的基本功能,我们可以继续优化很多体验,比如我们也可以使用webpack插件实现本地编译时的增量更新,可以在我们更改本地配置文件时自动下断点和调试代码。逻辑相对简单。在插件的apply循环中使用内置库chokidar来监听配置文件的变化,然后触发编译,重新调用AST编译生成带有debug代码和断点的代码:constchokidar=require('chokidar');this.watcher=chokidar.watch(["../src/**/.debug.json"],{usePolling:true,ignored:this.options.ignored});总结一下,这方面调试相关的文章不多,一路上跳了很多坑,感谢团队成员的支持,让这个计划最终得以顺利实施。也希望有更多志同道合的人加入我们腾讯文档团队,一起探索,一起旅行。最后,希望这篇文章能给大家一些启发。【本文为专栏作者《腾讯技术工程》原创稿件,转载请联系原作者(微信ID:Tencent_TEG)】点此查看该作者更多好文