Univer 的渲染引擎参考了 KonvaJs / FabricJs 以及 BabylonJs,基于 Canvas2D 实现。
渲染引擎设计的目的是整合文档、电子表格和幻灯片的渲染,复用渲染能力并让它们的渲染可以相互嵌套。在电子表格中,单元格内文本的排版和渲染与文档的排版和渲染完全复用,所以单元格支持文档的所有排版能力,并且能够在单元格编辑器中保持完全一致的渲染效果。

整体架构
渲染引擎采取面向对象的思路,把每一个需要绘制的元素抽象为 Object,并且通过 Group SceneViewer 等元素实现嵌套结构。
整体架构图如下:

渲染引擎
Engine
管理 canvas 实例(例如修改 canvas 画布的宽高),提供 API 驱动 scene 进行逐帧绘制,封装事件机制提供给下层使用。
Scene
Scene 是所有渲染对象存在的空间,它的面积可能超过当前 Viewport (Engine) 的大小。

Scene 需要添加到 Engine 中去,一个 Engine 可以有多个 Scene,可以通过 Engine.runRenderLoop 切换当前需要渲染的 Scene。
const engine = new Engine()const scene = new Scene(SCENE_NAMESPACE, engine, { width, height })engine.runRenderLoop(() => { scene.render()})每个 Scene 有自己的事件监听,并且所有的渲染对象 Object 都要加入到 Scene 才能被渲染。
const spreadsheet = new Spreadsheet(SHEET_VIEW_KEY.MAIN)const spreadsheetRowHeader = new SpreadsheetRowHeader(SHEET_VIEW_KEY.ROW)const spreadsheetColumnHeader = new SpreadsheetColumnHeader(SHEET_VIEW_KEY.COLUMN)const SpreadsheetLeftTopPlaceholder = new Rect(SHEET_VIEW_KEY.LEFT_TOP, { zIndex: 2, left: -1, top: -1, fill: 'rgb(248, 249, 250)', stroke: 'rgb(217, 217, 217)', strokeWidth: 1,})scene.addObjects([spreadsheet, spreadsheetRowHeader, spreadsheetColumnHeader, SpreadsheetLeftTopPlaceholder])可以对 Scene 直接添加事件,就像在 DOM 中为 document 添加一个全局事件一样。
// 为 scene 新增一个 MouseMove 事件scene.onPointerMoveObserver.add((moveEvt: IPointerEvent | IMouseEvent) => { const { offsetX: moveOffsetX, offsetY: moveOffsetY } = moveEvt /// ...})Viewport
为了支持电子表格中冻结的场景,参考了 BabylonJs 的 Camera 概念,可以设置 Viewport 的位置和宽高来指定渲染 Scene 的哪个部分。
如下图所示,电子表格在常规状态下,会有 4 个 Viewport,分别对应左上方的全选块,行标题,列标题以及主内容区,只为主内容区添加 ScrollBar。在行列冻结的情况下,Viewport 会多达 9 个。若再支持行尾冻结,则会增加到 12 个。

