騰訊文檔智能表格渲染層 Feature 設計

閃念基因 發佈 2023-02-15T18:26:46.789787+00:00

1. 前言騰訊文檔智能表格的界面是用 Canvas 進行繪製的,這部分稱為 Canvas 渲染層。出於性能的考慮,這裡採用了雙層 Canvas 的形式,將頻繁變化的內容和不常變化的內容進行了分層。image.png-29.

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 等。

  1. bootstrap:插件初始化的鉤子,適合做一些變量的初始化。
  2. updated:插件將要更新的鉤子,一般是在編輯等場景下。
  3. addActivedEvents:綁定事件的鉤子,比如選區會監聽滑鼠 wheel 事件,但需要在選區繪製之後才監聽,避免沒有選區就去監聽帶來不必要的浪費。
  4. removeActivedEvents:解綁事件的鉤子,和 addActivedEvents 是對應的。
  5. destroy:銷毀的鉤子,一般是當前應用銷毀的時候。

有了這些鉤子之後,每個 Feature 類就會比較固定且規範了。

假設我們需要實現一個功能,點擊某個單元格,讓這個單元格的背景高亮顯示,該怎麼做呢?

  1. 綁定滑鼠的點擊事件,根據點擊的 x、y 找到對應的單元格。
  2. 給對應的單元格繪製高亮背景。
  3. 監聽滾動等事件,讓高亮的背景實時更新。

這裡使用 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();

仔細觀察這裡面存在的幾個問題:

  1. 封裝比較差,Feature 作為渲染層的一小部分,外界不應該感知到它的存在。
  2. 命令式的寫法,且 Feature 的數據和 UI 沒有分離,可讀性比較差。
  3. 沒有推導出來類型,需要手動做類型斷言。

如果開發過 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

關鍵字: