Studio 前端架构:从画布到组件的设计思考
一个图像编辑工作台的前端架构,本质上是在回答三个问题:如何渲染,如何交互,如何扩展。
画布的选择
Canvas API 还是 DOM?这个问题在项目初期困扰了很久。
Canvas 性能更高,这是公认的。Photoshop 和 Figma 都选择了基于 Canvas 的渲染方案。但 Studio 最终选择了 DOM。
这个决定不是因为 DOM 更好,而是因为它更适合当时的约束条件:小团队,需要快速迭代,用户场景相对简单。
DOM 提供了现成的响应式布局能力。Flexbox 和 Grid 让画布可以自适应不同屏幕尺寸,而 Canvas 需要自己计算和重绘每个元素的位置。这不是说 Canvas 做不到,而是说 DOM 可以让浏览器帮你做这些计算。
事件处理也是类似的考量。在 DOM 中,每个元素都是独立的事件目标,点击、拖拽这些交互可以直接绑定。而 Canvas 需要自己判断鼠标点击的坐标是否在某个元素的边界内。诚然,Fabric.js 和 Konva 这些成熟的 Canvas 库已经解决了这些问题,但引入这些库本身也是成本。
但这个选择有代价。DOM 的性能上限确实不如 Canvas。当元素数量多了,或者需要频繁重绘,DOM 会开始卡顿。目前 Studio 的使用场景还没有触及这个上限,但不代表未来不会遇到。
选择 DOM 时假设的使用场景是:
- 元素数量有限(一般在 10-30 个之间)
- 交互操作不频繁(拖拽时才需要实时更新)
- 不需要复杂的视觉效果(粒子、模糊、混合模式等)
如果这些假设不成立,Canvas 可能是更好的选择。Photoshop 和 Figma 处理的是更复杂的图形编辑场景,DOM 无法满足性能要求。
当前的架构没有预留从 DOM 迁移到 Canvas 的路径,这是一个技术债。
状态管理的困境
一个编辑器最难的部分不是 UI,而是状态管理。
Studio 的状态结构经历了两次重构。初期所有状态都放在一个大对象里,后来发现每次更新都会触发整个应用重新渲染。现在的结构是按照数据的更新频率分层:
const StudioState = {
// 画布配置(变化少)
layoutSlots: LayoutSlot[],
layout: LayoutType,
backgroundColor: string,
// 资源列表(中等频率)
assets: Asset[],
// 交互状态(变化频繁)
selectedSlotId: string | null,
isGenerating: boolean,
isControlPanelCollapsed: boolean,
}
这种分层的想法是:高频更新的状态应该和低频更新的状态隔离,避免一个状态的改变导致其他不相关组件重新渲染。理论上是这样,实践中还是会遇到问题。
比如 isControlPanelCollapsed 这个状态,本意是只控制右侧面板的折叠,不应该影响画布。但实际上画布宽度会随着面板折叠而改变,所以还是需要重新计算布局。这种依赖关系在设计时没有完全考虑清楚。
Zustand 选择的原因很简单:Redux 太重,Context API 性能不够。但 Zustand 也有局限,它的选择器机制不如 Redux 的 reselect 强大,需要手动优化。
Studio 使用 useShallow 来细化订阅粒度:
const { assets, selectedSlotId } = useStudioStore(
useShallow(state => ({
assets: state.assets,
selectedSlotId: state.selectedSlotId,
}))
)
这确实减少了不必要的重渲染,但代价是代码变得啰嗦。每个组件都要写这样的选择器,容易出错。
如果重新设计,可能会考虑将状态拆分成多个独立的 store,而不是用一个大 store 加选择器的方式。但现在重构的成本已经很高了。
组件拆分的尺度
Studio 的组件架构试图遵循容器和展示分离的原则,但实践中这个边界很模糊。
CanvasCore 本来应该是纯逻辑容器,只管理状态和事件。但实际上它还包含了布局计算、元素定位、边界检测这些逻辑。这些逻辑属于 UI 层还是业务层?很难说清楚。
const CanvasCore = () => {
const { layoutSlots, updateSlot } = useStudioStore()
const handleDrop = (item: DraggableItem) => {
// 这里其实还有复杂的位置计算逻辑
updateSlot(item.slotId, item.asset)
}
return <CanvasView slots={layoutSlots} onDrop={handleDrop} />
}
这个示例简化了很多。真实的 CanvasCore 有 500 多行代码,包含了大量的事件处理、状态同步、性能优化逻辑。是否应该进一步拆分?可能应该,但每次拆分都会增加组件间通信的复杂度。
组件拆分的粒度没有标准答案。拆得太细,组件多了反而难以理解。拆得太粗,单个组件又难以维护。这个平衡点需要在实践中不断调整。
资源管理的复杂性
资源管理看起来简单,实际上是最容易出问题的地方。
Studio 支持四种资源类型,每种资源的加载方式、状态管理、错误处理都不一样:
type Asset =
| LocalAsset // 用户上传,同步加载
| RemoteAsset // AI 生成,异步等待
| IdolAsset // 预设照片,需要预加载
| TextAsset // 文字输入,实时编辑
最麻烦的是 AI 生成资源。生成时间从 2 秒到 30 秒不等,中间可能失败、超时、或者服务端返回错误的资源。UI 需要处理所有这些状态:等待中、生成中、完成、失败、超时。
初期设计时没有考虑这么多状态,导致用户经常看到资源"卡住"不动,不知道是在加载还是已经失败。后来加了进度条、重试按钮、超时提示,但这些都是补救措施,不是最初设计的一部分。
拖拽系统用的是 React DnD Kit。选择它而不是原生 HTML5 拖拽 API,主要是因为移动端的触摸支持。但 DnD Kit 的学习曲线比想象中陡峭,文档也不够完善。
资源的状态比最初预期的复杂得多:
interface AssetState {
status: 'idle' | 'loading' | 'generating' | 'success' | 'error' | 'timeout'
progress?: number
error?: Error
retryCount?: number
thumbnail?: string
fullSize?: string
}
这还不包括资源在画布上的状态(位置、大小、旋转角度等)。
如果重新设计,可能会考虑用状态机(XState)来管理资源的生命周期,而不是用 boolean flags 和 enum 的组合。
相关阅读:指数退避与超时处理 讨论了异步操作的可靠性。
相机系统的意外复杂度
相机功能在最初的设计中只是一个小特性,但最后成了最复杂的模块之一。
浏览器的相机 API 看起来简单,实际使用时有无数的兼容性问题。不同设备的相机分辨率不同,权限请求的行为不同,前后摄像头切换的方式也不同。Safari 和 Chrome 对 MediaStream 的处理还有细微差异。
CameraView、PrepareStage、CameraControls 这三个组件的拆分,不是一开始就这样设计的。初期所有逻辑都在一个组件里,后来发现相机预览、拍照准备、拍照控制这三个阶段的生命周期完全不同,才被迫拆分。
偶像照片功能是后来加的。有些用户不想用自己的照片,希望用偶像或虚拟角色的照片。这个需求让相机系统变成了"相机或照片选择"系统,复杂度又上升了一个层级。
现在回头看,如果当时把相机系统作为一个独立的模块来设计,而不是"顺便加个拍照功能",可能会做得更好。
导出引擎的妥协
导出功能是整个系统中最不稳定的部分。
Studio 使用 html-to-image 将 DOM 转换为图片。这个库的原理是先把 DOM 序列化成 SVG,然后用 Canvas 渲染。听起来简单,实际上有很多边缘情况。
Web 字体加载可能还没完成,导出的图片里文字会显示成默认字体。跨域图片无法转换为 Data URL,导出会失败。CSS 动画和变换在 SVG 中的表现和浏览器不一致,导出结果可能错位。
// 导出前需要等待很多东西
await document.fonts.ready // 字体可能还没加载完
await waitForImages() // 图片可能还在下载
await waitForCSSTransitions() // 动画可能还在进行
const dataUrl = await toPng(element, {
pixelRatio: 3, // 为什么是 3?因为 iPhone 是 3x
})
pixelRatio: 3 这个值是试出来的。2x 在高分辨率屏幕上模糊,4x 导出太慢而且文件太大。3x 是一个折中的选择,但对于 2x 或 2.5x 的 Android 设备来说,其实不是最优的。
导出失败的原因很多,而且很难预测:
- 用户使用了跨域图片,浏览器拒绝转换
- 自定义字体还在加载中,导出时还没准备好
- CSS 样式太复杂,SVG 序列化出错
- 浏览器内存不足,Canvas 渲染失败
当前的做法是出错就重试,重试几次还失败就提示用户。但这不是真正的解决方案,只是把问题暴露给用户。
更好的方案可能是服务端渲染。把画布数据发到服务端,用 Puppeteer 或类似工具截图,可靠性会高很多。但这会增加后端成本和延迟。
性能优化的有限空间
性能优化听起来很技术化,但实际上大部分时候是在做取舍。
首屏加载时间的优化主要靠代码分割。相机、导出、AI 生成这些功能都是动态导入,只在用户需要时才加载。但这种延迟加载会带来新的问题:用户点击按钮后需要等待模块加载,会有短暂的延迟感。
交互响应的优化用了 React.memo、useCallback、useMemo 这些常见手段。但这些优化都有成本:代码变得更冗长,调试变得更困难。有些时候优化过度,反而引入了新的 bug。
CSS Transform 确实比 JavaScript 动画性能好,但它也有局限。复杂的路径动画、弹性动画这些效果用 CSS 很难实现。最后还是会用 JavaScript 动画库(Framer Motion),性能退回到原点。
导出速度是最难优化的。浏览器的 Canvas 渲染速度就是这么快,没有什么魔法可以让它变得更快。只能通过提示用户"正在导出,请稍候"来降低心理预期。
扩展性的理想与现实
扩展性在设计时总是被强调,但实际上很少真正用到。
Studio 预留了自定义资源类型的接口,允许第三方开发者添加新的资源渲染逻辑:
interface CustomAsset extends BaseAsset {
type: 'custom';
customData: any;
renderer: ComponentType<AssetRendererProps>;
}
这个接口看起来很灵活,但实际上没有人用。因为要使用这个接口,开发者需要理解整个资源系统的内部结构,学习成本太高。
扩展性和易用性是矛盾的。预留太多扩展点,会让核心功能变得复杂和难以理解。但不预留扩展点,后期添加新功能又会很困难。
目前的架构已经有了一些扩展性,但不够完善。自定义布局、自定义滤镜、插件系统这些功能都还在计划中,但实现起来需要重构现有代码。这就是扩展性设计的困境:做得太早,可能用不上;做得太晚,重构成本太高。
Studio 前端是 Purikura 整体架构 中的一层,专注于用户交互和内容创作。
后端负责 AI 处理、存储和认证,前端负责编辑体验。这种分离让两边可以独立演进,但也带来了接口协调的成本。
前后端的边界划分不是一开始就清晰的。有些逻辑在前端做更合适,有些在后端做更合适。这个边界一直在调整,现在也还没有完全稳定。
架构设计不是一次性的完美规划,而是在约束条件下持续调整的过程。
Studio 的架构有很多不足:DOM 可能会成为性能瓶颈,状态管理还有优化空间,导出引擎的可靠性不够,扩展性设计还没有真正落地。
这些问题有些可以解决,有些需要重构,有些可能需要推翻重来。但在当下的约束条件下,这些选择都有它们的合理性。
最后更新:2025-11-06
Related Posts
Articles you might also find interesting
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
Props Drilling
数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。
Context 驱动的认证状态管理
认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。
定价界面优化的三层方法
数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题
离屏渲染:照片捕获为什么需要独立的 canvas
实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
为什么用 DOM 而非 Canvas 实现画布
深入探讨在 Purikura Studio 中选择 DOM 而非传统 Canvas 的技术决策过程,以及如何在技术选型中进行完整的权衡推演
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
ESM 模块
模块的本质不是代码的组织方式,而是对依赖关系的明确声明
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
多厂商 AI 调度:统一混乱的供应商生态
当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。
分布式 Workers 的解耦设计
通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。
React Context Provider
Context 不是状态管理。它是数据传递的通道。这个区别决定了你应该如何使用它。
Studio 系统架构:从状态机到端到端流程
深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑
第三方回调的状态映射完整性
KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。
统一积分系统的设计实践
从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计
Zustand Store
状态管理的本质不在于框架的复杂度,而在于你如何理解数据流动的边界