2025-12-29-记canvas画布项目开发
GitHub 仓库 https://github.com/Zhongye1/BDdraw_DEV
项目把高性能渲染、无限画布、撤销重做、实时协作这几块比较硬的功能凑到了一起,所以下面的记录也主要围绕性能优化、状态管理、图形渲染、架构设计这几个方向展开,顺带聊一聊实际工程里踩过的坑。
1. 项目整体介绍与架构设计
项目的主要功能、目标用户以及它解决了哪些实际问题?
BDdraw_DEV 是一个基于 React + TypeScript + PixiJS 构建的现代化协同 2D 画布编辑器。它支持矩形、圆形、菱形、线条、箭头、画笔等多种基础图形的绘制,每种图形都能设置背景色、边框宽度、边框颜色等属性;除此之外还提供富文本编辑、图片插入与滤镜处理。在画布交互层面,它实现了无限画布的缩放与拖拽、minimap 缩略导航,以及元素的选择、分组、旋转和尺寸调整,并配套了撤销重做、快捷键、数据持久化、本地优先编辑和海量元素处理等能力。
项目架构是如何设计的?为什么把 React 用于 UI 层、Zustand 用于状态管理、PixiJS 用于渲染层做这样的分离?
项目的核心是一套三层架构:React 负责 UI 层,Zustand 负责状态层,PixiJS 负责渲染层,整体走的是”数据驱动视图”的模式。之所以这么切,是因为这套分层能比较自然地撑起撤销重做、数据持久化和多人协同这些进阶功能。
具体的好处可以从几个角度看。首先是解耦,渲染层、状态层和逻辑层彼此独立,维护和扩展时不会牵一发动全身。其次是便于协同,所有状态都集中在 Zustand Store 里管理,多人协同时有一个清晰的同步入口。再者是撤销重做容易实现,因为只要保存和恢复 Store 的快照就能完成完整的回退。最后是可持久化,集中管理的状态数据很容易序列化和反序列化,无论是本地保存还是网络传输都很顺。
项目的目录结构是怎么组织的?这种模块化设计带来了哪些好处?
前端部分主要分成五个模块。
src/api 负责 API 客户端和类型定义,也就是前后端通信这一层。它内部又分为 types(API 类型定义)和 utils(API 工具函数),整体是对 API 服务的封装和客户端工具的集合。
src/components 存放各种 React UI 组件,包括画布工具栏 canvas_toolbar、协作功能 collaboration、页面头部 header、属性面板 property-panel 以及富文本编辑器 richtext_editor。
src/hooks 是自定义 React Hooks,主要承担两类事情:一是状态管理,用简单的本地存储来保存用户偏好、UI 状态等;二是快捷键处理。
src/lib 是工具库和核心功能模块,命令模式的实现就放在这里,包括 AddElementCommand.ts、RemoveElementCommand.ts 和 UndoRedoManager.ts,此外还有常量定义 constants.ts 和通用工具函数 utils.ts。
src/pages 是页面组件,分为认证相关的 auth、主页 home 和房间管理 room。其中最重的一块是 canvas/Pixi_STM_modules,也就是 Pixi.js 的状态管理模块,它内部再细分为 core(核心类和初始化逻辑)、interaction(拖拽、缩放、选择等交互处理)、utils(各项操作的封装)和 shared(共享类型定义)。
src/stores 是状态管理层,底层是 Yjs + IndexedDB 这套用于协同的复杂数据存储,专门存放画布元素数据,并支撑实时协同和离线编辑。它包含画布状态 canvasStore.ts、持久化状态 persistenceStore.ts 和主题状态 themeStore.ts。
后端部分的设计相对常规,主要由四块构成:房间管理系统支持房间的创建、修改、删除和查询;用户认证系统提供登录、注册和权限验证;实时协作能力通过 collab.ts 实现;数据库相关的连接和操作则收敛在 db.ts 里。数据库在原型验证阶段用的是 sqlite,每个房间的画布数据存放在对应表的 content 字段中。

