Zrender源码分析-Canvas的绘制引擎

2021-07-27 by uino 73 源码分析 研发

导读:ZRender是Echarts的底层图形绘制引擎,它是一个独立发布的基于Canvas/SVG/VML的2D图形绘制引擎,提供功能:

  • 图形绘制及管理(CRUD、打组)
  • 图形(包含文字)动画管理
  • 图形(包含文字)事件管理(canvas中实现dom事件)
  • 基于“响应式”(dirty标记)的高效帧渲染机制
  • 可选择的渲染器机制(Canvas/SVG/VML(5.0已放弃VML支持))

Tips:图形特指2D矢量图型

1.整体架构

1.1 基于MVC模式整体架构

Zrender架构图

如上图所示,ZRender是整体设计思路是面向对象的MVC模式,视图层负责渲染,控制层负责用户输入交互,数据层负责数据模型的编排与存储,其对应的文件和作用如下:

  • Storage.ts(数据模型):用于存储所有需要绘制的图形数据,并且提供相关数据的LRU缓存机制,提供数据的CURD管理;
  • PainterBase.ts(视图绘制):PainterBase是绘制的基类,系统提供的Canvas、SVG、VML视图绘制类都继承于PainterBase类,用户也可以自行继承实现如webgl的绘制能力;
  • Handler.ts(交互控制):事件交互控制模块,为图形元素实现和HTMLDOMElement一样的事件交互逻辑,如图形的选中、单击、触摸等事件;

除了上述MVC3大模块以外,还有以下辅助功能模块:

1.2 辅助功能模块

  • 动画管理模块(animation):管理图形的动画,绘制前会将对象的动画计算成帧对象保存在动画管理器中,伴随着动画触发条件将帧数据推送给视图绘制模块进行动画绘制;
  • 工具类模块(tool、core):提供颜色转换、路径转换、变换矩阵运算、基础事件对象封装、向量计算、基础数据结构等独立辅助计算函数或者类;
  • 图形对象模块(graphic):提供元素的对象类(包含Image、Group、Arc、Rect等),所有元素其最顶层都继承于Element类;
  • 图形对象辅助模块(contain):提供用于判断包含关系的算法,比如:坐标点是否在线上,坐标点是否在图形内;

Tips:上文中“元素”包含Group和2D图形,而图形只包含2D图形,不包含Group

2.源码文件结构

2.1 源码目录结构

src/
  -|config.ts
  -|Element.ts
  -|Storage.ts
  -|Handler.ts
  -|PainterBase.ts
  -|zrender.ts //入口文件
  -|export.ts
  -|animation/
    -|Animation.ts
    -|Animator.ts
    -|Clip.ts
    ...
  -|canvas/
    -|Painter.ts
    ...
  -|svg/
    -|Painter.ts
    ...
  -|vml/
    -|Painter.ts
    ...
  -|conatin/
    -|arc.ts
    ...
  -|core/
    -|LRU.ts
    -|matrix.ts
    ...
  -|dom/
  -|graphic/
    -|Group.ts
    -|Image.ts
    -|Path.ts
    -|shape/
      -|Arc.ts
      -|Rect.ts
      -|Circle.ts
      ...
    ...
  -|mixin/
    -|Draggable.ts
  -|tool/
    -|color.ts
    -|ParseSVG.ts
    -|parseXML.ts

