阅读 257

前端交互渲染入门[1]—Canvas富文本效果渲染为载体,实践MVC思维模式

一. 前言

1.1 本文的初衷

在初入前端接触JavaScript的时候,会看到JS的定义——一门面向对象的弱类型语言。而身为程序猿,或多或少会听到面向对象编程(OOP)MVC编程思维之类的术语。而我平时也有思考怎么将这些概念应用到平时的项目中,怎么指导项目的开发。

一般来说,一个产品或项目会经历需求分析软件设计程序开发测试上线五个阶段。而我认识的很多前端研发人员可能拿到售前整理的原型图之后就直接进入软件研发阶段了。正所谓研发一时爽、迭代火葬场,没有经历一个良好的软件设计阶段,之后的项目迭代大概率会变得十分痛苦。

从自己毕业到现在也差不多3年了,因此也想把自己做过的有意思的项目进行一个总结和回顾,把自己的一些经历和心得分享出来,大家一同进步。而第一篇文章,我准备来分享怎么通过MVC编程思维来一步步进行软件设计。也希望看了这篇文章的人也能有所启发,前端也是可以很好的运用OO编程推进项目。

回到正题,作为系列文章的第一篇,我想先以Canvas的基础用法入门。Canvas的相关使用相信网上有很多系列的用法,但是怎么在软件设计阶段,将一个个简单的渲染需求串接起来,完成富文本效果的渲染,里面还是有很多有意思的东西能讲,这也是我写这篇文章的初衷。

1.2 Canvas介绍

Canvas的历史

在学习一项新技术之前,先了解这项技术的历史发展及成因会帮助我们更深刻的理解这项技术。

历史上,canvas最早是由苹果公司提出的,在Mac OS X webkit中创建控制板组件使用,而在canvas称为HTML草案及标准之前,前端是通过一些替代方式去绘图的,比如为人所诟病的Flash,以及非常强大的SVG(Scalable Vector Graphics,可伸缩的矢量标记图),还有只能在IE(IE 5.0以上的版本)中使用的VML(Vector Markup Language,矢量可标记图)。甚至于有些前端可以使用div+css来完成绘图。

总的来说,没有canvas的时候,在浏览器绘制图形是比较复杂的,而在canvas出现之后,绘制2D图形相对变得容易了。

Canvas是什么

Canvas(画布)是在HTML5中新增的标签,用于在网页实时生成图像,并且可以操作图像内容,是一个可以用JavaScript操作的位图(bitmap)。它没有自己的行为,但是定义了一个 API 支持脚本化客户端绘图操作,绘制的动作通过js来完成。

Canvas特性

  • 依赖分辨率(以px为单位的绘制)
  • 能够以 .png 或 .jpg 格式保存结果图像
  • 不支持事件处理器
  • 弱的文本渲染能力
  • 局部改动时,需要重绘整个Canvas

Canvas常见的应用场景

  1. 用于可视化领域。制作可视化图表等内容。
  2. 制作图形编辑器等工具。
  3. 可内嵌多种媒体内容和特效内容。且能与 web 进行很好的兼容。
  4. 游戏

我们为什么要学Canvas

抛开业务谈技术都是耍流氓。俗话说技多不压身,随着时代的发展,前端开发碰到移动端交互效果越来越炫酷、越来越复杂的业务场景的频率越来越高。纯css交互或者svg效果很难做到处理复杂游戏逻辑的同时保证性能的流畅性,而Canvas刚好能满足上述需求。

1.3 前言的最后

这算是我写的第一篇技术交流与分享类型的文章,前前后后大概历时了一个半月,修修改改也有十六版了,但还可能或多或少会有写的不太好或者啰嗦的地方,还请各位大佬见谅。

二.需求分析

2.1 背景

现在假定有一个Canvas相关富文本渲染的项目,它的业务场景也比较简单:

  • 用户在客户端通过拖拽的方式绘制页面效果,设置页面之间的交互动画;
  • 用户能在移动端查看绘制好的页面效果,同时能有对应的交互动画;

现在我们分配到第二点任务,根据已知的数据结构与渲染需求,通过Canvas进行还原,并处理好交互动画。

2.2 整理实现思路

描述越简单的需求,越笼统。上述第二点需求缺乏各个功能的落脚点。因此我们先逆推进行分析。

从渲染页面效果切入

用CSS模拟渲染是一种可行的方式,但是页面效果的复杂度是未知的,势必整合后Dom元素的嵌套关系可能会很深,而且考虑交页面与页面的交互,单纯用HTML+CSS进行渲染交互,性能也会是一个很大的问题。

考虑到性能优化,一个可行的方向是尽量减少Dom元素未知的嵌套层级,那么可以考虑将渲染效果输出到图片上,将页面从Dom元素层叠渲染的形式转化为单纯的图片展示。而交互带来的性能问题也会一并迎刃而解。而Canvas刚好能承担渲染好页面效果后,将其输出成图片的职责。

实现思路

  1. 获取客户端提供的渲染信息,将其通过渲染至Canvas上;
  2. 通过Canvas将其进行渲染并输出为图片;
  3. 监听移动端响应事件完成交互动画。

既然整个需求的实现思路已经打通,那么接下来需要思考的事情便是,以一个什么样的逻辑去通过Canvas渲染富文本效果?

2.3 渲染原理

首先来类比一下设计师用PS之类的绘图软件完成各种页面效果,是通过不同的**图层(Layer)图层组(LayerGroup)**层层叠加完成最终的视觉效果。我们通过Canvas完成富文本效果渲染的原理也是如此。

假设现在有个复杂页面需要进行渲染,那么就按一定的规则拆解成多个图层;如果某个图层的效果还是相对复杂,那么接着细化下去,直到能用Canvas的API完成对应效果的绘制。

下图展示的就是如何层层拆解左侧的页面效果。特别需要说明的是描线图层(maskLayer),它不直接体现在渲染效果的图层上,它更主要是充当图层本体的骨架,渲染其他效果的图层时进行辅助和定位。

另外需要补充的是画板的概念,对于用户侧来说,最后进行交互的最小单元称之为页面;而在研发侧来说,页面的定位过于宽泛,因此在之后的场景,统一将其叫做画板(Artboard)

2.4 渲染思路

将复杂页面效果拆解成足够简单的图层,从而进行组合,是实现整个渲染效果的核心原理。但是这个原理想要落地,就需要各个量化的渲染需求做支撑。

首先需要对图层的所属类别进行划分,根据图层内容以及拓展性,将其分为三大类:图形图层(ShapeLayer)文字图层(TextLayer)图层组(GroupLayer)

而涉及到的渲染效果,根据目前的渲染需求,将其分为了四大类填充效果(Fill)、描边效果(Stroke)、阴影效果(Shadow)、模糊效果(Blur)

2.5 数据结构

上一个章节简单对图层类型与渲染效果进行了分类,我们先根据上述的信息,简单的拟定数据结构第一个版本。

首先下面展示的数据结构是一个画板的数据结构,而画板和图层组原则上来说,图层组是依附于画板上的,因此视为其中的一个属性。同时图层除了渲染效果以外,还有一个重要的字段形变信息(transform),用来描述当前图层在画板中的位置(translate)缩放(scale)旋转角度(rotate)

映射成js对象的结构如下所示:

{
	//画板信息
	artboard: {
		width, // 宽度
		height, // 高度
		visibleHeight, // 可视高度
		//样式信息
		style: {},
		//图层列表
		layerList: [{
			layerType, // 图层类型 shape: 图形 text:文字 group: 图层组
			shape: {}, // 图形渲染
			text: {}, // 文字 
			group: [], //图层组
			//渲染效果
			style: {
				fill: {}, //填充效果
				stroke: {}, //描边效果
				//滤镜效果
				filter: {
					shadow: {}, // 阴影效果
					blur: {}, //模糊效果 
					//...
				}
			},
            transform:{} //形变信息
		}]
	}
}
复制代码

三. 软件设计

前言部分提到开发一款软件的五个步骤,需求分析完毕之后将进入软件设计阶段,也是这篇文章重点描述的一章。在软件设计的过程中,我将借助领域模型,以UML( 统一建模语言)为工具,将代码实现的逻辑通过图表的形式,提前进行布局。

3.1 领域模型简介

领域模型:是对领域内的概念类或现实世界中对象的可视化表示[摘自百度];是一种分析模型,在软件开发过程分析阶段用于分析如何满足系统功能性需求,属于软件开发范畴,在UML中主要使用类图来描述领域模型。

为什么要做领域模型?

前言部分也提到,一个产品或项目会经历需求分析软件设计程序开发测试上线五个阶段。而软件设计与程序开发的边界是不明显的,这其实是一个很危险的信号。

首先,没有一个好的软件设计的阶段,项目一次又一次的迭代,会导致代码内部的逻辑混乱且复杂。目前我接触过的项目,以vue项目为例,经过几次迭代,不同的人接手过后,最明显的感知就是vuex里面定义的数据结构会冗杂。有时一个小小的逻辑改动可能还得配合着把整个项目的流程跑一遍,效率是极低的。

其次,没有一个良好的软件设计,程序开发时,是很难处理一个复杂的项目,尤其是当需求瞬息万变时,战略上又没有做任何的领域建模,那么很可能会错过真正重要的东西,导致整个项目风险失控。

因此,学着去怎么进行领域建模,既有利于让其他人能快速了解项目的宏观架构和内部实现逻辑,也能尽可能降低项目开发过程中的风险。同时如果后续要进行代码重构,也能轻易的进行切入。

3.2 领域模型

用例

在正式进行领域建模之前,我们先回顾当前的用例有哪些。

  1. 还原客户端设置的页面之间的动态交互
  2. 还原客户端绘制的页面效果

领域模型

可以很明显的感知到,用例里面的内容,很难和现实中的实物对应起来。不过好在这个用例对应的领域边界比较小,因此我们可以直接按MVC的结构,先进行初步的划分。对于视图层(view)与控制层(controller),可以完全直接创建两个角色视图控制器(UIController)与核心控制器(APP),分别承担各自范围内的相应职责。其中视图层和浏览器是高度耦合的关系。

接下来我们把重心放在Model业务层的逻辑上。首先根据用例1,我们能感知到需要有一个角色承担渲染的职责,我们将其叫做渲染器(Renderer)。而根据用例2,对于视图层来说我们能感知到交互动画相关的职责似乎能单独划分一个角色进行内部封装,我们将其叫做动画控制器(AnimationController)。最后得到领域模型如下所示。

3.3 渲染模型

在得到领域模型之后,我们需要在对渲染器(Renderer)角色需要承担的职责在进行具象化,以便输出较为全面的类图。

首先就是将图层渲染的需求全部落地下来。之前只是将图层渲染的效果分成了四大类型,但是各个类型具体的内容没有进一步的说明,接下来的渲染模型将会补充剩下的部分细节。图形图层补充了描线模块,用于起草图形的形状,通过相关的字段明确具体的图形形象。

同时我将一些通用的渲染属性统一放置在图层组部分,比如图层是否固定、是否可视,是否形变等等。虽然图形图层和文字图层也有这些属性,只不过在渲染模型中为了避免重复,将其只体现在图层组部分。

3.4 数据模型

