Skip to content

Latest commit

 

History

History
47 lines (31 loc) · 13.4 KB

NOTE.md

File metadata and controls

47 lines (31 loc) · 13.4 KB

NOTE

数据结构

数据结构的设计,类似于DeltaSet,最终呈现的数据结构形式是扁平化的,但是在Core中需要设计State来管理树形结构,因为要设计Undo/Redo的功能,在不全量存储快照的情况下就意味着必须设计原子化的Op,因为想实现的功能有组合这个能力,所以最终实现的形式实际上是树形的结构,而我希望的结构是扁平化的,因为树形结构查找起来比较费劲,需要实现的Op类型也会变多,我希望能尽量减少Op的类型并且能够做到History,所以最终定下的数据结构是DeltaSet作为存储,通过State来管理整个编辑器状态。

History

原子化的Op已经设计好了,所以在设计History模块时就不需要全量保存快照了,但是如果每个操作都需要并入History Stack的话可能并不是很好,通常都是有NOp的一并Undo/Redo,所以这个模块应该有一个定时器,如果在N毫秒秒内没有新的Op加入的话就将Op并入History Stack,但是当时我在思考一个问题,如果这N毫秒内用户进行了Undo操作应该怎么办,后来想想实际上很简单,此时只需要清除定时器,将暂存的Op[]立即放置于Redo Stack即可。

绘制

任何元素都是矩形,数据结构也是据此设计抽象出来的,在绘制的时候分为两层Canvas重叠的方式,内层的Canvas是用来绘制具体图形的,这里预计需要实现增量更新,而外层的Canvas是用来绘制中间状态的,例如选中图形、多选、调整图形位置/大小等,在这里是会全量刷新的,并且后边可能会在这里绘制标尺。在实现交互的过程中我遇到了一个比较棘手的问题,因为不存在DOM,所有的操作都是需要根据位置信息来计算的,比如选中图形后调整大小的点就需要在选中状态下并且点击的位置恰好是那几个点外加一定的偏移量,然后再根据MouseMove事件来调整图形大小,而实际上在这里的交互会非常多,包括多选、拖拽框选、Hover效果,都是根据MouseDownMouseMoveMouseUp三个事件完成的,所以如何管理状态以及绘制UI交互就是个比较麻烦的问题,在这里我只能想到根据不同的状态来携带不同的Payload,进而绘制交互。

绘制状态

在实现绘制的时候,我一直在考虑应该如何实现这个能力,因为上边也说了这里是没有DOM的,所以最开始的时候我通过MouseDownMouseMoveMouseUp实现了一个非常混乱的状态管理,完全是基于事件的触发然后执行相关副作用从而调用Mask的方法进行重新绘制。再后来我觉得这样的代码根本没有办法维护,所以改动了一下,将我所需要的状态全部都存储到一个Store中,通过我自定义的事件管理来通知状态的改变,最终通过状态改变的类型来严格控制将要绘制的内容,也算是将相关的逻辑抽象了一层,只不过在这里相当于是我维护了大量的状态,而且这些状态是相互关联的,所以会有很多的if/else去处理不同类型的状态改变,而且因为很多方法会比较复杂,传递了多层,导致状态管理虽然比之前好了一些可以明确知道状态是因为哪里导致变化的,但是实际上依旧不容易维护。最终我又思考了一下,决定在绘图这里实现类似于DOM的能力,因为我想实现的能力似乎本质上就是DOM与事件的关联,而DOM结构是一种非常成熟的设计了,这其中有一些很棒的点子,例如DOM的事件流,我不需要扁平化地调整每个Node的事件,而是只需要保证事件是从ROOT节点起始,最终又在ROOT上结束即可,并且整个树形结构以及状态是靠用户利用DOMAPI来实现的,我们管理之需要处理ROOT就好了,这样就会很方便,下个阶段的状态管理是准备用这种方式来实现的。

渲染与事件

