阅读视图

发现新文章,点击刷新页面。

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

如果你想做一个类似 Figma 的设计工具,第一反应往往是:

  • 要有高性能画布渲染
  • 要有可组合的 UI 结构
  • 要有事件命中、选中框、拖拽缩放、文本编辑
  • 还要能接入 AI(生成、改图、改布局)

我这次在 apps/open-canvas-lab 里给出的思路是:
react-canvas 作为渲染与交互底座,逐步搭一个“Figma 工具内核”。

lab.png

项目地址


为什么选 react-canvas

react-canvas 这套能力很适合做设计工具,因为它天然覆盖了编辑器最核心的三层:

  1. 场景渲染层:CanvasKit + 场景树,支持复杂布局、文本、图片、矢量
  2. 交互命中层:pick buffer + pointer 事件分发,支持精确命中
  3. 运行时层:场景节点可增删改查,可做撤销重做、选择态同步

相比“直接裸写 Canvas 2D”,这个方案的关键优势是:
你不是在拼命堆 imperative 绘图代码,而是在维护一套可演进的场景模型。


一个可落地的 Figma 工具架构

建议把应用拆成 4 个子系统:

1) Scene(文档模型)

  • 以节点树表达 Frame / Group / Text / Image / Path
  • 每个节点有 transform、style、约束信息
  • 变更统一走 command(方便 undo/redo)

2) Renderer(渲染与命中)

  • 主渲染:react-canvas 场景渲染
  • 命中:pick buffer 解析到 nodeId
  • 选中态叠加:控制框、锚点、参考线

3) Interaction(编辑器手势)

  • pointer down/move/up 组合成 drag / resize / rotate
  • 框选、吸附、对齐辅助线
  • 多选与分组操作

4) Tooling(工具链)

  • 左侧图层树、右侧属性面板
  • 顶部工具栏(选择、文本、矩形、钢笔)
  • 快捷键系统(复制、粘贴、对齐、撤销重做)

在 open-canvas-lab 的实现路线(推荐)

如果你要从 0 到 1 做出可用 MVP,可以按这个顺序:

  1. 文档与选中

    • 建立 node schema
    • 点选节点、高亮边框
  2. 变换编辑

    • 拖拽移动
    • 8 点缩放
    • 基础旋转
  3. 文本与图片

    • 文本节点样式编辑(字体、字号、行高)
    • 图片节点 object-fit / 裁剪
  4. 编辑器体验

    • 框选、多选、组合
    • 对齐吸附与辅助线
    • 撤销重做 + 操作历史
  5. 协作与 AI(进阶)

    • JSON 文档持久化
    • CRDT 协同(多人编辑)
    • AI 生成组件/版式并写回场景树

AI 能力该怎么落地(重点)

很多编辑器把 AI 做成“聊天框 + 一键生成图”,但真正可用的 AI 设计工具,关键是:
AI 输出必须是结构化编辑指令,而不是一段不可控文本。

ai.png 建议把 AI 能力拆成 3 层:

1) 意图层(Prompt / Plan)

  • 输入:自然语言需求(如“生成一个电商详情页首屏”)
  • 输出:任务计划(页面结构、组件清单、风格约束)
  • 形态:可审阅的中间 plan(用户可确认/修改)

这一层不要直接改场景,先做“可解释计划”,能显著降低误生成成本。

2) 工具层(Structured Tools)

给模型的不是“任意写 JSON”,而是明确工具集合,例如:

  • create_frame({ parentId, x, y, width, height, name })
  • create_text({ parentId, text, style })
  • create_image({ parentId, src, fit })
  • update_style({ nodeId, patch })
  • align_nodes({ nodeIds, mode })

模型只负责“调用工具”,具体执行由编辑器 runtime 保证合法性。
这样能把 AI 变成“受约束的自动化操作员”。

3) 执行层(Command Pipeline)

工具调用最终都转换为 command:

  • command[] -> validate -> apply -> layout -> render
  • 全量写入 undo/redo 栈
  • 每一步都可回滚、可重放

这保证了 AI 操作和手动操作使用同一条数据通路,不会出现“双系统分叉”。

推荐的 AI 能力清单

open-canvas-lab 里,建议优先做这 6 类能力:

  1. 从描述生成线框

    • 输入“做一个登录页”,输出基础布局骨架(frame + text + button)
  2. 风格迁移

    • 对选区做“科技蓝 / 极简黑白 / 品牌色系”重绘(仅改 style,不改结构)
  3. 批量排版

    • 统一间距、字号层级、栅格对齐
  4. 组件重写

    • 例如把“普通卡片”一键转成“带封面 + 标签 + CTA”卡片
  5. 文案智能填充

    • 生成标题、副标题、按钮文案,并支持语气风格切换
  6. 设计审查(AI Review)

    • 检查对齐、对比度、可读性、间距一致性,输出可执行修复建议

