2025-12-17-前端画布设计Vol.1 实现基础元素渲染和状态控制
uuid: db982700-e361-11f0-9797-3749545ef624 title: 2025-12-17-前端画布设计Vol.1 PixiJS 实现基础元素渲染和状态控制 mathjax: true abbrlink: 43445 published: 2025-12-28 01:23:08 category: 前端 tags: - 前端 - PixiJS - Canvas - 渲染
PixiJS 实现基础元素渲染和状态控制
PixiJS 是一个强大的 2D 渲染引擎,底层同时利用 WebGL 和 Canvas 技术来高效地绘制图形。在我们的画布项目里,所有的渲染工作主要收敛到一个 ElementRenderer 类中完成,它既负责把抽象的数据描述翻译成屏幕上的图形,也承担起和状态层之间的同步职责。
基本图形渲染实现
画布里的基本图形都是通过 PixiJS 的 Graphics 类绘制出来的。目前支持矩形、圆角矩形、圆形和菱形四种类型,它们的绘制逻辑大同小异,区别只在于调用了不同的几何方法。矩形最简单,直接用 g.rect 给出左上角坐标和宽高即可;圆角矩形则换成 g.roundRect,在原有参数后面多带一个圆角半径;圆形借助 g.ellipse,把中心点放在图形几何中心、横纵半径都取宽高的一半,从而画出一个正圆;菱形稍微特殊,它没有现成的方法,而是用 g.poly 传入四个顶点坐标,手动连成一个菱形。四种图形在绘制完轮廓后,都统一调用 g.fill 填充颜色和透明度,再用 g.stroke 描出边框:
// 矩形g.rect(0, 0, data.width, data.height);g.fill({ color: fillColor, alpha });g.stroke({ width: strokeWidth, color: strokeColor });
// 圆角矩形g.roundRect(0, 0, data.width, data.height, data.radius);g.fill({ color: fillColor, alpha });g.stroke({ width: strokeWidth, color: strokeColor });
// 圆形g.ellipse(data.width / 2, data.height / 2, data.width / 2, data.height / 2);g.fill({ color: fillColor, alpha });g.stroke({ width: strokeWidth, color: strokeColor });
// 菱形g.poly([ data.width / 2, 0, data.width, data.height / 2, data.width / 2, data.height, 0, data.height / 2,]);g.fill({ color: fillColor, alpha });g.stroke({ width: strokeWidth, color: strokeColor });图形属性实现
每个图形元素的外观都由 CanvasElement 接口统一描述,几项可配置的视觉属性最终都落到了 PixiJS 的绘制调用上。背景色对应的是 fill 属性,通过 g.fill({ color: fillColor, alpha }) 实现,颜色值统一交给 PIXI.Color 类来解析,这样无论传进来的是十六进制字符串还是数值,都能被正确处理。边框的宽度和颜色则由 stroke 一并控制,写法是 g.stroke({ width: strokeWidth, color: strokeColor }),颜色同样经过 PIXI.Color 转换。透明度则单独由 alpha 字段决定,它被一起塞进 fill 的参数里,让整个图形按指定的不透明度呈现。
在 ElementRenderer 类内部,一次完整的图形渲染会按固定的顺序走完几步。最先做的是清空上一帧的绘制内容,调用 g.clear() 把画布擦干净,避免新旧图形叠加。紧接着把数据里的样式信息读出来,做好默认值兜底——边框宽度缺省取 2,透明度缺省取 1,边框色和填充色则各自包进 PIXI.Color:
const strokeWidth = data.strokeWidth ?? 2;const strokeColor = new PIXI.Color(data.stroke);const fillColor = new PIXI.Color(data.fill);const alpha = data.alpha ?? 1;样式准备好之后,再根据元素类型走对应的绘制分支画出形状。最后一步是确定图形在画布上的位置和姿态,这里用到了一个小技巧:先把锚点 pivot 设到图形中心,再让 position 也对齐到中心点,这样旋转时图形是绕着自身正中心转的,而不是绕左上角,视觉上更符合直觉:
g.pivot.set(data.width / 2, data.height / 2);g.position.set(data.x + data.width / 2, data.y + data.height / 2);g.rotation = data.rotation;除了这些基础能力,渲染层还做了几项额外处理。旋转正是依靠上面提到的 pivot 配合 rotation 实现的;圆角矩形借助 g.roundRect 的半径参数自然支持;对于图像类元素,则引入了纹理缓存来避免重复创建纹理的开销,并且纹理本身支持异步动态加载,先占位、加载完成后再替换,不至于阻塞渲染。
状态管理机制
整个画布的状态由 zustand 来承载,所有元素的数据都集中收拢到一个 CanvasStore 里统一管理。这套状态结构主要由四部分组成:elements 是一个以 ID 为键的记录对象,存放着画布上所有元素;selectedIds 是一个数组,记录当前被选中的元素 ID;tool 表示当前激活的工具类型;currentStyle 则保存着当前的绘制样式,包括填充色、边框色、边框宽度等,新建图形时会默认套用这套样式。
状态的变更也都通过 store 上挂载的一组方法来完成,而不是直接去改数据。新增元素走 addElement,修改属性走 updateElement,删除走 removeElements,选中状态的设置交给 setSelected。其中比较特别的是 batchUpdateElements,它用于一次性更新多个元素——比如框选后整体拖动时,与其逐个触发更新引发多次渲染,不如合并成一次批量提交,以此换取更好的性能。
元素渲染机制
渲染层的 ElementRenderer 并不是孤立工作的,它和状态层之间通过订阅建立起了一条紧密的同步链路。
整套机制的关键,在 Core_StageManager.ts 里的一段订阅代码。它向 store 注册了对 elements、selectedIds 和 tool 这几个字段的监听,一旦它们发生变化,回调就会被触发,重新驱动渲染器把元素和选择框画一遍:
useStore.subscribe( (state) => ({ elements: state.elements, selectedIds: state.selectedIds, tool: state.tool, }), (state) => { if (!this.state.destroyed) { this.elementRenderer.renderElements( state.elements, this.elementLayer, this.state.destroyed, ); this.transformerRenderer.renderTransformer( state.elements, state.selectedIds, this.elementRenderer.getSpriteMap(), this.onHandleDown, this.viewport.scale.x, ); // ... } }, { equalityFn: stateEqualityFn },);被触发后,真正干活的是 ElementRenderer.renderElements 方法。它会遍历当前所有元素,过程中借助 spriteMap 记录哪些元素已经被渲染过,从而判断该新建、复用还是销毁对应的显示对象;接着根据每个元素的类型——矩形、圆形、文本、图像等——走不同的渲染逻辑;最后把状态里描述的位置、大小、颜色这些属性应用到具体的渲染对象上。
这条链路带来的直接效果,是状态和画面之间始终保持实时一致。用户拖动元素时,updateElement 改写的是元素的 x、y 坐标;改变填充色时,更新的是 fill 属性;点选元素时,变动的是 selectedIds。无论哪一种,状态的改写都会顺着订阅链路立刻反映到屏幕上,UI 永远跟着数据走。
状态控制机制
在基础的状态读写之上,项目还围绕几类典型场景做了更精细的状态控制。
撤销和重做由 UndoRedoManager 配合命令模式实现。每一次添加、删除或更新操作,都会被封装成一个命令对象,对象内部同时携带”执行”和”撤销”两套逻辑,undo 与 redo 方法则负责操纵历史记录栈的进退,从而让任何一步操作都能被精确地回退或重放。
选择状态围绕 selectedIds 数组展开。这个数组追踪着当前选中的元素,它的每一次变化都会触发渲染更新,由 TransformerRenderer 负责把选择框和四周的调整手柄画出来,让用户清楚地看到自己正在操作哪些对象。
工具状态由 tool 属性记录。当前激活的是哪种工具,不仅会改变鼠标光标的样式,更会改变交互的语义——同样是一次鼠标点击,在选择工具下意味着选中,在绘制工具下却意味着落下一个新图形,不同工具对相同输入给出截然不同的响应。
实时协作则借助 Yjs 来完成。本地的状态变化会通过 Yjs 同步给其他在线用户,而 Yjs 的 observe 机制反过来保证了远端的改动也能及时合并进本地状态;为了避免协同过程中出现中间态,多个相关改动会被包进 transact 事务里,保证它们要么一起生效、要么都不生效,维持操作的原子性。
性能优化
性能上的考量贯穿在前面提到的多个环节里。订阅回调用 stateEqualityFn 做了状态比较,只有真正关心的字段发生变化时才触发重渲染,挡掉了大量无谓的更新。批量场景下,batchUpdateElements 把多个元素的改动合并成一次提交,显著减少了渲染次数。渲染层则依靠 spriteMap 复用已有的精灵对象,避免反复创建和销毁带来的开销。此外,设计中也为防抖预留了位置——尽管目前相关代码被注释掉了,但这条优化路径是被纳入整体考量的,后续在高频交互场景下可以随时启用。
分享到社交平台
将本文分享给你的朋友们
Zhongye