在思考类图之前,先思考一下整个领域模型中,数据格式的流转形式。我将其分为三个阶段

  1. 原始数据结构(originData),封装了原始渲染属性以及交互相关属性,通过接口从服务器端返回;
  2. 渲染数据结构(renderData),整合成根据一定的渲染流程,方便调用 Canvas的API的结构;
  3. 视图数据结构(viewData),整合Canvas渲染后输出的图片资源,适合直接在移动端进行展示。

接下来我们依次进行数据模型的建立。

原始数据结构

{
  artboardList: [], //画板列表
  clipShapeList: [], // 蒙版图层列表
  gradientList: [], //渐变信息列表
  interactionList: [], //交互信息列表
  resourceList: [], //图片资源列表
  perviewArtboadId: `` // 预览画布id
}
复制代码

其中画板列表的内部对象结构和2.5节的图是一致的。

渲染数据结构

原始数据结构是树结构,这时要进行渲染时,可以采取两种方式

  1. 通过深搜算法遍历的同时进行渲染;
  2. 通过深搜算法,遍历过程中,二次处理数据,并重新输出成队列结构

先说方法1,虽说能做到即时渲染,但是渲染逻辑和遍历树节点的逻辑耦合在一起,后续拓展需求很可能会对原来的逻辑结构有大的调整,拓展性不高。

因此采用方案2,先通过深搜遍历节点(图层),并按层级关系输出为队列结构。同事在图层解耦的过程中,将每个图层涉及到的形变属性、渐变属性、蒙版属性重新进行计算好。这时渲染器接收到每个图层信息时,只需要专注根据图形的属性进行渲染即可,不需要额外考虑是否会受其父图层的影响。

该结构和原始数据结构中的画布列表的内部对象结构没有太大的差别,只不过**图层类型(layerType)属性可选项只有图形图层和文字图层。同时会新增外接矩形(outterRect)**属性,因为Canvas元素对事件的弱支持,而该属性将用于后续定位交互事件的点击范围而使用。

{
  canvasList: [{
    id, //唯一标记位
    //...
    layerList: [{
      layerType, // 图层类型 shape: 图形 text:文字
      //...
      outterRect //外接矩形,用于处理交互的点击范围
    }]
  }],
  interactionList,
  perviewArtboardId
}
复制代码

视图数据结构

这是渲染数据通过渲染器进行渲染后输出的结构,可以看到此时图层列表的已经没有图层类型的属性了,取而代之的img属性与layerImg属性。

因此考虑到页面高度大于可视高度,那么会存在页面滑动的情况,而此时某个图层是固定加背景模糊的效果,那么势必原来渲染一张静态图层图片(img属性)的方式就行不通,为此就还是需要采用渲染的方式,依照进行背景模糊的渲染逻辑即时渲染,该方式渲染需要layerImage属性提供支持。

const viewData = {
  pageList: [
    {
      id: ``,
      //...
      layerList: [{
        width,
        height,
        position,
        outterRect, //图层外接矩形
        img, //渲染后的图片
        layerImage, //图层本体无渲染效果的图片
        fixed, //是否固定
        bgBlur, //是否背景模糊
      }]
    }
  ],
  interactionList,
  perviewArtboardId
}
复制代码

渲染数据的图层详细结构

之前涉及到图层部分的结构都是点到即止,因为它内部的结构属性比较多,所以下面单独进行展示。本质上还是遵循前面的大的数据结构,只不过针对很多具体的渲染效果进行了拓展。

1-图层数据结构.png

{
  // true:可见  false:不可见
  visible,
  // true: 图层固定 false:图层不固定
  fixed,
  // 图层类型 shape: 图形  text:文字 group: 图层组
  layerType,
  // 外接矩形区域
  bound: {
    x,
    y,
    width,
    height
  },
  // 样式信息
  style: {
    // 填充效果
    fill: {
      // solid:纯色填充  pattern:图形填充  gradient:渐变填充
      type,
      // `solid` === type
      color: {
        r,
        g,
        b,
        alpha
      },
      pattern: {
        // 填充形式:  cover/fill/none
        objectFit,
        resource // 图片资源
      },
      gradient: {
        // linear: 线性渐变  radial:径向渐变
        type,

        // `linear` === type  渐变线的位置
        x0,
        y0,
        x1,
        y1,

        // `radial` === type 
        cx,
        cy,
        cr,
        fx,
        fy,
        fr,

        //断点集合
        stopList: [{
            color: {
              r,
              g,
              b,
              alpha
            },
            offset,
          },
          // ...
          {
            color: {
              r,
              g,
              b,
              alpha
            },
            offset,
          }
        ]
      }
    },
    //边框效果
    stroke: {
      // 边宽
      lineWidth,
      // outside: 外部描边  inside:内部描边  center:中心描边(默认值)
      align,
      // 边框仅限纯色填充
      color: {
        r,
        g,
        b,
        alpha
      },
      // 同context2d lineJoin属性
      lineJoin,
      // 同context2d lineCap属性
      lineCap,
      // 同context2d lineDashOffset 属性
      dashLength,
      // 同context2d lineDashOffset 属性
      dashOffset,
    },
    //滤镜效果
    filter: {
      // 阴影样式
      shadow: {
        // 同context2d shadowColor 属性
        shadowColor: {
          r,
          g,
          b,
          alpha
        },
        // 同context2d shadowOffsetX 属性
        shadowOffsetX,
        // 同context2d shadowOffsetY 属性
        shadowOffsetY,
        // 同context2d shadowBlur 属性
        shadowBlur,
      },
      // 模糊效果
      blur: {
        //模糊类型 bgBlur: 背景模糊    objectBlur:对象模糊
        type,
        //模糊度
        blurAmount,
        //亮度值
        brightnessAmount,
        // 透明度值
        opacity
      },
      // 灰度百分比
      grayscale,

      //... 更多滤镜风格参考filter参数
    }
  },
  // 形变信息
  transform: {
    //水平缩放
    a,
    //垂直倾斜
    b,
    //水平倾斜
    c,
    //垂直缩放
    d,
    //水平移动
    tx,
    //垂直移动
    ty
  },
  // 图形信息
  shape: {
    // 图形类型 rect:矩形 circle:正圆形 ellipse:椭圆 line:线段 path:矢量路径 compound:组合图形
    type,

    // `rect` === type
    // 如果为圆角矩形的话,该字段表示圆角矩形四个角的弧度半径,类型为数组
    r: [],
    width,
    height,

    // `circle` === type
    //圆心x坐标
    cx,
    //圆心y坐标
    cy,
    //半径
    r,

    // `ellipse` === type
    cx,
    cy,
    // 椭圆长轴的半径
    rx,
    // 椭圆短轴的半径
    ry,

    // `line` === type
    // 起点坐标
    x1,
    y1,
    // 终点坐标
    x2,
    y2,

    // ·path· === type || `compound` === type
    //svg 路径描述
    path

  },
  // 文字信息
  text: {
    // 字体样式
    font,
    // 行间距
    letterSpacing,
    // 行文本内容
    rawText,
    //文本位置
    position: {
      x,
      y
    },
    //下划线
    underline: {
      // 是否可见
      visiblie,
      // 距文字的距离
      margin,
      // 下划线宽度
      borderWdith,
      color: {
        r,
        g,
        b,
        a
      }
    }
  },
  //图层组信息
  group: {
    childen: [{}]
  }
}
复制代码