一个最小 AI 执行链路(MVP)

可以先实现下面这个闭环:

  1. 用户输入需求
  2. 模型输出 tool calls
  3. 前端校验参数(schema)
  4. 转成 command 执行
  5. 在画布高亮本次改动节点
  6. 用户可 accept / undo / retry

这个 MVP 的价值是:
你不需要先做很强的模型能力,就能把“AI 可控编辑”体验跑通。

AI 接入时最容易踩的坑

坑 1:让模型直接返回整份文档 JSON

问题:diff 巨大、不可控、很难回滚。
建议:必须改为“增量工具调用 + command 化执行”。

坑 2:AI 操作绕开编辑器状态机

问题:会破坏选中态、历史栈、约束关系。
建议:AI 与用户操作走同一 command pipeline。

坑 3:没有失败兜底

问题:工具半执行状态下文档损坏。
建议:每批 AI 操作做事务边界(失败整体回滚)。

坑 4:可解释性不足

问题:用户不知道 AI 改了什么。
建议:展示“本次修改节点清单 + 属性 diff”。


JSON 设计(简版)

为了让 AI、编辑器、存储三方都能稳定协作,建议把 JSON 拆成两层:

  1. document schema:描述页面与节点树(可持久化)
  2. command schema:描述一次编辑动作(可回放、可撤销)

1) document schema 示例

{
  "version": "1.0",
  "meta": { "name": "Landing Page", "updatedAt": 1776259200000 },
  "rootId": "frame_root",
  "nodes": {
    "frame_root": {
      "id": "frame_root",
      "type": "frame",
      "name": "Page",
      "children": ["title_1", "btn_1"],
      "layout": { "x": 0, "y": 0, "width": 1440, "height": 900 },
      "style": { "backgroundColor": "#ffffff" }
    },
    "title_1": {
      "id": "title_1",
      "type": "text",
      "text": "Build with react-canvas",
      "layout": { "x": 120, "y": 160, "width": 600, "height": 72 },
      "style": { "fontSize": 56, "fontWeight": 700, "color": "#111827" }
    },
    "btn_1": {
      "id": "btn_1",
      "type": "frame",
      "name": "CTA",
      "children": [],
      "layout": { "x": 120, "y": 280, "width": 168, "height": 48 },
      "style": { "borderRadius": 12, "backgroundColor": "#2563eb" }
    }
  }
}

2) command schema 示例

{
  "id": "cmd_20260415_001",
  "type": "update_style",
  "payload": {
    "nodeId": "btn_1",
    "patch": { "backgroundColor": "#1d4ed8", "borderRadius": 14 }
  },
  "meta": { "source": "ai", "traceId": "run_xxx" }
}

这套拆分的好处是:

  • 文档 JSON 负责“当前状态”
  • command JSON 负责“如何到达这个状态”
  • AI 输出 command,比直接覆盖整份 document 更安全

关键实现细节(踩坑重点)

坐标系统一

编辑器里至少有 3 套坐标:

  • 视口(client)
  • 画布(stage)
  • 节点局部(local)

一定要优先统一坐标映射,否则拖拽、选框和命中会经常“看起来差几像素但很难查”。

命中与视觉分离

不要用“可见像素”直接做命中判断。
正确姿势是用独立 pick 语义层(nodeId 编码),可维护性和稳定性会高很多。

编辑器状态尽量事件化

把“鼠标按下后进入哪种模式”建成有限状态机(FSM),比 scattered boolean 更稳,后续加钢笔、裁剪工具也不容易崩。


一个简单但重要的结论

做 Figma 工具真正困难的不是“画出来”,而是:

  • 模型是否可持续演进
  • 交互是否可组合
  • 渲染/命中/状态是否解耦

react-canvas 的价值在于,它已经把底层最难啃的部分(渲染与交互基础设施)提前搭好。
你可以把主要精力放在“产品能力”和“编辑体验”上。


结语

如果你正在基于 apps/open-canvas-lab 做编辑器方向的实验,这个方向是可行的:
先做一个“可编辑画板 MVP”,再逐步补齐 Figma 级能力,而不是一上来追求完整复刻。

使用纯canvas绘制一个掘金首页

使用纯 Canvas 绘制一个掘金首页

在前端开发中,我们习惯了使用 HTML 和 CSS 来构建用户界面。但你是否想过,如果完全抛弃 DOM 树,使用纯 Canvas 来绘制一个复杂的现代 Web 页面(比如稀土掘金的首页),会是怎样的体验?

react-canvas 这个项目中,我们进行了一次硬核的尝试:基于 Skia (CanvasKit) 和 Yoga 布局引擎,使用 React 自定义渲染器从零构建了掘金的首页。