添加一个 Viewport 的例子如下:
// 为 Scene 新增一个 Viewport 并且添加一个滚动条const viewMain = new Viewport(VIEWPORT_KEY.VIEW_MAIN, scene, { left: rowHeader.width, top: columnHeader.height, bottom: 0, right: 0, isWheelPreventDefaultX: true,})new ScrollBar(viewMain)Viewport 也会在渲染时会把自己所在的视口信息向需要渲染的 Object 传递,可以避免渲染视口之外的 Object。
Layer
Layer 参考了 Konva 的设计,但开发者不需要手动创建 Layer,渲染引擎会通过 Scene 的方法自动创建 Layer。可以选择是否开启 Layer 层的缓存,在元素比较多的情况下会带来性能提升,但浏览器对 canvas 有总面积的限制,所以 Univer 需要用户指定打开哪些层的缓存。
class Scene { // Scene 在添加 object 的时候会判断是否需要新建 Layer addObject(o: BaseObject, zIndex: number = 1) { this.getLayer(zIndex)?.addObject(o) return this } getLayer(zIndex: number = 1) { for (const layer of this._layers) { if (layer.zIndex === zIndex) { return layer } } return this._createDefaultLayer(zIndex) } // 调用 scene 的方法开启指定 layer 的缓存 enableLayerCache(...layerIndexes: number[]) { layerIndexes.forEach((zIndex) => { this.getLayer(zIndex).enableCache() }) }}Object 会挂到 Layer 上,并且 Layer 会有一个离屏 canvas 作为缓存,当层非脏时,会直接复制缓存内容到画布上。
Object
所有需要绘制的对象都需要继承 BaseObject:

Shape
Shape 实现基本的形状,比如 Circle、Rect、Path、Polygon,并且画法都用静态函数实现,方便被其他 object 使用。
基本形状里封装了绘制原语,例如一个 Rect 形状的类实现如下:
export class Rect<T extends IRectProps = IRectProps> extends Shape<T> { private _radius: number = 0 constructor(key?: string, props?: T) { super(key, props) if (props?.radius) { this._radius = props.radius } } static override drawWith(ctx: UniverRenderingContext, props: IRectProps | Rect) { let { radius, width, height } = props radius = radius ?? 0 width = width ?? 30 height = height ?? 30 ctx.beginPath() if (props.strokeDashArray) { ctx.setLineDash(props.strokeDashArray) } ctx.rect(0, 0, width, height) ctx.closePath() this._renderPaintInOrder(ctx, props) } protected override _draw(ctx: UniverRenderingContext) { Rect.drawWith(ctx, this) }}Component
为了绘制更加复杂的对象,也就是电子表格、文档和幻灯片等,为了应对复杂的渲染逻辑,Univer 设计了一层名为 Skeleton 的 ViewModel,它们负责处理计算后的排版数据,提供 canvas 坐标与 Component 内部坐标的转换。Extension 会负责具体渲染 Component 的某个部分,可以由用户注入逻辑改变渲染行为,例如完成数据验证、条件格式、单元格图片等功能。

以电子表格为例。要绘制电子表格内容区域,需要考虑三个部分,背景色、文字、边框线,所以这里有 3 个 extension,他们接受 SpreadsheetSkeleton 作为输入,根据其提供的布局信息来按单元格进行绘制。
Component 支持滚动贴图,在滚动 sheet 的时候,渲染引擎只会绘制增量内容,极大提升了性能。

RenderUnit
为了支持在一个 Univer 内部渲染多个文档,我们在架构中引入了 RenderUnit 机制。

每个 RenderUnit 负责绘制一个文档,并持有:
- 一个
Engine实例 - 一个
Scene实例 - 一个
UnitModel实例,可能是文档、电子表格或幻灯片等的文档模型 - 一个
Injector实例,用于实例化专门用于渲染和交互逻辑的IRenderModule。这个注入器使得RenderUnit能够独立地完成渲染和交互逻辑,而不会相互干扰。
IRenderModule
业务如果需要实现渲染相关的业务逻辑,则需要实现一个 IRenderModule 接口的类,并注册到 IRenderManagerService。例如:
export class UniverSheetsUIPlugin extends Plugin { private _registerRenderBasics(): void { ;( [ [SheetSkeletonManagerService], [SheetRenderController], [ISheetSelectionRenderService, { useClass: SheetSelectionRenderService }], ] as Dependency[] ).forEach((m) => { this.disposeWithMe(this._renderManagerService.registerRenderModule(UniverInstanceType.UNIVER_SHEET, m)) }) }}注册时,需要将 IRenderModule 与其对应的文档类型,即 UniverInstanceType 进行关联。RenderUnit 初始化时,会根据文档的 UniverInstanceType 从 IRenderManagerService 中获取对应的 IRenderModule 依赖并实例化他们。
IRenderModule 的 constructor 的第一个参数是一个满足 IRenderContext 对象,它包含的属性包括:
engine:Engine实例scene:Scene实例unit:UnitModel实例unitId:UnitModel的 id
通过这些属性,IRenderModule 可以很方便地获取到渲染所需的各种资源,而无需(也不应当)注入 IUniverInstanceService 和 IRenderManagerService 等模块。
除了 IRenderContext,IRenderModule 可以通过依赖注入获取其他 IRenderModule。同时由于 RenderUnit 中的 Injector 是全局 Injector 的子节点,因此 IRenderModule 中也可以注入全局模块,例如:
export class RefSelectionsRenderService extends BaseSelectionRenderService implements IRenderModule { constructor( private readonly _context: IRenderContext<Workbook>, // 渲染上下文 @Inject(Injector) injector: Injector, // RenderUnit 内部注入器 @Inject(ThemeService) themeService: ThemeService, // 全局依赖 @IShortcutService shortcutService: IShortcutService, // 全局依赖 @Inject(SheetSkeletonManagerService) sheetSkeletonManagerService: SheetSkeletonManagerService, // RenderUnit 内部依赖 @IRefSelectionsService private readonly _refSelectionsService: SheetsSelectionsService, // 全局依赖 ) { // ... }}某些情形下,你可能会需要从全局模块获取一个 IRenderModule,这种情形下你可以从 IRenderManagerService 获取 RenderUnit 并调用 with 方法,例如:
renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService)