腾讯文档渲染层 Feature 设计

1. 前言

腾讯文档智能表格的界面是用 Canvas 进行绘制的,这部分称为 Canvas 渲染层。

出于性能的考虑,这里采用了双层 Canvas 的形式,将频繁变化的内容和不常变化的内容进行了分层。

image.png-29.5kB

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

Kapture 2023-01-07 at 13.30.01.gif-380kB

这些 Feature 又该怎么去管理呢?需要有一套固定的模板来规范它们的代码组织。

因此,我们提倡使用插件化的形式来开发,每个 Feature 都是一个插件类,它拥有自己的生命周期,包括 bootstrapupdateddestroyaddActivedEventsremoveActivedEvents 等。

  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 封装 observerwatch 两个装饰器方便调用。

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}`);
  }
}

至于 observerwatch 的实现也很简单。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 我们可以实现很多东西,让整体架构更加清晰简洁。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容