🔗 在线体验地址react-canvas-design.vercel.app/#/juejin
💻 GitHub 仓库github.com/ouzhou/reac…

screenshot-20260414-161131.png

技术栈揭秘

要实现这个目标,我们不能使用标准的 react-dom。我们的底层基础设施包括:

  1. CanvasKit (Skia WebAssembly):作为底层的 2D 图形渲染引擎,负责绘制所有的矩形、文本、图像和 SVG 路径。
  2. Yoga Layout:Facebook 开源的跨平台 Flexbox 布局引擎。由于 Canvas 本身没有布局概念,我们通过 Yoga 来计算每个元素的坐标和尺寸。
  3. @react-canvas/react-v2:我们自己实现的 React 渲染器,将 React 组件树映射为底层的渲染节点。

核心实现思路

在纯 Canvas 的世界里,没有 <div><span><img>。一切都是自定义的节点。

1. 基础组件映射

我们将传统的 HTML 标签替换为了 react-canvas 提供的基础组件:

  • <div> -> <View>:作为基础的容器,支持 Flexbox 布局。
  • <span> / <p> -> <Text>:用于文本渲染,底层调用 Skia 的 Paragraph API。
  • <img> -> <Image>:用于渲染网络图片(如掘金的 Logo)。
  • <svg> -> <SvgPath>:用于渲染矢量图标。
  • 滚动区域 -> <ScrollView>:由于 Canvas 没有原生滚动条,我们需要自己处理滚动事件和视口裁剪。

2. 初始化画布与字体

Canvas 绘制中文需要显式加载字体文件,否则会出现乱码(豆腐块)。我们在最外层使用 CanvasProvider 初始化运行时,并加载了思源黑体:

import { CanvasProvider, Canvas, View, Text } from "@react-canvas/react-v2";
import localParagraphFontUrl from "../assets/NotoSansSC-Regular.otf?url";

<CanvasProvider initOptions={{ defaultParagraphFontUrl: localParagraphFontUrl }}>
  {({ isReady, runtime }) => (
    <Canvas
      width={vw}
      height={vh}
      paragraphFontProvider={runtime.paragraphFontProvider}
      defaultParagraphFontFamily={runtime.defaultParagraphFontFamily}
    >
      {/* 页面内容 */}
    </Canvas>
  )}
</CanvasProvider>

3. Flexbox 布局与样式

得益于 Yoga,我们可以像写 React Native 一样使用 Flexbox 布局。所有的样式都是内联的 JS 对象,而不是 CSS 类:

// 掘金顶部导航栏的布局示例
<View
  style={{
    width: vw,
    height: 60,
    backgroundColor: "#ffffff",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    paddingLeft: 24,
    paddingRight: 24,
  }}
>
  {/* Logo 和 导航项 */}
</View>

4. 交互状态 (Hover)

在 DOM 中,我们通常用 :hover 伪类来处理鼠标悬停状态。在 react-canvas 中,style 属性支持传入一个函数,接收当前的交互状态:

<View
  style={({ hovered }) => ({
    padding: 16,
    backgroundColor: hovered ? "#fafafa" : "#ffffff", // 悬停时改变背景色
    cursor: "pointer",
  })}
>
  <Text>文章标题</Text>
</View>

5. 绘制细节与踩坑:分割线

在传统的 CSS 中,我们可以轻松地写出 border-bottom: 1px solid #eee。但在我们目前的自定义渲染器中,单边边框的支持还在完善中。

为了在 Canvas 中画出完美的 1px 分割线,我们采用了绝对定位的 <View> 元素来模拟:

// 模拟 border-bottom
<View style={{ 
  position: "absolute", 
  bottom: 0, 
  left: 0, 
  right: 0, 
  height: 1, 
  backgroundColor: "#f1f1f1" 
}} />

最终效果

通过组合这些基础能力,我们成功地 1:1 还原了掘金首页的复杂布局,包括:

  • 固定的顶部导航栏(带搜索框和图标)
  • 左侧固定的分类导航侧边栏
  • 中间的文章信息流(包含标题、摘要、作者、时间、点赞数和封面图)
  • 右侧的签到卡片、排行榜和活动 Banner
  • 右下角的悬浮按钮

所有的渲染都在一个 <canvas> 标签内完成!

总结

使用纯 Canvas 绘制复杂的 Web UI 是一次非常有趣的探索。虽然它失去了 DOM 带来的无障碍性(A11y)、SEO 和原生的文本选中能力,但它带来了极致的渲染控制权和跨平台的一致性(同一套代码可以轻易移植到原生 App 甚至桌面端)。

这正是 Flutter、React Native Skia 等技术的核心魅力所在。通过 react-canvas,我们在 Web 端也体验到了这种“掌控每一个像素”的快感。

❌