跳到主要内容

设计在线表格/Excel 系统

问题

如何设计一个支持百万级单元格、公式计算、样式渲染和多人协同编辑的在线表格系统?请从 Canvas 渲染引擎、数据模型、虚拟滚动、公式引擎、选区系统、协同编辑等核心模块详细说明设计思路与关键技术实现。

答案

在线表格系统是前端领域最复杂的应用之一,它融合了高性能渲染复杂数据建模公式引擎协同编辑等多项核心技术。Google Sheets、飞书多维表格、腾讯文档等产品均是业界标杆。与普通的 Web 应用不同,在线表格对渲染性能、内存管理和计算效率有极高的要求 —— 百万级单元格场景下,DOM 方案几乎不可行,必须依赖 Canvas 渲染 + 虚拟化技术。


一、需求分析

功能需求

模块功能点
单元格编辑双击编辑、回车确认、Tab 切换、输入法支持、多类型数据输入
公式系统公式输入(=SUM(A1:A100))、自动计算、单元格引用、跨表引用
样式系统字体/字号/颜色、边框、背景色、对齐、文本换行、条件格式
选区操作单选/多选/区域选择、Shift 扩选、Ctrl 多选、拖拽填充
行列操作插入/删除行列、调整行高列宽、隐藏/冻结行列
合并单元格合并/拆分、合并后选区处理、合并区域内公式处理
复制粘贴纯文本/富格式粘贴、跨表格粘贴、选择性粘贴(仅值/仅格式)
撤销重做无限撤销/重做、批量操作原子化
导入导出xlsx/csv 导入导出、保持样式
协同编辑多人实时编辑、光标感知、冲突解决

非功能需求

指标目标
海量数据支持 100 万+ 单元格(1000 行 x 1000 列),滚动流畅
渲染性能60fps 渲染,滚动/选区操作无卡顿
公式计算单次公式变更增量计算 < 50ms
首屏加载打开表格 < 2s,渐进式加载数据
内存控制百万单元格内存占用 < 500MB
协同延迟端到端同步延迟 < 300ms
核心挑战

在线表格的核心难点在于:用 Canvas 模拟 DOM 的全部能力(文字渲染、选区、光标、滚动、输入法等),同时保证百万级数据下的极致性能。这要求我们自建一套完整的"UI 渲染引擎"。


二、整体架构

分层架构

数据流转

架构要点
  • 命令模式:所有操作通过 Command 执行,支持撤销重做和协同同步
  • 脏区域标记:数据变更只标记受影响的区域,渲染引擎只重绘脏矩形
  • 引擎层独立:公式引擎、渲染引擎、样式引擎彼此独立,通过数据模型通信

三、核心模块设计

3.1 数据模型

在线表格的数据模型需要同时满足高效存储(百万单元格)和快速读写(O(1) 随机访问)。

稀疏矩阵存储

百万单元格中,实际有数据的往往不到 10%。使用稀疏矩阵(行索引 -> 列索引 -> 单元格数据)可以大幅节省内存:

model/data-model.ts
/** 单元格数据 */
interface CellData {
/** 原始值(用户输入的内容) */
raw: string | number | boolean | null;
/** 计算结果(公式计算后的值) */
computed?: string | number | boolean | null;
/** 公式文本(如 =SUM(A1:A10)) */
formula?: string;
/** 样式 ID(指向样式表,避免重复存储样式) */
styleId?: number;
/** 批注 */
comment?: string;
}

/** 合并单元格区域 */
interface MergeRange {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}

/** 行/列元信息 */
interface RowColMeta {
/** 行高/列宽(像素) */
size: number;
/** 是否隐藏 */
hidden: boolean;
}

/**
* 稀疏矩阵:双层 Map 存储
* 外层 key = 行号,内层 key = 列号
* 优势:空单元格不占内存,随机访问 O(1)
*/
type SparseMatrix = Map<number, Map<number, CellData>>;

class SheetModel {
/** 表名 */
name: string;
/** 单元格数据(稀疏存储) */
private cells: SparseMatrix = new Map();
/** 行元信息 */
private rowMeta: Map<number, RowColMeta> = new Map();
/** 列元信息 */
private colMeta: Map<number, RowColMeta> = new Map();
/** 合并单元格 */
private merges: MergeRange[] = [];
/** 默认行高 */
readonly defaultRowHeight = 25;
/** 默认列宽 */
readonly defaultColWidth = 100;
/** 最大行数 */
readonly maxRows = 1_000_000;
/** 最大列数 */
readonly maxCols = 26_000; // AZ...

constructor(name: string) {
this.name = name;
}

/** 获取单元格数据(O(1)) */
getCell(row: number, col: number): CellData | undefined {
return this.cells.get(row)?.get(col);
}

/** 设置单元格数据 */
setCell(row: number, col: number, data: CellData): void {
if (!this.cells.has(row)) {
this.cells.set(row, new Map());
}
this.cells.get(row)!.set(col, data);
}

/** 删除单元格 */
deleteCell(row: number, col: number): void {
const rowMap = this.cells.get(row);
if (rowMap) {
rowMap.delete(col);
if (rowMap.size === 0) {
this.cells.delete(row); // 清理空行
}
}
}

/** 获取行高 */
getRowHeight(row: number): number {
const meta = this.rowMeta.get(row);
if (meta?.hidden) return 0;
return meta?.size ?? this.defaultRowHeight;
}

/** 获取列宽 */
getColWidth(col: number): number {
const meta = this.colMeta.get(col);
if (meta?.hidden) return 0;
return meta?.size ?? this.defaultColWidth;
}

/** 获取有效数据的单元格数量 */
getCellCount(): number {
let count = 0;
for (const rowMap of this.cells.values()) {
count += rowMap.size;
}
return count;
}
}

行列树(前缀和优化)

对于动态行高/列宽场景,需要快速计算"第 N 行的 Y 坐标"以及"Y 坐标对应哪一行"。使用前缀和数组B+ 树可以将查询优化到 O(logn)O(\log n)

model/row-col-tree.ts
/**
* 行/列位置索引
* 用于快速计算:行号 → Y坐标、Y坐标 → 行号
* 使用前缀和数组,支持动态行高
*/
class PositionIndex {
/** 前缀和数组:prefixSum[i] = 前 i 行的总高度 */
private prefixSum: Float64Array;
private sheet: SheetModel;
private direction: 'row' | 'col';
private dirty = true;

constructor(sheet: SheetModel, direction: 'row' | 'col', maxCount: number) {
this.sheet = sheet;
this.direction = direction;
this.prefixSum = new Float64Array(maxCount + 1);
}

/** 重建前缀和(数据变更后调用) */
rebuild(): void {
const getSize = this.direction === 'row'
? (i: number) => this.sheet.getRowHeight(i)
: (i: number) => this.sheet.getColWidth(i);

this.prefixSum[0] = 0;
for (let i = 0; i < this.prefixSum.length - 1; i++) {
this.prefixSum[i + 1] = this.prefixSum[i] + getSize(i);
}
this.dirty = false;
}

/** 获取第 index 行/列的起始坐标(O(1)) */
getOffset(index: number): number {
if (this.dirty) this.rebuild();
return this.prefixSum[index];
}

/** 获取第 start 到 end 行/列的总高度/宽度(O(1)) */
getRangeSize(start: number, end: number): number {
if (this.dirty) this.rebuild();
return this.prefixSum[end + 1] - this.prefixSum[start];
}

/** 根据像素偏移找到对应的行号/列号(O(log n) 二分查找) */
getIndexByOffset(offset: number): number {
if (this.dirty) this.rebuild();
let low = 0;
let high = this.prefixSum.length - 1;

while (low < high) {
const mid = (low + high) >>> 1;
if (this.prefixSum[mid] <= offset) {
low = mid + 1;
} else {
high = mid;
}
}
return Math.max(0, low - 1);
}

/** 标记为脏,下次查询时重建 */
markDirty(): void {
this.dirty = true;
}
}
为什么用前缀和而不是累加?

直接累加求第 N 行的 Y 坐标是 O(n)O(n) 的,百万行时每次滚动都要遍历百万次。前缀和预计算后,查询变为 O(1)O(1),二分查找坐标到行号则是 O(logn)O(\log n)。对于频繁的行高变更场景,可进一步使用 线段树Fenwick 树(树状数组)实现 O(logn)O(\log n) 的单点更新。


3.2 Canvas 渲染引擎

为什么选择 Canvas 而不是 DOM?

维度DOM 方案Canvas 方案
节点数量每个单元格一个 DOM 节点,万级时卡顿一张画布,无 DOM 节点瓶颈
渲染性能触发回流重绘,百级单元格就需虚拟化直接像素绘制,万级单元格流畅
样式控制受 CSS 盒模型限制像素级控制,自由绘制边框/背景
内存占用每个 DOM 节点 ~1KB只有画布像素缓冲区
文字渲染原生支持,含输入法/选区需要自行实现文字排版、光标、输入法
无障碍天然支持需额外实现 ARIA
开发复杂度高(自建 UI 系统)
注意

Canvas 方案的代价是开发复杂度极高 —— 文字编辑、输入法处理、光标渲染、选区绘制、滚动条、右键菜单等所有交互都需要自行实现。但对于在线表格这种海量同构元素的场景,Canvas 是唯一可行的方案。

Canvas 事件绑定

Canvas 本身不支持元素级事件。我们需要在 Canvas 上监听原生事件,然后通过坐标计算映射到具体的单元格:

engine/event-bindng.ts
class SheetEventManager {
private canvas: HTMLCanvasElement;
private viewport: ViewportManager;
private selection: SelectionManager;

constructor(
canvas: HTMLCanvasElement,
viewport: ViewportManager,
selection: SelectionManager
) {
this.canvas = canvas;
this.viewport = viewport;
this.selection = selection;
this.bindEvents();
}

private bindEvents(): void {
// 所有事件都绑定在 Canvas 元素上
this.canvas.addEventListener('mousedown', this.onMouseDown);
this.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.addEventListener('mouseup', this.onMouseUp);
this.canvas.addEventListener('dblclick', this.onDoubleClick);
this.canvas.addEventListener('wheel', this.onWheel, { passive: false });
document.addEventListener('keydown', this.onKeyDown);
}

/** 鼠标坐标 → 单元格坐标 */
private hitTest(clientX: number, clientY: number): { row: number; col: number } | null {
const rect = this.canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;

// 减去冻结区域的偏移
const scrollX = this.viewport.scrollLeft;
const scrollY = this.viewport.scrollTop;

// 将像素坐标转换为行列号(利用前缀和二分查找)
const row = this.viewport.rowIndex.getIndexByOffset(y + scrollY);
const col = this.viewport.colIndex.getIndexByOffset(x + scrollX);

if (row < 0 || col < 0) return null;
return { row, col };
}

private onMouseDown = (e: MouseEvent): void => {
const cell = this.hitTest(e.clientX, e.clientY);
if (!cell) return;

if (e.shiftKey) {
// Shift + 点击:扩展选区
this.selection.extendTo(cell.row, cell.col);
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd + 点击:添加新选区
this.selection.addRange(cell.row, cell.col);
} else {
// 普通点击:新建选区
this.selection.startSelection(cell.row, cell.col);
}
};

private onDoubleClick = (e: MouseEvent): void => {
const cell = this.hitTest(e.clientX, e.clientY);
if (!cell) return;
// 双击进入编辑模式
this.selection.enterEditMode(cell.row, cell.col);
};

private onWheel = (e: WheelEvent): void => {
e.preventDefault();
this.viewport.scrollBy(e.deltaX, e.deltaY);
};

private onMouseMove = (_e: MouseEvent): void => { /* 拖拽选区、hover 效果 */ };
private onMouseUp = (_e: MouseEvent): void => { /* 结束拖拽 */ };
private onKeyDown = (_e: KeyboardEvent): void => { /* 键盘导航、快捷键 */ };
}

分层渲染与脏矩形

为了避免每次操作都全量重绘,渲染引擎采用多层 Canvas + 脏矩形策略:

engine/render-engine.ts
/** 脏矩形区域 */
interface DirtyRect {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}

class RenderEngine {
/** 多层 Canvas */
private layers: Map<string, HTMLCanvasElement> = new Map();
/** 脏区域队列 */
private dirtyRects: DirtyRect[] = [];
/** 是否有待渲染的帧 */
private renderScheduled = false;
/** 视口管理 */
private viewport: ViewportManager;
/** 数据模型 */
private sheet: SheetModel;

constructor(container: HTMLElement, viewport: ViewportManager, sheet: SheetModel) {
this.viewport = viewport;
this.sheet = sheet;

// 创建分层 Canvas
const layerNames = ['background', 'content', 'style', 'selection', 'editor'];
for (const name of layerNames) {
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;top:0;left:0;';
canvas.width = container.clientWidth * devicePixelRatio;
canvas.height = container.clientHeight * devicePixelRatio;
canvas.style.width = `${container.clientWidth}px`;
canvas.style.height = `${container.clientHeight}px`;
container.appendChild(canvas);
this.layers.set(name, canvas);
}
}

/** 标记脏区域 */
markDirty(rect: DirtyRect): void {
this.dirtyRects.push(rect);
this.scheduleRender();
}

/** 标记全量重绘(滚动时) */
markFullDirty(): void {
const { startRow, endRow, startCol, endCol } = this.viewport.getVisibleRange();
this.dirtyRects = [{ startRow, startCol, endRow, endCol }];
this.scheduleRender();
}

/** 调度渲染(合并同一帧内的多次标记) */
private scheduleRender(): void {
if (this.renderScheduled) return;
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderScheduled = false;
this.render();
});
}

/** 执行渲染 */
private render(): void {
if (this.dirtyRects.length === 0) return;

// 合并脏矩形
const merged = this.mergeDirtyRects(this.dirtyRects);
this.dirtyRects = [];

for (const rect of merged) {
this.renderBackground(rect);
this.renderStyles(rect);
this.renderContent(rect);
this.renderSelection(rect);
}
}

/** 渲染单元格内容 */
private renderContent(rect: DirtyRect): void {
const canvas = this.layers.get('content')!;
const ctx = canvas.getContext('2d')!;
const dpr = devicePixelRatio;

for (let row = rect.startRow; row <= rect.endRow; row++) {
for (let col = rect.startCol; col <= rect.endCol; col++) {
const cell = this.sheet.getCell(row, col);
if (!cell) continue;

// 获取单元格像素坐标
const x = this.viewport.colIndex.getOffset(col) - this.viewport.scrollLeft;
const y = this.viewport.rowIndex.getOffset(row) - this.viewport.scrollTop;
const w = this.sheet.getColWidth(col);
const h = this.sheet.getRowHeight(row);

// 清除旧内容
ctx.clearRect(x * dpr, y * dpr, w * dpr, h * dpr);

// 绘制文本
const displayValue = cell.computed ?? cell.raw;
if (displayValue != null) {
ctx.save();
ctx.scale(dpr, dpr);
ctx.font = '13px -apple-system, "Segoe UI", sans-serif';
ctx.fillStyle = '#333';
ctx.textBaseline = 'middle';
ctx.fillText(String(displayValue), x + 4, y + h / 2, w - 8);
ctx.restore();
}
}
}
}

/** 合并重叠的脏矩形 */
private mergeDirtyRects(rects: DirtyRect[]): DirtyRect[] {
if (rects.length <= 1) return rects;
// 简化实现:合并为一个包含所有脏区域的最小矩形
let startRow = Infinity, startCol = Infinity;
let endRow = -Infinity, endCol = -Infinity;
for (const r of rects) {
startRow = Math.min(startRow, r.startRow);
startCol = Math.min(startCol, r.startCol);
endRow = Math.max(endRow, r.endRow);
endCol = Math.max(endCol, r.endCol);
}
return [{ startRow, startCol, endRow, endCol }];
}

private renderBackground(_rect: DirtyRect): void { /* 网格线、行列头 */ }
private renderStyles(_rect: DirtyRect): void { /* 背景色、边框 */ }
private renderSelection(_rect: DirtyRect): void { /* 选区高亮 */ }
}
脏矩形优化的核心思想

全量重绘一张百万单元格的表格代价极大。脏矩形策略将"重绘区域"限制在实际变更的范围内。例如用户修改了 B3 单元格,只需重绘 B3 所在区域(以及受公式影响的关联单元格),而非整个可视区。配合 requestAnimationFrame 合并同一帧内的多次更新,可以大幅减少绘制量。


3.3 虚拟滚动

虚拟滚动是在线表格的性能基石 —— 视口内通常只需渲染 50100 行 x 2030 列的单元格,而非全部百万个。

行列虚拟化

engine/virtual-scroll.ts
/** 可视区域范围 */
interface VisibleRange {
startRow: number;
endRow: number;
startCol: number;
endCol: number;
}

