Studio 前端架构:从画布到组件的设计思考

3 min read
Zekari
Purikura 项目前端架构React组件设计

一个图像编辑工作台的前端架构,本质上是在回答三个问题:如何渲染,如何交互,如何扩展。

画布的选择

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 的处理还有细微差异。

CameraViewPrepareStageCameraControls 这三个组件的拆分,不是一开始就这样设计的。初期所有逻辑都在一个组件里,后来发现相机预览、拍照准备、拍照控制这三个阶段的生命周期完全不同,才被迫拆分。

偶像照片功能是后来加的。有些用户不想用自己的照片,希望用偶像或虚拟角色的照片。这个需求让相机系统变成了"相机或照片选择"系统,复杂度又上升了一个层级。

现在回头看,如果当时把相机系统作为一个独立的模块来设计,而不是"顺便加个拍照功能",可能会做得更好。

导出引擎的妥协

导出功能是整个系统中最不稳定的部分。

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的页面系统

3 min read

通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。

架构设计React
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

Context 驱动的认证状态管理

3 min read

认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。

软件设计认证系统
Read More
Featured

定价界面优化的三层方法

4 min read

数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题

UI/UX定价策略
Read More

离屏渲染:照片捕获为什么需要独立的 canvas

2 min read

实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。

架构设计前端开发
Read More

集中式配置:让 Reddit 组件脱离重复泥潭

2 min read

当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。

架构设计React组件
Read More

让文档跟着代码走

2 min read

文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。

文档软件工程
Read More

为什么用 DOM 而非 Canvas 实现画布

2 min read

深入探讨在 Purikura Studio 中选择 DOM 而非传统 Canvas 的技术决策过程,以及如何在技术选型中进行完整的权衡推演

技术选型前端架构
Read More

双重导出管道的架构选择

2 min read

在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。

架构设计图像导出
Read More

ESM 模块

3 min read

模块的本质不是代码的组织方式,而是对依赖关系的明确声明

JavaScript模块系统
Read More

重复数据的迁移实践:从 N 个文件到 1 个真相源

3 min read

当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。

架构设计配置管理
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

分层修复

3 min read

生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。

工程实践问题修复
Read More

多厂商 AI 调度:统一混乱的供应商生态

3 min read

当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。

Purikura 项目系统架构
Read More

分布式 Workers 的解耦设计

3 min read

通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。

Purikura 项目系统架构
Read More

React Context Provider

3 min read

Context 不是状态管理。它是数据传递的通道。这个区别决定了你应该如何使用它。

ReactContext API
Read More

Studio 系统架构:从状态机到端到端流程

3 min read

深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑

Purikura 项目系统架构
Read More

第三方回调的状态映射完整性

5 min read

KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。

Purikura 项目系统设计
Read More

统一积分系统的设计实践

2 min read

从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计

系统架构数据库设计
Read More

Zustand Store

2 min read

状态管理的本质不在于框架的复杂度,而在于你如何理解数据流动的边界

React状态管理
Read More