腾讯文档智能表格渲染层 Feature 设计
1. 前言
腾讯文档智能表格的界面是用 Canvas 进行绘制的,这部分称为 Canvas 渲染层。
出于性能的考虑,这里采用了双层 Canvas 的形式,将频繁变化的内容和不常变化的内容进行了分层。
如上图所示,表格部分如果没有编辑的话,一般情况下是不需要重绘的,而选区是容易频繁改变的部分。
也有一些竞品将选区用 DOM 来实现,这样也是一种分层,但对于全面拥抱 Canvas 的我们来说不是个很好的实践。
我们将背景不变的部分称为 BoardCanvas,和交互相关的 Canvas 称为 Feature Canvas。
今天主要简单来讲一下 Feature Canvas 这层的设计。
2. 插件化
首先,如何来定义 Feature 这个概念呢?在我们看来,所有和用户交互相关的都是 Feature,比如选区、选中态、hover 阴影、行列移动、智能填充等等。
这一层允许它频繁变化,因为绘制的内容比较有限,重绘的成本明显小于背景部分的绘制。
这些 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 我们可以实现很多东西,让整体架构更加清晰简洁。