一般这种需求的解决方案大体上可以分为以下几种:
方案分析:
萌生想法:
在梳理了要做的功能后,一个简易的canvas排版引擎浮现脑海。
排版引擎(layoutengine),也称为浏览器引擎(browserengine)、页面渲染引擎(renderingengine)或样版引擎,它是一种软件组件,负责获取标记式内容(如HTML、XML及图像文件等等)、整理信息(如CSS及XSL等),并将排版后的内容输出至显示器或打印机。所有网页浏览器、电子邮件客户端、电子阅读器以及其它需要根据表示性的标记语言(Presentationalmarkup)来显示内容的应用程序都需要排版引擎。
摘自Wikipedia对浏览器排版引擎的描述,对于前端同学来说这些概念应该是比较熟悉的,常见的排版引擎比如webkit、Gecko等。
本次需求承载了以下几个目标:
总结下来就是在可以在canvas里写“网页”。
在最初的设想里,打算使用类似vuetemplate语法来作为结构样式数据,但是这么做会增加编译成本,对于我想要实现的核心功能来说它的起点有点太远了。在权衡过后,最终打算使用类似ReactcreateElement的语法+Javascriptstyleobject的形式的api,优先实现核心功能。
另外需要注意的是,我们的目标不是在canvas里实现浏览器标准,而是尽可能贴近css的api,以提供一套方案能实现文档流布局。
目标api长这样
//创建图层constlayer=lib.createLayer(options);//创建节点树//c(tag,options,children)constnode=lib.createElement((c)=>{returnc("view",//节点名{styles:{backgroundColor:"#000",fontSize:14,padding:[10,20],},//样式attrs:{},//属性比如srcon:{click(e){console.log(e.target);},},//事件如clickload},[c("text",{},"HelloWorld")]//子节点);});//挂载节点node.mount(layer);如上所示,api的核心在于创建节点的三个参数:
functionbutton(c,text){returnc("view",{styles:{//...},},text);}//注册一个自定义标签lib.component("button",(opt,children,c)=>button(c,children));//使用constnode=lib.createElement((c)=>{returnc("view",{},[c("button",{},"这是全局组件")]);});我们期望执行以上api后可以在canvas中渲染出文字,并且点击后可以响应相应事件。
框架的首次渲染将按以下流程执行,后面也会按照这个顺序进行讲解:
下面会将流程图中的关键细节进行讲述,代码中涉及到一些算法以及数据结构需要注意。
在拿到视图模型(即开发者通过createElementapi编写的模型)后,需要首先对其进行预处理,这一步是为了过滤用户输入,用户输入的模型只是告诉框架意图的目标,并不能直接拿来使用:
initStyles(){this._extendStyles()this._completeStyles()this._initRenderStyles()}布局处理在上一步预处理过后,我们就得到了一个带有完整样式的节点树,接下来需要计算布局,计算布局分为尺寸和位置的计算,这里需要注意的是,流程里为什么要先计算尺寸呢?仔细思考一下,如果我们先计算位置,像文字,图片这种之后的节点,是需要在上一个尺寸位置计算完毕再去参考计算。所以这一步是所有节点原地计算尺寸完毕后,再计算所有节点的位置。
整个过程如下动画。
更专业一点的说法应该是计算盒模型,说到盒模型大家应该是耳熟能详了,基础面试几乎必问的。
在css中,可以通过box-sizing属性来使用不同的盒模型,但是我们本次不支持调整,默认为border-box。
对于一个节点,他的尺寸可以简化为几种情况:
梳理好这几种模式之后就可以开始遍历计算了,对于一个树我们有多种遍历模式。
广度优先遍历:
深度优先遍历:
这里我们对上面几种情况分别做考虑:
这里出现了一个问题,第1种和第3种所需遍历方式出现了冲突,但是回过头来看预处理部分正是从父到子的遍历,因此1、2部分计算尺寸的任务可以提前在预处理部分计算好,这样到达这一步的时候只需要计算第3部分,即根据子节点计算。
classElementextendsTreeNode{//...//父节点计算高度_initWidthHeight(){const{width,height,display}=this.styles;if(isAuto(width)||isAuto(height)){//这一步需要遍历,判断一下this.layout=this._measureLayout();}if(this._InFlexBox()){this.line.refreshWidthHeight(this);}elseif(display===STYLES.DISPLAY.INLINE_BLOCK){//如果是inline-block这里仅计算高度this._bindLine();}}//计算自身的高度_measureLayout(){letwidth=0;//需要考虑原本的宽度letheight=0;this._getChildrenInFlow().forEach((child)=>{//calcwidthandheight});return{width,height};}//...}代码部分就是遍历在文档流中的直接子节点来累加高度以及宽度,另外处理上比较麻烦的是对于一行会有多个节点的情况,比如inline-block和flex,这里增加了Line对象来辅助管理,在Line实例中会对当前行内的对象进行管理,子节点会绑定到一个行实例,直到这个Line实例达到最大限制无法加入,父节点计算尺寸时如果读取到Line则直接读取所在行的实例。
这里TextImage等自身有内容的节点就需要继承后重写_measureLayout方法,Text在内部计算换行后的宽度与高度,Image则计算缩放后的尺寸。
classTextextendsElement{//根据设置的文字大小等来计算换行后的尺寸_measureLayout(){this._calcLine();returnthis._layout;}}计算位置计算完尺寸后就可以计算位置了,这里遍历方式需要从父到子进行广度优先遍历,对于一个元素来说,只要确定了父元素以及上一个元素的位置,就可以确定自身的位置。
这一步只需要考虑根据父节点已经上一个节点的位置来确认自身的位置,如果不在文档流中则根据最近的参考节点进行定位。
相对复杂的是如果是绑定了Line实例的节点,则在Line实例内部进行计算,在Line内部的计算则是类似的,不过需要另外处理对齐方式以及自动换行等逻辑。
//代码仅保留核心逻辑_initPosition(){//初始化ctx位置if(!this._isInFlow()){//不在文档流中处理}elseif(this._isFlex()||this._isInlineBlock()){this.line.refreshElementPosition(this)}else{this.x=this._getContainerLayout().contentXthis.y=this._getPreLayout().y+this._getPreLayout().height}}classLine{//计算对齐refreshXAlign(){if(!this.end.parent)return;letoffsetX=this.outerWidth-this.width;if(this.parent.renderStyles.textAlign==="center"){offsetX=offsetX/2;}elseif(this.parent.renderStyles.textAlign==="left"){offsetX=0;}this.offsetX=offsetX;}}好了这一步完成后布局处理器的工作就完成了,接下来框架会将节点输入渲染器进行渲染。
对于绘制单个节点来说分为以下几个步骤:
对于渲染单个节点来说,功能比较常规,渲染器基本功能是根据输入来绘制不同的图形、文字、图片,因此我们只需要实现这些api就可以了,然后将节点的样式通过这些api按顺序来渲染出来,这里又说到顺序了,那么渲染这一步我们应该按照什么顺序呢。这里给出答案深度优先遍历。
canvas默认合成模式下,在同一位置绘制,后渲染的会覆盖在上面,也就是说后渲染的节点的z-index更大。(由于复杂度原因,目前没有实现像浏览器合成层的处理,暂时是不支持手动设置z-index的。)
另外我们还需要考虑一种情况,如何去实现overflow:hidden效果呢,比如圆角,在canvas中超出的内容我们需要进行裁剪显示,但是仅仅对父节点裁剪是不符合需求的,在浏览器中父节点的裁剪效果是可以对子节点生效的。
在canvas中一个完整的裁剪过程调用是这样的.
//savectxstatusctx.save();//doclipctx.clip();//dosomethinglikepaint...//restorectxstatusctx.restore();//需要了解的是,CanvasRenderingContext2D中的状态以栈的数据结构保存,当我们多次执行save后,每执行一次restore就会恢复到最近的一次状态
也就是说只有在clip到restore这个过程内绘制的内容才会被裁减,因此如果要实现父节点裁剪对子节点也生效,我们不能在渲染一个节点后马上restore,需要等到内部子节点都渲染完后再调用。
下面通过图片讲解
如图,数字是渲染顺序
由于我们在预处理中已经实现了Fiber结构,并且知道节点所在父节点的位置,只需要在每个节点渲染完成后进行判断,需要调用多少次restore。
至此,经过漫长的debug以及重构,已经能正常将输入的节点渲染出来了,另外需要做的是增加对其他css属性的支持,此时内心已经是激动万分,但是看着控制台里输出的渲染节点,总觉得还能做点什么。
对了!每个图形的模型都保存了,那是不是可以对这些模型进行修改以及交互呢,首先定一个小目标,实现事件系统。
canvas中的图形并不能像dom元素那样响应事件,因此需要对dom事件进行代理,判断在canvas上发生事件的位置,再分发到对应的canvas图形节点。
如果按照常规的事件总线设计思路,我们只需要将不同的事件保存在不同的List结构中,在触发的时候遍历判断点是否在节点区域,但是这种方案肯定不行,究其原因还是性能问题。
经过一阵的头脑风暴后想到事件其实也可以保存在树结构中,将有事件监听的节点抽离出来组成一个新的树,可以称之为“事件树”,而不是保存在原节点树上。
如图,在1、2、3节点挂载click事件,会在事件处理器内生成另一个回调树结构,在回调时只需要对这个树进行遍历,并且可以进行剪枝优化,如果父节点没有触发,则这个父节点下的子元素都不需要遍历,提高性能表现。
另外一个重点就是判定事件点是否在元素内,对于这个问题,已经有了许多成熟的算法,如射线法:
算法思想:以被测点Q为端点,向任意方向作射线(一般水平向右作射线),统计该射线与多边形的交点数。如果为奇数,Q在多边形内;如果为偶数,Q在多边形外。
但是对于我们这个场景,除了圆角外都是矩形,而圆角处理起来会比较麻烦,因此初版都是使用矩形来进行判断,后续再作为优化点改进。
按照这个思路就可以实现我们简易的事件处理器。
classScrollViewextendsView{//...constructor(options,children){//...//内部再初始化一个scroll-view,高度自适应,外层宽高固定this._scrollView=newView(options,[this]);//...}//为自己注册事件addEventListener(){//注册捕获事件,修改事件的相对位置this.eventManager.EVENTS.forEach((eventName)=>{this.eventManager.addEventListener(eventName,(e)=>{if(direction.match("y")){e.relativeY-=this.currentScrollY;}if(direction.match("x")){e.relativeX-=this.currentScrollX;}},this._scrollView,true);});//处理滚动this.eventManager.addEventListener("mousewheel",(e)=>{//doscroll...});//...}}重排重绘除了生成静态布局功能外,框架也有重绘重排的过程,当修改了节点的属性后会触发,内部提供了setStyle,appendChild等api来修改样式或者结构,会根据属性值来确认是否需要重排,如修改width会触发重排后重绘,修改backgroundColor则只会触发重绘,比如scroll-view滚动时,只是改变了transform值,只会进行重绘。
虽然框架本身不依赖dom,直接基于CanvasRenderingContext2D进行绘制,但是一些场景下仍需要作兼容性处理,下面举几个例子。
虽然框架本身已经支持大部分场景的布局,但是业务需求场景复杂多变,所以提供了自定义绘制的能力,即只进行布局,绘制方法交给开发者自行调用,提供更高的灵活性。
engine.createElement((c)=>{returnc("view",{render(ctx,canvas,target){//这里可以获取到ctx以及布局信息,开发者绘制自定义内容},});});web框架中使用虽然api本身相对简单,但是仍然需要写一些重复的代码,结构复杂的时候不便于阅读。
当在现代web框架中使用时,可以采用相应的框架版本,比如vue版本,内部会将vue节点转换为api调用,使用起来会更易于阅读,但是需要注意,由于内部会有节点转换过程,相比直接使用会有性能损耗,在结构复杂时差异会较明显。
经过亲身体验,在一般页面的开发效率上,已经与写html不相上下,这里为了展示成果,我写了一个简单的组件库demo页。
框架在经过几次重构后已经取得了不错的表现,性能表现如下
已经做了的优化:
待优化:
从最初想实现一个简单的图片渲染功能,最后实现了一个简易的canvas排版引擎,虽然实现的feature有限并且还有不少细节与bug需要修复,但是已经具有基本的布局以及交互能力,其中还是踩了不少坑,重构了很多次,同时也不禁感叹浏览器排版引擎的强大。并且从中也体会到了算法与数据结构的魅力,良好的设计是性能高、维护性佳的基石,也获得不少乐趣。
另外这种模式经过完善后个人觉得还是有不少想象力,除了简单的图片生成,还可以用于h5游戏的列表布局、海量数据的表格渲染等场景,另外后期还有一个想法,目前社区渲染这块已经有很多做的不错的库,所以想将布局以及计算换行、图片缩放等功能独立出来一个单独的工具库,通过集成其他库来进行渲染。