當渲染層 JS 資源加載完成后,直接省略反序列化、初始化 Model、計算排版數據等階段,將 FVG 轉換成 Widget 進行 Canvas 渲染,這一步非常接近于 React 的 hydrate,很巧妙。
用過 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,很巧妙。