class ViewportManager {
/** 滚动偏移 */
scrollTop = 0;
scrollLeft = 0;
/** 视口尺寸 */
viewportWidth: number;
viewportHeight: number;
/** 行列位置索引 */
rowIndex: PositionIndex;
colIndex: PositionIndex;
/** 冻结行列数 */
frozenRows = 0;
frozenCols = 0;
/** 预渲染缓冲区(额外渲染的行列数) */
private buffer = 5;

constructor(
width: number,
height: number,
rowIndex: PositionIndex,
colIndex: PositionIndex
) {
this.viewportWidth = width;
this.viewportHeight = height;
this.rowIndex = rowIndex;
this.colIndex = colIndex;
}

/** 计算可视区域内需要渲染的行列范围 */
getVisibleRange(): VisibleRange {
const startRow = Math.max(
this.frozenRows,
this.rowIndex.getIndexByOffset(this.scrollTop) - this.buffer
);
const endRow = Math.min(
this.rowIndex.getIndexByOffset(this.scrollTop + this.viewportHeight) + this.buffer,
1_000_000 - 1
);
const startCol = Math.max(
this.frozenCols,
this.colIndex.getIndexByOffset(this.scrollLeft) - this.buffer
);
const endCol = Math.min(
this.colIndex.getIndexByOffset(this.scrollLeft + this.viewportWidth) + this.buffer,
26_000 - 1
);

return { startRow, endRow, startCol, endCol };
}

/** 滚动 */
scrollBy(deltaX: number, deltaY: number): void {
const maxScrollTop = this.rowIndex.getOffset(1_000_000) - this.viewportHeight;
const maxScrollLeft = this.colIndex.getOffset(26_000) - this.viewportWidth;

this.scrollTop = Math.max(0, Math.min(maxScrollTop, this.scrollTop + deltaY));
this.scrollLeft = Math.max(0, Math.min(maxScrollLeft, this.scrollLeft + deltaX));
}

/** 滚动到指定单元格 */
scrollToCell(row: number, col: number): void {
const cellTop = this.rowIndex.getOffset(row);
const cellLeft = this.colIndex.getOffset(col);

if (cellTop < this.scrollTop) {
this.scrollTop = cellTop;
} else if (cellTop + 25 > this.scrollTop + this.viewportHeight) {
this.scrollTop = cellTop + 25 - this.viewportHeight;
}

if (cellLeft < this.scrollLeft) {
this.scrollLeft = cellLeft;
} else if (cellLeft + 100 > this.scrollLeft + this.viewportWidth) {
this.scrollLeft = cellLeft + 100 - this.viewportWidth;
}
}
}

冻结行列

冻结行/列需要将视口分为四个区域独立渲染:

engine/frozen-render.ts
/** 冻结区域渲染 */
class FrozenRenderer {
private viewport: ViewportManager;

constructor(viewport: ViewportManager) {
this.viewport = viewport;
}

/** 获取四个渲染区域 */
getRenderZones(): {
frozenCorner: VisibleRange;
frozenRows: VisibleRange;
frozenCols: VisibleRange;
mainContent: VisibleRange;
} {
const { frozenRows, frozenCols } = this.viewport;
const main = this.viewport.getVisibleRange();

return {
// 冻结角:不随滚动变化
frozenCorner: {
startRow: 0, endRow: frozenRows - 1,
startCol: 0, endCol: frozenCols - 1,
},
// 冻结行:只跟随水平滚动
frozenRows: {
startRow: 0, endRow: frozenRows - 1,
startCol: main.startCol, endCol: main.endCol,
},
// 冻结列:只跟随垂直滚动
frozenCols: {
startRow: main.startRow, endRow: main.endRow,
startCol: 0, endCol: frozenCols - 1,
},
// 主区域:双向滚动
mainContent: main,
};
}
}

3.4 公式引擎

公式引擎是在线表格最核心的模块,需要实现公式解析(AST)、依赖收集(有向图)、增量计算(拓扑排序)和循环引用检测

公式 AST 解析

formula/parser.ts
/** AST 节点类型 */
type ASTNode =
| { type: 'number'; value: number }
| { type: 'string'; value: string }
| { type: 'boolean'; value: boolean }
| { type: 'cellRef'; row: number; col: number; absolute: boolean }
| { type: 'rangeRef'; start: { row: number; col: number }; end: { row: number; col: number } }
| { type: 'functionCall'; name: string; args: ASTNode[] }
| { type: 'binaryOp'; op: '+' | '-' | '*' | '/' | '>' | '<' | '=' | '&'; left: ASTNode; right: ASTNode }
| { type: 'unaryOp'; op: '-' | '+'; operand: ASTNode };

/** Token 类型 */
type Token =
| { type: 'NUMBER'; value: number }
| { type: 'STRING'; value: string }
| { type: 'CELL_REF'; value: string }
| { type: 'RANGE_REF'; value: string }
| { type: 'FUNCTION'; value: string }
| { type: 'OPERATOR'; value: string }
| { type: 'LPAREN' } | { type: 'RPAREN' }
| { type: 'COMMA' } | { type: 'COLON' };

class FormulaParser {
private tokens: Token[] = [];
private pos = 0;

/** 词法分析:公式字符串 → Token 序列 */
tokenize(formula: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// 跳过开头的 '='
if (formula.startsWith('=')) i = 1;

while (i < formula.length) {
const ch = formula[i];

// 跳过空白
if (/\s/.test(ch)) { i++; continue; }

// 数字
if (/\d/.test(ch) || (ch === '.' && /\d/.test(formula[i + 1]))) {
let num = '';
while (i < formula.length && /[\d.]/.test(formula[i])) {
num += formula[i++];
}
tokens.push({ type: 'NUMBER', value: parseFloat(num) });
continue;
}

// 单元格引用或函数名(如 A1, $A$1, SUM)
if (/[A-Za-z$]/.test(ch)) {
let word = '';
while (i < formula.length && /[A-Za-z0-9$]/.test(formula[i])) {
word += formula[i++];
}

// 判断是否为区域引用(如 A1:B3)
if (i < formula.length && formula[i] === ':') {
tokens.push({ type: 'CELL_REF', value: word });
tokens.push({ type: 'COLON' });
i++;
continue;
}

// 判断是函数名还是单元格引用
if (i < formula.length && formula[i] === '(') {
tokens.push({ type: 'FUNCTION', value: word.toUpperCase() });
} else if (/^\$?[A-Z]+\$?\d+$/.test(word.toUpperCase())) {
tokens.push({ type: 'CELL_REF', value: word.toUpperCase() });
} else {
tokens.push({ type: 'STRING', value: word });
}
continue;
}

// 运算符与括号
if ('+-*/><&='.includes(ch)) {
tokens.push({ type: 'OPERATOR', value: ch }); i++; continue;
}
if (ch === '(') { tokens.push({ type: 'LPAREN' }); i++; continue; }
if (ch === ')') { tokens.push({ type: 'RPAREN' }); i++; continue; }
if (ch === ',') { tokens.push({ type: 'COMMA' }); i++; continue; }
if (ch === ':') { tokens.push({ type: 'COLON' }); i++; continue; }

// 字符串字面量
if (ch === '"') {
let str = '';
i++; // 跳过开引号
while (i < formula.length && formula[i] !== '"') {
str += formula[i++];
}
i++; // 跳过闭引号
tokens.push({ type: 'STRING', value: str });
continue;
}

i++; // 跳过未识别字符
}

return tokens;
}

/** 语法分析:Token 序列 → AST */
parse(formula: string): ASTNode {
this.tokens = this.tokenize(formula);
this.pos = 0;
return this.parseExpression();
}

private parseExpression(): ASTNode {
let left = this.parseTerm();

while (this.pos < this.tokens.length) {
const token = this.tokens[this.pos];
if (token.type === 'OPERATOR' && (token.value === '+' || token.value === '-')) {
this.pos++;
const right = this.parseTerm();
left = { type: 'binaryOp', op: token.value, left, right };
} else {
break;
}
}
return left;
}

private parseTerm(): ASTNode {
let left = this.parsePrimary();

while (this.pos < this.tokens.length) {
const token = this.tokens[this.pos];
if (token.type === 'OPERATOR' && (token.value === '*' || token.value === '/')) {
this.pos++;
const right = this.parsePrimary();
left = { type: 'binaryOp', op: token.value, left, right };
} else {
break;
}
}
return left;
}

private parsePrimary(): ASTNode {
const token = this.tokens[this.pos];

if (token.type === 'NUMBER') {
this.pos++;
return { type: 'number', value: token.value };
}

if (token.type === 'FUNCTION') {
return this.parseFunctionCall();
}

if (token.type === 'CELL_REF') {
return this.parseCellOrRange();
}

if (token.type === 'LPAREN') {
this.pos++;
const expr = this.parseExpression();
this.pos++; // skip RPAREN
return expr;
}

this.pos++;
return { type: 'number', value: 0 };
}

private parseFunctionCall(): ASTNode {
const name = (this.tokens[this.pos] as { type: 'FUNCTION'; value: string }).value;
this.pos++; // function name
this.pos++; // skip LPAREN

const args: ASTNode[] = [];
while (this.pos < this.tokens.length && this.tokens[this.pos].type !== 'RPAREN') {
args.push(this.parseExpression());
if (this.tokens[this.pos]?.type === 'COMMA') this.pos++;
}
this.pos++; // skip RPAREN

return { type: 'functionCall', name, args };
}

private parseCellOrRange(): ASTNode {
const ref = this.parseCellRef((this.tokens[this.pos] as { type: 'CELL_REF'; value: string }).value);
this.pos++;

// 检查是否为范围引用(A1:B3)
if (this.pos < this.tokens.length && this.tokens[this.pos].type === 'COLON') {
this.pos++; // skip ':'
const endRef = this.parseCellRef((this.tokens[this.pos] as { type: 'CELL_REF'; value: string }).value);
this.pos++;
return { type: 'rangeRef', start: ref, end: endRef };
}

return { type: 'cellRef', ...ref, absolute: false };
}

/** 解析单元格地址:A1 → { row: 0, col: 0 } */
private parseCellRef(ref: string): { row: number; col: number } {
const clean = ref.replace(/\$/g, '');
const match = clean.match(/^([A-Z]+)(\d+)$/);
if (!match) return { row: 0, col: 0 };

const colStr = match[1];
const rowStr = match[2];

// 列号转换:A=0, B=1, ..., Z=25, AA=26
let col = 0;
for (let i = 0; i < colStr.length; i++) {
col = col * 26 + (colStr.charCodeAt(i) - 64);
}

return { row: parseInt(rowStr) - 1, col: col - 1 };
}
}