3.5 UML类图

接下来我们以数据的变化流程形式为主轴进行分析,首先需要处理三种数据格式,根据高内聚的原则,都需要单独设置一个类进行处理。同时针对接口请求,我也封装了一个名为**数据传输对象(Data Transfer Object)**的类。而领域模型中视图层和控制层可以直接映射到类图结构之中,最后类图如下图所示。

  • View(视图层)
    • UIController:视图层控制器,封装交互事件的监听以及一些辅助渲染的方法,提供了和APP类配套的方法;
      • 初始化页面,预览图
      • 初始化交互事件
      • 通知交互动画执行交互动画
    • AnimationController:动画控制器,封装了具体的动画交互效果的逻辑,目前的交互动画分4大类:弹层显示(overlay)动画弹层消失(cancelOverlay)动画滑动(slide)入场/出场动画推动(push)入场/出场动画
      • 四种交互动画对外的方式
      • 具体执行四种交互动画的方法
      • 计算交互的帧动画,每一帧的位置
      • 封装缓动函数
  • Controller(控制层)
    • APP:核心控制器,对外提供初始化(init)数据与启动项目(launch)的两个入口方法,内部封装了其他消息转发的方法;
      • 对外部初始化接口
      • 对外部启动项目接口
      • 各种消息转发的接口
  • Model(逻辑层)
    • ProcessService:数据处理类,调度原始数据类获取原始数据,并整合成渲染数据;同时调度渲染类得到视图数据,将其转发至核心控制器;
      • 向下通知原始数据类获取原始数据;
      • 将原始数据整合为渲染数据
        • 将原始数据的树结构转换成方便canvas层层叠加渲染的队列结构
        • 计算因图层组嵌套关系而影响父子图层的属性(比如形变的旋转属性,子图层旋转角度受父图层的影响)
        • 计算复杂文本效果不同文字的位置关系(canvas文字支持较弱,类似字间距的实现,需要转化位置关系)
    • RendberService:渲染类,承担渲染器的职责,将渲染数据转化成视图数据;
      • 渲染画板列表
      • 渲染某个画板列表上的图层列表
      • 绘制渲染类别图层(因为有些图层还需要进一步拆解,因此也可以视为某种意义上的画板)
      • 渲染相关效果
    • CanvasDatbaService:原始数据类,从数据传输对象中获取画板与图层信息,并进行整合得到原始数据;
    • DTO:数据传输对象,封装XMLHttpRequest,用于调度接口获取服务器端数据;

3.6 UML时序图

接下来主要展示从初始化的时候,内部各个类调度请求的过程。

  1. 核心控制器(APP)接收到初始化(init)展示页面效果的请求;
  2. 数据处理类(ProcessSerevice)接收APP的指令,向下发起请求(queryOriginData)获取原始数据;
  3. 数据处理类先内部将原始数据转化成方便渲染类(RenderService)渲染的数据结构;
  4. 数据处理类再向渲染类发起请求,通过方法(initRenderData)获取到了视图数据,并向上返回结果;
  5. 核心控制器将视图数据转发给视图层控制器(UIController)并视图层完成展示的工作。

四. Canvas入门须知

在进行研发之前,有必要简单将入门Canvas时会踩到的一些坑和基本概念简单提一下。

4.1 初识Canvas

Canvas(画板)与renderingContext(渲染上下文)

canvas元素可以理解成是Dom树上的一个节点,它创造了一个固定大小的画布,并且公开了一个 或多个渲染上下文。渲染上下文可以用来绘制和处理需要展示再画布上的内容。

渲染上下文的大小可以通过canvas元素的width与height属性进行设置,而canvas元素本身的宽高则通过style属性进行设置。当没有设置canvas元素的width和height属性时,默认渲染上下文的宽度为300像素,高度为150像素。

初次接触canvas时需要格外留意两者的宽高设置。如果canvas元素的宽高比与渲染上下文的宽高比不一致时,容易造成canvas元素上显示的内容出现扭曲。

CanvasRenderingContext2D