在前边我们提到了我们想通过模拟DOM来完成Canvas的绘制与交互,那么在这里就很明显涉及到DOM的两个重要内容,即DOM渲染与事件处理。那么就先聊下渲染方面的内容,使用Canvas实际上就很像将所有DOMposition设置为absolute,所有的渲染都是相对于Canvas这个DOM元素的位置绘制,那么我们就需要考虑重叠的情况,那么想一个例子,AzIndex10A的子元素BzIndex100CA是平级的且zIndex20,那么当这三个元素重叠的时候,在最顶部的元素是C,也就是说zIndex实际上只看平级元素,再假如AzIndex10A的子元素BzIndex1,那么在这两个元素重叠的时候,在最顶部的元素是B,也就是说子元素通常都是渲染在父元素之上的。那么我们在这里也需要模拟这个行为,但是因为我们没有浏览器的渲染合成层,我们能够操作的只有一层,所以在这里我们需要根据一定的策略进行渲染,在渲染时我们与DOM的渲染策略相同,即先渲染父元素再渲染子元素,类似于深度优先递归遍历的渲染顺序,不同的是我们需要在每个节点遍历之前,将子节点根据zIndex排序来保证同层级的节点渲染重叠关系。

在渲染的基础上,我们还需要考虑事件的实现,例如我们的选中状态,八向调整元素大小的点一定是在选区节点的上层的,那么假如现在我们需要实现onMouseEnter事件的模拟,那么因为Resize这八个点位与选区节点是有一定重叠的,所以如果此时鼠标移动到重叠的点因为Resize的实际渲染位置更高,所以只应该触发这个点的事件而不应该触发后边的选区节点事件,而实际上由于没有DOM结构的存在我们就只能使用坐标计算,那么在这里我们最简单的方法就是保证整个遍历的顺序,也就是说高节点的遍历一定是要先于低节点的,当我们找到这个节点就结束遍历然后触发事件,事件的捕获与冒泡机制我们也需要模拟,实际上这个顺序跟渲染是反过来的,我们想要的是优点顶部的元素,优先更像树的右子树优先后序遍历,也就是把前序遍历的输出、左子树、右子树三个位置调换一下即可,但是问题来了,在onMouseMove这种高频事件触发的时候,我们每次都去计算节点的位置并且采用深度优先遍历,是非常耗费性能的,所以在这里实现一个典型的空间换时间,将当前节点的子节点按顺序全部存储起来,如果有节点的变动,就直接通知该节点的所有每一层父节点重新计算,这里做成按需计算即可,这样当另一颗子树不变的时候还可以节省下次计算的时间,并且存储的是节点的引用,不会有太大的消耗,这样就变递归为迭代了,另外因为找到了当前的节点,在模拟捕获与冒泡的时候就不需要再递归触发了,通过两个栈即可模拟。

焦点

平时我做富文本相关的功能比较多,所以在实现画板的时候总想按照富文本的设计思路来实现,因为之前也说过要实现History以及在编辑面板富文本的能力,所以焦点就很重要,如果焦点不在画板上的时候如果按下Undo/Redo键画板是不应该响应的,所以现在就需要有一个状态来控制当前焦点是否在Canvas上,经过调研发现了两个方案,方案一是使用document.activeElement,但是Canvas是不会有焦点的,所以需要将tabIndex="-1"属性赋予Canvas元素,这样就可以通过activeElement拿到焦点状态了,方案二是在Canvas上方再覆盖一层div,通过pointerEvents: none来防止事件的鼠标指针事件,但是此时通过window.getSelection是可以拿到焦点元素的,此时只需要再判断焦点元素是不是设置的这个元素就可以了。

无限画布

之前因为没有打算实现平移拖拽也就是无限画布的能力,但是后来真的开始通过这个主框架来实现想做的业务功能的时候发现这样是不行的,所以在后期想把这个能力加上,虽然本身这个能力并不复杂,但是因为最开始没有设计这个能力,导致后边做的时候有点难受,比如Mask批量刷新频率不对齐、ctxtranslate应该是偏移值取反、之前多处超出画布不绘制的计算有误等等,就感觉在没有设计的情况下突然增加功能确实是有点难受的,不过好处是不需要大规模重构,只是个别点位的修正。