依赖图与增量计算

公式之间存在依赖关系(如 C1 = A1 + B1,修改 A1 需要重算 C1)。使用有向无环图(DAG)管理依赖,通过拓扑排序确定计算顺序:

formula/dependency-graph.ts
/** 单元格标识 */
type CellKey = string; // 格式:"sheetName!row,col"

class DependencyGraph {
/**
* 正向依赖:cell → 它依赖的所有 cell
* 例如 C1=A1+B1,则 dependsOn["C1"] = Set(["A1", "B1"])
*/
private dependsOn: Map<CellKey, Set<CellKey>> = new Map();

/**
* 反向依赖:cell → 所有依赖它的 cell
* 例如 C1=A1+B1,则 dependedBy["A1"] = Set(["C1"])
*/
private dependedBy: Map<CellKey, Set<CellKey>> = new Map();

/** 注册依赖关系(公式解析后调用) */
setDependencies(cell: CellKey, deps: CellKey[]): void {
// 清除旧依赖
const oldDeps = this.dependsOn.get(cell);
if (oldDeps) {
for (const dep of oldDeps) {
this.dependedBy.get(dep)?.delete(cell);
}
}

// 设置新依赖
this.dependsOn.set(cell, new Set(deps));

for (const dep of deps) {
if (!this.dependedBy.has(dep)) {
this.dependedBy.set(dep, new Set());
}
this.dependedBy.get(dep)!.add(cell);
}
}

/**
* 获取需要重算的单元格列表(拓扑排序)
* 当某个 cell 的值变更时,找出所有受影响的下游单元格,并按正确顺序排列
*/
getRecalcOrder(changedCells: CellKey[]): CellKey[] {
// BFS 找出所有受影响的单元格
const affected = new Set<CellKey>();
const queue = [...changedCells];

while (queue.length > 0) {
const cell = queue.shift()!;
const downstream = this.dependedBy.get(cell);
if (!downstream) continue;

for (const dep of downstream) {
if (!affected.has(dep)) {
affected.add(dep);
queue.push(dep);
}
}
}

// 拓扑排序
const inDegree = new Map<CellKey, number>();
for (const cell of affected) {
inDegree.set(cell, 0);
}
for (const cell of affected) {
const deps = this.dependsOn.get(cell);
if (deps) {
for (const dep of deps) {
if (affected.has(dep)) {
inDegree.set(cell, (inDegree.get(cell) ?? 0) + 1);
}
}
}
}

const sorted: CellKey[] = [];
const zeroQueue: CellKey[] = [];
for (const [cell, degree] of inDegree) {
if (degree === 0) zeroQueue.push(cell);
}

while (zeroQueue.length > 0) {
const cell = zeroQueue.shift()!;
sorted.push(cell);
const downstream = this.dependedBy.get(cell);
if (!downstream) continue;
for (const dep of downstream) {
if (affected.has(dep)) {
const newDegree = (inDegree.get(dep) ?? 1) - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) zeroQueue.push(dep);
}
}
}

return sorted;
}

/** 检测循环引用 */
detectCycle(startCell: CellKey): boolean {
const visited = new Set<CellKey>();
const recursionStack = new Set<CellKey>();

const dfs = (cell: CellKey): boolean => {
visited.add(cell);
recursionStack.add(cell);

const deps = this.dependsOn.get(cell);
if (deps) {
for (const dep of deps) {
if (!visited.has(dep)) {
if (dfs(dep)) return true;
} else if (recursionStack.has(dep)) {
return true; // 发现循环
}
}
}

recursionStack.delete(cell);
return false;
};

return dfs(startCell);
}

/** 清除单元格的依赖 */
removeDependencies(cell: CellKey): void {
const deps = this.dependsOn.get(cell);
if (deps) {
for (const dep of deps) {
this.dependedBy.get(dep)?.delete(cell);
}
this.dependsOn.delete(cell);
}
}
}

公式求值器

formula/evaluator.ts
class FormulaEvaluator {
private sheet: SheetModel;
/** 内置函数表 */
private functions: Map<string, (...args: unknown[]) => unknown> = new Map();

constructor(sheet: SheetModel) {
this.sheet = sheet;
this.registerBuiltinFunctions();
}

private registerBuiltinFunctions(): void {
this.functions.set('SUM', (...args: unknown[]) => {
return this.flattenArgs(args).reduce((sum: number, v) => sum + Number(v), 0);
});
this.functions.set('AVERAGE', (...args: unknown[]) => {
const values = this.flattenArgs(args);
return values.reduce((sum: number, v) => sum + Number(v), 0) / values.length;
});
this.functions.set('MAX', (...args: unknown[]) => {
return Math.max(...this.flattenArgs(args).map(Number));
});
this.functions.set('MIN', (...args: unknown[]) => {
return Math.min(...this.flattenArgs(args).map(Number));
});
this.functions.set('COUNT', (...args: unknown[]) => {
return this.flattenArgs(args).filter(v => typeof v === 'number').length;
});
this.functions.set('IF', (condition: unknown, trueVal: unknown, falseVal: unknown) => {
return condition ? trueVal : falseVal;
});
this.functions.set('VLOOKUP', (
searchKey: unknown, range: unknown[][], colIndex: unknown, isSorted: unknown
) => {
// 简化实现
const col = Number(colIndex) - 1;
for (const row of range) {
if (row[0] === searchKey || (!isSorted && String(row[0]) === String(searchKey))) {
return row[col];
}
}
return '#N/A';
});
}

/** 求值 AST 节点 */
evaluate(node: ASTNode): unknown {
switch (node.type) {
case 'number':
case 'string':
case 'boolean':
return node.value;

case 'cellRef': {
const cell = this.sheet.getCell(node.row, node.col);
return cell?.computed ?? cell?.raw ?? 0;
}

case 'rangeRef':
return this.getRangeValues(node.start, node.end);

case 'functionCall': {
const fn = this.functions.get(node.name);
if (!fn) return `#NAME?`; // 未知函数
const args = node.args.map(arg => this.evaluate(arg));
try {
return fn(...args);
} catch {
return '#ERROR!';
}
}

case 'binaryOp': {
const left = this.evaluate(node.left) as number;
const right = this.evaluate(node.right) as number;
switch (node.op) {
case '+': return left + right;
case '-': return left - right;
case '*': return left * right;
case '/': return right === 0 ? '#DIV/0!' : left / right;
case '>': return left > right;
case '<': return left < right;
case '=': return left === right;
case '&': return String(left) + String(right);
default: return 0;
}
}

case 'unaryOp': {
const operand = this.evaluate(node.operand) as number;
return node.op === '-' ? -operand : operand;
}
}
}

/** 获取区域内所有单元格的值(二维数组) */
private getRangeValues(
start: { row: number; col: number },
end: { row: number; col: number }
): unknown[][] {
const values: unknown[][] = [];
for (let r = start.row; r <= end.row; r++) {
const rowValues: unknown[] = [];
for (let c = start.col; c <= end.col; c++) {
const cell = this.sheet.getCell(r, c);
rowValues.push(cell?.computed ?? cell?.raw ?? 0);
}
values.push(rowValues);
}
return values;
}

/** 扁平化参数(处理区域引用产生的二维数组) */
private flattenArgs(args: unknown[]): unknown[] {
const result: unknown[] = [];
for (const arg of args) {
if (Array.isArray(arg)) {
for (const row of arg) {
if (Array.isArray(row)) result.push(...row);
else result.push(row);
}
} else {
result.push(arg);
}
}
return result;
}
}
循环引用处理