2.2 目录及文件介绍

  • config.ts:全局配置文件,可配置debug模式、retina屏幕高清优化、深/浅主题色值等
  • Element.ts:所有可绘制图形元素和Group的基类,其中定义了基础属性(如:id,name,type,isGroup等),对象的基础成员方法(hidden,show,animate,animateTo,copyValue等)
  • Storage.ts:M层,对象模型层/存储器层,存储并管理元素对象实例,元素对象实例存储在_displayableList数组中,每次绘制时会根据zlevel->z->插入顺序进行排序,提供添加、删除、清空注销元素对象实例的方法
  • Handler.ts:C层,控制层/器,用于向元素上绑定事件,实现DOM式事件管理机制
  • PainterBase.ts:V层,视图层/渲染器层,PainterBase是渲染器的基类,5.0版本默认提供Canvas、SVG渲染器,5.0版之前版本还提供VML渲染器,元素的绘制就是由渲染器决定,系统默认Canvas渲染器渲染
  • zrender.ts:ZRender入口文件,也是编译主入口,
    • 暴露全局方法:init用于初始化ZRender实例,delInstance用于删除ZRender实例,dispose用于注销某个ZRender实例,disposeAll用于注销所有ZRender实例,registerPainter用于注册新的渲染器
    • ZRender类:用于管理ZRender实例里的所有元素对象实例,存储器(Storage)实例,渲染器(Painter)实例,事件控制器(Handler)实例,动画管理器(Animation)实例
  • export.ts:编译时调用,用于对外导出API
  • animation:存放动画相关的代码文件,如:Animation,Animator等
  • canvas:存放Canvas渲染器相关的代码文件
  • svg:存放svg渲染器相关的程序文件
  • vml:存放vml渲染器相关的程序文件
  • contain:用于补充特殊元素的坐标包含关系计算方法,如贝塞尔曲线上的点包含关系计算
  • core:大杂烩文件夹,我这里把它归纳为工具方法文件,包含LRU缓存,包围盒计算,浏览器环境判断,变换矩阵,触摸事件实现等大杂烩方法
  • dom:仅HandlerProxy.ts一个程序文件,用于实现DOM事件代理,所有画布内元素的事件都是从画布DOM的事件进行代理进入
  • graphic:所有元素的实体对象类都存放在这个文件夹,包含Group,可绘制对象基类Displayable,路径,圆弧,矩形等
  • mixin:仅Draggable.ts一个文件,用于管理元素的拖拽事件,因为Echarts用不上拖拽,所以拖拽事件还没有在ts版本中实现(后面会分享个人实现的版本代码)
  • tool:工具方法,提供颜色计算,SVG路径转换等工具类

3.入口文件源码分析(zrender.ts)

3.1 ZRender全局暴露的方法

zrender.ts中对外暴露的全局方法(见如下代码注释),全局方法可通过zrender.xxx即可调用,如:zrender.init()

全局方法主要用于管理ZRender实例(初始化,删除,查找,注销等操作)

// 用于存放渲染器
const painterCtors: Dictionary<PainterBaseCtor> = {};

// 用于存放ZRender实例,后文对于实例统称zr
let instances: { [key: number]: ZRender } = {};

/**
 * 按id删除ZRender实例
 */
function delInstance(id: number) {
  // 代码省略
}

/**
 * 初始化ZRender实例,需要传入dom节点作为canvas父级
 */
 export function init(dom: HTMLElement, opts?: ZRenderInitOpt) {
  const zr = new ZRender(zrUtil.guid(), dom, opts);
  instances[zr.id] = zr;
  return zr;
}

/**
* 注销zr实例,注销后会将zr实例内的图形全部删除,不可恢复
*/
export function dispose(zr: ZRender) {
  zr.dispose();
}

/**
* 注销ZRender中管理的所有zr实例
*/
export function disposeAll() {
  // 代码省略
}

/**
* 通过实例id获取zr实例
*/
export function getInstance(id: number): ZRender {
  return instances[id];
}

/**
 * 注册渲染器,系统在启动时会默认注册Canvas和SVG渲染器
 */
export function registerPainter(name: string, Ctor: PainterBaseCtor) {
  painterCtors[name] = Ctor;
}

class ZRender {
  // 后文详解
}

3.2 ZRender对象类

ZRender类写在入口文件zrender.ts中,本节通过对代码精简加注释的方式进行源码分析,精简源文件代码为了便于读者理解

class ZRender {
  // 画布渲染的容器根节点,必须是一个HTML元素
  dom: HTMLElement
  // zr实例id
  id: number
  // 存储器对象实例
  storage: Storage
  // 渲染器对象实例
  painter: PainterBase
  // 控制器对象实例
  handler: Handler
  // 动画管理器对象实例
  animation: Animation