CanvasRenderingContext2D为对象canvas元素的绘图提供2D渲染上下文,可用域绘制形状、文本、图像和其他对象。本文接下来完成富文本渲染效果调用的API由该对象提供。

const canvas = document.querySelector(`canvas`);
const context = canvas.getContext('2d');//返回CanvasRenderingContext2D对象
复制代码

WebGLRenderingContext

WebGLRenderingContext 对象提供基于 OpenGL ES2.0(OpenGL for Embedded Systems) 规范 的绘图上下文,用于在canvas元素内绘图。OpenGL可以提供功能完善的2D和3D图形应用程序接口API,也是canvas制作3D效果的基石。

const canvas = document.querySelector(`canvas`);
const context = canvas.getContext('webgl');//返回WebGLRenderingContext对象
复制代码

devicePixelRatio(设备像素比)

设备像素比表示当前显示设备的物理像素分辨率CSS像素分辨率之比。也可以解释为像素大小的比率:一个CSS像素的大小与一个物理像素的大小。

简单来说,设备像素比告诉浏览器应使用多少屏幕实际像素来绘制单个CSS像素。首先明白一点,通过canvas元素绘制的图形都是位图,位图放大会失真。一般来说浏览器的设备像素比为1,而移动端的设备像素比常见的iphone5/6/7为2,iphoneX为3。通过canvas绘制同等宽高的内容,在浏览器看很清晰,但是到移动端显示会模糊,就是因为设备像素比的差别。因此为了保证移动端绘制的内容,尤其是文字能够清晰的展示出来,在设置渲染上下文宽高时,务必要乘上设备像素比。

const canvas = document.querySelector(`canvas`);
const context = canvas.getContext('2d');

//canvas本身撑满整个屏幕
canvas.style.setProperty(`width`, document.documentElement.clientWidth);
canvas.style.setProperty(`height`, document.documentElement.clientHeight);

//渲染上下文的内容撑满整个屏幕
canvas.width = document.documentElement.clientWidth * window.devicePixelRatio;
canvas.height = document.documentElement.clientHeight * window.devicePixelRatio;
复制代码

4.2 Canvas的坐标

Canvas坐标系

Canvas坐标系有且只有一个,且是唯一不变的,其坐标原点在Canvas的左上角,从坐标原点向右为x轴的正半轴,从坐标原点向下为y轴的正半轴。

Canvas绘图坐标系

绘图坐标系不是唯一不变的,它与canvas的矩阵(Matrix)有关系,当矩阵发生改变的时候,绘图坐标系也对应着进行改变,同时这个过程是不可逆的。 矩阵又是通过我们设置translate(平移)、rotate(旋转)、scale(缩放)、skew(斜拉)来进行改变。

每次矩阵改变都是针对当前绘图坐标系进行变化。

let canvas = document.querySelector (`#canvas`);
let context = canvas.getContext (`2d`);

canvas.width = document.documentElement.clientWidth * window.devicePixelRatio;
canvas.height = document.documentElement.clientHeight * window.devicePixelRatio;

context.fillStyle = `#444`;
context.fillRect (0, 0, canvas.width, canvas.height);

context.strokeStyle = `#cffcff`;
context.lineWidth = 10;

let rectOption = {
    width: canvas.width / 2,
    height: canvas.height / 4,
    top: 0,
    left: 0,
}

// 绘图坐标系原点默认canvas的左上角
context.strokeRect (rectOption.left, rectOption.top, rectOption.width, rectOption.height);

// 将原点进行平移
context.translate(canvas.width / 4, canvas.height / 2);
context.strokeRect (rectOption.left, rectOption.top, rectOption.width, rectOption.height);

// 将绘图坐标系旋转30°
context.rotate(30 * Math.PI / 180);
context.strokeRect (rectOption.left, rectOption.top, rectOption.width, rectOption.height);
复制代码

下图为代码运行之后的效果图,在未进行操作前直接绘制矩形,矩形以原点为起点进行绘制。之后平移渲染坐标系,再次绘制矩形,起点改变之后,矩形的位置发生变化;此时将坐标系旋转30°后绘制矩形,矩形绕原点旋转。

save()与restore()

save()和restore()是用来保存和恢复canvas状态, canvas的状态就是当前画面应用的所有样式和变形的一个快照。 Canvas状态存储在中,每当save()方法被调用后,当前的状态就被推送到栈中保存。每一次调用restore方法,上一次保存的状态就从栈中弹出,所有设定都恢复。

一个绘画状态包括:

  • 当前应用的变形(即移动,旋转,和缩放)
  • strokeStyle、fillStyle、globalAlpha...
  • 当前的裁剪路径(clipping path)

绕中点旋转

context.strokeRect (rectOption.left, rectOption.top, rectOption.width, rectOption.height);
//坐标系调整之前,最好保存当前canvas状态,方便后续操作时,绘图坐标系的一致
context.save ();

// 将原点设置到旋转图形的中点处
context.translate(rectOption.left + rectOption.width / 2, rectOption.top + rectOption.height / 2);
// 设置旋转角度
context.rotate(45 * Math.PI / 180);
// 此时原点在图形的中心处,此时需要接着将坐标原点反向平移,保证和原来的坐标系相比,只是角度进行了变化
context.translate(-(rectOption.left + rectOption.width / 2), -(rectOption.top + rectOption.height / 2));

context.strokeRect (rectOption.left, rectOption.top, rectOption.width, rectOption.height);

//绘制完毕之后,将绘图坐标系还原
context.restore();
复制代码

镜像对称

以水平镜像翻转为例,使用下面属性即可

transform: scaleX(-1);
复制代码

canvas元素做水平镜像翻转也是同样的道理,如果还需要保证水平镜像后的图形位置和原图层保持一致的话,再接着堆绘图坐标系进行平移处理即可,在此就不再详细展开了。

