最近在尝试写一个类似Chrome Performance里面这种执行栈样的组件,这里BrainStorm一下。
可行性
这里探讨一下技术可行性。
平台
浏览器,而且应该只会在Chrome浏览器上测试使用。
图形引擎
首先我想到的就是Pixi.js,Pixi.js是一个高效的2D 图形引擎。相对来说Pixi.js用在这里是最好的。
状态管理
这里状态管理使用React+Redux,因为Stack中各Stack的长度层级都可以变化,因此需要一个复杂的状态管理及更新工具
既然要是用React,那就需要一个Pixi.js与React的桥梁,React16以前我用的是reac-pixi,但现在有一个叫react-pixi-fiber的替换库,这个库customize了一个react-reconciler的renderer,因此可以直接得到react的调度控制。
React UI库
由于最近用Chakra-UI比较多,而且他很符合我的使用习惯,所以就直接用Chakra-UI了。
动画引擎
动画引擎暂时定为framer-motion,首先这是Chakra-UI官方推荐的动效库,第二个是我应该可以通过MotionValue来直接对Pixi.js组件进行操作
开发环境
最近比较懒,直接在CodeSandBox上写。
需求
我想写这个的主要原因是在React代码时,感觉没有一个可以直接从执行栈角度比较可视化直观的了解,而我最开始真正看懂React一部分源码时就是我通过Chrome Devtools performance的执行栈timeline,我感觉通过执行栈直接看代码从逻辑上的用途,而不是干啃代码是一个非常有效的学习方法,因此我开始尝试写一个这样的执行栈可视化应用。同时我希望这个软件后面能够集成到LinkCat中作为其一个RenderModule,这个RenderModule定义了一种新的资源protocol=stack,在这个渲染模块只对打了render=Stack的资源进行渲染,而渲染的结果就是我上面设想的。我希望的是能够直接将Github某一行的permlink自动帖到设置好函数名的执行栈上,比较复杂,后面再考虑。
聊完的目的,现在再来看看真正的功能需求有哪些。
我们把一个函数调用所渲染的方框称为一个StackFrame栈帧。
StackFrame栈帧
这里定义一下(不是真正CS中的栈帧定义)栈帧是执行栈里面最小单位,一个栈帧上拥有栈帧名(大部分时间为函数名),而栈帧可以拥有标签(方便为某类执行进行分类),栈帧是树形关系,但父栈帧长度大于等于所有子栈帧长度和,兄弟栈帧直接可以有空隙。栈帧通过颜色分辨其类型,比如红色函数、蓝色if块、绿色switch块等。
栈帧之间的链接构成是树形结构,双亲节点与子节点有双向指针,兄弟节点直接有双向指针。根节点的双亲指针为null。由于这里可能包括if块栈帧,所以不能直接命名双亲节点属性名为return。
首先是最基础的增删改查,而后的是直接对StackFrame长度进行拖拽伸展,例如上图中的beginWork,我可以直接点选后进行拖拽,但默认吸附到父栈帧底部,可以脱离父栈帧转移到其他栈帧上。
我们将子栈帧长度占用父栈帧的长度成为子栈帧occupy(占用)父栈帧长度,下文直接称为occupy。
父栈帧会随着子栈帧的拖拽自动生长。但父栈帧应该有最长值限制,但是在根节点附近的栈帧因为其长度是子节点长度和会出问题,所以暂时考虑非叶子节点栈帧长度没有限制。
栈帧拥有一个注解集合(其实就是LinkCat的Annotation,详细见文章LinkCat),默认内置注解为描述description和tag两种。
变焦
这里引入应用中变焦的定义,首先应用是有一个关注点栈帧,而类似与ChromeDevTools内的Performance鼠标滚轮导致可视时段的变化。这里的变焦也是将为了给全局和局部都增加一个快捷的变换方式。
—-未完待续—
细节思考
栈帧逻辑实现细节思考
栈帧树存储在Redux的store内,操作通过dispatch事件操作。
栈帧树是一个plain object,栈帧的所有引用都是以id的形式引用。
栈帧的cursor变化操作应该由一个ActionExecutor执行,通过ActionName 映射到具体的Executor,后面好进行拓展。这里其实有点reducer的意思了。
栈帧渲染实现细节思考
首先栈帧是一个巨大的树,所以不能将全部栈帧直接渲染到页面上,首先需要一个cursor指向当前的栈帧焦点,而后通过上下左右移动cursor进行相关栈帧树渲染,比如渲染焦点节点上下两层节点,这个可以lazy load,用户自主点击某一个未加载完全栈帧的交互点进行额外加载。
这里我们将渲染到浏览器上的栈帧的最上层根节点成为渲染根节点。这里我们架设渲染根节点会可以无限增长,没有限制。这里定义渲染根节点所处的channel(频道,其实就是stack depths)为0,往下一层,则channel+1,这个属性可以帮助我们渲染栈帧到特定y值上(通过ChannelWidth和StackWidth)。
栈帧长度问题是一个很复杂的计算,因为子栈帧会影响到整个栈帧树上从根节点到当前节点的路径。例如用户拖拽叶子栈帧增长,叶子栈帧的长度将其余栈帧压缩到最小栈帧长度后,该节点的父栈帧就会开始增长,而这又是个递归的问题,直至根节点,而且渲染根节点下的其他子节点也有可能受到影响导致其发生位移。
这里理顺一下逻辑:
- 某一栈帧长度增长
- 其增长长度在父栈帧当前长度承受范围内,无向上传播
- 其增长长度大于父栈帧当前长度的承受范围,向上传播增长事件
所以这里是一个事件bubble的过程,但由于限制了渲染的栈帧层数,所以这样的性能还是可以接受的,同时这种事件应该由Redux进行统一管理,其内部的reducer再直接对栈帧列表中需要修改的栈帧进行修改。而且应该由一个effect buffer,用于记录上一次几次的修改的传播链。这样可以再提高效率。
考虑了父子栈帧的一个极大边界情况,然后再考虑极小的边界情况。
栈帧有一个最小长度值,这个值应该是一个全局变量。
关于变焦能力的思考
变焦能力是一个比较复杂的问题。首先我们需要一个ratio变量来确定当前的变焦程度,初始状态下ratio=1,而后使用鼠标滚轮进行滚动,ratio变化,引起整个树的重渲染,这又是一个性能大户,我忘了Pixi有没有GPU boost,如果直接用js实现性能比较差,我考虑用WebAssembly将这段计算代码提高效率。这里需要遍历整个渲染树,首先要判断该节点的变化后的长度时候小于最小渲染长度,如果小于就直接不渲染该节点及其子树,如果不小于直接将ratio后的长度给该节点的渲染长度。而后需要计算cursor指向的节点相对于root节点容器的位置,然后重计算整个viewport(视点、画幅)的偏移量。
还需要注意的是需要设置最大和最小变焦率。同时因为World有边界,计算时需要在极值下判断一下需不需要重新将viewport移动(设置边界后如果画幅尝试渲染世界外不知道会不会出问题)。
在变焦的过程中应该屏蔽任何对栈帧的操作,除了可能拖拽栈帧可能需要,但是后面设置一个FactoryRing提供粘贴板应该更好。
关于执行模拟的思考
这里再来考虑下一个叫执行模拟的功能,其实就是将整个栈帧树遍历,然后将每一个的定义?输出一次。但由于每个栈帧上都有具体描述,这样整个流程可以非常清晰的打印出来。
关于多栈帧树的思考
这里考虑的是一个文件内的多栈帧树,这里考虑一下多栈帧树,其实也比较简单,直接设置一个flag值,然后通过
TREE_ROOT & current.flag == NO_FLAG
就可以判断一个节点是不是根节点,如果是,就把他分配到ChannelGroup[tree_id]的新channel上,或者新的Widget(需要多个PIXI.Application)上。
泛化设计
这里我泛化一下应用场景,这个可以用来记录每天的规划流程,然后把每个流程都定义成类似与函数的操作,而每天写日记的时候就可以直接call对应的函数。
例如把用户输入的 我买了三个面包 进行提取和存储:
首先定义两个用于正则匹配的函数:
在栈帧左上角的小方块是标签,代表这个栈帧的用途,这里的def是定义,默认定义函数,call是调用函数,set是将值设置到payload上,ret是设置返回值。
这里的左上角多了个string matcher标签,是指这里定义的是一个字符串匹配器,如果在这个函数中按子函数顺序匹配成功,则将payload更新,否则abort掉更新。
其实这里的匹配机制还有个巨大的性能问题,可以通过设计特殊的匹配器解决。
这里的左侧是输入,右侧是结果中payload的新增字段。这里为了简化只匹配行,即一行一次匹配。
然后就是持久化,其中由$开头的块我默认为全局预设变量。
这样就将原来的应用能力泛化开了,有非常大的拓展性。
这里其实用过Node-Red的读者已经发现,这个和Node-Red很相似。但这个最大的区别是组合方式发生了巨大变化。
这上面这种设想是将这个应用当成某个文字记录软件的用户自定义模块。这也是我一直在寻找的日记、记账软件。这种函数块的构建方式还可以在前面加Selector,即在某个时段才能执行某个函数,这样就可以在午餐晚餐之后想记录用餐情况时,可以直接开始匹配限制在这个时段内的用餐情况函数块。我希望的记账软件的设计就是可以随时记账,同时在特定时段推荐、匹配特定的记录预设。
v1.0 wep 2022/03/05 完成文章编写