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

ReactCoreTeam成员开发的“火焰图组件”技术启示

时间:2023-03-18 14:58:43 科技观察

前言在最近的业务开发中,业务方需要我们的性能监控平台提供火焰图来展示函数栈和相关耗时信息。根据BrendanGregg在FlameGraph[1]主页中的定义:Flamegraphs是一种可视化的异形软件,可以让最频繁的代码路径快速准确地识别出来Flamegraph是一种可视化分析软件,可以让我们快速准确地找到频繁出现的代码路径称为函数栈。可以在此处查看火焰图的示例[2]。其实不仅仅是调用频率,火焰图也适合用来描述函数调用的栈和耗时频率,比如ChromeDevTools中的火焰图:其实就是根节点在顶部和底部的叶子节点称为冰柱图(icecolumnFigure)更为合适,但为了便于理解,下文将统称为火焰图。本文要分析的源码不是以上任何一个,而是React浏览器插件中使用的火焰图组件,它是由React官方成员BrianVaughn开发的react-flame-graph[3].本地调试react-flame-graph库本身是通过rollup搭建的,react-flame-graph[4]的示例网站是用webpack搭建的。所以如果你想在本地调试,克隆这个库后:分别在根目录和网站目录安装依赖。在根目录执行npmlink链接到全局,然后到网站目录npmlinkreact-flame-graph建立软链接。在根目录下执行npmrunstart开启rollup的watch编译模式,将react-flame-graph编译到dist目录下。在网站目录下执行npmrunstart,开启webpackdev模式,进入示例网站,通过编写ReactAppDemo进行调试。由于这个库比较老,最好用nrm把node版本调整到10.15.0。我在这个版本下成功安装了依赖。先简单看一下火焰图的效果:组件揭秘要使用这个组件,必须传入的数据是width和data。宽度指的是整个火焰图容器的宽度,后续计算每个宽度都需要这个宽度。数据格式为树形结构:constsimpleData={name:"foo",value:5,children:[{name:"customtooltip",value:1,tooltip:"Customtooltipshownonhover",},{name:"custombackgroundcolor",value:3,backgroundColor:"#35f",color:"#fff",children:[{name:"leaf",value:2,},],},],};除了标准树的name和children之外,还有一个必要的属性值,它根据每一层的值来决定每一个火焰块的宽度。比如这个数据的宽度树是width:5-width1-width3-width2,那么生成的火焰图也会遵循这个宽度比例:在业务场景中,每个矩形块一般对应一个函数调用,它会counttotal耗时,这个值可以作为值。数据转换组件的第一步是将递归数据转换为展平数组。递归数据虽然更直观地展现了层级,但是使用起来渲染起来比较麻烦。整个火焰图的渲染其实就是逐行渲染每一层对应的所有矩形块,所以层数相等的数组比较合适。我们的目标是将数据组织成这种结构:levels:[["_0"],["_1","_2"],["_3"],],nodes:{_0:{width:1,depth:0,left:0,name:"foo",...}_1:{width:0.2,depth:1,left:0,name:"customtooltip",...}_2:{width:0.6,depth:1,left:0.2,name:"custombackgroundcolor",...}_3:{width:0.4,depth:2,left:0.2,name:"leaf",...}}一目了然,层级对应层级关系和每一层的节点id,nodes是id对应的节点数据。其实这一步很关键。这个数据基本上决定了渲染的层次和风格。这里节点中的宽度已经通过width:value/maxValue进行了处理,maxValue其实就是根节点定义的宽度。在这个例子中,对应的值为5,所以:第一层节点宽度为5/5=1第二层节点宽度自然为1/5=0.2,3/5=0.6。这里处理的好处是渲染时可以直接乘以火焰图容器的宽度,即真实dom节点的宽度,得到矩形块的真实宽度。转换部分其实是一个递归,代码如下::number,leftOffset:number):ChartNode{const{backgroundColor,children,color,id,name,tooltip,value,}=sourceNode;constuidOrCounter=id||`_${uidCounter}`;//把这个节点放入地图consttargetNode=(nodes[uidOrCounter]={backgroundColor:backgroundColor||getNodeBackgroundColor(value,maxValue),color:color||getNodeColor(value,maxValue),depth,left:leftOffset,name,source:sourceNode,tooltip,//widthpropertyis(currentnodevalue/valueoftherootelement)width:value/maxValue,});//记录每一层对应的uid列表if(levels.length<=depth){levels.push([]);}levels[depth].push(uidOrCounter);//放入全局UID计数器+1uidCounter++;if(Array.isArray(children)){children.forEach((sourceChildNode)=>{//进一步递归consttargetChildNode=convertNode(sourceChildNode,depth+1,leftOffset);leftOffset+=targetChildNode.width;});}returntargetNode;}convertNode(rawData,0,0);constrootUid=rawData.id||"_0";return{height:levels.length,levels,nodes,root:rootUid,};}数据结构转换完成后,开始渲染部分了作者BrianVaughn在这里使用了他编写的React虚拟滚动库react-window[5]来优化长列表的性能。//FlamGraph.jsconstitemData=this.getItemData(data,focusedNode,...,width);{ItemRenderer};这里需要注意的是,将从外部导入的一些数据集成到虚拟列表组件需要的itemData中。方法如下:importmemoizefrom"memoize-one";getItemData=memoize((data:ChartData,disableDefaultTooltips:boolean,focusedNode:ChartNode,focusNode:(uid:any)=>void,handleMouseEnter:(event:SyntheticMouseEvent<*>,node:RawData)=>void,handleMouseLeave:(event:SyntheticMouseEvent<*>,node:RawData)=>void,handleMouseMove:(event:SyntheticMouseEvent<*>,node:RawData)=>void,width:number)=>({data,disableDefaultTooltips,focusedNode,focusNode,handleMouseEnter,handleMouseLeave,handleMouseMove,scale:(value)=>(value/focusedNode.width)*width,}:ItemData));memoize-one是一个用于函数缓存的库。它的作用是当传入的参数没有变化时,直接返回上次计算的值。对于新版本的React,直接使用useMemo和dependencies也可以达到类似的效果。这里只是简单的保存了数据,唯一不同的是定义了一个新的方法scale:scale:value=>(value/focusedNode.width)*width,负责计算真正的DOM宽度,以及所有节点的宽度将focusNode的宽度乘以火焰图的实际DOM宽度来计算。所以点击一个节点使其聚焦后,其子节点的宽度也会发生变化。focusNode为根节点时:点击自定义背景色节点后:这里在children位置的花括号中放置了一个组件引用ItemRenderer。其实这就是renderprops的用法,相当于:{(props)=>}ItemRenderer组件实际上负责渲染每一行通过数据的矩形块。由于数据中有3个级别,因此该组件将被调用3次。每次都可以获取到对应层级的uid,通过uid可以获取节点相关信息,完成渲染。//ItemRendererconstfocusedNodeLeft=scale(focusedNode.left);constfocusedNodeWidth=scale(focusedNode.width);consttop=parseInt(style.top,10);constuids=data.levels[index];returnuids.map((uid)=>{constnode=data.nodes[uid];constnodeLeft=scale(node.left);constnodeWidth=scale(node.width);//太小的矩形块不渲染if(nodeWidthfocusedNodeLeft+focusedNodeWidth){returnnull;}return(itemData.focusNode(uid)}x={nodeLeft-focusedNodeLeft}y={top}/>);});这里所有的值都是根据容器宽度通过scale计算出来的真实DOM宽度。这里计算偏移量的巧妙之处在于,最终传递给矩形块组件LabeledRect的x是横轴的偏移量,是根据focusedNode的left值计算出来的。如果父节点获得焦点,则占满整行,子节点的x也会跟着父节点偏移到最左边。比如这个图中的focused节点是foo,那么最底下的叶子节点计算偏移量时,focusedNodeLeft为0,它的偏移量会保持自己的left不变。当聚焦节点变为自定义背景色时,由于聚焦节点的左边为200,所以叶子节点也会向左移动200像素。可能有同学会疑惑,自定义背景色聚焦时,其父节点foo节点本身的偏移量为0,再减去200,不就是负数吗,所以父节点的矩形块可以保证占了一整行这里重温一下scale的逻辑:value=>(value/focusedNode.width)*width,计算父节点的width时是scale(父节点的width),此时的width父节点大于焦点节点,所以最终的宽度可以保证当偏移量为负到一定程度时,父节点仍然占据整行。最后LabeledRect就是用svg渲染一个矩形,没什么特别的。总结一下看似复杂的火焰图,在设计好数据结构和组件结构之后,层层梳理起来也不难。仅仅一篇文章,我们就完整分析了react-devtools中广泛使用的火焰图组件,性能分析这个强大的工具就这样掌握了原理。参考文献[1]FlameGraph:http://www.brendangregg.com/flamegraphs.html[2]火焰图示例:http://www.brendangregg.com/FlameGraphs/cpu-mysql-updated.svg[3]react-flame-graph:react-flame-graph[4]React-flame-graph示例网站:https://react-flame-graph.now.sh/[5]react-window:https://github.com/bvaughn/react-window本文转载自微信公众号《前端从进阶到入学》,可关注下方二维码。转载本文请联系前端公众号进阶到录取。