此外多扯点别的,这个项目除了一些辅助性的工具例如resize-observer以及组件库例如arco-design都是自己写的,相当于实现了Canvas的引擎,特别是在现在的core-delta-plugin-utils结构设计下,是完全可以抽离处理作为工具包使用的,当然易用性与性能方面肯定比不上那些有名的开源框架。只不过今天我恰好看到了一个评论说的挺好的:如果是个人能力提升,那么最好是首先理解开源库,然后仿照实现开源库的功能,主要的目标是学习;而如果是商业化的使用,那就变成了知名的开源库优先,这样可以很大程度上降低成本。

性能优化

在实现的过程中,绘制的性能优化主要有:

  1. 可视区域绘制,完全超出画布的元素不绘制。
  2. 按需绘制,只绘制当前操作影响范围内的元素。
  3. 分层绘制,高频操作绘制在上层画布,基础元素绘制在下层画布。
  4. 节流批量绘制,高频操作节流绘制,上层画布收集依赖批量绘制。

超链接

众所周知Canvas绘制出来就是纯粹的图片,而实际使用导出PDF的超链接是可以点击的,而我们当前就单纯只是图片无法做到这一点,所以需要解决这个问题,我想到的一个解决方案是在导出的时候,通过DOM生成透明的a标签,覆盖在原本的超链接位置,这样就可以实现点击跳转效果了。PDF本身也是文件格式,所以是可以借助PDFKit/PDFjsPDF排版生成工具来导出的,通过这种方式也可以直接在导出的时候直接将其写入固定位置,并且可以不受浏览器打印的分页限制。

渲染图片

前段时间有个issue,是不清楚应该如何替换图片的问题,因为我最开始预期的交互是通过左上角的图形选择来完成这件事,所以在右侧面板并没有设计这个功能。那么隔天晚上我就在右侧面板把这个功能加上了,然后我测试了一下这个功能的效果,本来感觉效果还是可以的,但是我心血来潮在FireFox上试了一下,就发现问题了,直接切换节点的时候无法正常渲染图片,必须要再触发一次渲染才可以。

图片的渲染是一个比较棘手的问题,因为Canvas是无法直接渲染base64数据的,而想要绘制图片就必须借助HTMLCanvasElement | ImageBitmap类型的对象,但是无论是哪种都是异步加载的。例如我们使用Image绘制图片,应该是需要在OnLoad事件结束之后再绘制图片,但是这样会有一个问题,就是图片加载是异步的,所以我们的同步绘制就可能会出现图片未加载完成就开始绘制的情况,此时绘制在Canvas上的图片是空白的。

最开始我们可能会想到如果是因为图片未OnLoad导致图片绘制不了,那么我们不如直接在OnLoad之后再触发一次图片绘制不就能解决这个问题了,如实际上如果只有图片在这里的话是没问题的,但是我们的图形很多,会存在重叠的问题,假如此时我们有图形A和图片B,并且A的层级是在B之上的,那么此时如果我们在OnLoad再次绘制一次图片B就会导致BA之上,致使图形层级绘制出现问题。

那么如果直接将我们的同步绘制更改为异步的绘制方案是不是就可以了,这个方法实际上也是有些问题的,我们继续以图形A和图片B为例,此时B的层级是在A之上的,那么在异步绘制的时候,因为存在绘制层级顺序的关系,我们会先绘制A再绘制B从而保证层级,那么此时因为B是异步的绘制,就会导致短暂的A图形首先绘制,然后再绘制图片B,整体感官上会导致闪烁的问题。此外我们必须要保证所有的绘制人物是呈现队列的形式依次调用,不能同时有很多的绘制任务批量调度,否则可能会导致ctx的方法调用顺序混乱。

实际上绘制图片确实是件麻烦事,因为我们是插件化的设计,我们又必须要保证整个应用的插件通用性。通过上述两个方案的遇到的问题,我设想了新的方案,因为整体实际上还是图片绘制的调度问题,并且我们这层绘制不能仅仅由插件自己调度,我们绘制是必须要通过应用层面调度的,因此我收集插件调用后的返回值来判断当前是否有其他异步任务,如果有的话就等待所有异步任务结束之后再次触发批量绘制行为,这样通过在应用层面的调度绘制解决了问题。再往后可以考虑异步与队列的渲染方案,实际上这种方案能够有更好的绘制性能,不容易造成长任务导致用户交互卡顿的问题。