腾讯文档智能表格渲染层 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 都是一个插件类,它拥有自己的生命周期,包括 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}`);
  }
}

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

【四轴飞行器】非线性三自由度四轴飞行器模拟器研究(Matlab代码实现)内容概要:本文围绕非线性三自由度四轴飞行器模拟器的研究展开,重点介绍基于Matlab代码实现的四轴飞行器动力学建模与仿真方法。研究构建了考虑非线性特性的飞行器数学模型,涵盖姿态动力学与运动学方程,实现了三自由度(滚转、俯仰、偏航)的精确模拟。文中详细阐述了系统建模过程、控制算法设计思路及仿真结果分析,帮助读者深入理解四轴飞行器的飞行动力学特性与控制机制;同时,该模拟器可用于算法验证、控制器设计与教学实验。; 适合人群:具备一定自动控制理论基础和Matlab编程能力的高校学生、科研人员及无人机相关领域的工程技术人员,尤其适合从事飞行器建模、控制算法开发的研究生和初级研究人员。; 使用场景及目标:①用于四轴飞行器非线性动力学特性的学习与仿真验证;②作为控制器(如PID、LQR、MPC等)设计与测试的仿真平台;③支持无人机控制系统教学与科研项目开发,提升对姿态控制与系统仿真的理解。; 阅读建议:建议读者结合Matlab代码逐模块分析,重点关注动力学方程的推导与实现方式,动手运行并调试仿真程序,以加深对飞行器姿态控制过程的理解。同时可扩展为六自由度模型或加入外部干扰以增强仿真真实性。
基于分布式模型预测控制DMPC的多智能体点对点过渡轨迹生成研究(Matlab代码实现)内容概要:本文围绕“基于分布式模型预测控制(DMPC)的多智能体点对点过渡轨迹生成研究”展开,重点介绍如何利用DMPC方法实现多智能体系统在复杂环境下的协同轨迹规划与控制。文中结合Matlab代码实现,详细阐述了DMPC的基本原理、数学建模过程以及在多智能体系统中的具体应用,涵盖点对点转移、避障处理、状态约束与通信拓扑等关键技术环节。研究强调算法的分布式特性,提升系统的可扩展性与鲁棒性,适用于多无人机、无人车编队等场景。同时,文档列举了大量相关科研方向与代码资源,展示了DMPC在路径规划、协同控制、电力系统、信号处理等多领域的广泛应用。; 适合人群:具备一定自动化、控制理论或机器人学基础的研究生、科研人员及从事智能系统开发的工程技术人员;熟悉Matlab/Simulink仿真环境,对多智能体协同控制、优化算法有一定兴趣或研究需求的人员。; 使用场景及目标:①用于多智能体系统的轨迹生成与协同控制研究,如无人机集群、无人驾驶车队等;②作为DMPC算法学习与仿真实践的参考资料,帮助理解分布式优化与模型预测控制的结合机制;③支撑科研论文复现、毕业设计或项目开发中的算法验证与性能对比。; 阅读建议:建议读者结合提供的Matlab代码进行实践操作,重点关注DMPC的优化建模、约束处理与信息交互机制;按文档结构逐步学习,同时参考文中提及的路径规划、协同控制等相关案例,加深对分布式控制系统的整体理解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值