腾讯文档渲染层 Feature 设计
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 我们可以实现很多东西,让整体架构更加清晰简洁。
转载自:https://juejin.cn/post/7185789225902538811