  constructor(id: number, dom: HTMLElement, opts?: ZRenderInitOpt) {
    // 初始化容器根节点
    this.dom = dom;
    // 全局init函数会生成guid传入
    this.id = id;
    // new存储器实例
    const storage = new Storage();
    // 默认渲染器类型为canvas
    let rendererType = opts.renderer || 'canvas';
    // 创建渲染器
    const painter = new painterCtors[rendererType](dom, storage, opts, id);
    // 将存储器实例赋值给成员变量(作者认为这步是脱了裤子放屁,还多const一个storage变量)
    this.storage = storage;
    // 将渲染器赋值给成员变量
    this.painter = painter;
    // 创建事件管理器
    this.handler = new Handler(storage, painter, handerProxy, painter.root);
    // 创建动画管理器并启动动画管理程序
    this.animation = new Animation({
      stage: {
        update: () => this._flush(true)
      }
    });
    this.animation.start();
  }

  /**
   * 向画布添加元素,等待下一帧渲染
   */
  add(el: Element) {
    // 代码省略,后续的方法体代码如果无特别说明都会省略
  }

  /**
   * 从存储器中间元素删除,下一帧该元素将不会被渲染
   */
  remove(el: Element) { }

  /**
   * 配置图层顺序、开启动态模糊等
   */
  configLayer(zLevel: number, config: LayerConfig) { }

  /**
   * 设置画布背景色
   */
  setBackgroundColor(backgroundColor: string | GradientObject | PatternObject) { }

  /**
   * 获取画布背景色
   */
  getBackgroundColor() { }

  /**
   * 将zr强制设置为深色模式
   */
  setDarkMode(darkMode: boolean) { }

  /**
   * 查询当前zr是否深色模式
   */
  isDarkMode() { }

  /**
   * 执行强制刷新画布
   */
  refreshImmediately(fromInside?: boolean) { }

  /**
   * 执行下一帧刷新画布
   */
  refresh() { }

  /**
   * 执行所有刷新操作
   */
  flush() {
    this._flush(false);
  }

  /**
   * 设置动画静止帧数,动画将会在设置的帧数后停止执行
   */
  setSleepAfterStill(stillFramesCount: number) {
    this._sleepAfterStill = stillFramesCount;
  }

  /**
   * 唤醒动画,等下次渲染时执行
   */
  wakeUp() { }

  /**
   * 下一帧显示鼠标悬浮状态
   */
  refreshHover() { }

  /**
   * 强制执行鼠标悬浮状态
   */
  refreshHoverImmediately() { }

  /**
   * 调整画布大小
   */
  resize(opts?: {
    width?: number | string
    height?: number | string
  }) { }

  /**
   * 强制停止并清空动画
   */
  clearAnimation() { }

  /**
   * 获取画布宽度
   */
  getWidth(): number { }

  /**
   * 获取画布高度
   */
  getHeight(): number { }

  /**
   * 将路径绘制成图片,提高绘制性能
   */
  pathToImage(e: Path, dpr: number) { }

  /**
   * 设置鼠标样式
   * @param cursorStyle='default' 例如 crosshair
   */
  setCursorStyle(cursorStyle: string) { }

  /**
   * 查找鼠标当前位置元素的对象实例
   */
  findHover(x: number, y: number): {
    target: Displayable
    topTarget: Displayable
  } { }

  /**
   * 挂载全局事件,这里是ts的on方法多态
   */
  on<Ctx>(eventName: ElementEventName, eventHandler: ElementEventCallback<Ctx, unknown>, context?: Ctx): this
  on<Ctx>(eventName: string, eventHandler: EventCallback<Ctx, unknown>, context?: Ctx): this
  on<Ctx>(eventName: string, eventHandler: EventCallback<Ctx, unknown> | EventCallback<Ctx, unknown, ElementEvent>, context?: Ctx): this { }

