2025-12-29-记canvas画布项目开发
最后更新时间:
页面浏览: 加载中...
GitHub 仓库 https://github.com/Zhongye1/BDdraw_DEV
该项目技术栈先进(React 18 + TypeScript + Vite + TailwindCSS + Zustand + PixiJS v8),涉及高性能渲染、无限画布、撤销/重做、实时协作等复杂功能,因此问题往往聚焦于性能优化、状态管理、图形渲染、架构设计以及实际工程实践。
1. 项目整体介绍与架构设计
项目的主要功能、目标用户以及它解决了哪些实际问题?
BDdraw_DEV 是一个现代化的协同 2D 画布编辑器,采用 React + TypeScript + PixiJS 技术栈构建。该项目提供多种基本图形(矩形、圆形、菱形、线条、箭头、画笔等)元素的绘制,支持背景色、边框宽度、边框颜色等图形属性设置、富文本编辑、图片插入与滤镜处理,支持无限画布缩放、拖拽、提供 minimap,实现元素选择、分组、旋转、调整大小,支持撤销重做,快捷键,数据持久化,本地优先编辑,海量元素处理等交互功能。
该项目作为一个集成协同编辑、离线编辑的无限画布,来解决团队协作协作效率和同步的问题
项目架构是如何设计的?为什么选择将 React 用于 UI 层、Zustand 用于状态管理、PixiJS 用于渲染层分离?
项目的核心在于其三层架构设计:React 负责 UI 层、Zustand 管理状态层、PixiJS 处理渲染层,实现数据驱动视图的模式。其便于实现撤销/重做、数据持久化和多人协同编辑等高级功能
其优势在于:
解耦设计:渲染层、状态管理层和逻辑层相互独立,便于维护和扩展
便于协同:所有状态都集中管理在 Zustand Store 中,便于实现多人协同编辑
易于撤销/重做:通过保存和恢复 Store 的快照实现完整的撤销/重做功能
可持久化:状态数据可以轻松序列化/反序列化,便于保存和传输
项目是如何组织目录结构的?这种模块化设计带来了哪些好处?
前端部分主要是分为五个模块:
src/api - API 客户端和类型定义(处理前后端通信)
- types - API 类型定义
- utils - API 工具函数
- API 服务封装和客户端工具
src/components - React UI 组件(各种 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(表现层状态转移应用编程接口,是一种基于 REST 架构风格设计的 Web API),前端通过 TypeScript 封装的 API 层统一管理所有 HTTP 请求,使用 Axios 作为 HTTP 客户端,配置了请求和响应拦截器来处理认证、错误处理和加载状态。其通过环境变量管理不同环境的基础 URL,定义了统一的响应格式和类型定义来确保类型安全,同时并在需要实时协作的场景下使用 WebSocket 进行双向通信。
Axios 是一个基于 Promise 的网络请求库,用于在浏览器和 Node.js 中进行 HTTP 请求,并支持请求/响应拦截、取消,并发请求,自动转换数据等功能
详情见博客文章:
前端学习-接口类型定义、Axios 封装与请求规范 | 笔记站 (zhongye1.github.io)
身份验证管理实现:
- 使用 JWT Token 进行身份验证
- 通过 setAuthToken 和 clearAuthToken 管理认证状态
onAuthenticate钩子验证用户权限
详情见博客文章:
前端学习-身份验证管理-基于 JWT Token 的实现 (zhongye1.github.io)
实时协作部分是如何实现的?
实时协作功能通过 Yjs、Hocuspocus 和 IndexedDB 实现:
可以看博客:
2025-12-27-前端画布设计 Vol.3 实现 CRDT | Notes|笔记站 (zhongye1.github.io)
- 前端(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 是项目核心状态工具,轻量且无 boilerplate。
- 为什么选择 Zustand 而非 Redux 或 Context API?在画布状态管理中,它相比其他方案的优势体现在哪里?
Zustand 的 API 设计非常简洁,避免了 Redux 中大量样板代码(boilerplate code)的问题。在 Redux 中,我们需要定义 actions、reducers、store 等多个部分,而 Zustand 只需一个函数即可创建 store Zustand 在性能优化方面,可以实现选择性订阅,避免不必要的组件重新渲染。Context API 在状态更新时会触发所有子组件的重新渲染,而 Zustand 允许我们精确地控制哪些组件需要响应特定状态变化。
- 如何使用 Zustand 管理画布元素状态(elements: Record
)?如何实现持久化(Zustand-persist + localForage + IndexedDB)?
如何使用 Zustand 管理画布元素状态?
在我们的项目中,画布元素状态是通过 CanvasState 接口定义的,其中 elements 属性是一个 Record<string, CanvasElement> 类型的对象,用于存储所有画布元素:
1 | |
我们通过直接操作 Yjs 共享数据类型来管理元素状态,从而实现协同编辑功能:
1 | |
如何实现持久化(Zustand-persist + localForage + IndexedDB)?
在我们的实现中,持久化是通过 Yjs 的 IndexedDB 持久化机制完成的,而不是使用传统的 zustand-persist。我们使用 IndexeddbPersistence 与 HocuspocusProvider 组合:
1 | |
这种设计的优势在于:
- Yjs 会自动处理 IndexedDB 的读写操作,无需手动管理
- 提供了离线支持,即使在断网情况下数据也能保存在本地
- 当重新连接网络时,会自动同步本地和远程数据
- IndexedDB 的异步操作不会阻塞 UI 线程,保证了应用的响应性
在多用户协作场景下,Zustand 与 Y.js CRDT 如何结合?如何处理冲突和状态同步?
Zustand 作为前端状态管理工具,提供状态访问接口
Y.js 作为协同编辑引擎,处理多用户间的数据同步和冲突解决
通过 Y.js 的 observe 机制,将 Y.js 的数据变化同步到 Zustand 状态中
3. 高性能渲染与 PixiJS 集成
PixiJS WebGL 渲染是项目性能关键,面试官会深入考察。
为什么引入 PixiJS 而非纯 Canvas 或 SVG?它在实现 60 FPS 和无限画布时发挥了什么作用?
PixiJS 是一个基于 WebGL 的 2D 渲染引擎,它具有极高的性能优势,可以充分利用 GPU 加速。相比纯 Canvas API,PixiJS 提供了更高层次的抽象,开发者无需手动管理底层的渲染细节,同时能够获得更好的性能表现。 与 SVG 相比,PixiJS 在处理大量图形元素时表现更佳。SVG 是基于 DOM 的,当元素数量增加时,DOM 操作的开销会显著增加,导致性能下降。而 PixiJS 直接在 GPU 层面进行渲染,即使处理数千个元素也能保持流畅性能。 对于无限画布的实现,PixiJS 提供了强大的 pixi-viewport 插件,它可以处理大规模场景的渲染优化。通过视口裁剪(view culling)技术,PixiJS 只渲染当前可见区域内的元素,大幅减少了渲染开销。
如何使用 pixi-viewport 实现无限画布的缩放、平移和边界限制?
实现缩放、平移和边界限制:
1 | |
缩放功能通过 pinch 和 wheel 插件实现,用户可以通过双指手势或鼠标滚轮进行缩放。平移功能通过 drag 插件实现,用户可以拖拽画布。clamp 功能用于限制视口边界,防止用户将视口拖拽到画布内容之外的区域。
viewport 提供多个配置选项,如缩放级别限制、平滑动画等
项目中如何缓存 PixiJS 对象(spriteMap)以避免拖拽/缩放时的重复创建?这对性能有何影响?
在项目中,我们使用 spriteMap 来缓存 PixiJS 对象,避免在拖拽、缩放等操作中重复创建和销毁元素。spriteMap 是一个以元素 ID 为键的 Map,存储了每个画布元素对应的 PixiJS 显示对象。
1 | |
当画布元素更新时,我们首先检查 spriteMap 中是否已存在对应的显示对象,如果存在则直接更新其属性,而不是创建新的对象。
- 减少了对象创建和垃圾回收的开销
- 提高了渲染效率,因为现有对象只需更新属性而非重新创建
- 保持了对象状态的连续性,例如动画状态、事件监听器等
图像滤镜(BlurFilter、ColorMatrixFilter)和富文本(HTMLText)是如何在 PixiJS 中实现的?遇到过哪些渲染挑战?
在项目中,我们使用 PixiJS 的滤镜系统实现图像效果。对于 BlurFilter 和 ColorMatrixFilter 等滤镜,我们通过以下方式应用:
1 | |
对于富文本渲染,我们使用了 pixi-text-html 库,它允许我们在 PixiJS 中渲染 HTML 样式的文本。HTMLText 组件可以解析 HTML 标签并渲染出格式化的文本。
小地图(Minimap)如何通过 cacheAsBitmap 实现实时更新?为什么需要单独的 Pixi Application?
小地图的实现主要通过 cacheAsBitmap 属性来优化性能。cacheAsBitmap 将显示对象及其子对象渲染到一个内部纹理中,后续渲染只需绘制该纹理,而无需重新计算所有子对象的渲染,从而大幅提升性能。
1 | |
小地图需要单独的 Pixi Application 实例,主要原因包括:
性能隔离:小地图的渲染频率可能与主画布不同,独立的实例可以更好地控制渲染性能
独立交互:小地图可能需要独立的交互逻辑,如点击跳转到画布特定位置
资源管理:独立的实例可以更好地管理小地图相关的纹理和资源
缩放独立性:小地图需要保持固定比例的缩略图,独立的渲染上下文更容易实现这一功能
4. 撤销/重做机制(命令模式)
项目中撤销/重做是如何实现的?为什么采用 Command Pattern?
撤销/重做功能是通过命令模式(Command Pattern)实现的。我们定义了一个 Command 接口,它包含 execute、undo 和 redo 三个方法:
1 | |
我们为不同类型的画布操作创建了相应的命令类,如 AddElementCommand、RemoveElementCommand 和 UpdateElementCommand 等。每个命令类都保存了执行操作所需的信息,能够在 undo 和 redo 时恢复到相应的状态。
采用命令模式的主要原因有以下几点:
解耦:命令模式将操作的执行者与请求者解耦,使我们可以轻松地添加新的命令类型而无需修改现有代码。
状态一致性:在协同编辑环境中,命令模式确保所有操作都可以被准确地撤销和重做,保持状态一致性。
易于扩展:我们可以轻松地添加新的命令类型,如分组、取消分组等。
每个命令(如 AddElementCommand、UpdateElementCommand)如何存储前后状态快照(structuredClone)?这在内存和性能上有哪些权衡?
AddElementCommand 的撤销与重做实现
1 | |
- 撤销(undo):通过移除新增的元素恢复原状态,仅需元素 ID。
- 重做(redo):直接重复添加操作,无需额外存储数据。
RemoveElementCommand 的撤销与重做实现
1 | |
- 撤销(undo):依赖 execute 时存储的元素完整数据进行恢复。
- 重做(redo):使用存储的数据重复移除,避免直接依赖外部状态。
UpdateElementCommand 的撤销与重做实现
1 | |
- 撤销(undo):通过存储的 previousValues 恢复属性原值。
- 重做(redo):重复应用新值,确保操作可重复性。
此设计的核心原则是:在命令对象中存储足够的信息(而非完整状态快照),以独立实现 undo 和 redo 操作,从而支持高效的命令模式撤销/重做栈管理。
在内存和性能上的权衡包括:
- 内存占用:每个命令都需要保存足够的信息来执行撤销/重做操作,这会增加内存使用。特别是 SnapshotCommand 会保存整个状态的副本,这在元素较多时会占用大量内存。
- 性能影响:创建状态快照需要时间,特别是当画布中有大量元素时。使用
structuredClone深拷贝大型对象会影响性能。 - 存储优化:为减少内存占用,我们对不同的操作采用不同的存储策略。对于添加/删除操作,只需存储元素本身;对于更新操作,只需存储变更前的值和变更的属性。
改进:
限制历史栈大小以防止内存溢出
对于连续的多个操作,可以合并成一个批量命令,减少栈中命令的数量
对于包含大量数据的命令,如图像元素操作,可以在命令不再需要时清理其内部引用的数据
对于频繁的操作(如拖拽移动),可以使用防抖机制将连续操作合并为一个命令,减少命令栈的增长速度
操作分组:将相关的连续操作视为一个逻辑操作,例如,将创建一个复杂图形的多个步骤合并为一个撤销单位
操作描述:为每个命令添加描述,让用户在 UI 上看到具体可撤销/重做的操作内容
历史持久化:将撤销/重做历史保存到本地存储,即使页面刷新后也能恢复历史记录。
自适应栈大小:根据当前画布复杂度动态调整栈大小,元素较多时使用较小的栈,元素较少时使用较大的栈
5. 交互与用户体验
变换控件(Transform Controls)的检测与处理
变换控件由 TF_controler_Renderer.ts 模块负责渲染,包括包围选中元素的边界框、8 个缩放手柄(位于边角)和 1 个旋转手柄(通常位于顶部或底部)
手柄检测基于鼠标位置与手柄边界框的距离计算:
- 当鼠标进入手柄区域时,光标样式相应改变(例如,边角手柄显示对角箭头,旋转手柄显示旋转图标)
- 每个手柄对应特定操作:
- 8 个边角手柄:用于非均匀缩放(保持或不保持宽高比,根据修饰键)
- 旋转手柄:用于旋转选中元素(可能以组中心为旋转锚点)
在 Stage_InteractionHandler.ts 中处理实际变换逻辑:
1 | |
交互模式切换逻辑
项目定义了多种交互模式,主要在 Stage_InteractionHandler.ts 中管理,确保同一时刻仅一种模式活跃。
1 | |
模式优先级:transforming > dragging > panning > selecting > idle,确保变换手柄始终优先响应。
对齐指南(Alignment Guidelines)的计算与绘制
对齐指南功能由 guidelineUtils.ts 实现,在元素拖拽过程中实时提供视觉反馈和吸附效果。
1 | |
绘制与吸附:
- 在拖拽过程中,每帧调用 detectAlignments,若检测到对齐,则使用 PixiJS 的 Graphics 对象绘制虚线指南(通常为蓝色或绿色,带一定透明度)。
- 若移动偏移导致对齐,则自动吸附(snap)元素位置到精确对齐点,提供精准布局体验。
6. 性能优化与工程实践
项目中具体的性能优化措施
项目采用了多项针对性优化,确保在复杂画布场景下的流畅运行:
- 对象缓存机制 使用 spriteMap 缓存 PixiJS 显示对象,避免频繁创建和销毁导致的性能开销。
1 | |
- WebGL 渲染优化 充分利用 PixiJS 的 GPU 加速,并通过 pixi-viewport 实现视口裁剪,仅渲染可见区域元素,显著减少绘制调用。
1 | |
- Vite HMR(热模块替换) 在开发环境中利用 Vite 的快速热更新,无需完整页面刷新即可反映代码变更,大幅提升迭代效率。
TypeScript 在项目中的作用:
- 类型安全 通过严格接口定义,确保数据一致性与错误早发现。
1 | |
- 智能提示与类型推断:显著提高编码效率。
- 重构安全:类型系统可在大型重构时快速定位影响范围。
- 接口契约:明确模块间数据结构,提升代码可维护性。
构建与部署方面
- 选用 Vite 的原因
- 极快的开发服务器启动与构建速度
- 即时热模块替换(HMR)
- 出色的构建性能与 Tree Shaking
- 开箱即用的 TypeScript、JSX 和 CSS Modules 支持
- Docker 与 GitHub Actions CI/CD
- 项目根目录提供 Dockerfile 和 docker-compose.yml,支持容器化部署。
- GitHub Actions 配置自动化流程:代码检查 → 单元测试 → 构建产物 → 镜像推送 → 部署至目标环境。
样式一致性保证
为统一多组件库外观,项目实施以下策略:
- TailwindCSS 统一设计系统
1 | |
- CSS 变量系统:定义全局变量(如 —color-primary),确保所有组件引用统一值。
- 主题管理:通过 themeStore.ts(基于 Zustand 或类似状态管理)集中控制主题切换。
- 组件包装:对第三方库组件进行二次封装,统一应用项目样式和行为规范。
7. 挑战与改进
开发过程中遇到的主要技术难点及解决方案
在项目开发中,我们遇到了几个关键技术挑战,主要集中在实时协作、渲染同步以及性能优化方面。
实时协作冲突处理 最大的难点之一是多用户同时编辑画布时的数据冲突,可能导致操作覆盖或状态不一致。
- 采用 Yjs 的 CRDT(Conflict-free Replicated Data Type)算法,自动合并并发修改,无需中央锁定机制,确保最终一致性。
- 通过 HocuspocusProvider 建立 WebSocket 连接,实现低延迟实时同步。
在 canvasStore.ts 中,利用 Yjs 的 observe 机制监听变更并同步到本地状态管理器:
1
2
3
4
5yElements.observe(() => {
useStore.setState({
elements: yElements.toJSON(),
});
});额外实现锁定机制,防止同步过程中向撤销/重做栈添加无效命令,避免历史污染。
PixiJS 与 React 状态同步 另一个重大挑战是保持 PixiJS 渲染层与 React/Zustand 状态的实时一致性,尤其在元素数量较多时易导致延迟或不一致。
- 创建 Pixi_stageManager.ts 作为桥梁层,负责双向同步 React 状态与 PixiJS 显示对象。
- 使用 spriteMap 缓存 PixiJS 对象,避免重复创建/销毁。
- 引入防抖(debounce)机制,限制频繁同步频率。
- 实现选择性更新,仅针对变更元素进行渲染。
性能优化挑战 当画布元素数量激增时,渲染和交互性能显著下降。
- 启用视口裁剪(viewport culling),仅渲染当前可见区域元素。
- 引入对象池和缓存机制,减少内存分配开销。
- 采用批量更新(batchUpdateElements),降低状态变更引起的多次重渲染。
针对静态元素启用 cacheAsBitmap,将内容烘焙为位图以减少重绘。
1
2
3
4// 示例:针对静态元素启用位图缓存
if (sprite.isStatic && !sprite.cacheAsBitmap) {
sprite.cacheAsBitmap = true;
}