Univer拆解——从类与方法的视角分析

67 阅读6分钟

最近在学习Univer这个开源项目,边学边整理为笔记。本篇主要从面向对象的角度出发,通过拆解Univer Sheet中的各种类,以及它们的关系,来进一步掌握项目架构。

项目链接:github.com/dream-num/u…

一、数据对象

UnitModel

packages/core/src/common/unit.ts

唯一一个真正的抽象基类

export abstract class UnitModel<D = object, T extends UniverInstanceType = UniverInstanceType> 
extends Disposable {
    abstract readonly type: T;
    abstract getUnitId(): string;
    abstract name$: Observable<string>;
    abstract setName(name: string): void;
    abstract getSnapshot(): D;
    abstract getRev(): number;
    abstract incrementRev(): void;
    abstract setRev(rev: number): void;
}

而Workbook,即Univer Sheet项目中的工作区,继承了该基类

Workbook

聚合根(Aggregate Root)  是 DDD 的说法:外部只能通过它来访问内部对象,它保证内部一致性。Workbook 就是表格领域的聚合根。

Workbook可以理解为一整本Excel表格文件在内存中的对象。Workbook中有两个重要对象,分别是snapshot和worksheet。Snapshot是数据快照,可以理解为形如json形状的一种数据,用来存盘,拷贝,协作时发送给服务器等。例如下面的这种形态。

{
  id, name, styles,
  sheetOrder: ['sheet1', 'sheet2'],
  sheets: {
    sheet1: { name: 'Sheet1', cellData: {...}, ... },
    sheet2: { name: 'Sheet2', ... }
  }
}

而worksheet则是workbook中的一张工作表。可以理解为是运行时缓存snapshot的镜像。worksheet也是一个类,在运行时中其相关方法会被调用。当然,worksheet也有自己sheet级的snapshot。

worksheets: Map<string, Worksheet>  ← ★ 运行时对象缓存snapshot 的镜像
名字像什么干什么
Workbook整个 .xlsx管多张表、激活表、整本样式、保存
snapshot文件内容/JSON存、传、协作同步的“真相数据”
Worksheet某一个 Sheet 页管这一页的格子和行列,是对象,有方法

当用户对表格进行改动时,底层的数据对象的变化是一致的。即workbook的snapshot.sheet和worksheet的snapshot是同一份数据。

workbook._snapshot.sheets[id]  ──┐
                                 ├──►  { id, name, cellData, ... }   ← 同一块
worksheet._snapshot            ──┘
                                      cellData ──►  {}
                                                    ▲
worksheet._cellData._matrix  ───────────────────────┘  也是同一块

Worksheet

worksheet是整个表格领域访问最频繁的构造。当需要查询表格单元格内容,单元格行列宽度,是否合并,最终样式等等,都需要落脚到worksheet。worksheet是sheet表的操作入口,底层数据在snapshot中。worksheet中的方法往往和表格数据的读写相关。例如下所示

写:getCellMatrix().setValue(...)  
读原始单元格数据:getCellRaw
读被拦截后的显示:getCell
读最终样式:getComposedCellStyle
合并:getMergedCell / getSpanModel
区域:getRange(...)

但是不是所有交互都改 Worksheet。例如选区高亮、悬浮、部分 UI 面板状态,可能只在 sheets-ui / 渲染层,不一定写进 Worksheet。除此之外,有的交互落在 Workbook,不在某一张 Worksheet。比如新建/删除/重命名表、工作簿级样式池、dateSystem 等,改的是 Workbook。

拦截器

在worksheet中,有好几种读取单元格内容的方法。getcell,getcellraw等等。为什么会有这些呢,是因为在不同场景下,我们对读取单元格内容的形式要求不同。换句话说,单元格的显示和单元格数据模型中真正存的数据可能不同。【这其中也有性能优化的巧思】

比如,用户在 A1 输入 0.5,并把格式设成百分比。

  • 存盘(raw):大致是 { v: 0.5, t: NUMBER, s: '...' }
  • 屏幕上应显示:50%

格式化结果一般不写回真 cellData(否则存盘会变成字符串、协作/计算也麻烦)。所以在读的时候拦截。

「拦截」就体现在:走 getCell,不走 getCellRaw。中间被插进了一段逻辑

worksheet方法,有下面几种getcell方法

// 读原始数据
getCellRaw(row: number, col: number): Nullable<ICellData> {
        return this.getCellMatrix().getValue(row, col);
}

// 读viewModel的数据,相当于做了一下拦截,没有直接读raw
getCell(row: number, col: number): Nullable<ICellDataForSheetInterceptor> {
    return this._viewModel.getCell(row, col);
}