当用户输入 A1=B1, B1=A1 这样的循环引用时,依赖图中会形成环。引擎必须在设置依赖前检测循环,发现循环后:

  1. 拒绝写入公式
  2. 将单元格显示值设为 #CIRCULAR!
  3. 保留公式文本,不更新依赖图

Excel 的处理方式是允许循环引用但限制最大迭代次数(默认 100 次),最终收敛或报错。


3.5 选区系统

选区是用户与表格交互的核心,需要支持单选、多选、区域选择、Shift 扩选和合并单元格处理。

core/selection.ts
/** 选区范围 */
interface SelectionRange {
/** 起始行 */
startRow: number;
/** 起始列 */
startCol: number;
/** 结束行 */
endRow: number;
/** 结束列 */
endCol: number;
}

/** 选区状态 */
interface SelectionState {
/** 当前活动单元格(光标所在) */
activeCell: { row: number; col: number };
/** 所有选区范围(支持多选) */
ranges: SelectionRange[];
/** 是否在编辑模式 */
isEditing: boolean;
}

class SelectionManager {
private state: SelectionState = {
activeCell: { row: 0, col: 0 },
ranges: [{ startRow: 0, startCol: 0, endRow: 0, endCol: 0 }],
isEditing: false,
};
private sheet: SheetModel;
private eventBus: EventBus;

constructor(sheet: SheetModel, eventBus: EventBus) {
this.sheet = sheet;
this.eventBus = eventBus;
}

/** 开始新选区(普通点击) */
startSelection(row: number, col: number): void {
// 处理合并单元格:如果点击的是合并区域,选中整个合并区域
const merge = this.findMerge(row, col);
if (merge) {
this.state.activeCell = { row: merge.startRow, col: merge.startCol };
this.state.ranges = [{
startRow: merge.startRow,
startCol: merge.startCol,
endRow: merge.endRow,
endCol: merge.endCol,
}];
} else {
this.state.activeCell = { row, col };
this.state.ranges = [{ startRow: row, startCol: col, endRow: row, endCol: col }];
}
this.state.isEditing = false;
this.eventBus.emit('selection:changed', this.state);
}

/** Shift + 点击扩展选区 */
extendTo(row: number, col: number): void {
const lastRange = this.state.ranges[this.state.ranges.length - 1];
// 从 activeCell 到目标位置
lastRange.startRow = Math.min(this.state.activeCell.row, row);
lastRange.startCol = Math.min(this.state.activeCell.col, col);
lastRange.endRow = Math.max(this.state.activeCell.row, row);
lastRange.endCol = Math.max(this.state.activeCell.col, col);

// 扩展以覆盖完整的合并单元格
this.expandRangeForMerges(lastRange);
this.eventBus.emit('selection:changed', this.state);
}

/** Ctrl + 点击添加新选区 */
addRange(row: number, col: number): void {
this.state.activeCell = { row, col };
this.state.ranges.push({ startRow: row, startCol: col, endRow: row, endCol: col });
this.eventBus.emit('selection:changed', this.state);
}

/** 进入编辑模式 */
enterEditMode(row: number, col: number): void {
this.state.activeCell = { row, col };
this.state.isEditing = true;
this.eventBus.emit('edit:start', { row, col });
}

/** 键盘导航(方向键) */
navigate(direction: 'up' | 'down' | 'left' | 'right'): void {
let { row, col } = this.state.activeCell;

switch (direction) {
case 'up': row = Math.max(0, row - 1); break;
case 'down': row = Math.min(this.sheet.maxRows - 1, row + 1); break;
case 'left': col = Math.max(0, col - 1); break;
case 'right': col = Math.min(this.sheet.maxCols - 1, col + 1); break;
}

// 跳过合并单元格的非主单元格
const merge = this.findMerge(row, col);
if (merge) {
row = merge.startRow;
col = merge.startCol;
}

this.startSelection(row, col);
}

/** 查找包含指定位置的合并区域 */
private findMerge(row: number, col: number): MergeRange | undefined {
return this.sheet.getMerges().find(m =>
row >= m.startRow && row <= m.endRow &&
col >= m.startCol && col <= m.endCol
);
}

/** 扩展选区以覆盖完整的合并单元格 */
private expandRangeForMerges(range: SelectionRange): void {
let expanded = true;
while (expanded) {
expanded = false;
for (const merge of this.sheet.getMerges()) {
// 如果合并区域与选区有交集但未完全包含
if (this.intersects(range, merge) && !this.contains(range, merge)) {
range.startRow = Math.min(range.startRow, merge.startRow);
range.startCol = Math.min(range.startCol, merge.startCol);
range.endRow = Math.max(range.endRow, merge.endRow);
range.endCol = Math.max(range.endCol, merge.endCol);
expanded = true; // 扩展后可能影响其他合并区域,需要重新检查
}
}
}
}

private intersects(a: SelectionRange, b: MergeRange): boolean {
return a.startRow <= b.endRow && a.endRow >= b.startRow &&
a.startCol <= b.endCol && a.endCol >= b.startCol;
}

private contains(a: SelectionRange, b: MergeRange): boolean {
return a.startRow <= b.startRow && a.endRow >= b.endRow &&
a.startCol <= b.startCol && a.endCol >= b.endCol;
}
}

3.6 样式系统

分层样式架构

样式系统采用样式表 + 样式 ID 引用的模式,避免每个单元格重复存储样式对象:

model/style-system.ts
/** 单元格完整样式 */
interface CellStyle {
/** 字体 */
fontFamily?: string;
fontSize?: number;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
color?: string;
/** 背景 */
backgroundColor?: string;
/** 对齐 */
textAlign?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
/** 边框 */
borderTop?: BorderStyle;
borderRight?: BorderStyle;
borderBottom?: BorderStyle;
borderLeft?: BorderStyle;
/** 文本 */
textDecoration?: 'none' | 'underline' | 'line-through';
wordWrap?: boolean;
/** 数字格式 */
numberFormat?: string; // 如 "#,##0.00", "yyyy-MM-dd"
}

interface BorderStyle {
width: number;
style: 'solid' | 'dashed' | 'dotted';
color: string;
}

/**
* 样式表:统一管理所有样式
* 相同的样式只存储一份,通过 ID 引用
* 百万单元格中实际样式种类通常 < 1000
*/
class StyleSheet {
private styles: Map<number, CellStyle> = new Map();
/** 样式指纹 → ID 的映射(用于去重) */
private fingerprints: Map<string, number> = new Map();
private nextId = 1;

/** 注册样式,返回样式 ID(自动去重) */
registerStyle(style: CellStyle): number {
const fp = this.fingerprint(style);
const existing = this.fingerprints.get(fp);
if (existing !== undefined) return existing;

const id = this.nextId++;
this.styles.set(id, { ...style });
this.fingerprints.set(fp, id);
return id;
}

/** 获取样式 */
getStyle(id: number): CellStyle | undefined {
return this.styles.get(id);
}

/** 合并样式(返回新样式 ID) */
mergeStyle(baseId: number | undefined, patch: Partial<CellStyle>): number {
const base = baseId ? this.getStyle(baseId) ?? {} : {};
return this.registerStyle({ ...base, ...patch });
}

/** 计算样式指纹(用于去重) */
private fingerprint(style: CellStyle): string {
return JSON.stringify(style, Object.keys(style).sort());
}
}

条件格式

model/conditional-format.ts
/** 条件格式规则 */
interface ConditionalFormatRule {
id: string;
/** 应用范围 */
range: SelectionRange;
/** 条件类型 */
type: 'greaterThan' | 'lessThan' | 'equal' | 'between' | 'containsText' | 'formula';
/** 条件值 */
values: (string | number)[];
/** 满足条件时应用的样式 */
style: Partial<CellStyle>;
/** 优先级(数字越小优先级越高) */
priority: number;
}

class ConditionalFormatEngine {
private rules: ConditionalFormatRule[] = [];

addRule(rule: ConditionalFormatRule): void {
this.rules.push(rule);
this.rules.sort((a, b) => a.priority - b.priority);
}

/** 获取单元格的条件格式样式 */
getConditionalStyle(
row: number,
col: number,
cellValue: unknown
): Partial<CellStyle> | null {
for (const rule of this.rules) {
// 检查是否在范围内
if (row < rule.range.startRow || row > rule.range.endRow) continue;
if (col < rule.range.startCol || col > rule.range.endCol) continue;

// 评估条件
if (this.evaluateCondition(rule, cellValue)) {
return rule.style;
}
}
return null;
}

private evaluateCondition(rule: ConditionalFormatRule, value: unknown): boolean {
const numValue = Number(value);
switch (rule.type) {
case 'greaterThan': return numValue > Number(rule.values[0]);
case 'lessThan': return numValue < Number(rule.values[0]);
case 'equal': return value === rule.values[0] || numValue === Number(rule.values[0]);
case 'between': return numValue >= Number(rule.values[0]) && numValue <= Number(rule.values[1]);
case 'containsText': return String(value).includes(String(rule.values[0]));
default: return false;
}
}
}