context.scale(-1, 1);
复制代码

有兴趣的人可以参考这篇文章,有很详细的说明:

[www.zhangxinxu.com/wordpress/2…

4.3 绘制图片

drawImage()

CanvasRenderingContext2D.drawImage() 方法提供了多种方式在Canvas上绘制图像。具体使用可以参考相关文档,值得注意的是5参数和9参数用法的参数位置是不同的。

图片预加载与跨域

canvas绘制图片的时候,图片源需要保证不跨域。同时绘制图片时,必须绘制已经预加载完毕的图片才有效果,否则调用方法之后,并不会将图片绘制到画板上。

let img = new Image ();
// 允许跨域
img.setAttribute (`crossOrigin`, ``);
//设置图片路径
img.sestAttribute (`src`, path);

new Promise (resolve => {
    // 图片是否预加载完毕 ? resolve() : 预加载图片;
   img.complete ? resolve(img) : img.onload = () => { resolve(img) };
}).then (img => {
   context.drawImage(image, 0, 0);
})
复制代码

图片尺寸不宜过大

在实际开发中遇到,canvas绘制尺寸或drawImage插入图像、getImageDate获取图形资源等尺寸大于某个阈值时(最好不宜超过一万像素),渲染出来的图片整个都是空白。这个具体的阈值不确定,跟运行环境有关,但这也是drawImage绘制的一个不知何时爆发的隐患。

五. 程序开发

如果将整个代码逻辑都复述一遍,整篇文章的篇幅很明显不够用,因此这一章我重点讲述渲染图形图层时的一些代码细节。

5.1 渲染流程

以渲染图形图层为例,下图将展示渲染一个静态图层经历的步骤。大致的思路依旧遵循描线填充描边阴影模糊的步骤进行处理。再此需要特说明两个地方的渲染。

  • 在渲染描线图层时,需要见判断是否存在遮罩层。描线图层的可视区域会受遮罩层的影响,因此需要提前处理好;
  • 背景模糊的操作需要依赖整个背景画板、同时会对填充图层的渲染有影响,所以也需要提前就行处理。

下面展示渲染一个图形图层的关键代码,主要是渲染服务类的代码展示。

  • paintShapeCanvas()方法承担整合的职责,根据上述的渲染思路,最终输出渲染后的图形图层画板;不过一些逻辑细节因为和技术选型有关系,所以有局部的调整;这个调整的一处就体现在渲染填充画板的方法上;
  • paintFillCanvas()方法负责输出渲染之后的画板。它会根据渲染类型的不同,调用不同的渲染类型的方法,得到渲染后的画板之后,会根据是否有遮罩层,再处理可视范围,可渲染流程最开始的部分有一定的出入,但是不影响渲染的最终效果。
class RenderService {
  constructor() {}

  // ...otherMethod

   /**
   * 权限级别: private
   * 说明: 绘制图形图层画板
   * 参数: width: 画板宽
   * 	  height: 画板高
   * 	  layer:图层信息
   *      artboard: 背景画板
   * 返回值:canvas对象
   */
  paintShapeCanvas(width, height, layer, artboard) {
    // 创建一个全新的Canvas元素,并得到一块空白的画板,最后输出经过渲染后的画板
    let canvas = document.createElement(`canvas`);
    let context = canvas.getContext(`2d`);

    canvas.width = width;
    canvas.height = height;

    // 如果图层不可视 || 图层全局透明度为0 || (图层既无填充效果又无描边效果)
    if (!layer.visible || 0 === layer.globalAlpha || (!layer.isFill && !layer.isStroke)) {
      return canvas;
    }

    // 得到填充画板
    layer.isFill && (layer.fillCanvas = this.paintFillCanvas(width, height, layer));

    // 获取背景模糊的画板
    layer.isBackgroundBlur && (layer.fillfillCanvas = this.renderBgBlurCanvas(artboard, layer));

    // 获取描边画板
    layer.isStroke && (layer.strokeCanvas = this.paintStrokeCanvas(width, height, layer));

    // 渲染填充效果
    layer.isFill && context.drawImage(layer.fillCanvas, 0, 0);

    // 渲染描边效果
    layer.isStroke && context.drawImage(layer.strokeCanvas, 0, 0);

    // 渲染阴影效果
    layer.isShadow && this.renderShadow(canvas, layer);

    // 渲染对象模糊效果
    !layer.isBackgroundBlur && this.renderObjBlur(canvas, layer);

    return canvas;
  }

  /**
   * 权限级别: private
   * 说明: 绘制填充效果画板
   * 参数: width:画板宽
   * 	  height:画板高
   * 	  layer:图层信息
   * 返回值:canvas对象
   */
  paintFillCanvas(width, height, layer) {
    let canvas = document.createElement(`canvas`);
    let context = canvas.getContext(`2d`);

    canvas.width = width;
    canvas.height = height;

    context.save();

    // 根据填充类型渲染填充样式
    switch (layer.style.fill.type) {
      // 纯色填充
      case `solid`:
        this.renderFillSolid(canvas, layer);
        break;
        // 图案填充
      case `pattern`:
        this.renderFillPattern(canvas, layer);
        break;
        // 渐变填充
      case `gradient`:
        this.renderFillGradient(canvas, layer);
        break;
      default:
        break;
    }

    // 如果存在蒙层,那么仅显示蒙层可视区的内容
    if (layer.isClip) {
      let maskCanvas = this.paintMaskCanvas(width, height, layer);

      context.restore();
      context.globalCompositeOperation = `destination-in`;
      context.drawImage(maskCanvas, 0, 0);
    }

    context.restore();

    return canvas;
  }
}
复制代码

5.2 渲染数据结构的转化

原始数据结构图层关系是树结构,其中子节点的一些属性,比如形变关系、透明度等,会受到父节点的影响,而在渲染的过程中,我们更期望只关注当前图层本身的一些属性直接进行对应的渲染操作。因此有必要将树结构转化成更清晰的队列结构。下面的代码将展示,通过深搜(Depth-First-Search)算法,将原始数据结构转化成渲染数据结构。

class ProcessService {
  constructor() {}

  // ... otherMethod

  processLayerGroup(layerGroup) {
    let layerQueue = [];

    if (!layerGroup || !layerGroup.length) {
      return layerQueue;
    }

    let stack = [];

    //存放图层组的栈
    let groupStack = [];

    //对应图层组栈各元素当前还未遍历完的子元素
    let groupChildrenCountStack = [];

    //先将第一层节点放入栈
    for (let layer of layerGroup) {
      stack.push(layer);
    }

    //深搜
    while (stack.length) {

      let layer = stack.shift();

      layerQueue.push(layer);

      let parentLayer;

      groupStack.length - 1 >= 0 && (parentLayer = groupStack[groupStack.length - 1]);

      // ...处理图层相关的信息,比如 isFill、isStroke、isClip等属性的判定

      //迭代受group影响的layer的transform的值
      groupStack.length > 0 && this.iterateGroupTransform(layer, groupStack[groupStack.length - 1]);

      //如果该节点有子节点,继续添加进入栈顶
      if (`group` === layer.type &&
        layer.group.children &&
        layer.group.children.length) {

        stack = layer.group.children.concat(stack);

        //记录图层组有几个子图层
        groupChildrenCountStack.push(layer.group.children.length);
        groupStack.push(layer);

      } else {
        layer.style.opacity = (layer.style.opacity === undefined) ?
          1 : layer.style.opacity;

        //计算当前图层的交互区域
        this.calculateLayerOutterRect(layer);

        //迭代其父图层组的交互区域
        this.iterateGroupOutterRect(layer, groupStack, groupChildrenCountStack);
      }
    }

    return layerQueue;
  }
}
复制代码

5.3 图形图层各形状描线

矩形

context.rect(0, 0, layer.shape.width, layer.shape.height);
复制代码

圆角矩形

// r:Array 表示矩形四个角的弧度半径
context.beginPath();

context.moveTo(layer.shape.r[0], 0);

//该方法可以应对r小于宽高最小值的情况
context.arcTo(width, 0, width, height, Math.min (layer.shape.r[1], width, height));
context.arcTo(width, height, 0, height, Math.min (layer.shape.r[2], width, height));
context.arcTo(0, height, 0, 0, Math.min (layer.shape.r[3], width, height));
context.arcTo(0, 0, width, 0, Math.min (layer.shape.r[0], width, height));

context.closePath();
复制代码

正圆

context.arc(layer.shape.cx, layer.shape.cy, layer.shape.r, 0, 2 * Math.PI); 
复制代码

椭圆

context.ellipse(layer.shape.cx, layer.shape.cy, layer.shape.rx, layer.shape.ry, 0, 0, 2 * Math.PI);
复制代码

线段

context.beginPath();

context.moveTo(startX, startY);
context.lineTo(endX, endY);

context.closePath();
复制代码

贝塞尔曲线(svg/组合图形)

let pathList = layer.shape.pathList

context.beginPath();	

for (let path of pathList) {

    switch (path.action) {
        case `M`:
            context.moveTo(path.points[0], path.points[1]);
            break;
        case `C`:
            context.bezierCurveTo(path.points[0], path.points[1], path.points[2], path.points[3], path.points[4], path.points[5]);
            break;
        case `L`:
            context.lineTo(path.points[0], path.points[1]);
            break;
        default:
            break;
    }
}

context.closePath();
复制代码

5.4 图形图层边框填充

渲染需求

针对图形边框宽度的设置,除了设置宽度的数值之外,还会要求有内部描边、外部描边、中心描边三种渲染模式。但是Canvas的边宽属性lineWidth只会从中心部分向两边扩展的,也就是默认是v中心描边。因此最好能有一套通用的写法,仅根据配置项不同就能渲染出不一样的效果。

实现思路

下面是2种不同的实现思路:

  1. 根据lineWidth的特性,边宽按照配置项的数值设置,但是通过stroke()进行绘制时,需要将左上角的坐标和图形的宽高减去相应的数值;
  2. 保持图形原本的位置与宽高,调整边宽。内部描边或外部描边时,将边宽设置为2倍,之后消除多余的部分。

如果处理比较简单的图形,方案1或许会比较方便。但是如果涉及到复杂图形,比如矢量图,方案1处理起来后续反而会遇到各种麻烦,虽然接着上述的思路,通过缩放的方式,再处理或许可以达到效果,但是总觉得治标不治本,在此我们按方案2的思路去进行拓展。

首先是globalCompositeOperation(合成或混合模式操作)的应用,再多图层进行绘制时,通过不同的模式,能完成不同的效果,在此我们会使用它的两个模式。

  • destination-in:现有的画布内容保持在新图形和现有画布内容重叠的位置。其他的都是透明的。

  • destination-out:现有内容保持在新图形不重叠的地方。

下面是不同描边模式的实现思路:

  • 中心描边:正常使用lineWidth属性即可;
  • 外部描边:将lineWidth数值设置为2倍,globalCompositeOperation设置为destination-out,之后绘制一遍原图形,这样就之后显示外部边框,同时得到一个边框图层,之后还原globalCompositeOperation属性,再绘制一次图形本体即可;
  • 内部描边:同外部描边,不过需要将globalCompositeOperation设置为destination-in。

代码demo

html,body {
	padding: 0;
	margin: 0;
	background: #9e9e9e;
}

.canvas {
	width: 100vw;
	height: 100vh;
	position: absolute;
	top: 0;
	left: 0;
}
复制代码
<canvas id="outSideCanvas" class="canvas"></canvas>
<canvas id="centerCanvas" class="canvas"></canvas>
<canvas id="insideCanvas" class="canvas"></canvas>
复制代码
let canvasList = document.querySelectorAll(`.canvas`);

let canvasWidth = document.documentElement.clientWidth * devicePixelRatio;
let canvasHeight = document.documentElement.clientHeight * devicePixelRatio;

for (let canvas of canvasList) {
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;
}

// 封装渲染描边类型的方法
let renderStorkeCanvas = (canvas, option) => {
    let context = canvas.getContext(`2d`);

    context.save();

    // 根据设置项描边,如果非中心描边,将边宽设置为2倍
    context.lineWidth = (`center` === options.strokeAlign) ? options.lineWidth : options.lineWidth * 2;
    context.strokeStyle = `white`;
    context.rect(options.left, options.top, options.width, options.height);
    context.stroke();

    // 根据描边模式将 混合图层的参数进行设置
    if (`outside` === options.strokeAlign) {
        context.globalCompositeOperation = `destination-out`;
    } else if (`inside` === options.strokeAlign) {
        context.globalCompositeOperation = `destination-in`;
    }

    // 视为叠加一层原本的描边图层
    if (`center` !== options.strokeAlign) {
        context.rect(options.left, options.top, options.width, options.height);
        context.fill();
    }

    // 不设置边宽情况下的矩形边框,用于对比
    context.restore();
    context.strokeStyle = `red`;
    context.lineWidth = 2;
    context.strokeRect(options.left, options.top, options.width, options.height);

    context.restore();
}

// 默认使用矩形进行演示
let options = {
    width: canvasWidth / 4, 
    height: canvasWidth / 4,
    strokeAlign: `outside`, //描边类型  inside:内部描边  outside:外部描边  center:中心描边
    left: canvasWidth * (3 / 8),
    top: canvasHeight / 8,
    lineWidth: 30 //边宽
}

// 默认 0:外描边 1:中心描边 2:内描边
renderStorkeCanvas(canvasList[0], options);

options.strokeAlign = `center`;
options.top += (options.height + options.lineWidth + 150);
renderStorkeCanvas(canvasList[1], options);

options.strokeAlign = `inside`;
options.top += (options.height + options.lineWidth + 150);
renderStorkeCanvas(canvasList[2], options);
复制代码

最后运行的效果如下图所示:

5.5 模糊效果的应用

渲染需求

一般模糊需求分为两种,一种是对象模糊,针对本身的图层进行模糊处理;另一种是背景模糊,多图层层级关系中,置于该图层以下的图层会进行模糊处理。

实现思路

针对对象模糊,我之前找了2种处理方式,首先如果能兼容context2d的filter(滤镜)属性的话,可以通过设置滤镜的模糊度直接达到模糊效果,不过该属性存在兼容问题,所以需要视场景使用,而另一种的话,是我从github上找的一个高斯模糊的库,StackBlur。github的链接放在下面了,有兴趣的可以去看一看。github.com/flozz/Stack…

另一种背景模糊的处理方式其实也是基于对象模糊做的进一步处理。首先需要找到背景模糊图层之下的图层,依次按照配置项进行对象模糊的处理,之后根据位置关系,设置globalCompositeOperation属性,各个图层只展示背景模糊图层的区域即可。下面算是伪代码吧。

/**
* canvas: 已经渲染好需要进行背景模糊的画板
* layer: 图层配置参数
*/
renderBgBlurCanvas (canvas, layer) {
    let context = canvas.getContext (`2d`);
    
	//创建背景模糊的画板
    let blurCanvas = document.createElement(`canvas`);
    let blurContext = blurCanvas.getContext(`2d`);
	
    //初始化画板宽高
    blurCanvas.width = canvas.width;
    blurCanvas.height = canvas.height;
	
    //先将背景图层绘制在模糊画板上
    blurContext.drawImage(canvas, 0, 0);
	
    //使用模糊库提供的接口将画板进行模糊
    let stackBlur = new StackBlur();
    stackBlur.canvasRGB(blurCanvas, 0, 0, 
                        canvas.width, canvas.height, layer.blurFilter.blurAmount * 2.2);
	
    //感觉背景模糊图层的信息,获取描边画板
    let maskCanvas = this.paintMaskCanvas (canvas.width, canvas.height, layer);
	
    //将描边画板作为遮罩层,仅显示模糊区域
    blurContext.save ();
    blurContext.globalCompositeOperation = `destination-in`;
    blurContext.drawImage (maskCanvas, 0, 0);
    blurContext.restore ();
	
    // 针对背景模糊图层本身的边框
    ...
}
复制代码

六. 结语

来来回回修改也历经了一个半月。最初的内容只有第三节部分和第五节的内容,定位也是给有一定Canvas经验的人去看。但是给同事看过初稿之后,他反馈说看不懂来龙去脉,最好将文章的门槛降低一些,所以我重新进行了定位,

  • 对于Canvas完全不懂的人,准备了第四章的内容,帮助快读对Canvas有一定的印象;
  • 对于Canvas有一定经验的人来说,准备了第五节的部分内容,展示了一些Canvas很少用的写法;

而这篇文章的核心想法,还是希望以富文本渲染为例,把自己在软件设计阶段的思路过程展现出来,一起沟通,讨论。之后的一些文章也是,主要还是希望展示自己在软件设计阶段的想法,有兴趣的朋友可以一起评论交流自己的想法和心得。

PS:赌5毛钱肯定没什么评论_(:з)∠)_

文章分类
前端
文章标签