1. 前言
騰訊文檔智能表格的界面是用 Canvas 進行繪製的,這部分稱為 Canvas 渲染層。
出於性能的考慮,這裡採用了雙層 Canvas 的形式,將頻繁變化的內容和不常變化的內容進行了分層。
image.png-29.5kB
如上圖所示,表格部分如果沒有編輯的話,一般情況下是不需要重繪的,而選區是容易頻繁改變的部分。
也有一些競品將選區用 DOM 來實現,這樣也是一種分層,但對於全面擁抱 Canvas 的我們來說不是個很好的實踐。
我們將背景不變的部分稱為 BoardCanvas,和交互相關的 Canvas 稱為 Feature Canvas。
今天主要簡單來講一下 Feature Canvas 這層的設計。
2. 插件化
首先,如何來定義 Feature 這個概念呢?在我們看來,所有和用戶交互相關的都是 Feature,比如選區、選中態、hover 陰影、行列移動、智能填充等等。
這一層允許它頻繁變化,因為繪製的內容比較有限,重繪的成本明顯小於背景部分的繪製。
Kapture 2023-01-07 at 13.30.01.gif-380kB
這些 Feature 又該怎麼去管理呢?需要有一套固定的模板來規範它們的代碼組織。
因此,我們提倡使用插件化的形式來開發,每個 Feature 都是一個插件類,它擁有自己的生命周期,包括 bootstrap、updated、destroy、addActivedEvents、removeActivedEvents 等。
- bootstrap:插件初始化的鉤子,適合做一些變量的初始化。
- updated:插件將要更新的鉤子,一般是在編輯等場景下。
- addActivedEvents:綁定事件的鉤子,比如選區會監聽滑鼠 wheel 事件,但需要在選區繪製之後才監聽,避免沒有選區就去監聽帶來不必要的浪費。
- removeActivedEvents:解綁事件的鉤子,和 addActivedEvents 是對應的。
- destroy:銷毀的鉤子,一般是當前應用銷毀的時候。
有了這些鉤子之後,每個 Feature 類就會比較固定且規範了。
假設我們需要實現一個功能,點擊某個單元格,讓這個單元格的背景高亮顯示,該怎麼做呢?
- 綁定滑鼠的點擊事件,根據點擊的 x、y 找到對應的單元格。
- 給對應的單元格繪製高亮背景。
- 監聽滾動等事件,讓高亮的背景實時更新。
這裡使用 Konva 這個 Canvas 庫來簡單寫一個 Demo:
class HighLight {
public Name = 'highLight';
public cell = {
row: 0,
column: 0,
};
public bootstrap() {
// 創建一個容器節點
this.container = new Group();
// 將其添加到 Feature 圖層
this.layer.add(this.container);
// 監聽 mouseDown 事件
this.mouseDownEvent = global.mousedown.event(this.onMouseDown);
}
public updated() {
this.paint();
}
public addActivedEvents() {
// 綁定滾動事件
this.scrollEvent = global.scroll.event(this.onScroll);
}
public removeActivedEvents() {
this.scrollEvent?.dispose();
}
public destroy() {
this.container?.destroy();
this.removeActivedEvents();
}
private onMouseDown(param: IMouseDownParam) {
const { x, y } = param;
// 根據點擊的 x、y 坐標點獲取當前觸發的單元格
this.cell = this.getCell(x, y);
// 繪製
this.paint();
// 只有在滑鼠點擊之後,才需要綁定滾動等事件,避免不必要的開銷
this.addActivedEvents();
}
private onScroll(delta: IDelta) {
const { deltaX, deltaY } = delta;
// 根據滾動的 delta 值更新高亮背景的位置
const position = this.container.position();
this.container.x(position.x + deltaX);
this.container.y(position.y + deltaY);
}
/**
* 繪製背景高亮
*/
private paint() {
// 根據單元格獲取對應的位置和寬高信息
const cellRect = this.getCellRect(this.cell);
// 創建一個矩形
const rect = new Rect({
fill: 'red',
x: cellRect.x,
y: cellRect.y,
width: cellRect.width,
height: cellRect.height,
});
// 將矩形加入到父節點
this.container.add(rect);
}
}
從上方的示例可以看到,一個 Feature 的開發非常簡單,那麼插件要怎麼註冊呢?
在一個統一的入口處,可以將需要註冊的插件引入進來一次性註冊。
// 所有的 feature
const features: IFeature[] = [
[Search, { reqUIredEdit: false }],
[Selector, { requiredEdit: false, canUseInServer: true }],
[RecordHover, { requiredEdit: false, canUseInServer: true }],
[ToolTip, { requiredEdit: false }],
[Scroller, { requiredEdit: false, canUseInServer: true }],
];
class FeatureCanvas {
public bootstrap() {
// 安裝 feature 插件
this.installFeatures(features);
}
/**
* 安裝 features
* @param features
*/
public installFeatures(features: IFeature[]) {
features.forEach((feature) => {
const [FeatureConstructor, featureSetting] = feature;
// 獲取配置項
const { requiredEdit, canUseInServer = false } = featureSetting;
// 檢查是否具有相關權限
if (
(requiredEdit && !this.canEdit()) ||
(!canUseInServer && this.isServer())
) {
return;
}
const featureInstance = new FeatureConstructor(this);
featureInstance.bootstrap();
this.features[name] = featureInstance;
});
}
}
這樣一個簡單的插件機制就已經完成了,管理起來也相當方便快捷。
3. 數據驅動
在交互中往往伴隨著很多狀態的產生,最初這些狀態是維護在 Feature 中的,如果需要在外部訪問狀態或者修改 UI,就要使用 getFeature('xxx').yyy 的形式,這是一種不合理的設計。
舉個例子,我想要知道上面的高亮單元格是哪個,那麼要怎麼獲取呢?
(this.getFeature('highLight') as HighLight).cell;
那如果想要復用這個 Feature 來高亮具體的單元格,要怎麼做呢?
const highLight = this.getFeature('highLight') as HighLight;
highLight.cell = {
row: 100,
column: 100,
};
highLight.paint();
仔細觀察這裡面存在的幾個問題:
- 封裝比較差,Feature 作為渲染層的一小部分,外界不應該感知到它的存在。
- 命令式的寫法,且 Feature 的數據和 UI 沒有分離,可讀性比較差。
- 沒有推導出來類型,需要手動做類型斷言。
如果開發過 React/Vue,都會想到這裡需要做的就是實現一個 Model 層,專門存放這些中間狀態。
其次要建立 Model 和 Feature 的關聯,實現修改 Model 就會觸發 Feature UI 更新的機制,這樣就不需要從 Feature 上獲取數據和修改 UI 了。
這裡選用了 Mobx 來做狀態管理,因為它可以很方便的實現我們想要的效果。
import { makeObservable, observable, action } from 'mobx';
class Model {
public count = 0;
public constructor() {
// 將 count 設置為可觀察屬性
makeObservable(this, {
count: observable,
increment: action,
});
}
public increment() {
this.count++;
}
}
那麼在 Feature 中如何使用呢?可以基於 Mobx 封裝 observer、watch 兩個裝飾器方便調用。
import { observer, watch } from 'utils/reactive';
@observer()
class XXXFeature {
private title = new KonvaText();
/*
* 監聽 model.count,如果發生變化,將自動調用 refresh 方法
*/
@watch('count')
public refresh(count: number) {
this.title.text(`${count}`);
}
}
至於 observer 和 watch 的實現也很簡單。watch 裝飾器用於監聽屬性的變化,從而執行被裝飾的方法。
那這裡為什麼還需要 observer 呢?因為通過裝飾器無法獲取到類的實例,所以將 $watchers 先掛載到原型上面,再通過 observer 攔截構造函數,進而去執行所有的 $watchers,這樣就可以將掛載到類上的 Model 實例傳進去。
import get from 'lodash/get';
import { autorun } from 'mobx';
// 監聽裝飾器,在這裡是用於攔截目標類,去註冊 watcher 的監聽
export const observer =
() =>
<T extends new (...args: any[]) => any>(Constructor: T) =>
class extends Constructor {
public constructor(...args: any[]) {
super(...args);
// 取出所有的 $watchers,遍歷執行,觸發 Mobx 的依賴收集
Constructor.prototype?.$watchers?.forEach((watcher) => watcher(this, this.model));
}
};
// 觀察裝飾器,用於觀察 Model 中某個屬性變化後自動觸發 watcher
export const watch = (path: string) =>
function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) {
if (!_target.$watchers) {
_target.$watchers = [];
}
// 將 autorun 掛載到 $watchers 上面,方便之後執行
_target.$watchers.push((context: unknown, model: Model) => {
// 使用 autorun 觸發依賴收集
autorun(() => {
const result = get(model, path);
descriptor.value.call(context, result);
});
});
return descriptor;
};
使用 Mobx 改造之後,避免了直接獲取 Feature 內部的數據,或者調用 Feature 暴露的修改 UI 方法,讓整體流程更加清晰直觀了。
4. 總結
這裡只是對渲染層 Feature Canvas 插件機制的一個小總結,基於 Mobx 我們可以實現很多東西,讓整體架構更加清晰簡潔。
作者:nuIl
來源:微信公眾號:前端小館
出處:https://mp.weixin.qq.com/s/nPPau5yF4vTyAkef5K7fPw