3.7 编辑系统

单元格编辑是用户交互的核心,需要处理双击进入编辑输入法兼容复制粘贴等问题。

core/edit-manager.ts
class EditManager {
/** 隐藏的 textarea(用于接收输入法和复制粘贴) */
private textarea: HTMLTextAreaElement;
/** 浮动编辑框(显示在单元格上方) */
private editorOverlay: HTMLDivElement;
/** 当前编辑的单元格 */
private editingCell: { row: number; col: number } | null = null;
private sheet: SheetModel;
private viewport: ViewportManager;
private commandManager: CommandManager;

constructor(
container: HTMLElement,
sheet: SheetModel,
viewport: ViewportManager,
commandManager: CommandManager
) {
this.sheet = sheet;
this.viewport = viewport;
this.commandManager = commandManager;

// 创建隐藏的 textarea(始终保持聚焦,接收键盘输入和输入法事件)
this.textarea = document.createElement('textarea');
this.textarea.style.cssText = `
position: absolute;
left: -9999px;
top: 0;
width: 1px;
height: 1px;
opacity: 0;
`;
container.appendChild(this.textarea);
this.textarea.focus();

// 编辑浮层
this.editorOverlay = document.createElement('div');
this.editorOverlay.style.cssText = `
position: absolute;
display: none;
border: 2px solid #1a73e8;
padding: 0 4px;
font: 13px -apple-system, "Segoe UI", sans-serif;
outline: none;
background: white;
z-index: 10;
white-space: pre-wrap;
overflow: hidden;
`;
this.editorOverlay.contentEditable = 'true';
container.appendChild(this.editorOverlay);

this.bindEvents();
}

private bindEvents(): void {
// 输入法组合事件
let isComposing = false;
this.editorOverlay.addEventListener('compositionstart', () => {
isComposing = true; // 输入法开始组合,暂不提交
});
this.editorOverlay.addEventListener('compositionend', () => {
isComposing = false;
});

this.editorOverlay.addEventListener('keydown', (e: KeyboardEvent) => {
if (isComposing) return; // 输入法组合中,不处理

if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.commitEdit();
// Enter 后移到下一行
this.moveAfterCommit('down');
} else if (e.key === 'Tab') {
e.preventDefault();
this.commitEdit();
this.moveAfterCommit(e.shiftKey ? 'left' : 'right');
} else if (e.key === 'Escape') {
this.cancelEdit();
}
});

// 全局键盘事件(非编辑模式下)
this.textarea.addEventListener('keydown', (e: KeyboardEvent) => {
// 直接输入时进入编辑模式
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
this.startEdit('');
}
});
}

/** 开始编辑 */
startEdit(initialValue?: string): void {
if (!this.editingCell) return;
const { row, col } = this.editingCell;

// 定位编辑浮层到单元格位置
const x = this.viewport.colIndex.getOffset(col) - this.viewport.scrollLeft;
const y = this.viewport.rowIndex.getOffset(row) - this.viewport.scrollTop;
const w = this.sheet.getColWidth(col);
const h = this.sheet.getRowHeight(row);

this.editorOverlay.style.display = 'block';
this.editorOverlay.style.left = `${x}px`;
this.editorOverlay.style.top = `${y}px`;
this.editorOverlay.style.minWidth = `${w}px`;
this.editorOverlay.style.minHeight = `${h}px`;

if (initialValue !== undefined) {
this.editorOverlay.textContent = initialValue;
} else {
// 双击编辑时显示原始值(公式显示公式文本)
const cell = this.sheet.getCell(row, col);
this.editorOverlay.textContent = cell?.formula ?? String(cell?.raw ?? '');
}
this.editorOverlay.focus();

// 将光标移到末尾
const range = document.createRange();
range.selectNodeContents(this.editorOverlay);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}

/** 提交编辑 */
private commitEdit(): void {
if (!this.editingCell) return;
const { row, col } = this.editingCell;
const value = this.editorOverlay.textContent ?? '';

// 通过命令系统提交(支持撤销重做)
this.commandManager.execute(new SetCellValueCommand(this.sheet, row, col, value));

this.editorOverlay.style.display = 'none';
this.textarea.focus();
}

/** 取消编辑 */
private cancelEdit(): void {
this.editorOverlay.style.display = 'none';
this.textarea.focus();
}

/** 设置编辑中的单元格 */
setEditingCell(row: number, col: number): void {
this.editingCell = { row, col };
}

private moveAfterCommit(_direction: 'down' | 'left' | 'right'): void {
// 根据方向移动 activeCell
}
}

复制粘贴

core/clipboard.ts
class ClipboardManager {
private sheet: SheetModel;
private selection: SelectionManager;
private commandManager: CommandManager;

constructor(sheet: SheetModel, selection: SelectionManager, commandManager: CommandManager) {
this.sheet = sheet;
this.selection = selection;
this.commandManager = commandManager;

document.addEventListener('copy', this.onCopy);
document.addEventListener('paste', this.onPaste);
document.addEventListener('cut', this.onCut);
}

private onCopy = (e: ClipboardEvent): void => {
e.preventDefault();
const range = this.selection.getActiveRange();
if (!range) return;

// 生成纯文本(TSV 格式,Tab 分隔)
const textRows: string[] = [];
// 生成 HTML(用于富格式粘贴)
let html = '<table>';

for (let r = range.startRow; r <= range.endRow; r++) {
const textCols: string[] = [];
html += '<tr>';
for (let c = range.startCol; c <= range.endCol; c++) {
const cell = this.sheet.getCell(r, c);
const value = String(cell?.computed ?? cell?.raw ?? '');
textCols.push(value);
html += `<td>${value}</td>`;
}
textRows.push(textCols.join('\t'));
html += '</tr>';
}
html += '</table>';

e.clipboardData?.setData('text/plain', textRows.join('\n'));
e.clipboardData?.setData('text/html', html);
};

private onPaste = (e: ClipboardEvent): void => {
e.preventDefault();
const html = e.clipboardData?.getData('text/html');
const text = e.clipboardData?.getData('text/plain') ?? '';

const target = this.selection.getActiveCell();
if (!target) return;

// 优先解析 HTML(保留格式)
if (html && html.includes('<table')) {
this.pasteFromHtml(html, target.row, target.col);
} else {
// 解析 TSV 纯文本
this.pasteFromText(text, target.row, target.col);
}
};

private pasteFromText(text: string, startRow: number, startCol: number): void {
const rows = text.split('\n');
const commands: SetCellValueCommand[] = [];

for (let r = 0; r < rows.length; r++) {
const cols = rows[r].split('\t');
for (let c = 0; c < cols.length; c++) {
commands.push(
new SetCellValueCommand(this.sheet, startRow + r, startCol + c, cols[c])
);
}
}

// 批量执行(作为一个撤销原子)
this.commandManager.executeBatch(commands);
}

private pasteFromHtml(
_html: string,
_startRow: number,
_startCol: number
): void {
// 解析 HTML table,提取单元格值和样式
}

private onCut = (e: ClipboardEvent): void => {
this.onCopy(e);
// 清空选区内容
const range = this.selection.getActiveRange();
if (range) {
this.commandManager.execute(new ClearRangeCommand(this.sheet, range));
}
};
}

3.8 协同编辑

在线表格的协同编辑通常采用 OT(Operational Transformation)CRDT(Conflict-free Replicated Data Type)。相比文档协同,表格有天然优势 —— 单元格粒度的操作天然减少了冲突。

OT(操作变换) 是 Google Docs/Sheets 采用的方案。核心思想是:当两个用户的操作并发时,通过变换函数调整操作,使得无论以何种顺序应用,最终状态一致。

collab/ot-engine.ts
/** 操作类型 */
type Operation =
| { type: 'setCellValue'; row: number; col: number; value: string; oldValue: string }
| { type: 'setCellStyle'; row: number; col: number; styleId: number; oldStyleId: number }
| { type: 'insertRow'; index: number; count: number }
| { type: 'deleteRow'; index: number; count: number }
| { type: 'insertCol'; index: number; count: number }
| { type: 'deleteCol'; index: number; count: number };