项目中如何处理前端与后端(ALD_Backend)的交互?
项目是前后端分离的,常规交互走 REST API。前端用一层 TypeScript 封装的 API 模块统一管理所有 HTTP 请求,底层以 Axios 作为客户端,并配置了请求和响应拦截器来集中处理认证、错误和加载状态。不同环境的基础 URL 通过环境变量切换,同时定义了统一的响应格式和类型,保证了端到端的类型安全;而在需要实时协作的场景下,则改用 WebSocket 做双向通信。
这里选 Axios 是因为它是一个基于 Promise 的网络请求库,浏览器和 Node.js 都能用,并且原生支持请求/响应拦截、请求取消、并发请求和数据自动转换等能力,省去了不少自己造轮子的功夫。这部分的接口定义和封装细节,我整理在了另一篇博客里:前端-接口类型定义、Axios 封装与请求规范 | 笔记站。
身份验证这块用的是 JWT Token:通过 setAuthToken 和 clearAuthToken 来管理认证状态的写入与清除,并用 onAuthenticate 钩子校验用户权限。具体实现可以看:前端-身份验证管理-基于 JWT Token 的实现。
实时协作部分是如何实现的?
实时协作整体由 Yjs、Hocuspocus 和 IndexedDB 三者配合完成
前端(React)这边,用 Yjs 的 CRDT 数据结构来做多客户端的状态同步,通过 HocuspocusProvider 连到后端的 WebSocket 服务器,再结合 IndexedDB 持久化实现离线编辑。后端(Bun)则用 Hocuspocus 作为 Yjs 的协作服务器,并实现了一个数据库扩展,把 Yjs 文档状态持久化到 SQLite,所有实时通信都走 WebSocket 协议。
认证和权限是嵌在协作链路里的。WebSocket 连接本身需要 JWT Token 认证,服务器会校验用户是否有权访问某个房间,如果一个有效用户还不是房间成员,就自动把他加入房间。
数据同步的具体路径是这样的:前端用 Yjs 的 Y.Map 存放画布元素数据,通过 IndexeddbPersistence 把它落到浏览器的 IndexedDB,再由 HocuspocusProvider 同步到服务器和其他客户端。在线和离线两种状态下行为不同——在线时数据实时同步到服务器,离线时数据先存在本地 IndexedDB,等重新连上网络后,本地的改动会借助 CRDT 自动合并回服务器,不需要人工介入。最后是用户在线状态:项目用 Yjs 的 Awareness 功能来跟踪在线用户,通过广播机制实时展示协作者的光标位置和选中状态,配合后端认证保证只有授权用户能进入协作。
2. 状态管理(Zustand)
Zustand 是项目里最核心的状态工具,最大的特点就是轻量、几乎没有样板代码。
为什么选 Zustand,而不是 Redux 或 Context API?在画布场景下它的优势在哪?
主要是两点。一是 API 足够简洁,避开了 Redux 那套样板代码的负担——在 Redux 里你得分别定义 actions、reducers、store 好几块,而 Zustand 一个函数就能把 store 建起来。二是性能,Zustand 支持选择性订阅,组件只在它真正关心的那部分状态变化时才重渲染;相比之下 Context API 一旦状态更新就会触发所有子组件重渲染,在画布这种状态频繁变动的场景里差距会被放大。
如何用 Zustand 管理画布元素状态?又如何实现持久化?
画布元素状态由 CanvasState 接口定义,其中 elements 是一个 Record<string, CanvasElement>,用来存放所有画布元素:
interface CanvasState { elements: Record<string, CanvasElement>; selectedIds: string[]; // ... 其他状态}不过要注意,因为要支持协同编辑,元素的增删改实际上是直接操作 Yjs 的共享数据类型来完成的,而不是直接改 Zustand:
// 添加元素addElement: (el) => { currentYDoc?.transact(() => { currentYElements?.set(el.id, el) })},
// 更新元素updateElement: (id, attrs) => { currentYDoc?.transact(() => { const oldEl = currentYElements?.get(id) if (oldEl) { currentYElements?.set(id, { ...oldEl, ...attrs }) } })},
// 删除元素removeElements: (ids) => { currentYDoc?.transact(() => { ids.forEach((id) => currentYElements?.delete(id)) })}持久化这块也顺着协同的思路走,并没有用传统的 zustand-persist,而是直接用 Yjs 自带的 IndexedDB 持久化机制,把 IndexeddbPersistence 和 HocuspocusProvider 组合起来:
// 在 persistenceStore.ts 中创建持久化提供者const indexeddbProvider = new IndexeddbPersistence( `canvas-local-db-${roomId}`, yDoc,);这么做有几个实打实的好处:Yjs 会自动接管 IndexedDB 的读写,不用手动管理;天然带离线支持,断网时数据照样留在本地;网络恢复后本地和远端会自动同步;而且 IndexedDB 是异步读写,不会卡住 UI 线程,应用的响应性得以保证。
多用户协作时,Zustand 和 Yjs CRDT 怎么配合,冲突和同步怎么处理?
分工很清楚:Zustand 在前端充当状态访问的接口层,Yjs 作为协同引擎负责多用户之间的数据同步和冲突解决,两者之间靠 Yjs 的 observe 机制连接起来——Yjs 的数据一变,就把变化同步进 Zustand 状态里,前端组件再据此更新。
3. 高性能渲染与 PixiJS 集成
PixiJS 的 WebGL 渲染是这个项目性能的命门,所以这块也写得细一些。
为什么用 PixiJS,而不是纯 Canvas 或 SVG?它在做到 60 FPS 和无限画布上起了什么作用?
PixiJS 是一个基于 WebGL 的 2D 渲染引擎,性能上的优势来自能直接吃到 GPU 加速。和纯 Canvas API 比,它提供了更高层次的抽象,开发者不用手动去抠底层渲染细节,同时还能拿到更好的性能。和 SVG 比,差距主要体现在元素数量上——SVG 基于 DOM,元素一多 DOM 操作开销就急剧上升、性能直线下滑,而 PixiJS 直接在 GPU 层渲染,哪怕画布上有几千个元素也能保持流畅。
无限画布这块则很依赖 pixi-viewport 插件,它能针对大规模场景做渲染优化,核心是视口裁剪(view culling)——只渲染当前可见区域内的元素,把屏幕外的开销直接省掉。
怎么用 pixi-viewport 实现无限画布的缩放、平移和边界限制?
这几个能力基本是靠 viewport 上链式挂载的几个插件搞定的:
// 在 Stage_InteractionHandler.ts 中实现视口功能viewport .drag() // 启用拖拽平移 .pinch() // 启用双指缩放 .wheel() // 启用滚轮缩放 .clamp({ direction: "all" }) // 边界限制 .bounce(); // 边界弹性效果缩放由 pinch 和 wheel 两个插件提供,用户既能用双指手势也能用鼠标滚轮;平移由 drag 插件负责,直接拖画布即可;clamp 用来限制视口边界,防止用户把画面拖到内容之外的空白区。除此之外 viewport 还暴露了缩放级别限制、平滑动画等配置项,可以按需微调手感。
项目里怎么用 spriteMap 缓存 PixiJS 对象,避免拖拽/缩放时反复创建?这对性能影响有多大?
项目用一个叫 spriteMap 的 Map 来缓存 PixiJS 对象,键是元素 ID,值是该元素对应的 PixiJS 显示对象,目的就是避免在拖拽、缩放这类高频操作里反复创建和销毁元素:
// 在 Pixi_stageManager.ts 中定义spriteMap private spriteMap: Map<string, PIXI.DisplayObject> = new Map()每当画布元素更新时,先去 spriteMap 里查有没有现成的显示对象,有就直接改属性,没有才新建。带来的收益主要是三方面:一是减少了对象创建和垃圾回收的开销,二是渲染效率更高,因为大多数情况只是改属性而非重建对象,三是对象的连续状态(比如动画状态、事件监听器)得以保留,不会因为重建而丢失。
图像滤镜(BlurFilter、ColorMatrixFilter)和富文本(HTMLText)在 PixiJS 里怎么实现,遇到过哪些渲染难题?
滤镜直接用 PixiJS 的滤镜系统就行,BlurFilter、ColorMatrixFilter 这类滤镜实例化之后挂到 sprite 的 filters 上即可:
import { BlurFilter, ColorMatrixFilter } from "pixi.js";
// 为图像元素添加滤镜const blurFilter = new BlurFilter();const colorFilter = new ColorMatrixFilter();
sprite.filters = [blurFilter, colorFilter];富文本渲染则借助了 pixi-text-html 库,它能在 PixiJS 里渲染带 HTML 样式的文本,HTMLText 组件负责解析 HTML 标签并把格式化后的文本画出来。
小地图(Minimap)怎么靠 cacheAsBitmap 做到实时更新?为什么要单独开一个 Pixi Application?
小地图的性能优化主要靠 cacheAsBitmap 这个属性。它会把显示对象连同其子对象一次性渲染进一张内部纹理,之后的渲染只要画这张纹理就行,不必每帧重新计算所有子对象,性能提升很明显:
stage.cacheAsBitmap = true;至于为什么要给小地图单独开一个 Pixi Application 实例,原因有几条。一是性能隔离,小地图的渲染频率往往和主画布不一样,独立实例能各自控制各自的渲染节奏。二是交互独立,小地图常需要自己的交互逻辑,比如点一下就跳到画布对应位置。三是资源管理,独立实例能更干净地管理小地图相关的纹理和资源。四是缩放独立,小地图要始终维持一个固定比例的缩略图,单独的渲染上下文更容易实现这一点。
4. 撤销/重做机制(命令模式)
撤销/重做是怎么实现的?为什么选命令模式(Command Pattern)?
撤销重做走的是经典的命令模式。先定义一个 Command 接口,包含 execute、undo、redo 三个方法:
export interface Command { execute(): void; undo(): void; redo(): void;}然后为每类画布操作各写一个命令类,比如 AddElementCommand、RemoveElementCommand、UpdateElementCommand。每个命令类内部都保存了执行操作所需的信息,从而能在 undo 和 redo 时把状态准确地还原回去。
选命令模式主要图三点。一是解耦,它把操作的执行者和发起者分开,想加新的操作类型时不用动现有代码。二是状态一致性,在协同编辑这种环境里,命令模式能保证每一步操作都能被精确撤销和重做,状态不会跑偏。三是易扩展,分组、取消分组这类新命令都能很自然地补进来。
每个命令如何存储前后状态,这在内存和性能上要怎么权衡?
这里有个值得强调的设计取向:命令对象里存的是”恢复操作所需的最小信息”,而不是动不动就拍整份状态快照。三个典型命令各自的做法略有不同。
AddElementCommand 是最省的,因为新增的逆操作就是删除,只需要元素 ID 就够;重做时直接重复一次添加即可:
export class AddElementCommand implements Command { constructor(private payload: { element: CanvasElement }) {}
execute = () => { // 添加元素到画布 useStore.getState().addElement(this.payload.element); };
undo = () => { // 从画布移除元素,实现撤销 useStore.getState().removeElements([this.payload.element.id]); };
redo = () => { // 重新添加元素,实现重做(与 execute 相同) useStore.getState().addElement(this.payload.element); };}RemoveElementCommand 则相反,删除的逆操作是把元素原样加回来,所以它必须在执行删除的那一刻先把被删元素的完整数据存下来,撤销时才有东西可恢复:
export class RemoveElementCommand implements Command { private elementData: CanvasElement | null = null;
constructor(private payload: { element: CanvasElement }) {}
execute = () => { // 先保存被移除元素的完整数据(用于后续恢复) this.elementData = { ...this.payload.element }; // 执行移除 useStore.getState().removeElements([this.payload.element.id]); };
undo = () => { // 使用保存的数据重新添加元素,实现撤销移除 if (this.elementData) { useStore.getState().addElement(this.elementData); } };
redo = () => { // 重复移除操作,实现重做 if (this.elementData) { useStore.getState().removeElements([this.elementData.id]); } };}UpdateElementCommand 走的是”只存差异”的路子,在构造函数里就把将被修改的那几个属性的旧值记下来,撤销时拿旧值覆盖回去,重做时再应用一次新值:
export class UpdateElementCommand implements Command { private previousValues: Partial<CanvasElement>;
constructor( private elementId: string, private newValues: Partial<CanvasElement>, ) { // 在构造函数中保存更新前的属性值(旧状态) const currentState = useStore.getState().elements[this.elementId]; this.previousValues = {}; Object.keys(newValues).forEach((key) => { this.previousValues[key as keyof CanvasElement] = currentState[key as keyof CanvasElement]; }); }
execute = () => { // 应用新值 useStore.getState().updateElement(this.elementId, this.newValues); };
undo = () => { // 恢复旧值,实现撤销 useStore.getState().updateElement(this.elementId, this.previousValues); };
redo = () => { // 重新应用新值,实现重做(与 execute 相同) useStore.getState().updateElement(this.elementId, this.newValues); };}把这三个放一起看,设计的核心原则就一句话:在命令对象里只存能独立完成 undo/redo 的那部分信息,而不是完整状态快照,这样撤销重做栈才能管得轻、跑得快。
当然也有权衡。内存上,每个命令都要存恢复信息,命令一多就吃内存,尤其是那种存整份状态副本的 SnapshotCommand,在元素多的时候会很占地方。性能上,生成状态快照本身要花时间,画布元素一多,用 structuredClone 深拷贝大对象会明显拖慢操作。所以项目里按操作类型分别用了不同的存储策略:增删只存元素本身,更新只存变更前的值和被改的属性,尽量把每个命令的体积压到最小。
后续还有一串可以继续优化的方向:给历史栈设上限防止内存溢出;把连续的多个操作合并成一个批量命令、减少栈里的命令数;对图像这类大数据命令,在它不再需要时主动清掉内部引用;对拖拽这种高频操作用防抖把连续动作并成一条命令,减缓栈的增长;把创建复杂图形的多个步骤这类相关操作分组成一个逻辑撤销单元;给每条命令加上文字描述,让 UI 上能显示具体能撤销/重做什么;把历史持久化到本地存储,刷新后也能恢复;甚至可以根据当前画布复杂度动态调整栈大小,元素多时用小栈、元素少时用大栈。
5. 交互与用户体验
变换控件(Transform Controls)的检测与处理
变换控件由 TF_controler_Renderer.ts 负责渲染,包括包住选中元素的边界框、四角和四边共 8 个缩放手柄,以及通常位于顶部或底部的 1 个旋转手柄。
手柄的命中检测靠的是鼠标位置和手柄边界框之间的距离计算。鼠标进入某个手柄区域时,光标样式会相应变化——比如边角手柄显示对角箭头,旋转手柄显示旋转图标。每个手柄对应一种操作:8 个边角手柄用于非均匀缩放,是否保持宽高比取决于修饰键;旋转手柄则用于旋转选中元素,旋转锚点可能取整组的中心。
真正的变换逻辑放在 Stage_InteractionHandler.ts 里。无论缩放还是旋转,思路都是遍历所有选中元素、算出各自的新尺寸或新角度,最后用一次批量更新提交,避免多次重渲染:
// 处理缩放操作handleScale(dx: number, dy: number, handleType: string) { const updates: Record<string, Partial<CanvasElement>> = {};
selectedIds.forEach(id => { const element = elements[id]; // 根据手柄类型(e.g., 'top-left', 'bottom-right')计算缩放比例和位置偏移 updates[id] = calculateNewDimensions(element, dx, dy, handleType); });
// 批量更新元素,避免多次重渲染 useStore.getState().batchUpdateElements(updates);}
// 处理旋转操作(示例)handleRotate(deltaAngle: number, pivotPoint: { x: number; y: number }) { const updates: Record<string, Partial<CanvasElement>> = {};
selectedIds.forEach(id => { const element = elements[id]; updates[id] = calculateRotatedElement(element, deltaAngle, pivotPoint); });
useStore.getState().batchUpdateElements(updates);}交互模式切换逻辑
项目定义了多种交互模式,统一在 Stage_InteractionHandler.ts 里管理,并且保证同一时刻只有一种模式处于激活状态。模式的判定写在 onPointerDown 里,本质是一串带优先级的条件分支:
onPointerDown = (event: PIXI.FederatedPointerEvent) => { const { x, y } = this.viewport.toLocal(event.global);
// 1. 优先检测变换手柄(最高优先级) if (this.isOverTransformHandle(x, y)) { this.currentMode = 'transforming'; this.startTransform(x, y, this.getCurrentHandleType()); return; }
// 2. 检测是否点击元素 const hitElementId = this.isOverElement(x, y); if (hitElementId) { if (event.data.originalEvent.shiftKey) { // Shift + 点击:多选切换 this.toggleSelection(hitElementId); } else { // 普通点击:单选或重新开始选择 this.selectElement(hitElementId); } this.currentMode = 'dragging'; this.startDrag(x, y); return; }
// 3. 空格键平移 if (event.data.originalEvent.code === 'Space') { this.currentMode = 'panning'; this.startPan(event); return; }
// 4. 默认:框选模式 this.currentMode = 'selecting'; this.startSelectionBox(x, y);};整个优先级链是 transforming > dragging > panning > selecting > idle,这样能保证变换手柄永远最先响应,不会被底下的元素点击或框选抢走。
对齐指南(Alignment Guidelines)的计算与绘制
对齐指南由 guidelineUtils.ts 实现,作用是在拖拽元素时实时给出视觉参考线和吸附效果。核心函数 detectAlignments 会拿正在移动的元素去和其余所有元素逐一比对,在左右边缘、水平中心等位置上判断它们的坐标差是否落在容差范围内,落在范围内就记一条对齐线:
// 检测对齐位置function detectAlignments( movingElements: CanvasElement[], allElements: CanvasElement[], tolerance: number = 5) { const alignments = { vertical: [] as { position: number; type: string }[], horizontal: [] as { position: number; type: string }[], };
movingElements.forEach(moving => { allElements.forEach(element => { if (movingElements.some(m => m.id === element.id)) return;
// 左/右边缘对齐 if (Math.abs(element.x - moving.x) < tolerance) { alignments.vertical.push({ position: element.x, type: 'left-edge' }); } if (Math.abs(element.x + element.width - (moving.x + moving.width)) < tolerance) { alignments.vertical.push({ position: element.x + element.width, type: 'right-edge' }); }
// 水平中心对齐 const movingCenterX = moving.x + moving.width / 2; const elementCenterX = element.x + element.width / 2; if (Math.abs(movingCenterX - elementCenterX) < tolerance) { alignments.vertical.push({ position: elementCenterX, type: 'center' }); }
// 类似处理水平对齐(top/bottom/center)... }); });
// 等间距检测(可选扩展) // detectEqualSpacing(...);
return alignments;}绘制和吸附是连在一起的。拖拽过程中每一帧都会调用 detectAlignments,一旦检测到对齐,就用 PixiJS 的 Graphics 画出虚线参考线,通常是带点透明度的蓝色或绿色;同时如果偏移量已经足够接近,就把元素位置自动吸附(snap)到精确的对齐点上,给到一种”咔哒一下对齐”的精准布局体验。
6. 性能优化与工程实践
项目里具体做了哪些性能优化?
为了让复杂画布也能跑得顺,项目堆了好几层针对性优化。
第一层是对象缓存,前面提过的 spriteMap 就是核心——更新元素时优先复用已有的 PixiJS 显示对象,只在首次出现时才创建并缓存:
// 在 Pixi_stageManager.ts 中 private spriteMap: Map<string, PIXI.DisplayObject> = new Map();
updateElement(id: string, attrs: Partial<CanvasElement>) { const sprite = this.spriteMap.get(id); if (sprite) { // 重用现有对象,直接更新属性 Object.assign(sprite, attrs); } else { // 首次创建并缓存 const newSprite = this.createSprite(attrs); this.spriteMap.set(id, newSprite); this.container.addChild(newSprite); } }第二层是 WebGL 渲染优化,一方面吃满 PixiJS 的 GPU 加速,另一方面靠 pixi-viewport 的视口裁剪只渲染可见区域,把绘制调用压下来:
// viewport 配置示例const viewport = new Viewport({ interaction: app.renderer.plugins.interaction, cull: true, // 启用视口裁剪});
viewport.on("frame-end", () => { // 帧结束时可执行额外优化,如清理不可见资源});第三层是开发体验上的,借 Vite 的 HMR 做到改代码不刷新整页就能看到效果,迭代效率提升很明显。
TypeScript 在项目里起了什么作用?
最直接的是类型安全,通过严格的接口定义保证数据一致、把错误尽量提前到编译期暴露,比如核心的 CanvasElement 接口:
export interface CanvasElement { id: string; type: ToolType; x: number; y: number; width: number; height: number; fill: string; stroke: string; strokeWidth: number; alpha?: number; points?: number[][]; rotation?: number; // 文本相关 text?: string; fontSize?: number; fontFamily?: string; textAlign?: "left" | "center" | "right"; // 图像相关 imageUrl?: string; filter?: "none" | "blur" | "brightness" | "grayscale"; // 分组相关 groupId?: string;}除此之外还有几层好处:智能提示和类型推断让编码效率明显提高;大型重构时类型系统能快速框出受影响的范围,改起来心里有底;接口本身也充当了模块间的数据契约,让协作和维护更省心。
构建与部署是怎么做的?
构建工具选了 Vite,看中的是它极快的开发服务器启动和构建速度、即时的 HMR、出色的构建性能与 Tree Shaking,以及对 TypeScript、JSX、CSS Modules 开箱即用的支持。部署上,项目根目录提供了 Dockerfile 和 docker-compose.yml 支持容器化,并用 GitHub Actions 串起了一条自动化流水线:代码检查 → 单元测试 → 构建产物 → 镜像推送 → 部署到目标环境。
样式一致性怎么保证?
项目同时用到了多套组件库,为了让外观统一,主要靠四招。一是用 TailwindCSS 建立统一的设计系统,把主色、辅色、间距等都收敛到配置里:
module.exports = { theme: { extend: { colors: { primary: colors.blue, secondary: colors.gray, }, spacing: { '18': '4.5rem', '88': '22rem', }, }, }, };二是配一套 CSS 变量系统,定义 --color-primary 这类全局变量,保证所有组件引用的是同一个值。三是主题管理统一收口到 themeStore.ts,集中控制主题切换。四是对第三方库的组件做二次封装,把项目自己的样式和行为规范统一套上去,避免各家组件各说各话。
7. 开发过程中遇到的主要技术难点
整个开发里,最费劲的几块集中在实时协作、渲染同步和性能优化上。
最棘手的是实时协作的冲突处理——多个用户同时改画布,很容易出现操作互相覆盖、状态不一致。解决思路是把冲突交给 Yjs 的 CRDT 算法来自动合并并发修改,整个过程不需要中央锁,就能保证最终一致性;实时通道则用 HocuspocusProvider 建立 WebSocket 连接来压低延迟。在 canvasStore.ts 里,靠 Yjs 的 observe 机制监听变更并同步回本地状态:
yElements.observe(() => { useStore.setState({ elements: yElements.toJSON(), });});这里还额外做了一层锁定机制,专门防止在同步过程中往撤销/重做栈里塞进无效命令,避免协同带来的历史污染——这是本地撤销系统和协同系统打架时很容易忽略的一个坑。
第二个难点是 PixiJS 渲染层和 React/Zustand 状态层的实时一致性,元素一多就容易出现延迟或对不上。应对办法是专门写了 Pixi_stageManager.ts 当桥梁层,负责双向同步 React 状态和 PixiJS 显示对象,配合 spriteMap 缓存避免反复创建销毁,再用防抖限制过于频繁的同步,并做选择性更新,只重绘真正变化的元素。
第三个是性能。元素数量一上来,渲染和交互性能都会明显掉。这块是组合拳:启用视口裁剪只渲染可见区域,引入对象池和缓存减少内存分配,用批量更新 batchUpdateElements 把多次状态变更合并、降低重渲染次数,并对静态元素启用 cacheAsBitmap,把内容烘焙成位图来省掉重复绘制:
// 示例:针对静态元素启用位图缓存if (sprite.isStatic && !sprite.cacheAsBitmap) { sprite.cacheAsBitmap = true;}分享到社交平台
将本文分享给你的朋友们
Zhongye