最近在学习Univer这个开源项目,边学边整理为笔记。本篇主要从面向对象的角度出发,通过拆解Univer Sheet中的各种类,以及它们的关系,来进一步掌握项目架构。
一、数据对象
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 ← 注意:类型变了,多了拦截器字段