class OTEngine {
private pendingOps: Operation[] = [];
private version = 0;

/**
* 变换函数:当两个操作并发时,调整操作以保持一致性
* transform(opA, opB) → [opA', opB']
* 使得 apply(apply(state, opA), opB') === apply(apply(state, opB), opA')
*/
transform(opA: Operation, opB: Operation): [Operation, Operation] {
// 单元格操作:不同单元格天然不冲突
if (this.isCellOp(opA) && this.isCellOp(opB)) {
if (opA.row === opB.row && opA.col === opB.col) {
// 同一单元格:后到的操作基于新值
return [opA, { ...opB, oldValue: (opA as any).value }];
}
// 不同单元格:无需变换
return [opA, opB];
}

// 插入行 vs 单元格操作:调整行号
if (opA.type === 'insertRow' && this.isCellOp(opB)) {
if (opB.row >= opA.index) {
return [opA, { ...opB, row: opB.row + opA.count }];
}
return [opA, opB];
}

return [opA, opB];
}

private isCellOp(op: Operation): op is Operation & { row: number; col: number } {
return op.type === 'setCellValue' || op.type === 'setCellStyle';
}
}

3.9 撤销重做

采用命令模式实现,每个操作封装为一个 Command 对象,包含 executeundo 方法:

core/command.ts
/** 命令接口 */
interface Command {
execute(): void;
undo(): void;
/** 命令描述(用于操作历史面板) */
description: string;
}

/** 设置单元格值的命令 */
class SetCellValueCommand implements Command {
description: string;
private sheet: SheetModel;
private row: number;
private col: number;
private newValue: string;
private oldData: CellData | undefined;

constructor(sheet: SheetModel, row: number, col: number, newValue: string) {
this.sheet = sheet;
this.row = row;
this.col = col;
this.newValue = newValue;
this.description = `设置 ${this.getCellAddress()} = ${newValue}`;
this.oldData = structuredClone(sheet.getCell(row, col)); // 快照旧数据
}

execute(): void {
const isFormula = this.newValue.startsWith('=');
this.sheet.setCell(this.row, this.col, {
raw: isFormula ? this.newValue : this.parseValue(this.newValue),
formula: isFormula ? this.newValue : undefined,
});
}

undo(): void {
if (this.oldData) {
this.sheet.setCell(this.row, this.col, this.oldData);
} else {
this.sheet.deleteCell(this.row, this.col);
}
}

private parseValue(value: string): string | number | boolean {
if (value === 'TRUE' || value === 'true') return true;
if (value === 'FALSE' || value === 'false') return false;
const num = Number(value);
if (!isNaN(num) && value.trim() !== '') return num;
return value;
}

private getCellAddress(): string {
const col = String.fromCharCode(65 + this.col);
return `${col}${this.row + 1}`;
}
}

/** 清空区域命令 */
class ClearRangeCommand implements Command {
description = '清空区域';
private sheet: SheetModel;
private range: SelectionRange;
private snapshot: Map<string, CellData> = new Map();

constructor(sheet: SheetModel, range: SelectionRange) {
this.sheet = sheet;
this.range = range;
// 快照区域内所有数据
for (let r = range.startRow; r <= range.endRow; r++) {
for (let c = range.startCol; c <= range.endCol; c++) {
const cell = sheet.getCell(r, c);
if (cell) this.snapshot.set(`${r},${c}`, structuredClone(cell));
}
}
}

execute(): void {
for (let r = this.range.startRow; r <= this.range.endRow; r++) {
for (let c = this.range.startCol; c <= this.range.endCol; c++) {
this.sheet.deleteCell(r, c);
}
}
}

undo(): void {
for (const [key, data] of this.snapshot) {
const [r, c] = key.split(',').map(Number);
this.sheet.setCell(r, c, data);
}
}
}

class CommandManager {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
private maxHistory = 100;

execute(command: Command): void {
command.execute();
this.undoStack.push(command);
this.redoStack = []; // 新操作清空重做栈
if (this.undoStack.length > this.maxHistory) {
this.undoStack.shift();
}
}

/** 批量执行(作为一个撤销原子) */
executeBatch(commands: Command[]): void {
const batch: Command = {
description: `批量操作 (${commands.length} 项)`,
execute: () => commands.forEach(c => c.execute()),
undo: () => [...commands].reverse().forEach(c => c.undo()),
};
this.execute(batch);
}

undo(): void {
const cmd = this.undoStack.pop();
if (cmd) {
cmd.undo();
this.redoStack.push(cmd);
}
}

redo(): void {
const cmd = this.redoStack.pop();
if (cmd) {
cmd.execute();
this.undoStack.push(cmd);
}
}
}

3.10 导入导出

io/xlsx-handler.ts
/**
* 导入导出处理器
* 使用 SheetJS (xlsx) 库处理 Excel 文件
*/
import type { WorkBook, WorkSheet } from 'xlsx';

class SpreadsheetIO {
/** 导入 xlsx 文件 */
async importXlsx(file: File): Promise<SheetModel[]> {
const XLSX = await import('xlsx');
const buffer = await file.arrayBuffer();
const workbook: WorkBook = XLSX.read(buffer, { type: 'array', cellStyles: true });

const sheets: SheetModel[] = [];

for (const sheetName of workbook.SheetNames) {
const ws: WorkSheet = workbook.Sheets[sheetName];
const sheet = new SheetModel(sheetName);
const range = XLSX.utils.decode_range(ws['!ref'] ?? 'A1');

for (let r = range.s.r; r <= range.e.r; r++) {
for (let c = range.s.c; c <= range.e.c; c++) {
const cellRef = XLSX.utils.encode_cell({ r, c });
const wsCell = ws[cellRef];
if (!wsCell) continue;

const cellData: CellData = {
raw: wsCell.v,
formula: wsCell.f ? `=${wsCell.f}` : undefined,
};
sheet.setCell(r, c, cellData);
}
}

// 导入合并单元格
if (ws['!merges']) {
for (const merge of ws['!merges']) {
sheet.addMerge({
startRow: merge.s.r,
startCol: merge.s.c,
endRow: merge.e.r,
endCol: merge.e.c,
});
}
}

sheets.push(sheet);
}

return sheets;
}

/** 导出为 xlsx */
async exportXlsx(sheets: SheetModel[], filename: string): Promise<void> {
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();

for (const sheet of sheets) {
// 将 SheetModel 转换为 SheetJS 的工作表格式
const data: unknown[][] = [];
const range = sheet.getUsedRange();

for (let r = range.startRow; r <= range.endRow; r++) {
const row: unknown[] = [];
for (let c = range.startCol; c <= range.endCol; c++) {
const cell = sheet.getCell(r, c);
row.push(cell?.computed ?? cell?.raw ?? '');
}
data.push(row);
}

const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(workbook, ws, sheet.name);
}

const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
this.downloadFile(new Blob([buffer]), filename);
}

/** 导出为 CSV */
exportCsv(sheet: SheetModel, filename: string): void {
const range = sheet.getUsedRange();
const rows: string[] = [];

for (let r = range.startRow; r <= range.endRow; r++) {
const cols: string[] = [];
for (let c = range.startCol; c <= range.endCol; c++) {
const cell = sheet.getCell(r, c);
let value = String(cell?.computed ?? cell?.raw ?? '');
// CSV 转义:包含逗号、引号或换行的值需要用双引号包裹
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
value = `"${value.replace(/"/g, '""')}"`;
}
cols.push(value);
}
rows.push(cols.join(','));
}

this.downloadFile(new Blob([rows.join('\n')], { type: 'text/csv' }), filename);
}

private downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
}

四、性能优化

优化策略汇总

优化维度策略效果
渲染脏矩形重绘只重绘变更区域,减少 90%+ 绘制量
渲染多层 Canvas背景/内容/选区独立更新
渲染requestAnimationFrame 合并同一帧内多次变更只渲染一次
滚动虚拟化 + 前缀和只渲染可视区域,O(logn)O(\log n) 定位
滚动预渲染缓冲区缓冲 5 行/列,减少滚动白屏
内存稀疏矩阵空单元格不占内存
内存样式去重样式表统一管理,ID 引用
计算增量公式计算只重算受影响的单元格
计算Web Worker公式计算放在 Worker 线程
加载分片加载先加载可视区域数据,后台加载剩余
加载虚拟滚动延迟加载远处的数据

Web Worker 公式计算

将耗时的公式计算放到 Web Worker 中,避免阻塞主线程渲染:

worker/formula-worker.ts
// ========== Worker 线程 ==========
// 在 Worker 中运行公式引擎,不阻塞 UI
self.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;

switch (type) {
case 'calculate': {
const { changedCells, cellData, dependencies } = payload;
// 在 Worker 中执行完整的公式求值
const results = calculateFormulas(changedCells, cellData, dependencies);
self.postMessage({ type: 'results', payload: results });
break;
}
}
};

