用过 Canvas 的都知道它的 API 比较多,使用起来也很麻烦,比如我想绘制一个圆形就要调一堆 API,对开发算不上友好。
为了解决这个痛点,诞生了例如 PIXI、ZRender、Fabric 等 Canvas 库,对 Canvas API 进行了一系列的封装。
今天主要介绍一下社区几个比较有代表性的 Canvas 渲染引擎的设计原理。
这篇文中不会从源码讲起,更像是一篇科普文章,介绍 Canvas 一些有趣的点。
1. 特性
Canvas 渲染引擎一般包括下面几个特点:
- 封装
将 Canvas API 的调用封装成更简单、清晰的形式,贴近于我们使用 DOM 的方式。
比如想画一个圆,直接调用封装好的绘制方法就行了,我们不需要关心是如何绘制的。
- 性能
虽然封装之后的 API 很贴近 HTML 语法,但也意味着开发者很难去做一些底层的性能优化。因此,大部分 Canvas 渲染引擎都会内置了一些性能优化手段。
常见的性能优化手段有离屏渲染、脏区渲染、异步渲染等等。
- 跨平台
一些渲染引擎为了更加通用,在底层做了更多抽象,不仅支持 Canvas Renderer,甚至还支持 WebGL、WebGPU、SVG、CanvasKit、小程序等等,真正实现了一套代码多种渲染。
针对底层的渲染流程和类进行抽象化,在不同平台具象化去实现具体的渲染逻辑,从而可以一套代码,只要切换渲染器就能实现多平台渲染。
2. 封装
2.1 虚拟节点
Canvas 是一张画布,里面的内容都是自己调用 API 绘制的,所以更像是我们拿起画笔来作画。
目前主流的 Canvas 渲染引擎都会将要绘制的图形封装成类,以方便开发者去调用,复用性也比较强。调用方式类似于 DOM,每个实例可以当做一个虚拟节点。
使用 AntV/g 的例子:
import { Circle, Canvas, CanvasEvent } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
// or
// import { Renderer as WebGLRenderer } from '@antv/g-webgl';
// import { Renderer as SVGRenderer } from '@antv/g-svg';
// 创建画布
const canvas = new Canvas({
container: 'container',
width: 500,
height: 500,
renderer: new CanvasRenderer(), // 选择一个渲染器
});
// 创建一个圆
const circle = new Circle({
style: {
cx: 100,
cy: 100,
r: 50,
fill: 'red',
stroke: 'blue',
lineWidth: 5,
},
});
canvas.addEventListener(CanvasEvent.READY, function () {
// 加入画布
canvas.appendChild(circle);
// 监听 `click` 事件
circle.addEventListener('click', function () {
this.style.fill = 'green';
});
});
在此基础上,可以进一步针对 React/Vue 语法进行封装,让用户对底层的实现无感知。
使用 React-Konva 的例子(通过 react-reconciler 实现):
import React, { Component } from 'react';
import { render } from 'react-dom';
import { Stage, Layer, Rect, Text } from 'react-konva';
import Konva from 'konva';
class ColoredRect extends React.Component {
state = {
color: 'green',
};
handleClick = () => {
this.setState({
color: Konva.Util.getRandomColor(),
});
};
render() {
return (
<Rect
x={20}
y={20}
width={50}
height={50}
fill={this.state.color}
shadowBlur={5}
onClick={this.handleClick}
/>
);
}
}
class App extends Component {
render() {
return (
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
<Text text="Try click on rect" />
<ColoredRect />
</Layer>
</Stage>
);
}
}
render(<App />, document.getElementById('root'));
除了内置的图形类,很多渲染引擎还会提供自定义绘制图形类的能力。
以 Konva 为例,每个图形类都需要实现 sceneFunc 方法,在这个方法里面去调用 Canvas API 来进行绘制。
如果需要自定义新的图形,就可以继承 Shape 来实现 sceneFunc 方法。
Konva 里面圆形绘制类的实现:
export class Circle extends Shape<CircleConfig> {
_sceneFunc(context) {
context.beginPath();
context.arc(0, 0, this.attrs.radius || 0, 0, Math.PI * 2, false);
context.closePath();
context.fillStrokeShape(this);
}
}
参照 DOM 树的结构,每个 Konva 应用包括一个舞台 Stage、多个画布 Layer、多个分组 Group,以及若干的叶子节点 Shape,这些虚拟节点关联起来最终形成了一棵树。
在 Konva 中,一个 Stage 就是根节点,Layer 对应一个 Canvas 画布,Group 是指多个 Shape 的集合,它本身不会进行绘制,但同一个 Group 里面的 Shape 可以一起应用旋转、缩放等变换。
Shape 则是指具体的绘制节点,比如 Rect、Circle、Text 等等。
2.2 包围盒
既然有了虚拟节点,那知道每个虚拟节点的位置和大小也比较重要,它会涉及到判断两个图形是否相交、事件等等。
有时候元素的形状不是很规则,如果直接对不规则元素进行碰撞检测会比较麻烦,所以就有了一个近似的算法,就是在物体外侧加上包围盒,如图:
目前主流的包围盒有 AABB 和 OBB 两种。
AABB 包围盒:
实现方式简单,直接用最大最小的横纵坐标来生成包围盒,但不会跟着元素旋转,因此空白区域比较多,也不够准确。
也是目前 Konva 和 AntV 使用的方式。(适合表格业务)
OBB 包围盒:
实现方式相对复杂,通过构建协方差矩阵来计算出新的坐标轴方向,将其顶点投射到坐标轴上面来得到新的包围盒。
所以 OBB 包围盒更加准确一些,也是 cocos2d 使用的方式。
碰撞检测:
两个包围盒在所有轴(与边平行)上的投影都发生重叠,则判定为碰撞;否则,没有发生碰撞。
2.3 排版系统
绘制 Canvas 的时候一般是通过相对坐标来确定当前要绘制的位置,所以都是通过各种计算来拿到 x、y。
即使是 Konva 也是依赖于 x、y 来做相对定位。
因此,在 AntV 和 SpriteJS 这类 Canvas 渲染引擎里面,都内置支持了盒模型的语法糖,底层会将盒模型属性进行一次计算转换成 x、y。
以 AntV 为例子,排版能力是基于 Facebook 开源的 Yoga 排版引擎(React Native)来实现的,支持一套非常完整的盒模型和 Flex 布局语法。
const container = new Rect({
style: {
width: 500, // Size
height: 300,
display: 'flex', // Declaring the use of flex layouts
justifyContent: 'center',
alignItems: 'center',
x: 0,
y: 0,
fill: '#C6E5FF',
},
});
在腾讯开源的 Hippy 里面自己实现了一套类似 Yoga 的排版引擎,叫做 Titank。
在飞书文档多维表格里面,排版语法更加接近于 Flutter,实现了 Padding、Column、Row、Margin、Expanded、Flex、GridView 等 Widget。
下面的示例是 Flutter 的:
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, // 对齐方式
children: [
Icon(Icons.star, color: Colors.green[500]),
Icon(Icons.star, color: Colors.green[500]),
Icon(Icons.star, color: Colors.green[500]),
const Icon(Icons.star, color: Colors.black),
const Icon(Icons.star, color: Colors.black),
],
);
Align(
alignment: Alignment.bottomRight,
child: Container(width: 100, height: 100, color: red),
)
Padding(
padding: EdgeInsets.fromLTRB(30, 30, 0, 30),
child: Image.network(
"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1581413255772&di=52021e3e656744094d0339e7016994bb&imgtype=0&src=http%3A%2F%2Fimg8.zol.com.cn%2Fbbs%2Fupload%2F19571%2F19570481.jpg",
fit: BoxFit.cover,
),
)
Widget _buildGrid() => GridView.extent(
maxCrossAxisExtent: 150,
padding: const EdgeInsets.all(4),
mainAxisSpacing: 4,
crossAxisSpacing: 4,
children: _buildGridTileList(30));
实现了盒模型和 Flex 布局,可以让 Canvas 的排版能力更上一层楼。
不仅可以减少代码中的大量计算,也可以让大家从 DOM 开发无缝衔接进来,值得我们参考。
canvas-flexbox - CodeSandbox
3. 事件
Canvas 本身是一块画布,所以里面的内容都是画出来的,在 DOM 树里面也只是一个 Canvas 的节点,所以如何才能知道当前点击的是哪个图形呢?
由于 Canvas 渲染引擎都会封装虚拟节点,每个节点都有自己的包围盒,所以为实现 Canvas 的事件系统提供了可能性。
主流的 Canvas 渲染引擎都是针对 Canvas 节点或者上层节点进行事件委托,监听用户相关的事件(mouseDown、click、touch等等)之后,匹配到当前触发的元素,将事件分发出去,并且拥有一套向上冒泡的机制。
目前主流的两种事件实现方式分别是取色值法和几何法。
3.1 取色值法
取色值法是 Konva 采用的实现方式,它的实现方式非常简单,匹配精确度很高,适合不规则图形的匹配。
取色值法的原理如下:
- 在主 Canvas 绘制一个图形的时候,会为这个图形生成一个随机的 colorKey(十六进制的颜色),同时建立类似于 Map<colorKey, Shape> 的映射。
getRandomColor() {
var randColor = ((Math.random() * 0xffffff) << 0).toString(16);
while (randColor.length < 6) {
randColor = ZERO + randColor;
}
return HASH + randColor;
},
-
绘制的同时会在内存里的 hitCanvas 同样位置绘制一个一模一样的图形,填充色是刚才的 colorKey。
-
当用户鼠标点击 Canvas 画布的时候,可以拿到鼠标触发的 x、y,将其传给内存里面的 Canvas。
-
内存里面的 Canvas 通过 getImageData 来获取到当前的颜色,进而通过 colorKey 来匹配到对应的图形。
从上述原理可以看出来,Konva 对于不规则图形的匹配依然很精确,但缺点也很明显,每次都需要绘制两份,导致绘制性能变差。
同时,getImageData 耗时比较高,在频繁触发的场景(onWheel)会导致帧率下降严重。
3.2 几何法
几何法有很多种实现方式,这里主要讲解引射线法,因为需要进行一系列几何计算,所以这里我称之为几何法。
几何法是 AntV 和飞书文档采用的实现方式,实现方式相对复杂一些,针对不规则图形的匹配效率偏低。
几何法的实现原理如下:
- 基于当前虚拟节点的包围盒来构建一棵 R Tree
-
当用户触发事件的时候,利用 R Tree 来进行空间索引查找,依据 z-index 找到最顶层的一个图形。
-
从目标点出发向一侧发出一条射线,看这条射线和多边形所有边的交点数目。
-
如果有奇数个交点,则说明在内部,如果有偶数个交点,则说明在外部。
为什么奇数是在内部,偶数是在外部呢?我们假设射线与这个图形的交点,进入图形叫做穿入,离开图形叫做穿出。
在图形内部发出的射线,一定会有穿出但没有穿入的情况。但在外部发出的射线,穿入和穿出是相对的。
但是射线刚好穿过顶点的情况比较特殊,因此需要单独进行判断。
几何法的优势在于不需要在内存里面进行重复绘制,但依赖于复杂的几何计算,因此不适合有大量不规则图形的情况。
在 AntV 里面支持对不规则图形的匹配,但飞书文档由于是表格业务,所以可以将所有图形都当做矩形来处理,反而更简单一些。
4. 性能
由于 Canvas 渲染引擎都会进行大量的封装,所以开发者想针对底层做性能优化是非常难的,需要渲染引擎自身去支持一些优化。
4.1 异步批量渲染
在飞书文档 Bitable 和 Konva 里面都支持异步渲染,将大量绘制进行批量处理。
const rect = new Rect({ /... });
// 多次修改属性,可能会触发多次渲染
rect.x(100);
rect.fill('red');
rect.y(100);
由于每次修改图形的属性或者添加、销毁子节点都会触发渲染,为了避免同时修改多个属性时导致的重复渲染,因此约定每次在下一帧进行批量绘制。
batchDraw() {
if (!this._waitingForDraw) {
this._waitingForDraw = true;
Util.requestAnimFrame(() => {
this.draw();
this._waitingForDraw = false;
});
}
return this;
}
这种渲染方式类似于 React 的 setState,避免短时间内多次 setState 导致多次 render。
4.2 离屏渲染
离屏渲染我们应该都比较熟悉了,就是两个 Canvas 来回用 drawImage 绘制可复用部分,从而减少绘制的耗时。
这里主要讲解 Konva 和飞书 Bitable 里面的离屏渲染。
在 Konva 中的离屏渲染主要是针对 Group 级别来做的,通过调用 cache 方法就能实现离屏渲染。
基于 Group 来做离屏渲染的原理是:
-
调用 cache 方法,创建一个离屏 Canvas 节点。
-
遍历 Group 子节点进行绘制,同时将其绘制到离屏 Canvas 上面。
-
下次 batchDraw 的时候判断是否有缓存,如果有,那么直接走 drawImage 的形式。
这种离屏渲染的调用方式比较简单,Group 的粒度可以由开发者自己决定,但也有一定的问题。
-
比较难应用于表格这种形式的业务
-
Konva 没有脏检测能力,即使 Group 里面的 Shape 属性改变了,依然不会更新离屏 Canvas。
-
由于使用色值法来匹配图形,导致开启了离屏渲染,实际上至少要绘制四份(主canvas、事件 hitCanvas、离屏 cacheCanvas、离屏事件 cacheHitCanvas)。
为什么需要绘制四份呢?因为离屏渲染是 drawImage 的形式,这样就不会有 colorKey 和 Shape 对应的情况了,所以离屏 Canvas 也要有一个自己的 hitCanvas 来做 getImageData,也就是 cacheHitCanvas。
另一种场景的离屏渲染就是飞书 Bitable 里面的实现。
飞书在底层之上封装了虚拟列表的 Widget,也就是基于业务定制的 Widget,这也是一种有趣的思路。
-
创建一个虚拟列表的 Widget 类,将列表数据传入
-
实现列表每一项的绘制方法,将列表绘制出来
-
滚动的时候虚拟列表内部进行节点的回收创建,但不会进行异步批量渲染,针对可复用的部分进行离屏渲染
-
更新阶段,通过 key 对比来决定是回收、创建还是复用。
在多维表格看板视图里面,每个分组都是一个虚拟列表,多个分组(虚拟列表)又组合成一个大的虚拟列表。
多选单元格编辑器也可以基于虚拟列表实现。
虚拟列表 Widget 类适合多维表格这种业务,多个视图都需要有自己的滚动容器,不同视图都需要处理节点的回收、复用、新建,通过公用 Widget 可以一步到位去支持,也方便在内部去做更多性能优化。
4.3 脏区渲染
对于 Konva 来说,每次重新渲染都是对整个 Canvas 做 clearRect 清除,然后重新绘制,性能相对比较差。
更好的做法是检测到当前的改动影响到的范围,计算出重绘范围后,只清除重绘区的内容重新进行绘制。
在 Canvas 中可以通过 rect 和 clip 限制绘制区域,从而做到只对部分区域重绘。
以前 ECharts 底层的 ZRender 为例来讲解:
-
根据图形前后变化,来计算出重绘区域,比如上图的区域,在飞书文档中会将整个移动的路径当做重绘区域。
-
如果有多个重绘区域,那么优先尝试将相交(包围盒)的重绘区进行合并,并且优先合并相交面积最大的重绘区。
-
如果合并完成后,当前剩余的重绘区数量大于5,则进一步进行合并,直到数量只剩5。
-
依次遍历这些重绘区域,先清除掉原有的内容,再进行绘制。
飞书文档多维表格没有做 Canvas 渲染分层,但对各种交互响应速度非常快,也是得益于底层渲染引擎对脏矩形渲染的支持,它的性能也是所有同类产品里面最好的。
除了上述的这些,还有在文档这边使用的一些优化手段,比如合并相同属性的图形绘制(线、矩形、文本等)、Canvas 分层等等,这些就不多做阐述了。
5. 跨平台
很多 Canvas 渲染引擎并不满足于只做 Canvas,一般还会支持一些其他的渲染模式,比如 SVG 渲染、WebGL 渲染、WebGPU 渲染等等。
在 AntV 里面通过引入对应的 package 来实现加载渲染器的,在 ZRender 中则是通过 register 来注册不同的渲染器。
AntV 中使用 CanvasKit 渲染:
import { Renderer as CanvaskitRenderer } from '@antv/g-canvaskit';
const canvaskitRenderer = new CanvaskitRenderer();
关于跨平台的架构这里不做讲解,主要是抹平不同平台的差异,这里主要讲解一下针对于服务端渲染的不同处理。
主流的服务端渲染方式有两种,一种是用 node-canvas 来输出一张图片,在 echarts 等库中都有使用,缺陷在于文本排版不够准确,对于自适应浏览器窗口的情况无法处理。因此它不适用于文档直出的场景。
const { createCanvas, loadImage } = require('canvas')
const canvas = createCanvas(200, 200)
const ctx = canvas.getContext('2d')
// Write "Awesome!"
ctx.font = '30px Impact'
ctx.rotate(0.1)
ctx.fillText('Awesome!', 50, 100)
// Draw line under text
var text = ctx.measureText('Awesome!')
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
ctx.beginPath()
ctx.lineTo(50, 102)
ctx.lineTo(50 + text.width, 102)
ctx.stroke()
// Draw cat with lime helmet
loadImage('examples/images/lime-cat.jpg').then((image) => {
ctx.drawImage(image, 50, 0, 70, 70)
console.log('<img src="' + canvas.toDataURL() + '" />')
})
另一种就是通过 SVG 来模拟 Canvas 的效果,输出 SVG DOM 字符串。但它的实现会比较麻烦,也无法 100% 还原 Canvas 的效果。
但很多 Canvas 渲染引擎本身也支持 SVG 渲染,即使不支持,也可以通过 canvas2svg 这个库来进行转换。
var ctx = new C2S(500,500);
//draw your canvas like you would normally
ctx.fillStyle="red";
ctx.fillRect(100,100,100,100);
//serialize your SVG
var mySerializedSVG = ctx.getSerializedSvg();
//If you really need to you can access the shadow inline SVG created by calling:
var svg = ctx.getSvg();
对于更加通用的场景来说,在浏览器端使用 Canvas 渲染,服务端使用 SVG 渲染是更合理的形式。
在新版 ECharts 里面,针对 SVG 服务端渲染的能力,还支持了 Virtual DOM 来代替 JSDOM,最后转换成 DOM 字符串。
在飞书文档中使用了一种完全独立于 node-canvas 和 SVG 的解决方式,非常值得我们借鉴。
由于飞书多维表格底层统一了渲染引擎,所有绘制元素都是 Widget(对齐 Flutter),可以脱水转换成下面 FVG 格式。
一般来说,文档业务首屏加载是下面这么几步:
获取首屏数据 -> 资源加载 -> 首屏数据反序列化 -> 初始化 Model 层 -> 计算排版数据 -> Canvas 渲染
在飞书文档里面直出渲染层 Widget 的数据结构,这个数据结构是最后提供给 Canvas 渲染的数据,也就是已经经过了计算排版数据阶段。
当渲染层 JS 资源加载完成后,直接省略反序列化、初始化 Model、计算排版数据等阶段,将 FVG 转换成 Widget 进行 Canvas 渲染,这一步非常接近于 React 的 hydrate,很巧妙。