How to migrate Vue UI to React with minimal features?
This post might have stale content, as HEAD is 378 commits ahead.
- 分析 packages 下每个 package 的作用和使用场景 2. 分析 src 下每个模块的作用和使用场景 3. 因为 src 下面是 vue 代码,我希望将其迁移到 react 且只需要展示最小的能力,去掉各种 sidebar / panel 等等,只保留核心的 canvas 交互区域,我该如何在 react 中实现(我理解应该会引用 packages/core )
完美!现在我有足够的信息了。让我生成一个完整的分析文档:
##📋 OpenPencil 项目分析
###1️⃣ Packages 分析
####packages/core - 核心引擎 ⭐
作用: 这是整个项目的心脏,包含所有与设计编辑无关的核心算法和数据结构。
主要功能模块:
- scene-graph.ts - 设计文档的数据模型,定义所有设计节点类型(Frame、Shape、Text、Component等)
- renderer.ts - SkiaRenderer:使用 Skia 渲染引擎在 Canvas 上绘制场景
- layout.ts - 布局算法(类似 Figma 的自动布局 Auto-layout)
- text-editor.ts - 文本编辑核心逻辑
- undo.ts - 撤销/重做管理
- vector.ts - 向量路径处理(钢笔工具)
- kiwi/ - Figma .fig 文件序列化/反序列化,包括 Kiwi 二进制协议
- render/ - JSX 渲染系统(可将设计导出为 React 组件)
- tools/ - AI 工具定义系统(与 AI 聊天集成)
- fonts.ts - 字体管理
- color.ts - 颜色处理
依赖:
{
"canvaskit-wasm": "Skia的WebAssembly绑定",
"yoga-layout": "Facebook的布局引擎",
"culori": "颜色处理库",
"fflate": "ZIP压缩",
"fzstd": "Zstandard压缩"
}
使用场景: 任何需要处理设计数据的应用(web、CLI、MCP服务器、React迁移)都需要这个包。
####packages/cli - 命令行工具
作用: 无头设计工具,可在命令行或脚本中操作 .fig 文件。
主要命令:
eval- 执行脚本修改设计export- 导出设计为图片/JSXfind- 搜索设计中的节点tree- 打印设计树结构analyze- 分析设计variables/pages/node- 操作特定对象
使用场景: CI/CD 流程、自动化设计处理、MCP 服务器的后端支持。
####packages/mcp - Model Context Protocol 服务器
作用: 让 Claude Code、Cursor 等 LLM 编辑器可以直接读写 .fig 文件。
主要功能:
- 暴露 75+ AI 工具供 LLM 调用
- HTTP 和 stdio 两种协议支持
- 使用 Zod 进行请求验证
使用场景: AI 编辑集成、自动化设计任务。
####packages/docs - 文档站点
作用: 项目文档和开发者指南。
###2️⃣ Src 模块分析(Vue 代码结构)
src/
├── components/ # Vue UI 组件
├── composables/ # Vue 组合函数(逻辑层)
├── views/ # 页面级组件
├── stores/ # Pinia 状态管理
├── engine/ # 引擎封装
├── kiwi/ # Figma 协议实现
├── ai/ # AI 集成
└── types.ts # TypeScript 类型
####核心 Composables(最重要!)
| Composable | 作用 | 关键功能 |
|---|---|---|
| use-canvas.ts | 画布初始化和渲染循环 | • 初始化 WebGL Surface • 创建 SkiaRenderer • 触发渲染循环 |
| use-canvas-input.ts | 鼠标/触摸交互处理 | • 框选、拖拽、旋转 • 工具状态管理 • 快捷键处理 |
| use-text-edit.ts | 文本编辑 | • 文本选择和输入 • 光标管理 |
| use-keyboard.ts | 快捷键绑定 | • 快捷键事件处理 |
| use-collab.ts | 实时协作(P2P WebRTC) | • 多用户光标/选区同步 • Yjs 状态管理 |
####核心 Store(编辑器状态)
EditorStore (stores/editor.ts) - 管理:
- graph: SceneGraph // 设计文档数据结构
- selectedIds: string[] // 当前选中节点
- activeTool: Tool // 当前工具
- zoom, panX, panY // 视图变换
- currentPageId // 当前页面
- clipboard // 剪贴板
// 关键方法:
- addNode(), deleteNode(), updateNode() // 编辑操作
- undo(), redo() // 撤销重做
- copy(), paste() // 剪贴板
####组件层次结构
EditorView.vue (主容器)
├── TabBar (标签栏)
├── SplitterGroup (分割布局)
│ ├── LayersPanel (左侧图层面板)
│ ├── EditorCanvas (中心画布)
│ │ ├── canvas[DOM]
│ │ ├── Toolbar (工具栏)
│ │ └── CanvasContextMenu
│ └── PropertiesPanel (右侧属性面板)
│ ├── DesignPanel
│ ├── CodePanel
│ └── ChatPanel (AI 聊天)
└── CollabPanel (协作状态)
####EditorCanvas 的工作流程
EditorCanvas.vue
↓ ref
<canvas>
↓ useCanvas()
├─ 初始化 SkiaRenderer
├─ 监听 store.state 变化
└─ 每帧调用 renderer.render()
↓ useCanvasInput()
├─ 监听 mouse/touch 事件
├─ 计算交互逻辑
└─ 修改 store(自动触发重新渲染)
###3️⃣ Vue → React 迁移方案(仅保留 Canvas 核心)
####架构设计
最小化的 React 实现只需要:
src-react/
├── components/
│ ├── Canvas.tsx # 核心画布组件(替代 EditorCanvas.vue)
│ └── App.tsx # 主组件
├── hooks/ # 替代 Vue composables
│ ├── useCanvas.ts # 画布初始化和渲染
│ ├── useCanvasInput.ts # 交互处理
│ └── useEditor.ts # 编辑器状态(替代 Pinia store)
├── types.ts # 类型定义
└── index.tsx # 入口
####核心实现步骤
Step 1: 创建编辑器状态管理(React Context + useState)
// hooks/useEditor.ts
import { useState, useCallback } from 'react'
import { SceneGraph, SkiaRenderer, type SceneNode } from '@open-pencil/core'
interface EditorState {
graph: SceneGraph
selectedIds: string[]
activeTool: 'SELECT' | 'RECTANGLE' | 'HAND'
zoom: number
panX: number
panY: number
currentPageId: string
}
export function useEditor() {
const [state, setState] = useState<EditorState>({
graph: new SceneGraph(),
selectedIds: [],
activeTool: 'SELECT',
zoom: 1,
panX: 0,
panY: 0,
currentPageId: 'page-1'
})
const selectNode = useCallback((ids: string[]) => {
setState(prev => ({ ...prev, selectedIds: ids }))
}, [])
const updateNode = useCallback((id: string, changes: Partial<SceneNode>) => {
setState(prev => {
const newGraph = new SceneGraph()
// 复制原有的图形
Object.assign(newGraph, prev.graph)
// 更新节点
const node = newGraph.nodeById(id)
if (node) Object.assign(node, changes)
return { ...prev, graph: newGraph }
})
}, [])
return { state, selectNode, updateNode, setState }
}
Step 2: 画布初始化和渲染
// hooks/useCanvas.ts
import { useEffect, useRef, useCallback } from 'react'
import { getCanvasKit, SkiaRenderer } from '@open-pencil/core'
import type { CanvasKit } from 'canvaskit-wasm'
export function useCanvas(
canvasRef: React.RefObject<HTMLCanvasElement>,
state: EditorState
) {
const rendererRef = useRef<SkiaRenderer | null>(null)
const ckRef = useRef<CanvasKit | null>(null)
const rafRef = useRef<number | null>(null)
// 初始化渲染器
useEffect(() => {
const init = async () => {
const ck = await getCanvasKit()
const canvas = canvasRef.current
if (!canvas) return
ckRef.current = ck
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.clientWidth * dpr
canvas.height = canvas.clientHeight * dpr
// 创建 WebGL Surface
const surface = ck.MakeWebGLCanvasSurface(canvas)
if (!surface) {
console.error('Failed to create WebGL surface')
return
}
rendererRef.current = new SkiaRenderer(ck, surface)
await rendererRef.current.loadFonts()
scheduleRender()
}
init()
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rendererRef.current?.destroy()
}
}, [])
// 渲染循环
const scheduleRender = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
const renderer = rendererRef.current
if (!renderer) return
// 配置渲染参数
renderer.dpr = window.devicePixelRatio || 1
renderer.panX = state.panX
renderer.panY = state.panY
renderer.zoom = state.zoom
renderer.viewportWidth = canvasRef.current?.clientWidth ?? 0
renderer.viewportHeight = canvasRef.current?.clientHeight ?? 0
renderer.pageId = state.currentPageId
// 执行渲染
renderer.render(
state.graph,
state.selectedIds,
{
hoveredNodeId: null,
editingTextId: null,
// 其他渲染覆盖层配置...
}
)
scheduleRender()
})
}, [state])
// 监听状态变化触发重新渲染
useEffect(() => {
scheduleRender()
}, [state, scheduleRender])
}
Step 3: 交互处理
// hooks/useCanvasInput.ts
import { useEffect } from 'react'
import type { EditorState } from './useEditor'
interface DragState {
type: 'move' | 'pan' | 'marquee' | 'resize'
startX: number
startY: number
[key: string]: any
}
export function useCanvasInput(
canvasRef: React.RefObject<HTMLCanvasElement>,
state: EditorState,
onStateChange: (newState: Partial<EditorState>) => void
) {
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let dragState: DragState | null = null
const handleMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 根据当前工具和按键确定拖拽类型
if (e.button === 2 || e.spaceKey) {
// 空格或右键 -> 平移
dragState = {
type: 'pan',
startX: x,
startY: y,
startPanX: state.panX,
startPanY: state.panY
}
} else if (state.activeTool === 'RECTANGLE') {
// 绘制矩形
dragState = {
type: 'move',
startX: x,
startY: y
}
}
}
const handleMouseMove = (e: MouseEvent) => {
if (!dragState) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (dragState.type === 'pan') {
const dx = x - dragState.startX
const dy = y - dragState.startY
onStateChange({
panX: dragState.startPanX + dx,
panY: dragState.startPanY + dy
})
}
}
const handleMouseUp = () => {
dragState = null
}
canvas.addEventListener('mousedown', handleMouseDown)
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseup', handleMouseUp)
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
return () => {
canvas.removeEventListener('mousedown', handleMouseDown)
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseup', handleMouseUp)
}
}, [canvasRef, state, onStateChange])
}
Step 4: React 主组件
// components/Canvas.tsx
import { useRef } from 'react'
import { useEditor } from '../hooks/useEditor'
import { useCanvas } from '../hooks/useCanvas'
import { useCanvasInput } from '../hooks/useCanvasInput'
export function Canvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const { state, setState } = useEditor()
// 初始化渲染
useCanvas(canvasRef, state)
// 初始化交互
useCanvasInput(canvasRef, state, (changes) => {
setState(prev => ({ ...prev, ...changes }))
})
return (
<div style={{ width: '100vw', height: '100vh' }}>
<canvas
ref={canvasRef}
style={{ display: 'block', width: '100%', height: '100%' }}
/>
</div>
)
}
Step 5: App 入口
// App.tsx
import { Canvas } from './components/Canvas'
export function App() {
return (
<div style={{ display: 'flex', height: '100vh' }}>
<Canvas />
</div>
)
}
####关键导入
// 从 @open-pencil/core 中导入
import {
SceneGraph, // 设计数据结构
SkiaRenderer, // 渲染器
getCanvasKit, // 获取 CanvasKit 实例
computeLayout, // 布局计算
type SceneNode, // 节点类型
type RenderOverlays // 渲染覆盖层类型
} from '@open-pencil/core'
####最小功能集(可逐步扩展)
✅ 第一阶段(MVP)
- 基础画布渲染
- 框选和单选
- 拖拽移动节点
- 平移和缩放视图
- 基础矩形绘制
📝 第二阶段
- 其他形状工具(圆形、线条)
- 节点编辑(改变大小、颜色)
- 撤销/重做
🎨 第三阶段
- 文本编辑
- 自动布局
- 组件和实例
####避免的陷阱
- ❌ 不要重新实现 SceneGraph - 直接使用
@open-pencil/core - ❌ 不要绘制 UI - 专注于 Canvas 交互
- ❌ 不要建立复杂的状态管理 - 用 useState + Context 即可
- ❌ 不要处理 Figma 导入/导出 - 先用简单的 JSON 或直接在内存中工作
####包大小对比
原生 Vue 版本: ~300KB (包含所有 UI)
React 最小版本: ~150KB (仅 Canvas)
core 包: ~500KB (共享)
###总结
| 层级 | Vue 技术 | React 等价物 |
|---|---|---|
| 状态管理 | Pinia (EditorStore) | Context API + useState |
| 逻辑组织 | Composables | Custom Hooks |
| 渲染 | template | JSX/TSX |
| 副作用 | watch, onMounted | useEffect |
| 核心 | EditorCanvas.vue | Canvas.tsx |
关键洞察:
- EditorCanvas.vue 的核心逻辑其实就是
useCanvas+useCanvasInput两个 composable - 整个 UI 框架都是围绕这个中心 Canvas 组件的
- React 版本可以直接复用
@open-pencil/core中的所有算法,只需要改变上层的状态管理和交互处理
希望这个分析对你有帮助!需要我详细讲解某个具体部分吗?
END OF POST