动手打造一款canvas排版引擎在canvas中进行排版布局的一些实践,在web以及各类小程序(如微信小程序

一般这种需求的解决方案大体上可以分为以下几种:

方案分析:

萌生想法:

在梳理了要做的功能后,一个简易的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游戏的列表布局、海量数据的表格渲染等场景,另外后期还有一个想法,目前社区渲染这块已经有很多做的不错的库,所以想将布局以及计算换行、图片缩放等功能独立出来一个单独的工具库,通过集成其他库来进行渲染。

THE END
1.免费设计在线平面设计工具借助海量免费模板、图片和字体等,轻松创建一切设计。使用零门槛拖拽式编辑器,设计从构想变为现实。简单点击几下,下载或分享设计。 为公众号、抖音、小红书等宣传渠道制作引人入胜的视觉物料,并用Canva可画完成一键发布。 浏览模板 备受全球亿万用户喜爱 1.35亿+ https://www.canva.cn/free/
2.Canva可画在线设计协作平台平面设计作图软件在线设计协作平台Canva可画提供了海量的设计模板,涵盖海报、简历、名片、Logo、PPT、手抄报、二维码、Banner等数十种平面设计场景,更有千款中英文字体及千万张正版图片素材可供使用。精彩设计,随时随地!http://canva.me/
3.Canvas:网页上的画布canvas可画网页版文章浏览阅读1.1k次,点赞39次,收藏11次。想象力比知识更重要,因为知识是有限的,而想象力概括着世界的一切,推动着进步,并且是知识进化的源泉。_canvas可画网页版https://blog.csdn.net/chaosweet/article/details/143704806
4.基于HTML5Canvas的网页画板实现教程51CTO博客HTML5的功能非常强大,尤其是Canvas的应用更加广泛,Canvas画布上面不仅可以绘制任意的图形,而且可以实现多种多样的动画,甚至是一些交互式的应用,比如网页网版。这次我们要来看的就是一款基于HTML5 Canvas的网页画板,在这里仅对一些关键性的代码进行记录,大家也可以下载全部源代码研究。 https://blog.51cto.com/u_15581727/5178204
5.使用canvas来绘制图形既然我们已经设置了 canvas 环境,我们可以深入了解如何在 canvas 上绘制。到本文的最后,你将学会如何绘制矩形,三角形,直线,圆弧和曲线,变得熟悉这些基本的形状。绘制物体到 Canvas 前,需掌握路径,我们看看到底怎么做。http://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes
6.HTML5Canvascanvas 元素用于在网页上绘制图形。什么是 Canvas? HTML5 的 canvas 元素使用 JavaScript 在网页上绘制图像。 画布是一个矩形区域,您可以控制其每一像素。 canvas 拥有多种绘制路径、矩形、圆形、字符以及添加图像的方法。创建Canvas 元素 向HTML5 页面添加 canvas 元素。 规定元素的 id、宽度和高度: <canvas id=https://www.w3school.com.cn/html5/html_5_canvas.asp
7.Canvas网页涂鸦板再次增强版腾讯云开发者社区后退和前进(撤回)功能,我的想法是每画一次就将整个画布的数据push到一个数组中,按前进和后退时再将对应的数据取出来,这个可以通过getImageData和putImageData方法实现,这两个方法的使用可以到http://www.w3school.com.cn/tags/html_ref_canvas.asp中查看。 选择画布颜色功能有两种(获取颜色板的方法和第二版设置画https://cloud.tencent.com/developer/article/2120753
8.简单易用的在线平面设计软件–Canva可画浏览器版本过低,请下载客户端 你使用的是旧版或我们不支持的浏览器。要继续使用Canva,请下载桌面客户端 下载Windows 10或更新版本或升级到以下任一浏览器的最新版本 Chrome Firefox Safari(仅限 macOS) Edgehttp://www.canva.com/design/DAGX-h61UyU/SJh1lUDerU-D36qK7Zkhmg/view?embed&meta
9.在网页上画一个点(HTML5Canvas作图)HTMLCanvas本文节选自我金海龙2010年写的《HTML5 Canvas 作图函数库2.0版本.pdf》, 在HTML5刚登陆中国大陆的时候,奇缺权威资料,我及时写作,刚一发布,就引起了关注,也成为被盗版的目标,当你们在Google搜索:HTML5 作图就可以看到我金海龙作品的排名。 这正说明了我的编程实力。 https://www.cnblogs.com/htmlcanvas/archive/2012/08/06/2624646.html
10.Canvas在线画图插件canvas可编辑拖拽画板。Canvas在线画图插件网页特效,js特效Canvas在线画图插件源码,实用的前端网页js插件,jquery特效,jquery插件下载Canvas在线画图插件网页特效,网页小部件js代码就上bootstrap模板库https://www.bootstrapmb.com/tag/zaixianhuatu
11.画布将整个可绘制区域填充为a、r、g、b指定的颜色。相当于canvas.drawColor(colors.argb(a, r, g, b))。 canvas.drawColor(color) color{number} 颜色值 将整个可绘制区域填充为color指定的颜色。 canvas.drawColor(color, mode) color{number} 颜色值 https://www.kancloud.cn/theliang/autojs/2790150
12.前端小白写了个网页版五子棋游戏,使用原生JS+Canvas实现绘制user-scalable=no"><link rel="stylesheet"href="css/1.css"><link rel="stylesheet"href="css/2.css"><script src=""></script><title>html5网页手机五子棋游戏</title></head><body><div id="chessBox"><canvas id="canvas"width="0"height="0"></canvas></div><div id="chess_buttom_box"https://www.jianshu.com/p/1f0072358c22
13.OpenAIChatGPTCanvas进化:React渲染和文本格式化工具即将来袭Canvas 简介 OpenAI Canvas 是 ChatGPT 的一个新界面,用于处理需要编辑和修改的写作和编码项目。该功能最早于 2024 年 10 月推出,现在向所有 ChatGPT 用户开放。 Canvas 入口位于 ChatGPT 聊天机器人的旁边,点击按钮进入;用户也可以在提示词中加入“使用 Canvas……”,或在网页版 ChatGPT 中输入“打开 Canvas”https://www.ithome.com/0/818/604.htm
14.如何为您的网站在Canvas和SVG之间做出选择MicrosoftLearn最有趣的用例集并没有指出哪种技术是最终的胜利者。这些用例可以通过两种主要方案进行演示:制表/制图/绘制地图和二维游戏。 图表和图形都需要使用矢量图形,Canvas 或 SVG 都可以胜任。然而,由于 SVG 的固有功能,它通常是更好的选 择。 SVG制表/制图/绘制地图方案 https://msdn.microsoft.com/zh-cn/ie/hh377884
15.adobeanimateccan中文(英文)破解版64位/32位软件官方Animate将拥有大量的新功能,特别是在继续支持FlashSWF、AIR格式的同时,还将支持HTML5Canvas、WebGL,并且可以 安装教程 软件下载 Adobe Animate 2022 v22 中文破解版64位 下载 Animate更名为原Adobe Flash Professional,除了支持原Flash开发工具外,还增加了HTML5创作工具,为网页开发者提供更适合现有网页应用的https://www.yutu.cn/popsoft_40.html