viewmodel方法,下面代码体现了拦截器

getCell(...) {
    if (this._cellContentInterceptor) {
        return this._cellContentInterceptor.getCell(...); // 拦在这里
    }
    return this.getRawCell(row, col);
}

ObjectMatrix

ObjectMatrix 是Univer中用来存储稀疏二维表的工具类,它是用类似于字典的 key-value 嵌套的对象来表示「第几行第几列有什么」,而不是用T[][]这种二维数组。很明显这里有性能的考量。因为表格很容易出现,很大,但是有内容的格子很少的情况。

packages/core/src/shared/object-matrix.ts:250

export class ObjectMatrix<T> {
    private _matrix!: IObjectMatrixPrimitiveType<T>;   
    // 实际结构:{ [row: number]: { [col: number]: T } }
    constructor(matrix: IObjectMatrixPrimitiveType<T> = {})
        { this._setOriginValue(matrix); }
    ...
}
T[][]ObjectMatrix<T>
声明 100 万行立刻分配 100 万 个槽位{},零成本
稀疏性差天然稀疏
访问 [500000][3]越界返回 undefined,安全
序列化需要补全所有空行直接 JSON.stringify 就是紧凑的
遍历全量只遍历存在的行(forValue / forRow)

自然而然,ObjectMatrix会包含 增删改查单元格的相关方法。也包含插行删行插列删列的方法,体现在row-column这对key-value上的数值加减。

SheetViewModel

先前worksheet部分提到了拦截器。SheetViewModel就是在packages/core/src/sheets/view-model.ts中的定义拦截器的相关的类。拦截器有各种,有value类拦截,style拦截,筛选拦截等等。

styles

Styles 是表格背后的样式池,为workbook共用。因为很多格子会共用一套样式,例如同样的颜色,同样的加粗,边框等等。

因为读数据需要频繁读写styles,所以引入了基于LRU算法的缓存加速。在excel表格中,因为有很多格子会共用一套样式,例如同样的颜色,字体,同样的加粗,边框等。所以用了一个全局的styles类进行管理。每一个样式内容有其 id。所以当用户交互时,高频写入单元格时,靠 LRU 缓存「样式内容→id」,快速去重而不重复建样式。

后续在工程算法章节中进行展开。

ICellData

packages/core/src/sheets/typedef.ts:283

export interface ICellData {
    p?: Nullable<IDocumentData>;   // 富文本内容(Univer Docs 单元)
    s?: Nullable<IStyleData | string>;  // 样式 id 或样式对象
    v?: Nullable<CellValue>;       // 原始值 number | string | boolean
    t?: Nullable<CellValueType>;   // 1 字符串 2 数字 3 布尔 4 强制字符串
    f?: Nullable<string>;          // ★ 公式原文,如 "=SUM(A1:B4)"
    ft?: Nullable<FormulaType>;    // 公式类型
    fd?: Nullable<BooleanNumber>;  // 动态数组标记
    ref?: Nullable<string>;        // 数组公式的引用范围
    si?: Nullable<string>;         // 公式 id
    custom?: CustomData;
}

ICellData描述单元格对象有哪些字段,同时也是单元格真正在内存中的存在形式。而存盘时,会json化。如果再读取时,通过JSON.parse成对象。这意味着,所有"单元格能做的事"都必须外置成函数,例如packages/sheets/src/basics/

worksheet.getCell(2, 3) 发生了什么

调用者
  │  worksheet.getCell(2, 3)
  ▼
Worksheet.getCell()                                    
  │  ① 边界检查 row < 0 || col < 0
  ▼
SheetViewModel.getCell(2, 3)                           
  │  ② 有拦截器吗?
  ├── 没有 ──► getRawCell(2, 3)  ← 构造时传入的闭包
  │               │
  │               ▼
  │           Worksheet.getCellRaw(2, 3)               
  │               │
  │               ▼
  │           ObjectMatrix.getValue(2, 3)               
  │               │
  │               ▼
  │           { v: 42, t: 2 }   ← 一个普通对象,不是类实例
  │
  └── 有 ──► _cellContentInterceptor.getCell(2, 3, Value|Style)
                  │
                  ▼
              SheetInterceptorService 注册的那个聚合拦截器
              (逐个跑 CELL_CONTENT 拦截器 → 可能改写样式/值 → 追加 interceptorStyle 字段)
                  │
                  ▼
              ICellDataForSheetInterceptor   ← 注意:类型变了,多了拦截器字段

二、对象工厂