  /**
   * 卸载全局事件
   */
  off(eventName?: string, eventHandler?: EventCallback<unknown, unknown> | EventCallback<unknown, unknown, ElementEvent>) { }

  /**
   * 按照事件名称手动触发事件
   */
  trigger(eventName: string, event?: unknown) { }


  /**
   * 清空画布及其已绘制的图形元素
   */
  clear() { }

  /**
   * 将ZRender对象注销
   */
  dispose() { }
}

4.通过案例分析ZRender工作流程

4.1 案例

下面代码是绘制一个半径为30px的玫红色(色值#FF6EBE)圆形,并为圆形绑定左右移动循环动画。

// 1.申明绘制ZRender实例的DOM容器
let container = document.getElementsById('example-container')[0];
// 2.初始化ZRender实例zr、同时zr会绘制画布与container同宽高
let zr = zrender.init(container);

// 3.获取zr画布的宽高
let w = zr.getWidth();
let h = zr.getHeight();

// 4.设定圆的半径为30px
let r = 30;

// 5.创建圆形对象实例cr
let cr = new zrender.Circle({
  shape: {
    cx: r,
    cy: h / 2,
    r: r
  },
  style: {
    fill: 'transparent',
    stroke: '#FF6EBE'
  },
  silent: true
});

// 6.为圆cicle绑定形状动画,参数true表示循环执行
cr.animate('shape', true)
  .when(5000, {
    cx: w - r
  })
  .when(10000, {
    cx: r
  })
  .start();

// 7.将圆形对象实例cricle添加到zr实例中进行渲染
zr.add(cr);

效果图

4.2 ZRender绘制流程

这一节主要结合2.1的案例讲ZRender如何进行绘制和运行动画流程

ZRender绘制流程

  1. 创建ZRender实例:使用const zr = zrender.init(),可多zr实例,每实例拥有自己的画布
  2. 创建需要绘制的图形实例,图形类名可通过zrender.xxx获得,其中xxx为图形类名
  3. zr.add方法将图形实例添加到存储器
// zrender.ts
add(el: Element) {
  // 将el(这里为cr实例)添加到存储器
  this.storage.addRoot(el);

  // 并且将动画放入动画管理器
  el.addSelfToZr(this);

  // 启动绘制程序
  this.refresh();
}

3B、C、D. 将图形上绑定的动画添加到动画管理器,生成动画帧,启动动画绘制

  1. zr实例化时就已经启动逐帧扫描程序,只是这里存储器有可渲染的元素被捕获后才会执行渲染动作
// zrender.ts
class Zrender {
  constructor() {
    this.animation = new Animation({
      stage: {
        // 将渲染程序绑定到帧渲染策略
        update: () => this._flush(true)
      }
    });
    // 启动动画管理器,启动帧渲染扫描rAF程序
    this.animation.start();
  }

  // 下一帧执行渲染
  _flush() {
    this.refreshHoverImmediately();
  }

  // 强制渲染
  refreshHoverImmediately() {

    // 调用渲染器渲染程序
    this.painter.refresh();
  }
}
  1. 上一步的this.panter.refresh()会请求storage去获取渲染列表
// canvas/Painter.ts
class Painter {
  refresh() {
    // 获取渲染列表
    const list = this.storage.getDisplayList(true);
  }
}

// Storage.ts
class Storage {
  /**
    * 更新图形的绘制队列。
    * 每次绘制前都会调用,该方法会先深度优先遍历整个树,
    * 更新所有Group和Shape的变换并且把所有可见的Shape保存到数组中,
    * 最后根据绘制的优先级(zlevel > z > 插入顺序)排序得到绘制队列
    */
  getDisplayList() {
    // 返回渲染列表
    return this._displayList
  }
}
  1. 6、7启动并执行渲染程序,进行图形的路径绘制,主要方法为:doPaintList->doPaintEl

本章节END.

ZRender源码分析后续章节待续:

  • 元素对象源码解析
  • 事件管理器源码解析
  • 动画管理器源码解析
  • 渲染器源码解析