function calculateFormulas(
changedCells: string[],
cellData: Record<string, CellData>,
dependencies: Record<string, string[]>
): Record<string, unknown> {
// 拓扑排序 + 求值(在 Worker 线程中执行)
const results: Record<string, unknown> = {};
// ... 公式计算逻辑
return results;
}
engine/formula-bridge.ts
// ========== 主线程 ==========
class FormulaBridge {
private worker: Worker;
private pendingCallbacks: Map<string, (results: Record<string, unknown>) => void> = new Map();

constructor() {
this.worker = new Worker(
new URL('./formula-worker.ts', import.meta.url),
{ type: 'module' }
);

this.worker.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;
if (type === 'results') {
// 将计算结果应用到数据模型
this.applyResults(payload);
}
};
}

/** 发送计算任务到 Worker */
calculate(changedCells: string[], sheet: SheetModel): void {
this.worker.postMessage({
type: 'calculate',
payload: {
changedCells,
cellData: this.serializeCellData(sheet),
dependencies: this.serializeDependencies(),
},
});
}

private applyResults(_results: Record<string, unknown>): void {
// 将 Worker 返回的计算结果写回 SheetModel
}

private serializeCellData(_sheet: SheetModel): Record<string, CellData> {
return {};
}

private serializeDependencies(): Record<string, string[]> {
return {};
}
}

五、扩展设计

大数据量优化

对于超大数据量(百万行),客户端不可能一次性加载全部数据。采用分片按需加载策略:

  1. 首屏:只加载可视区域 + 预渲染缓冲区的数据(约 200 行)
  2. 滚动:当用户滚动到缓冲区边界时,异步请求下一片数据
  3. 缓存:已加载的数据保留在内存中,配合 LRU 策略淘汰远处数据
  4. 服务端:数据按 1000 行一片存储,通过 Redis 缓存热数据

插件系统

plugin/plugin-system.ts
/** 插件接口 */
interface SpreadsheetPlugin {
name: string;
version: string;
/** 初始化(注册自定义函数、菜单等) */
install(context: PluginContext): void;
/** 卸载 */
uninstall?(): void;
}

interface PluginContext {
/** 注册自定义公式函数 */
registerFunction(name: string, fn: (...args: unknown[]) => unknown): void;
/** 注册右键菜单项 */
registerMenuItem(item: MenuItem): void;
/** 注册工具栏按钮 */
registerToolbarItem(item: ToolbarItem): void;
/** 监听事件 */
on(event: string, handler: (...args: unknown[]) => void): void;
/** 获取数据模型 */
getSheet(): SheetModel;
}

interface MenuItem {
label: string;
action: () => void;
}

interface ToolbarItem {
icon: string;
tooltip: string;
action: () => void;
}

// 使用示例:自定义公式插件
const customFunctionsPlugin: SpreadsheetPlugin = {
name: 'custom-functions',
version: '1.0.0',
install(ctx: PluginContext) {
// 注册自定义函数 HELLO
ctx.registerFunction('HELLO', (name: unknown) => {
return `Hello, ${name}!`;
});

// 注册自定义函数 CNYMONEY(人民币大写)
ctx.registerFunction('CNYMONEY', (amount: unknown) => {
// 数字转人民币大写
return convertToCNY(Number(amount));
});
},
};

function convertToCNY(_amount: number): string {
// 人民币大写转换逻辑
return '';
}

常见面试问题

Q1: 为什么在线表格要用 Canvas 渲染,而不是 DOM?

答案

这是面试中最高频的问题,需要从性能控制力两个维度回答。

性能层面

维度DOMCanvas
节点数100x50 = 5000 个 DOM 节点1 个 Canvas 元素
内存~5MB(每节点 ~1KB)~8MB(1920x1080 像素缓冲区)
重排重绘修改一个单元格可能触发整个表格回流只重绘脏矩形区域
滚动需要 DOM 虚拟化(创建/销毁节点)只需偏移绘制坐标
百万单元格即使虚拟化,浏览器也难以承受频繁的节点创建毫无压力,只关心可视区

控制力层面

  • Canvas 可以像素级控制边框绘制(避免 CSS 边框合并问题)
  • Canvas 可以自由实现单元格内的多样式文本、斜线表头等复杂渲染
  • Canvas 的绘制流程完全可控,便于实现分层渲染和脏矩形优化

代价

  • 需要自建文字编辑系统(使用隐藏 textarea + 浮层覆盖)
  • 需要自建事件系统(坐标 hitTest → 单元格映射)
  • 无障碍(a11y)需要额外实现
  • 开发和维护成本极高
业界实践

Google Sheets 使用 Canvas 渲染 + DOM 覆盖层(编辑时)的混合方案。飞书多维表格和腾讯文档也采用类似的 Canvas 方案。Luckysheet/Univer 等开源库也验证了这条技术路线。


Q2: 公式引擎是怎么实现的?如何处理循环引用?

答案

公式引擎的核心是三个步骤:解析依赖管理求值

1. 解析(Parsing)

将公式字符串 =SUM(A1:B3) + C1 * 2 转换为 AST(抽象语法树):

BinaryOp(+)
├── FunctionCall(SUM)
│ └── RangeRef(A1:B3)
└── BinaryOp(*)
├── CellRef(C1)
└── Number(2)

解析过程分为词法分析(Tokenizer)和语法分析(Parser),通常使用递归下降解析器实现。

2. 依赖管理

使用有向无环图(DAG)管理单元格之间的依赖关系。当某个单元格的值变更时:

  1. 从依赖图中找出所有下游单元格(BFS 遍历反向依赖边)
  2. 对受影响的单元格进行拓扑排序,确保被依赖的单元格先计算
  3. 按拓扑序增量求值,只重算受影响的单元格

3. 循环引用检测

在设置依赖时,使用 DFS + 递归栈 检测是否存在环:

function detectCycle(graph: Map<string, Set<string>>, start: string): boolean {
const visited = new Set<string>();
const stack = new Set<string>();

function dfs(node: string): boolean {
visited.add(node);
stack.add(node); // 加入递归栈

for (const neighbor of graph.get(node) ?? []) {
if (!visited.has(neighbor)) {
if (dfs(neighbor)) return true;
} else if (stack.has(neighbor)) {
return true; // 在递归栈中发现已访问节点 = 环
}
}

stack.delete(node); // 回溯时移出递归栈
return false;
}

return dfs(start);
}

发现循环后,显示 #CIRCULAR! 错误,拒绝更新依赖图。


Q3: 百万单元格场景下如何优化性能?

答案

百万单元格的性能优化可以从渲染内存计算加载四个维度展开:

维度优化手段具体方案
渲染虚拟化只渲染可视区域(~50 行 x 30 列 = 1500 个单元格)
渲染脏矩形数据变更只重绘受影响的区域,非全量重绘
渲染分层 Canvas背景、内容、选区分层,变化频率高的层独立刷新
渲染RAF 合并同一帧内多次数据变更合并为一次渲染
内存稀疏矩阵空单元格不占内存(Map<number, Map<number, CellData>>
内存样式去重样式表统一管理,单元格只存样式 ID
内存TypedArray前缀和用 Float64Array,比普通数组内存更紧凑
计算增量计算公式变更只重算依赖链上的单元格,非全部公式
计算Web Worker复杂公式计算放到 Worker 线程,不阻塞 UI
计算计算缓存缓存公式 AST 和中间结果
加载分片加载首屏只加载可视区数据,后台按需加载
加载数据压缩传输时压缩稀疏数据,减少网络传输量

关键指标参考:

  • 可视区渲染:< 16ms(60fps)
  • 滚动响应:< 8ms
  • 公式增量计算:< 50ms
  • 内存占用:100 万有效单元格 < 500MB

Q4: 合并单元格在内部是如何处理的?

答案

合并单元格是在线表格中最容易出问题的功能之一,需要在数据模型渲染选区公式四个层面处理。

1. 数据模型

合并区域只有主单元格(左上角)存储数据,其他被合并的单元格标记为"被占用":

// 合并 A1:C3
const merge: MergeRange = {
startRow: 0, startCol: 0,
endRow: 2, endCol: 2,
};
// 只有 A1 (0,0) 存储实际数据
// B1, C1, A2, B2, C2, A3, B3, C3 标记为被合并

2. 渲染

  • 主单元格:绘制区域扩展为合并后的完整矩形
  • 被合并单元格:跳过渲染(不绘制内容和边框)
  • 需要先清除合并区域内所有网格线,再绘制合并区域的外边框

3. 选区

点击合并区域内任意位置,整个合并区域被选中。扩展选区时,如果新选区与合并区域有交集但未完全包含,必须自动扩展到覆盖完整的合并区域(可能触发级联扩展)。

4. 公式引用

引用合并区域时(如 =A1),返回主单元格的值。引用被合并的从单元格(如 =B2,B2 是被合并的),应返回 0 或空值。区域引用(如 =SUM(A1:C3))需要避免重复计算合并区域的值。


相关链接