從零實現並擴展可自由繪製的畫板
前言
作爲一個跑在教室大屏幕上的系統,免不了會與畫板打交道。實現一個優秀的畫板,可以很好地爲在線教學場景提供幫助。我們今天就從 0 開始,實現一個可以自由繪製的 Canvas 畫板。
目標有以下幾點:
-
可自定義顏色粗細的筆跡繪製、擦除、撤銷、重做、清空、數據存儲
-
支持觸摸與鼠標操作,支持多指觸控
-
高性能繪製,不掉幀
-
自適應佈局,無需指定寬高
-
提供可擴展性,如繪製粉筆筆跡、帶筆鋒的筆跡
好吧,話不多說,我們開始實現。
devicePixelRatio
dpr (device pixel ratio) 應該是逢 canvas 必涉及的問題了,畫布繪製出來的東西模糊?那很可能就是 dpr 沒有正確處理。關於 dpr 的詳細解釋,可以參考這裏 [1](當然,還可能是繪製文字時沒有設置抗鋸齒、繪製圖片時沒有設置平滑)。
| window.devicePixelRatio === 4 |
|
| --- | --- |
|
處理 dpr,筆跡非常清晰 |
未處理 dpr,筆跡較爲模糊 |
因此我們需要:
-
獲得它 window.devicePixelRatio
-
監聽並響應它的變化 (沒錯,它是會變的。當瀏覽器從一塊屏幕移動到另一塊屏幕、使用 Command + + 縮放等都可能導致 dpr 發生變化)
-
根據它的變化修改畫布的寬度和高度
-
縮放 canvas 的 context
監聽與響應變化
// 在 react 18 之後,訂閱此類外部事件可以使用 useSyncExternalStore
export function useDevicePixelRatio() {
const [dpr, setDpr] = useState(window.devicePixelRatio);
useEffect(() => {
const list = matchMedia(`(resolution: ${dpr}dppx)`);
const update = () => setDpr(window.devicePixelRatio);
list.addEventListener('change', update);
return () => list.removeEventListener('change', update);
}, [dpr]);
return { dpr };
}
縮放 context
// useEffect [dpr] const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); // scale 前先恢復變換矩陣,不然會重複 scale ctx.scale(dpr, dpr);
<canvas
width={dimension.width * dpr}
height={dimension.height * dpr}
style={{
width: dimension.width,
height: dimension.height,
}}
/>
縮放 context
// useEffect [dpr]
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0); // scale 前先恢復變換矩陣,不然會重複 scale
ctx.scale(dpr, dpr);
響應佈局變化
HTML 5 canvas 需要指定寬度、高度才能工作,在實際使用場景中,容器寬高通常是不定的,因此,我們需要對畫布進行佈局。
-
一個外層響應式容器,用於探測父容器及內容的寬度和高度,使用 ResizeObserver 監視它的寬高變化,並更新畫布元素的寬高。
-
一個容器來存放其他內容,比如在線教學時,老師可能會在學生作答上繪製,因此,畫板是與其他元素疊加顯示的。
最終,我們將 DOM 元素佈局爲
<div class> <!-- 1. 這個容器用來響應寬度高度變化 -->
<div class> <!-- 3. 使 canvas 脫離文檔流,且不影響佈局 -->
<canvas width={dimension.width} height={dimension.height}>
</div>
<div class> <!-- 2. 這個容器用來放內容 -->
{children}
</div>
</div>
/* 3. canvas 需有能力脫離文檔流 */
.container {
position: relative;
}
.canvas {
touch-action: none; /* 禁用瀏覽器默認的觸控響應,以更好支持多指繪製 */
user-select: none;
position: absolute;
width: 0;
height: 0;
left: 0;
top: 0;
}
/* 4. content 需在 canvas 之上,且不能阻擋 canvas 繪製 */
.content {
position: relative;
pointer-events: none;
}
import { addListener, removeListener } from 'resize-detector';
import { debounce } from 'lodash-es';
export type CanvasDimension = {
width: number;
height: number;
};
export function useDimensionDetector(ref: MutableRefObject<HTMLDivElement>) {
const [dimension, setDimension] = useState<CanvasDimension>({
width: 1,
height: 1,
});
useEffect(() => {
const { current } = ref;
const updateDimension = debounce(() => {
setDimension({
width: current.clientWidth,
height: current.clientHeight,
});
}, 100);
updateDimension();
addListener(current, updateDimension);
return () => {
updateDimension.cancel();
removeListener(current, updateDimension);
};
}, [ref]);
return { dimension };
}
此時,我們已經有了一個可以響應大小變化的 canvas。
繪製準備與初步繪製
取得繪製事件
繪製的事件監聽應該在 canvas 上還是在 document 上?應該是 document,因爲:
-
用戶可能會在繪製時超出畫布,繪製不能因爲超出畫布範圍而中斷
-
我們的畫布上可能有其他元素,繪製不能因爲進入其他元素範圍而中斷
-
| 預期效果,在 document 上 | badcase,在 canvas 上 | | --- | --- | |
| |
如果監聽在 document 上,我們怎麼判斷用戶是否預期在畫布上繪製?如果有畫布重疊,是想在哪個畫布上繪製?
我們可以給 canvas 添加 touchstart 事件,只有在 touchstart 後,才向 document 添加 touchmove 等事件,這樣就不會混淆畫布繪製了。
取得相對畫布的座標點
用戶的指針到底在目標 canvas 上的哪裏?因爲我們用的是 document 上的事件,TouchEvent 中不會有相應的信息,因此,我們需要自己根據 client 相關的信息來計算:
const { clientX, clientY } = event.changedTouches[0];
const { x, y } = canvas.getBoundingClientRect();
const point = {
x: clientX - x,
y: clientY - y,
}
但是這個方案並不完美,當元素有 css transform 時,得到的值並非實際在 Canvas 上的值。因此,這個元素不能有縮放和旋轉,否則位置計算不正確。 我們可以應用變換矩陣來處理這種情況,但以目前的瀏覽器能力,沒有辦法獲得一個元素實際的變換矩陣。即使有,還有 transform 3D + perspective 需要處理... 暫時先不考慮這種情況。 如果是鼠標事件,最新瀏覽器標準中有 offsetX 與 offsetY,考慮了這些 transformation。但在 Touch 中是沒有的。
scale + rotate 的畫布,一種未解決的 badcase
轉譯指針事件
鼠標事件和觸控事件不一致, 我們的畫板是觸控優先的,但也應當兼容其他指針事件。
瀏覽器有自動事件轉換,這也是即使我們的元素只監聽 mousedown,在觸屏設備上點擊也能正常工作的原因。一般來說,轉換順序是 pointer events > touch events > mouse events,如果一種事件沒有處理,瀏覽器會自動轉譯爲後續同類事件。詳情可以 參考這個測試 [2]。
但瀏覽器不會幫我們把非觸控事件轉換爲觸控事件,因此需要我們手動轉譯。
export function pointerToTouchInit(e: PointerEvent): TouchInit {
const { clientX, clientY, pageX, pageY, target, screenX, screenY } = e;
return {
clientX,
clientY,
pageX,
pageY,
target,
screenX,
screenY,
// 我們加一些觸控特有的模擬屬性
force: 1,
radiusX: 0,
radiusY: 0,
identifier: Infinity,
rotationAngle: 0,
};
}
export function pointerToTouchAdapter(getEventHandler: () => TouchAdapter) {
return (e: PointerEvent) => {
const isTouch = e.pointerType === 'touch';
if (isTouch) {
// 觸控事件由 touch 系列事件解決,不需要轉譯
return;
}
const { touchStart, touchMove, touchEnd, touchCancel } = getEventHandler();
const touchInit = mouseToTouchInit(e);
const init: TouchEventInit = {
touches: [new Touch(touchInit)],
changedTouches: [new Touch(touchInit)],
};
const typeMap = {
pointerdown: ['touchstart', touchStart],
pointermove: ['touchmove', touchMove],
pointerup: ['touchend', touchEnd],
pointercancel: ['touchcancel', touchCancel],
} as const;
const { type } = e;
if (!(type in typeMap)) {
return;
}
const next = typeMap[type as keyof typeof typeMap];
const [newEvent, eventHandler] = next;
const touchEvent = new TouchEvent(newEvent, init);
// 直接調用對應 event 的 handler,就不手動 dispatchEvent 了
eventHandler(touchEvent);
};
}
初步繪製
在前面的步驟中,我們已經得到了可以繪製在畫布上的座標點,只需將點連成線,繪製在畫布上即可。
const ctx = canvas.getContext('2d');
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 存一下上次的 offsetX 和 offsetY
let prev = { offsetX: 0, offsetY: 0 };
// touchstart
ctx.beginPath();
prev.offsetX = touch.offsetX;
prev.offsetY = touch.offsetY;
// touchstart, touchmove
ctx.moveTo(prev.offsetX, prev.offsetY);
ctx.lineTo(touch.offsetX, touch.offsetY);
// touchend,在這裏 stroke 可以一次把路徑繪製在畫布上
ctx.lineWidth = 4;
ctx.strokeStyle = '#66ccff';
ctx.stroke();
繪製流程
好像我們剛剛已經把路徑繪製到了畫布上,是不是此時就完成了呢?不,遠遠沒有! canvas 對於我們來說是輸出設備,一旦輸出給它,信息就丟失而讀不回來了。
比如,當 canvas 發生 resize 時,它的 context 會被銷燬並重建,我們就丟失了繪製在它上面的所有信息。
再比如,我們還需要實現撤銷 / 重做功能,如果記錄的內容是筆跡的路徑信息,我們才能隨時重放。
因此,我們需要將繪製所需的信息存儲在一個數據結構中,而不是將繪製結果存儲在畫布上。
存儲繪製過程
爲了存儲我們的繪製過程,我們首先需要一個類來管理我們的 canvas 和繪製。我們將它命名爲 CanvasDescriptor。
export class CanvasDescriptor {
canvas: HTMLCanvasElement;
paths: unknown[];
path: unknown;
draw(path: unknown) {
//
}
}
簡單查找,找到 Path2D 這一 API[3],可以替換 ctx.moveTo 來記錄我們的繪製信息,且可以隨時通過 ctx.draw 畫在 canvas 上。因此,我們只要稍稍改造我們的代碼來使用這個數據結構。
const desc = new CanvasDescriptor(canvas);
// touchStart
desc.path = new Path2D();
// touchStart, touchMove
desc.path.moveTo(prev.offsetX, prev.offsetY);
desc.path.lineTo(touch.offsetX, touch.offsetY);
// touchEnd
desc.paths.push(path);
desc.draw();
// CanvasDescriptor
ctx.beginPath();
ctx.lineWidth = 4;
ctx.strokeStyle = '#66ccff';
ctx.stroke(path);
但這時,新的問題出現了:
-
我們需要繪製不斷變化的元素,比如,正在繪製中的 path、指針當前位置的指示器。一個靜態數組不能支持我們繪製可變數據。
-
Path2D 不能區別不同 path 的 顏色和寬度,只使用 path 繪製,會導致所有的筆跡粗細、顏色都相同。
我們將繼續解決這些問題。
繪製流程設計
畫布上的信息只是一個位圖,輸出給 canvas 的元素是不能撤銷的,想要修改某一個元素,只能清空畫布(相關的部分)並重繪。
因此,爲了實現繪製臨時筆跡繪製的功能,我們需要給 canvas 引入一個繪製流程,幫助繪製那些不斷變化的元素。
我們將 path 分爲 pending 和 committed 兩類,沒有觸發 touchend 時爲 pending 狀態,觸發後轉換到 committed 狀態。並將繪製過程設計爲:
-
清空整塊畫布
-
繪製已經緩存的繪製
-
按順序繪製 committed 數組中的筆跡
-
繪製 pending 的筆跡
-
繪製其他臨時要素,如 當前指針位置 作爲 繪製預覽
這樣,我們只需執行繪製過程,就可以隨時更新畫布上的信息了。
那麼,這個繪製過程由誰來觸發呢?我們有兩種選擇:
-
當繪製信息變更時同步觸發,如 觸控事件、撤銷重做... 在畫布信息的同時,手動調用一次繪製
-
定時觸發,適合畫布中的存在動畫元素,即:元素會隨着時間發生變化 的情況... 每次 requestAnimationFrame 並調用一次繪製
初步考慮在畫板場景,筆跡一般是沒有動畫的,所以此時,我們選擇方案 1,讓 touch event 等直接同步觸發繪製。
此時的繪製效果,已經可以繪製鼠標指針了
數據結構設計
- 爲了讓我們的繪製流程統一,我們設計一個抽象類 Drawable。在整個繪製流程中,凡是向畫布繪製的,都通過 Drawable.draw() 方法操作。
export abstract class Drawable {
// 繪製只需要 context,但在實踐中發現,
// 當 canvas DOM 被替換後 ctx 就不對了。
// 因此,不能只存儲當時的 ctx,
// 而是存儲一個獲取 ctx 的方法
ctx: CanvasRenderingContext2D;
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
#desc: CanvasDescriptor;
get ctx(): CanvasRenderingContext2D | null {
return this.#desc.canvas?.getContext('2d') || null;
}
constructor(desc: CanvasDescriptor) {
this.#desc = desc;
}
abstract draw(): void;
}
- 爲了儘可能多地記錄繪製過程(比如,未來可能會想做筆鋒,那就需要繪製點的時間信息),我們設計一個數據結構,存儲事件中除了位置外的其他信息,比如,時間、力度等。
export type TrackedDrawEvent = {
top: number;
left: number;
force: number;
time: number; // high res timer (ms)
};
並設計 RawDrawable extends Drawable,爲記錄繪製過程實現 track 和 commit 方法。
export class RawDrawable extends Drawable {
events: TrackedDrawEvent[] = [];
#startTime: number | null = null;
#endTime: number | null = null;
get duration() {
if (this.#startTime == null) {
return 0;
}
if (this.#endTime == null) {
return performance.now() - this.#startTime;
}
return this.#endTime - this.#startTime;
}
track(touch: Touch) {
if (this.#startTime === null) {
this.#startTime = performance.now();
}
const { clientX, clientY, force } = touch;
const { ctx } = this;
const { canvas } = ctx;
const { clientWidth, clientHeight } = canvas;
const { top, left, width, height } = canvas.getBoundingClientRect();
const scaleX = clientWidth / width;
const scaleY = clientHeight / height;
this.events.push({
left: (clientX - left) * scaleX,
top: (clientY - top) * scaleY,
force,
time: performance.now() - this.#startTime,
});
}
commit() {
this.#endTime = performance.now();
}
draw() {
throw new Error(`I don't know how to draw`);
}
}
- 爲了支持 自由繪製以及存儲繪製配置,設計 PathDrawable extends RawDrawable,並實現 draw 方法。
export type PathDrawableConfig = Pick<
CanvasConfigType,
'color' | 'eraserWidth' | 'lineWidth' | 'type'
>;
export class PathDrawable extends RawDrawable {
config: PathDrawableConfig;
constructor(desc: CanvasDescriptor, config: PathDrawableConfig) {
super(desc);
this.config = { ...config };
}
draw(events?: TrackedDrawEvent[]) {
const { ctx, config } = this;
if (!ctx) {
return;
}
const { lineWidth, eraserWidth, color, type } = config;
ctx.globalCompositeOperation =
type === 'eraser' ? 'destination-out' : 'source-over';
ctx.lineWidth = type === 'eraser' ? eraserWidth : lineWidth;
ctx.strokeStyle = color;
ctx.beginPath();
(events ?? this.getEvents()).forEach(e => {
ctx.lineTo(Math.round(e.left), Math.round(e.top));
});
ctx.stroke();
}
}
同理,我們也可以繼續實現 CacheDrawable extends Drawable、MousePositionDrawable extends RawDrawable、EraserDrawable extends PathDrawable 等等,每次按繪製流程執行對應的 draw 方法就可以了。
- 修改我們的使用方法
export class CanvasDescriptor {
id: string;
config: CanvasConfigType;
mainCanvas: HTMLCanvasElement | null = null;
pending: Drawable[] = [];
committed: Drawable[] = [];
constructor(id: string, config?: CanvasConfigType) {
this.id = id;
this.config = config ?? defaultCanvasConfig;
}
draw() {
const pendingDrawable = [...this.acceptedTouches.values()];
const canvas = this.mainCanvas;
if (!canvas || canvas.height === 0 || canvas.width === 0) {
// 畫布不存在,不用繪製了
return false;
}
const ctx = canvas.getContext('2d');
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// clear canvas
new ClearDrawable(this).draw();
// draw cache
const cache = new CacheDrawable(this);
cache.draw();
cache.update();
// draw committed
this.committed.forEach(path => {
path.draw();
});
// draw pending
this.pending.forEach(path => {
path.draw();
});
// draw indicator, 代碼略
return true;
}
}
// touch start
path = new PathDrawable(this);
path.track(e);
// touch move
path.track(e);
// touch end
path.commit();
paths.push(path);
desc.draw();
畫布配置
我們已經在 PathDrawable 爲路徑預留了一部分 config。這裏,我們設計畫布的配置。畫布配置的更新隻影響後續繪製的路徑,而不影響已經繪製好的筆跡,因此配置變更,不會導致畫布重新繪製。
export type BrushType = 'pen' | 'eraser' | 'chalk' | 'stroke';
export type CanvasState = 'normal' | 'locked' | 'hidden';
export type CanvasConfigType = {
color: string;
lineWidth: number;
eraserWidth: number;
type: BrushType;
canvasState: CanvasState;
};
在 new Drawable() 時,將 config 傳入對應構造函數中即可。
多指繪製
在觸控設備上,用戶很可能會多指同時在屏幕上繪製。這些繪製可能在同一塊畫布上,也可以分別在不同的畫布上,因此,我們需要做一些操作來支持多指繪製。
// class CanvasDescriptor
acceptedTouches: Map<number, RawDrawable> = new Map();
-
對於觸控事件來說,changedTouches: TouchList 裏面包含所有改變的觸控信息。
-
其中的每一個 Touch,identifier 可唯一標識這個觸控
-
當 touchstart 時,將 identifier 即對應的 RawDrawable 存入 acceptedTouches
-
按照我們的設計,touchmove 和 touchend 是綁定在 document 上的,因此必須檢測這個事件是否來自 accepted 的 pointer。如果不是,可能是來自其他畫板實例,或不在畫板上,因此忽略即可。
-
touchend / touchcancel / touchleave 事件,需要從 acceptedTouches 中移除,防止 identifier 被複用而錯誤判斷。
// touch start
const touches = event.changedTouches;
for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
const accepted = new PathDrawable(this);
acceptedTouches.set(touch.identifier ?? Infinity, accepted);
accepted.track(event);
}
updateCanvas();
// touch move,對每一個 Touch
const id = touch.identifier ?? Infinity;
const accepted = acceptedTouches.get(id);
if (accepted) {
accepted.track(touch, rect);
} else {
// not accepted
}
// touch end,對每一個 Touch
if (accepted) {
accepted.commit(touch, rect);
this.committed.push(accepted);
acceptedTouches.delete(id);
}
當然,我們還需要對多指操作進行兼容,比如我們的指針位置指示器,有可能需要指示更多的指針位置。撤銷重做操作時,有可能需要同時撤銷 / 重做多指繪製,這些我們暫時略過。
擦除
擦除分爲兩種模式。
-
路徑擦除,當橡皮擦與某條 path 接觸時,移除整個 path,適合在書寫筆跡時使用。
-
位圖擦除,橡皮擦將畫布上的所有內容視爲位圖,擦除與之接觸的部分,適合在繪製時使用。
路徑擦除
路徑擦除需要碰撞檢測算法,將鼠標當前位置與畫布中的每一個 path 進行比較。可參考這裏的實現。
四叉樹碰撞檢測 [4]
位圖擦除
我們存儲的數據結構是路徑,要想進行位圖擦除,需要先得到位圖。回顧我們的繪製過程,已經繪製到 canvas 的路徑就是位圖,因此,我們的橡皮擦應當像 PathDrawable 一樣,繪製在 Canvas 上。
那麼如何擦除呢? canvas 提供了混合模式的設置,混合模式,是指即將繪製的內容與畫布已有內容的交互方式。用過 PhotoShop 的同學應該會比較熟悉。而畫布 canvas 也提供了一些混合方式。
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation [5]
globalCompositeOperation = destination-out 模式就是將目標重疊的部分從畫布上擦除。
因此,擦除對於我們來說也是一種繪製。無需修改繪製流程,只需在 PathDrawable 的 config 提供 eraser 模式,並改造 draw 方法,使其根據 config 調整 globalCompositeOperation 即可。
// PathDrawable.draw()
ctx.globalCompositeOperation =
type === 'eraser' ? 'destination-out' : 'source-over';
ctx.lineWidth = type === 'eraser' ? eraserWidth : lineWidth;
同時改造一下我們的指針位置指示器,讓它在繪製橡皮擦時有 60% 的透明度,以確保我們能看清楚要擦除的內容。
擦除支持與改造後的指示器
性能優化
按照上述方案,無論是繪製,還是擦除,對於我們來說都是繪製,都會導致 committed 數組中的 Drawable 越來越多。而我們的繪製流程每次都會清空全部筆跡並重新繪製,複雜度隨繪製筆畫線性增加。在 500 * 500 * 2 * 2(dpr) 的 canvas 上,350 筆畫後,繪製和擦除將會有明顯卡頓。
因此,我們需要對流程進行優化,確保我們的繪製複雜度不會無限增長。
緩存模式
將一個 canvas 繪製到另一個 canvas 上時是同步的,因此,我們可以創建一個新的 canvas 用來繪製緩存內容。我們添加 CacheDrawable extends Drawable,並補充至繪製流程。
// class CanvasDescriptor
// rename
// canvas => mainCanvas
get cacheCanvas(): HTMLCanvasElement | null {
const { mainCanvas } = this;
if (!mainCanvas) {
return null;
}
const cacheCanvas =
mainCanvas.cacheCanvas || document.createElement('canvas');
if (
cacheCanvas.height !== mainCanvas.height ||
cacheCanvas.width !== mainCanvas.width
) {
this.rasterizedLength = 0;
cacheCanvas.height = mainCanvas.height;
cacheCanvas.width = mainCanvas.width;
}
mainCanvas.cacheCanvas = cacheCanvas;
return cacheCanvas;
}
// 已經緩存的繪製長度,對應 committed 數組
rasterizedLength: number = 0;
draw() {
// draw committed,每次繪製時,只繪製尚未緩存的部分
this.committedDrawable.slice(this.rasterizedLength).forEach(path => {
path.draw();
});
}
CacheDrawable 的實現:
const BUFFER_SIZE = 2;
const MIN_THRESHOLD = 2;
export class CacheDrawable extends Drawable {
desc: CanvasDescriptor;
constructor(desc: CanvasDescriptor) {
super(desc);
this.desc = desc;
}
draw() {
const { ctx, desc } = this;
if (!desc.rasterizedLength || !ctx) {
return;
}
const { mainCanvas, cacheCanvas } = desc;
if (!mainCanvas || !cacheCanvas) {
return;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(
cacheCanvas,
0,
0,
mainCanvas.clientWidth,
mainCanvas.clientHeight,
);
}
update() {
const { desc } = this;
if (
desc.committedDrawable.length >=
desc.rasterizedLength + BUFFER_SIZE + MIN_THRESHOLD
) {
const before = desc.rasterizedLength;
const after = desc.committedDrawable.length - BUFFER_SIZE; // after > before
const toRasterize = desc.committedDrawable.slice(before, after);
toRasterize.forEach(path => {
path.draw();
});
const canvas = desc.mainCanvas!;
const cacheCanvas = desc.cacheCanvas!;
const cacheContext = cacheCanvas.getContext('2d')!;
cacheContext.imageSmoothingEnabled = true;
cacheContext.imageSmoothingQuality = 'high';
cacheContext.clearRect(0, 0, cacheCanvas.width, cacheCanvas.height);
cacheContext.drawImage(
canvas,
0,
0,
cacheCanvas.width,
cacheCanvas.height,
);
desc.rasterizedLength = after;
}
}
}
我們並沒有丟棄已緩存的繪製信息,而是用一個指針 rasterizedLength 指向了未緩存的繪製。
因爲,如果將它們丟棄,可能有一些問題,比如在實現撤銷操作時,會出現沒有足夠的信息可供撤銷。雖然提高 BUFFER_SIZE,並限制用戶的撤銷次數,比如只允許用戶進行 100 次撤銷,一般也足夠用了。但還有另一個原因更重要的原因!我們之前說過,當 dpr 變化,會導致 canvas 的 width 和 height 屬性變化時,進而會導致 canvas 被全部重置。如果我們已經丟棄了之前的繪製信息,就沒有機會重繪這些內容了。如果用戶在繪製時 dpr 發生變化,繪製的一部分內容就會突然消失,而且我們也沒辦法將它們找回來了。
那麼,存儲這麼多筆跡會不會有內存問題?暫時不用擔心。我們的筆跡佔用的存儲空間很小。
緩存後,整體繪製是比較快的,且可以預期,繪製所需時間不會隨着繪製步驟繼續增長。
離屏 Canvas
緩存的 Canvas 也可以直接換用 OffscreenCanvas。不過,一方面現在的性能已經足夠好了,另一方面,在同一個線程中,收益不大,主要是爲了在 web worker 中可用。
緩存帶來的問題
我們的性能測試是以在 500 * 500 * 2 * 2 (dpr) 畫布上進行的,倘若這個 canvas 非常大(如 5000 * 5000),我們的緩存反而會導致性能變得非常差。
| 用緩存,在 10 筆畫後已經達不到 10 fps | 不用緩存,性能還稍好一些 |
| --- | --- |
|
這是因爲,我們在讀 / 寫緩存時,操作的是整個畫布,可是我們實際更新的區域並不是整個畫布,讀寫緩存需要將大量沒有更新的內容重繪一遍,反而拖累了繪製性能。
最直接的優化方案:這是由於超大畫布引起的,因此,我們降低畫布的 dimension。
-
假設 canvas 的 client size 不能變,我們的 canvas size = client size * dpr,因此,可以降低 DPR。這樣繪製雖然會稍有模糊,但至少不會卡頓。
-
優化 client size。超大畫布沒有實際應用價值,絕大多數內容都不在屏幕上,因此,我們將畫布大小固定爲顯示區域大小,監聽外部容器的滾動事件,並隨之滾動畫布的繪製區域。這樣,我們可以在不降低 dpr 的前提下優化畫布繪製。
還有一些解決方案...
- 已知讀取和寫入緩存性能最差,我們可以這樣優化:
-
繪製不再由觸摸事件觸發,而是定時 requestAnimationFrame 觸發
-
每次記錄將要更新的 object,並獲取它更新前後的 Rect,我們只讀取 / 更新這一部分的緩存
-
將不變的內容繪製在其他 canvas 上,疊加另一個 canvas 繪製變化的內容
- 繪製 Path 性能較差,可以這樣優化:
-
對每一個繪製點,將其座標轉換爲整數 ,這樣畫布就不需要拆像素繪製,可以明顯提升繪製性能
-
使用算法平滑曲線,減少繪製點的數量
詳細瞭解,可以參考:
https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas [6]
多畫板下的信息存儲
我們可能存在多塊畫板共存的的情況,而對於需要翻頁的黑板來說,畫板組件需要在銷燬重建時保留筆跡。因此,我們將數據存儲提升在 context 中。
每一塊畫板擁有自己的 key,當畫板初始化時(對應 React 的 useEffect),向 context 註冊自己的實例,並更新屬於自己的數據。
畫板銷燬時,向 context 標註自身已被銷燬,但並不清除數據,對應需要保存筆跡的場景。
此處比較簡單,複雜的是在多畫板場景下,後續撤銷、重做、清空的支持。
畫布整體操作 - 鎖定,撤銷,重做,清空
鎖定與隱藏繪製
在 context 中,新增一個控制參數
export type CanvasState = 'normal' | 'locked' | 'hidden';
令畫板組件響應該參數變化。當爲 locked 時,置 畫板圖層 pointer-events: none,當爲 hidden 時,也置畫板圖層 opacity: 0 或置 visibility: none。
撤銷和重做
在我們的繪製流程中,每次都會重繪所有路徑,因此對於單畫布來說,撤銷只需跳過這些路徑的繪製即可。
我們添加一個新屬性,
let afterUndoLength: number | undefined = undefined;
這樣命名,是讓它與數組 length 協同工作。無論何時,如果 afterUndoLength === undefined,則 afterUndoLength = committed.length。
當 undo 時
-
如果 afterUndoLength > 0,則
-
令 afterUndoLength -= 1
-
如果 afterUndoLength 小於 rasterizedLength,說明我們已經撤銷了太多了,需要放棄緩存,重繪整個操作隊列。令 rasterizedLength = 0。
當 redo 時
-
如果 afterUndoLength 不是 undefined
-
令 afterUndoLength += 1
當讀取 committed 數組的數據時,不返回被撤銷的部分
- slice(0, afterUndoLength)
當寫入 committed 數據前,先清空重做隊列
-
令 committed.length = afterUndoLength ?? committed.length
-
令 afterUndoLength = undefined
按上述思路,我們給繪製流程添加一些屬性和方法。
export class CanvasDescriptor {
#committed: RawDrawable[] = [];
committedDrawable: Drawable[];
afterUndoLength: number | undefined = undefined;
constructor(id: string, config?: CanvasConfigType) {
this.id = id;
this.config = config ?? defaultCanvasConfig;
const desc = this;
this.committedDrawable = new Proxy(this.#committed, {
get(t, p, r) {
// get 的時候,告訴對方已經撤銷的數組部分不存在
if (p === 'length' && desc.afterUndoLength != null) {
return desc.afterUndoLength;
}
return Reflect.get(t, p, r);
},
set(t, p, v, r) {
// set 的時候,
// 清空已經撤銷的數據
if (p !== 'length') {
desc.afterUndoLength = undefined;
}
return Reflect.set(t, p, v, r);
},
});
}
undo() {
if (this.afterUndoLength == null) {
this.afterUndoLength = this.#committed.length;
}
if (this.afterUndoLength <= 0) {
return false;
}
this.afterUndoLength -= 1;
if (this.rasterizedLength > this.afterUndoLength) {
this.rasterizedLength = 0;
}
return true;
}
redo() {
if (
this.afterUndoLength == null ||
this.afterUndoLength >= this.#committed.length
) {
return false;
}
this.afterUndoLength += 1;
return true;
}
}
現在,我們的繪製數據是每塊畫板的數據是分別存儲,當多塊畫布數據改變時,應該如何整體撤銷呢?此時可以選擇兩種方法:
-
聚合所有畫布的數據更新到同一個數組,然後通過一個 proxy,爲每一塊畫布返回自己的數據
-
保持現有的數據結構,額外在 context 中添加更新記錄,記錄每次更新時是哪一塊畫板,當撤銷 / 重做時,先通過這個數據結構找到畫板,再對目標畫板進行操作。
最終我們選擇方案 2,因爲可能出現一次修改多塊畫板的情況。
爲此,我們給 canvas 添加屬性,用來通知 context 自己的數組有變化:
export class CanvasDescriptor {
onCommittedUpdated?: (target: CanvasDescriptor) => void;
constructor(id: string, config?: CanvasConfigType) {
this.id = id;
this.config = config ?? defaultCanvasConfig;
// eslint-disable-next-line consistent-this, @typescript-eslint/no-this-alias
const desc = this;
this.committedDrawable = new Proxy(this.#committed, {
get(t, p, r) {
// get 的時候,騙對方說已經撤銷的步驟不存在
if (p === 'length' && desc.afterUndoLength != null) {
return desc.afterUndoLength;
}
return Reflect.get(t, p, r);
},
set(t, p, v, r) {
if (p !== 'length') {
desc.onCommittedUpdated?.(desc);
desc.afterUndoLength = undefined;
}
return Reflect.set(t, p, v, r);
},
});
}
}
context 在註冊畫布時,爲其提供對應更新函數,調用時,寫入 modifiedCanvas 數組。撤銷重做操作與單畫布類似,也是 proxy + 判斷。
在整體中指定某些畫板撤銷重做
指定某些畫板撤銷,需要反向搜索 modifiedCanvas 隊列,將目標 canvas 重排序至隊尾,並執行撤銷操作。因爲畫板間的繪製是獨立的,因此重排序並不會影響整體繪製效果,也可以確保重做功能正常運行。
清空與取消註冊
我們之前提到 ClearDrawable,現在可以用上了。爲了讓撤銷重做的邏輯簡單,清空時也是向目標畫板的繪製序列插入一個特殊繪製指令。
export class ClearDrawable extends Drawable {
draw() {
const { ctx } = this;
if (!ctx) {
return;
}
const { canvas } = ctx;
const { clientWidth, clientHeight } = canvas;
ctx.clearRect(0, 0, clientWidth, clientHeight);
}
}
清空操作可能是一次控制多個畫板,這是我們之前將 modifiedCanvas 設計爲 (string[])[] 的原因。
此時撤銷重做清空的效果
另外,我們之前在畫板組件銷燬時,並沒有在 context 中清空它的數據。而清空操作,也只是 push 了新的繪製指令,因此,我們需要另外實現一個 hard clear 功能,即 unregister,徹底刪除某個畫板在 context 中的數據。
對於 hard clear,我們需要處理撤銷重做隊列,移除那些被徹底清除的畫布的繪製記錄,同時需要修改 afterUndoLength (如果存在)。
筆刷擴展
我們在 RawDrawable 中存儲了足夠的信息,因此可以對筆刷進行擴展。
一般形狀的繪製
一般形狀的繪製比較簡單,只需將 RawDrawable 中的信息取出一部分進行繪製即可。如 直線、圓、矩形 只需取頭尾兩個點,三角形可以通過雙指手勢、平滑算法等取 3 個點。
筆鋒
對於筆鋒效果,時間成了影響整個 Path 的因子,並通過一定的算法來生成整個繪製過程。
| 帶有筆鋒的筆跡(算法是憑感覺寫的,僅供演示) | 繪製對比 |
| --- | --- |
|
粉筆
算法參考 [7]
如果算法不是穩定的(例如,有隨機因子),那麼我們在繪製時也需要存儲這些隨機因子,否則在 update canvas 的時候,如果隨機因子變了,筆跡會發生變化,這是不符合預期的。
因此,我們需要提供自己的穩定隨機數生成算法來替代 Math.random。可以參考 https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript [8]
另外這種算法使用了 clearRect 來產生透明度,爲了避免它擦掉我們的背景,我們需要將它繪製在隔離的 context 上,再轉移到我們的畫布上。
// 隨機數生成器
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export class ChalkDrawable extends RawDrawable {
config: ChalkDrawableConfig;
seed: number;
constructor(ctx: CanvasRenderingContext2D, config: ChalkDrawableConfig) {
super(ctx);
this.config = { ...config };
// 固定種子
this.seed = Number(`${Math.random()}`.slice(2));
}
draw(events?: TrackedDrawEvent[]) {
const { ctx, config } = this;
const { lineWidth, color } = config;
const { clientWidth, clientHeight, width, height } = ctx.canvas;
const offscreen = new OffscreenCanvas(width, height);
const offscreenCtx = offscreen.getContext('2d')!;
offscreenCtx.scale(width / clientWidth, height / clientHeight);
const originalColor = Color(color);
offscreenCtx.fillStyle = originalColor.setAlpha(0.5).toHex8String();
offscreenCtx.strokeStyle = originalColor.setAlpha(0.5).toHex8String();
offscreenCtx.lineWidth = lineWidth;
offscreenCtx.lineCap = 'round';
let xLast: number | null = null;
let yLast: number | null = null;
const random = mulberry32(this.seed);
function drawPoint(x: number, y: number) {
if (xLast == null || yLast == null) {
xLast = x;
yLast = y;
}
offscreenCtx.strokeStyle = originalColor
.setAlpha(0.4 + random() * 0.2)
.toHex8String();
offscreenCtx.beginPath();
offscreenCtx.moveTo(xLast, yLast);
offscreenCtx.lineTo(x, y);
offscreenCtx.stroke();
const length = Math.round(
Math.sqrt(Math.pow(x - xLast, 2) + Math.pow(y - yLast, 2)) /
(5 / lineWidth),
);
const xUnit = (x - xLast) / length;
const yUnit = (y - yLast) / length;
for (let i = 0; i < length; i++) {
const xCurrent = xLast + i * xUnit;
const yCurrent = yLast + i * yUnit;
const xRandom = xCurrent + (random() - 0.5) * lineWidth * 1.2;
const yRandom = yCurrent + (random() - 0.5) * lineWidth * 1.2;
offscreenCtx.clearRect(
xRandom,
yRandom,
random() * 2 + 2,
random() + 1,
);
}
xLast = x;
yLast = y;
}
(events ?? this.getEvents()).forEach(e => {
drawPoint(e.left, e.top);
});
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(offscreen, 0, 0, clientWidth, clientHeight);
}
}
源碼
我們在文章中隱去了一些實現細節,可以在完整代碼 [9] 中詳細瞭解。
❤️ 謝謝支持
以上便是本次分享的全部內容,希望對你有所幫助 ^_^
喜歡的話別忘了 分享、點贊、收藏 三連哦~。
歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~
參考資料
[1]
參考這裏: https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio
[2]
參考這個測試: https://patrickhlauke.github.io/touch/tests/results/
[3]
這一 API: https://developer.mozilla.org/en-US/docs/Web/API/Path2D
[4]
四叉樹碰撞檢測: https://timohausmann.github.io/quadtree-js/simple.html
[5]
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation : https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
[6]
https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas : https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
[7]
算法參考: https://github.com/mmoustafa/Chalkboard
[8]
https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript : https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript
[9]
完整代碼: https://github.com/byted-meow/canvas-showcase
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bJPNGmW3FpOwGWp29UHbzQ