How does loora's tech stack work with Rust, GUI, SVG and HTML?
全面解析这个 repo 的技术栈和核心问题的解决方案; 另外我看到作者在用 rust + gui, 但是产品描述中解释主要使用 svg + html dom 实现, 详细解释
完美!现在我有了全面的理解。让我为用户写一个详细的解析文档:
#🎨 Loora - 全面技术栈与核心架构解析
##📋 Executive Summary
Loora 是一个创新的无限画布设计工具,核心特性是允许 Agent(AI 代理)通过 MCP(Model Context Protocol)实时协作编辑设计文档。项目采用**Rust(后端服务)+ TypeScript/React(前端)**的混合架构,其中最有意思的是:虽然用 Rust 构建 GUI 无关的服务,但整个 UI 渲染完全基于现代 Web 技术(SVG + HTML DOM + CSS)。
##🏗️ 技术栈概览
###核心技术选型
| 层级 | 技术栈 | 用途 |
|---|---|---|
| 前端框架 | React 19 + TypeScript | UI 组件、canvas 渲染 |
| 构建工具 | Vite + TanStack Start | 打包、SSR、路由 |
| 样式系统 | Tailwind CSS 4.3 | 设计系统、主题 |
| 桌面应用 | Tauri | 跨平台原生窗口 |
| 包管理 | Bun(Monorepo) | 工作空间、依赖管理 |
| 后端服务 | Rust(Axum) | MCP 服务、WebSocket 服务 |
| 数据库 | Neon(Postgres)+ Drizzle ORM | 数据持久化 |
| 认证 | Better Auth | OAuth、GitHub 集成 |
| 实时通信 | WebSocket(ws-server) | 协作、实时同步 |
| RPC 框架 | oRPC | 类型安全的 Client-Server 通信 |
| 计费 | Polar | 订阅管理 |
###Monorepo 工作空间结构
loora/
├── apps/
│ ├── web # TanStack Start web 应用(主应用)
│ └── desktop # Tauri 桌面应用(共用同一套 UI)
├── crates/ # Rust 服务
│ ├── mcp-server # MCP 协议实现(Agent 通信)
│ └── ws-server # WebSocket 实时服务
├── packages/ # 共享 TypeScript 包
│ ├── canvas # ⭐ 核心:document model、engine、export/import
│ ├── editor # 编辑器 UI(panels、menus、client sync)
│ ├── agent # Agent 工具定义(MCP 工具)
│ ├── rpc # oRPC 路由、storage、history
│ ├── shell # 认证后的产品界面
│ ├── ui # 设计系统(Button、Dialog 等)
│ ├── db # Drizzle schema + migrations
│ ├── auth # Better Auth 集成
│ ├── realtime # 实时协议
│ ├── assistant # 内置 ChatGPT Agent
│ └── billing # Polar 订阅
└── skills/ # AI 技能包(设计指南)
##🎯 核心问题解决方案:为什么 Rust + SVG/DOM?
###问题背景
你发现了一个有趣的矛盾:
- 项目用 Rust 编写了 GUI 服务(
crates/mcp-server、crates/ws-server) - 但产品描述说用 SVG + HTML DOM 实现
这不是矛盾,而是精妙的分层设计。让我详细解释:
###架构分层解释
####第一层:Rust 服务(后端基础设施)
Rust Crates:
├── mcp-server (crates/mcp-server)
│ ├── 用途:MCP 协议实现
│ ├── 依赖:axum、rmcp、redis、tokio
│ ├── 功能:
│ │ ├── 接收来自 Claude/Cursor/Agent 的 MCP 请求
│ │ ├── 验证 OAuth token + rate limiting
│ │ ├── 调用 canvas 工具集(通过 HTTP 回调到 web API)
│ │ └── 响应结构化数据(JSON)
│ └── 地址:mcp.loora.design(独立部署)
│
└── ws-server (crates/ws-server)
├── 用途:实时 WebSocket 通信
├── 依赖:axum、tokio、redis
├── 功能:
│ ├── 管理多人编辑的 WebSocket 连接
│ ├── Redis pub/sub 分布式消息总线
│ ├── 广播 canvas 事务更新
│ └── 处理 presence(谁在编辑)
└── 地址:ws.loora.design(独立部署)
为什么用 Rust?
- 高性能:处理高并发 WebSocket 连接
- 内存安全:关键的网络层不会有内存泄漏
- 轻量:无 GC 开销,Docker 容器足够小
- 并发模型:Tokio async/await 天生适合 I/O 密集型服务
####第二层:Canvas 模型(TypeScript,不涉及 GUI)
// packages/canvas/src/model.ts
export type CanvasNode =
| PageNode // 文档根节点
| FrameNode // 容器
| TextNode // 文本
| ImageNode // 图片
| VectorNode // 矢量(SVG path)
| ShapeNode // 形状(圆、矩形)
| ComponentNode // 可复用组件
| InstanceNode // 组件实例
export interface CanvasDocument {
id: string
version: number
schemaVersion: 2
nodes: Record<NodeId, CanvasNode>
breakpoints: CanvasBreakpoint[]
tokens: DesignToken[]
themes: CanvasTheme[]
activeTheme: ThemeId
animations: CanvasAnimation[]
}
// packages/canvas/src/engine.ts
export type CanvasOperation =
| { type: 'node.insert'; node: CanvasNode }
| { type: 'node.patch'; id: NodeId; patch: NodeMutationPatch }
| { type: 'node.move'; id: NodeId; parentId: NodeId | null; order: number }
| { type: 'node.delete'; id: NodeId }
// ... 其他操作
export interface CanvasTransaction {
id: string
label: string
operations: CanvasOperation[]
preconditions?: CanvasFieldPrecondition[]
documentUpdatedAt?: number
}
这一层完全是数据模型和业务逻辑,不涉及 GUI
####第三层:React 渲染层(SVG + HTML DOM)
// packages/canvas/src/react.tsx
export function CanvasProvider({ engine, children }: CanvasProviderProps) {
// 提供 canvas context 给整个编辑器
}
// 核心渲染函数
function RenderNode({ id, width, topLevel }: RenderNodeProps) {
const node = useCanvasNode(id)
const style = computeNodeStyle(node) // 转换成 CSS
if (node.type === 'vector') {
// 矢量图:渲染为 <svg><path /></svg>
return (
<svg viewBox={node.viewBox} style={style}>
{node.paths.map(path => (
<path key={path.id} d={path.d} fill={path.fill} />
))}
</svg>
)
}
if (node.type === 'text') {
// 文本:渲染为 <div> + CSS
return <div style={style}>{node.content}</div>
}
if (node.type === 'shape') {
// 形状:用 CSS border-radius 等实现
return <div style={style} />
}
// frame、component 等:渲染为结构化 div
return <div style={style}>{/* children */}</div>
}
为什么用 SVG + HTML DOM?
- 矢量图用 SVG:编辑
path d属性、viewBox属性 - 布局用 HTML + CSS:
- Flexbox(
display: flex) - CSS Grid(
display: grid) - Absolute positioning(
position: absolute)
- Flexbox(
- 样式用 CSS Properties:
fill、stroke、border-radius、opacity等 - 优势:
- 浏览器原生支持,不需要 Canvas API 或 WebGL
- 可以导出为真实 HTML/CSS/JSX
- 支持 CSS 动画和过渡
- 可访问性(a11y)友好
####第四层:导出层(HTML/JSX 生成)
// packages/canvas/src/export.ts
export async function exportCanvasAsHtml(
document: CanvasDocument,
options: CanvasExportOptions
): Promise<string> {
// 遍历 canvas tree,生成实际的 HTML
// <div style="display:flex; ...">
// <svg viewBox="..." aria-hidden="true">
// <path d="..." />
// </svg>
// <div style="font-size: 16px;">Text</div>
// </div>
// 同时生成对应的 CSS
// 用 Tailwind preflight 初始化基线
// 用 design tokens 生成 CSS variables
}
export async function exportCanvasAsReact(
document: CanvasDocument,
options: CanvasExportOptions
): Promise<{ jsx: string; css: string }> {
// 生成 React 组件代码
// 遵循原始的层级结构和 semantic tags
}
export async function renderElementToPng(
element: HTMLElement | SVGElement,
options: CanvasPngRenderOptions
): Promise<Buffer> {
// 用浏览器 API 将 DOM 元素转换为 PNG
// 使用 XMLSerializer + <foreignObject> 技巧
}
##🔄 数据流:从 Agent 到 Canvas 再到 HTML
###完整编辑流程
┌─────────────────────┐
│ Claude (Agent) │
│ 通过 MCP 调用工具 │
└──────────┬──────────┘
│
│ HTTP MCP 请求
│ (getDesignContext, readTree, patchNode, etc.)
▼
┌──────────────────────────────────────┐
│ Rust MCP Server (mcp.loora.design) │
│ crates/mcp-server │
│ │
│ ├─ auth.rs OAuth 验证 │
│ ├─ rate_limit.rs 速率限制 │
│ └─ server.rs 处理 MCP 请求 │
│ └─> 调用 Web API (oRPC) │
└──────────┬──────────────────────────┘
│
│ oRPC 请求 (JSON-RPC)
│
▼
┌──────────────────────────────────────┐
│ Web App (apps/web) │
│ Route: /api/rpc/$ │
│ │
│ packages/rpc/src/mcp-procedures.ts │
│ ├─ getDesignContext() 获取设计 │
│ ├─ readTree() 读取 tree │
│ └─ applyTransaction() 应用编辑 │
└──────────┬──────────────────────────┘
│
│ 读/写 Postgres (Drizzle)
│
▼
┌──────────────────────────────────────┐
│ Canvas Engine (packages/canvas) │
│ │
│ ├─ model.ts 定义数据结构 │
│ ├─ engine.ts 实现事务处理 │
│ └─ merge.ts 冲突合并 (CRDT) │
│ │
│ CanvasOperation[] → CanvasDocument│
└──────────┬──────────────────────────┘
│
│ WebSocket 广播 (ws-server)
│
▼
┌──────────────────────────────────────┐
│ React 编辑器 (packages/editor) │
│ react.tsx │
│ ├─ CanvasProvider │
│ ├─ CanvasNodeRenderer │
│ └─ 实时更新 UI │
│ │
│ Canvas DOM 树 + Viewport overlay │
└──────────┬──────────────────────────┘
│
│ 用户可导出为:
▼
┌──────────────────┐
│ • HTML + CSS │
│ • React TSX │
│ • JSON │
│ • PNG │
└──────────────────┘
###关键数据结构转换
####1️⃣ Model → Transactions (Agent 编辑时)
// Agent 通过 MCP 工具调用
// Input: { designId, nodeId: "frame-1", layout: { x: 100, y: 50 } }
// Canvas Server 处理
const transaction: CanvasTransaction = {
id: 'txn-123',
label: 'Agent moved frame',
operations: [
{
type: 'node.patch',
id: 'frame-1',
patch: {
layout: {
x: 100,
y: 50,
// 其他保持不变
}
}
}
]
}
// Engine 应用
const result = engine.apply(transaction)
// → 返回新的 CanvasDocument
// → 广播到所有连接的编辑器
####2️⃣ Document → React DOM (实时渲染)
// Canvas 树结构
{
nodes: {
'page-1': {
type: 'page',
id: 'page-1',
children: ['frame-1']
},
'frame-1': {
type: 'frame',
id: 'frame-1',
layout: { x: 100, y: 50, width: 300, height: 200 },
style: { fills: [{ type: 'solid', color: '#FF0000' }] },
children: ['text-1']
},
'text-1': {
type: 'text',
id: 'text-1',
content: 'Hello'
}
}
}
// React 渲染为
<CanvasProvider engine={engine}>
<div
data-loora-node="frame-1"
style={{
position: 'absolute',
left: '100px',
top: '50px',
width: '300px',
height: '200px',
background: '#FF0000'
}}
>
<div style={{ fontSize: '16px' }}>Hello</div>
</div>
</CanvasProvider>
####3️⃣ DOM → HTML Export (导出时)
// 遍历 canvas tree,生成字符串
export function renderNode(
node: CanvasNode,
index: CanvasChildIndex
): string {
const css = serializeStyle(node.style, node.layout)
if (node.type === 'frame') {
const children = orderedChildren(document, node.id, index)
.map(child => renderNode(child, index))
.join('')
return `
<div style="${css}">
${children}
</div>
`
}
if (node.type === 'vector') {
const paths = node.paths
.map(p => `<path d="${p.d}" fill="${p.fill}" />`)
.join('')
return `<svg viewBox="${node.viewBox}">${paths}</svg>`
}
// ...
}
// 输出真实 HTML 字符串
// <div style="display:flex;flex-direction:row;...">
// <svg viewBox="0 0 100 100" aria-hidden="true">
// <path d="M 10 10 L 90 90" fill="none" stroke="#000" />
// </svg>
// </div>
##🛠️ 关键技术深度解析
###1. Canvas Engine:事务引擎
// packages/canvas/src/engine.ts
export class CanvasEngine {
document: CanvasDocument
apply(transaction: CanvasTransaction): CanvasApplyResult {
// 1. 验证前置条件
if (transaction.preconditions) {
for (const cond of transaction.preconditions) {
const field = this.getField(cond)
if (field.hash !== cond.hash) {
throw new Error(`Precondition failed: ${cond.path}`)
}
}
}
// 2. 顺序执行每个 operation
let doc = this.document
for (const op of transaction.operations) {
doc = this.applyOperation(doc, op)
}
// 3. 触发事件、更新订阅者
this.document = doc
this.revision++
this.listeners.forEach(fn => fn())
return { newDocument: doc, revision: this.revision }
}
applyOperation(doc: CanvasDocument, op: CanvasOperation) {
switch (op.type) {
case 'node.insert':
return { ...doc, nodes: { ...doc.nodes, [op.node.id]: op.node } }
case 'node.patch':
return {
...doc,
nodes: {
...doc.nodes,
[op.id]: { ...doc.nodes[op.id], ...op.patch }
}
}
case 'node.move':
// 更新 parent、sibling 的 order
return { /* ... */ }
// ...
}
}
}
为什么用事务?
- 原子性:多个操作要么全部成功,要么全部失败
- 合并:CRDT(冲突自由复制数据类型)支持离线编辑
- 版本历史:每个事务都是一个 checkpoint
- Undo/Redo:逆向执行事务
###2. Merge 策略:CRDT
// packages/canvas/src/merge.ts
export function mergeDocuments(
base: CanvasDocument,
ours: CanvasTransaction[],
theirs: CanvasTransaction[]
): { document: CanvasDocument; conflicts: string[] } {
// 执行 ours
let merged = base
for (const txn of ours) merged = apply(merged, txn)
// 尝试应用 theirs(如果冲突,使用特定的合并规则)
for (const txn of theirs) {
try {
merged = apply(merged, txn)
} catch (err) {
// CRDT 的关键:用 order 和 timestamp 解决冲突
// 例如,两个人同时在同一个父节点插入子节点
// 用 order 值自动排序,无需手动冲突解决
merged = applyCrdtMerge(merged, txn, base)
}
}
return { document: merged }
}
###3. 响应式布局计算
// packages/canvas/src/style-css.ts
export function layoutDeclarations(
layout: CanvasLayout,
context: { parent?: LayoutParent; asRoot: boolean }
): string[] {
const decls: string[] = []
// Flex 容器
if (layout.mode === 'flex') {
decls.push('display: flex')
decls.push(`flex-direction: ${layout.direction || 'row'}`)
if (layout.gap) decls.push(`gap: ${layout.gap}px`)
decls.push(`padding: ${serializePadding(layout.padding)}`)
}
// Grid 容器
if (layout.mode === 'grid') {
decls.push('display: grid')
if (layout.columns) decls.push(`grid-template-columns: repeat(${layout.columns}, 1fr)`)
}
// 尺寸
if (layout.width.unit === 'fill' && context.parent?.mode === 'flex') {
// flex 中的 fill = flex: 1
decls.push('flex: 1')
} else {
decls.push(`width: ${lengthValue(layout.width, 'width')}`)
}
// ... 更多
return decls
}
支持的布局模式:
- Absolute:
position: absolute,用 x/y 定位 - Flex:
display: flex,direction 控制行/列,gap、padding、align、justify - Grid:
display: grid,columns 定义列数
###4. 实时协作:WebSocket + Redis
// crates/ws-server/src/main.rs
// Redis pub/sub
let client = redis::Client::open(redis_url)?
.get_connection_manager()?
// 处理 WebSocket 连接
tokio::select! {
msg = websocket.recv() => {
match msg {
Some(WsMessage::Text(payload)) => {
let txn: CanvasTransaction = serde_json::from_str(&payload)?
// 广播给同设计文件的所有客户端
client.publish(
format!("design:{}", design_id),
serde_json::to_string(&txn)?
).await?
}
}
}
msg = pubsub.on_message() => {
// 接收其他客户端的更新
websocket.send(WsMessage::Text(msg.get_payload()?)).await?
}
}
###5. MCP 工具体系
// packages/agent/src/canvas-tools.ts
// 定义 Zod schema 用于 MCP 验证
const createFrameSchema = z.object({
designId: z.string(),
name: z.string().max(256),
width: lengthSchema,
height: lengthSchema,
x: z.number().finite(),
y: z.number().finite(),
})
// crates/mcp-server/src/tools.json
// 内置所有可用工具的描述
[
{
"name": "createFrame",
"description": "Create a new frame on the canvas",
"inputSchema": {
"type": "object",
"properties": { /* ... */ }
}
},
{
"name": "patchNode",
"description": "Update a node's properties (layout, style, etc.)"
},
{
"name": "listDesigns",
"description": "List all designs accessible to the user"
}
]
##📊 多人协作流程详解
###场景:Claude 和用户同时编辑
时刻 0:
┌─────────────────────────────────────┐
│ Canvas (Main) │
│ frame-1: { x: 0, y: 0, width: 100 }│
└─────────────────────────────────────┘
时刻 1ms: 用户在编辑器中拖动 frame-1 → x: 50
编辑器生成事务:
Transaction {
id: 'txn-user-1',
operations: [
{
type: 'node.patch',
id: 'frame-1',
patch: { layout: { x: 50 } }
}
]
}
↓ WebSocket 发送给 ws-server
↓ ws-server 广播给所有客户端
↓ Redis pub/sub 持久化
时刻 5ms: Claude 同时拖动 frame-1 → x: 100
MCP 工具调用:
patchNode({
designId,
nodeId: 'frame-1',
layout: { x: 100 }
})
↓ MCP Server 调用 Web API
↓ 创建事务 Transaction { id: 'txn-claude-1', operations: [...] }
↓ Canvas Engine 应用
** 此时冲突了:frame-1 的 x 有两个值 **
Merge 策略(CRDT):
- 用 transaction.id 作为唯一标识
- 用 timestamp 或 lamport clock 排序
- 后来的编辑胜出(Last-Write-Wins)
最终状态:frame-1: { x: 100 } ← Claude 的编辑胜出
或者
最终状态:frame-1: { x: 50 } ← 用户的编辑胜出
(取决于具体的 CRDT 策略)
编辑器最终显示:双方都看到一致的状态
##🚀 部署架构
┌─────────────────────────────────────────────────────────────┐
│ Railway (Container Hosting) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Web App │ │ MCP Server │ │ WS Server │ │
│ │ apps/web │ │ Rust Axum │ │ Rust Tokio │ │
│ │ (TanStack Start)│ │ │ │ │ │
│ │ │ │ Port 3000 │ │ Port 3001 │ │
│ │ Port 80/443 │ │ │ │ │ │
│ └────────┬─────────┘ └──────┬───────┘ └─────┬───────┘ │
│ │ │ │ │
│ └───────────────────┼────────────────┘ │
│ │ │
├───────────────────────────────┼─────────────────────────────┤
│ │ │
│ ┌─────────────▼──────────────┐ │
│ │ Neon Postgres │ │
│ │ (Managed Database) │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Redis Cluster │ │
│ │ (Pub/Sub, Sessions) │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Docker 部署
# crates/mcp-server/Dockerfile
FROM rust:latest
WORKDIR /app
COPY . .
RUN cargo build --release -p loora-mcp-server
CMD ["./target/release/loora-mcp-server"]
##📦 关键包导出 API
###@loora/canvas
// Document Model
import { CanvasDocument, CanvasNode, CanvasOperation } from '@loora/canvas/model'
// Engine(事务处理)
import { CanvasEngine, CanvasTransaction } from '@loora/canvas/engine'
// React 渲染
import { CanvasProvider, useCanvasNode, useCanvasContext } from '@loora/canvas/react'
// 导出
import {
exportCanvasAsHtml,
exportCanvasAsReact,
exportCanvasAsJson,
renderElementToPng
} from '@loora/canvas/export'
// 导入
import { convertHtmlSnapshotToCanvas } from '@loora/canvas/import'
// Merge(CRDT)
import { mergeDocuments } from '@loora/canvas/merge'
###@loora/agent(MCP 工具)
import { canvasToolDefinitions } from '@loora/agent/canvas-tools'
// [
// { name: 'createFrame', inputSchema: { ... } },
// { name: 'patchNode', inputSchema: { ... } },
// { name: 'moveNode', inputSchema: { ... } },
// ...
// ]
import { repairLayout } from '@loora/agent/repair-layout'
// 修复布局冲突的工具函数
###@loora/rpc(Client API)
// oRPC 路由定义
import { appRouter } from '@loora/rpc'
// 使用示例
const client = createORpcClient({ url: '/api/rpc' })
// 读取
const design = await client.getDesignContext({
designId: 'xyz',
depth: 4
})
// 写入
const result = await client.applyTransaction({
designId: 'xyz',
transaction: { operations: [...] }
})
##🎓 总结:为什么这个架构很聪明?
###✅ 优点
-
分离关注点
- Rust 处理高并发网络 I/O(MCP、WebSocket)
- TypeScript 处理业务逻辑和 UI(Canvas Model、Rendering)
- 两者不互相依赖
-
可导出性
- Canvas 完全独立的数据模型 → 可导出为真实 HTML/CSS
- 不依赖任何 Canvas API 或特定渲染引擎
- Agent 可以理解和编辑纯数据结构
-
实时协作
- WebSocket + Redis Pub/Sub = 低延迟多人编辑
- CRDT Merge = 自动冲突解决,无需锁定
- Rust 的性能保证高并发
-
跨平台
- 同一套 React 组件 = Web + Desktop(Tauri)
- 代码复用率高
###❌ 权衡
-
学习曲线陡峭
- 需要理解 Canvas Model、Engine、CRDT、MCP、WebSocket 等概念
-
复杂性
- Monorepo 中有 15+ 个包
- 三种语言(Rust、TypeScript、SQL)
-
部署复杂
- 至少需要 3 个独立服务(Web、MCP、WS)
- Redis、Postgres 依赖
##🔗 关键文件速查表
| 功能 | 文件位置 |
|---|---|
| Document 结构定义 | packages/canvas/src/model.ts |
| 事务引擎 | packages/canvas/src/engine.ts |
| React 渲染 | packages/canvas/src/react.tsx (3000+ 行) |
| HTML 导出 | packages/canvas/src/export.ts |
| HTML 导入 | packages/canvas/src/import.ts |
| Merge CRDT | packages/canvas/src/merge.ts |
| 样式转换 | packages/canvas/src/style-css.ts |
| MCP 工具定义 | packages/agent/src/canvas-tools.ts |
| MCP 服务器 | crates/mcp-server/src/server.rs |
| WebSocket 服务 | crates/ws-server/src/server.rs |
| oRPC 路由 | packages/rpc/src/router.ts + mcp-procedures.ts |
| 数据库 | packages/db/ (Drizzle schema) |
希望这个解析清楚地回答了你的问题!Rust + SVG/DOM 并不是矛盾,而是一个精妙的分层设计:使用 Rust 处理网络基础设施的高并发需求,而用现代 Web 技术处理 UI 和业务逻辑。这样既获得了 Rust 的性能优势,又保留了 Web 的灵活性和可导出性。🚀
renderElementToPng 详细解释这个函数是如何实现的
完美!现在我有了完整的理解。让我创建一个详细的解析文档:
#🖼️ renderElementToPng 详细实现解析
##📌 核心概念:DOM → SVG → Canvas → PNG
这个函数巧妙地利用了浏览器的多个 API 来实现无痛苦的 DOM 到 PNG 转换。关键在于使用 SVG 的 <foreignObject> 元素作为桥接。
┌─────────────────────────────────────────────────────────────┐
│ HTML/SVG DOM Element (在浏览器中渲染的真实元素) │
│ <div style="..."> │
│ <svg viewBox="..."> │
│ <path d="..." /> │
│ </svg> │
│ </div> │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第1步:克隆 + 内联图片
▼
┌──────────────────────────────────────────────────────────────┐
│ 克隆的 DOM Tree │
│ 所有外部 URL 的 <img> 转换为 data: URLs │
│ 无法读取的图片用 1×1 透明 GIF 替代 │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第2步:序列化为 XML 字符串
▼
┌──────────────────────────────────────────────────────────────┐
│ XML 字符串 │
│ "<div style=\"...\"> │
│ <svg viewBox=\"...\"> │
│ <path d=\"...\" /> │
│ </svg> │
│ </div>" │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第3步:包装在 SVG 的 <foreignObject> 中
▼
┌──────────────────────────────────────────────────────────────┐
│ SVG 字符串 │
│ "<svg xmlns=\"...\" width=\"800\" height=\"600\"> │
│ <foreignObject width=\"100%\" height=\"100%\"> │
│ <!-- 上面的 XML 字符串 --> │
│ </foreignObject> │
│ </svg>" │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第4步:转换为 Blob URL
▼
┌──────────────────────────────────────────────────────────────┐
│ Data URL: blob:http://localhost/a1b2c3d4 │
│ MIME type: image/svg+xml │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第5步:加载为 Image 对象
▼
┌──────────────────────────────────────────────────────────────┐
│ HTMLImageElement │
│ { │
│ src: 'blob:...', │
│ width: 800, │
│ height: 600, │
│ data: /* 光栅化的 bitmap */ │
│ } │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第6步:绘制到 Canvas
▼
┌──────────────────────────────────────────────────────────────┐
│ Canvas 2D Context │
│ context.drawImage(image, 0, 0, width, height) │
│ /* 浏览器渲染 SVG 至 Canvas 的像素数据 */ │
└──────────────────┬──────────────────────────────────────────┘
│
│ 第7步:转换为 PNG Data URL
▼
┌──────────────────────────────────────────────────────────────┐
│ PNG Data URL │
│ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..." │
└──────────────────────────────────────────────────────────────┘
##🔍 逐步代码解析
###第1步:验证环境 & 获取尺寸
export async function renderElementToPng(
element: HTMLElement | SVGElement,
options: CanvasPngRenderOptions = {},
) {
// 验证是否在浏览器环境
if (typeof document === 'undefined' || typeof XMLSerializer === 'undefined') {
throw new Error('PNG rendering is available in a browser environment')
}
// 获取元素的实际视口尺寸(包括所有 CSS 变换)
const bounds = element.getBoundingClientRect()
// bounds = {
// top: 100,
// left: 50,
// width: 800,
// height: 600,
// right: 850,
// bottom: 700,
// x: 50,
// y: 100
// }
// 使用传入的尺寸,或者使用实际尺寸
const width = Math.max(1, Math.ceil(options.width ?? bounds.width)) // 800
const height = Math.max(1, Math.ceil(options.height ?? bounds.height)) // 600
为什么用 getBoundingClientRect()?
- 获取渲染后的尺寸(包括 zoom、scale 等 CSS 变换)
- 而不是
offsetWidth(只有布局尺寸)或clientWidth(不含 border)
###第2步:处理像素比和尺寸限制
// 像素比:用于高 DPI 屏幕(Retina 等)
const requestedPixelRatio = options.pixelRatio ?? window.devicePixelRatio ?? 1
// devicePixelRatio 可能是 1(普通屏)、2(Retina)、3(高端手机)
// 限制在合理范围内(防止浪费内存)
const pixelRatio = Math.max(0.1, Math.min(requestedPixelRatio, 4))
// 计算最终的 Canvas 像素尺寸
const pixelWidth = Math.max(1, Math.round(width * pixelRatio)) // 800 × 2 = 1600
const pixelHeight = Math.max(1, Math.round(height * pixelRatio)) // 600 × 2 = 1200
// 安全检查:防止内存溢出
const MAX_BROWSER_PNG_PIXELS = 32_000_000 // ~128 MiB 原始 RGBA
const MAX_BROWSER_PNG_DIMENSION = 8_192
if (
!Number.isFinite(pixelWidth) ||
!Number.isFinite(pixelHeight) ||
pixelWidth > MAX_BROWSER_PNG_DIMENSION ||
pixelHeight > MAX_BROWSER_PNG_DIMENSION ||
pixelWidth * pixelHeight > MAX_BROWSER_PNG_PIXELS
) {
throw new Error(
'The PNG is too large to capture safely. Use a smaller scope or scale.',
)
}
例子:
用户要求:width=800, height=600, pixelRatio=2(Retina)
最终 Canvas:pixelWidth=1600, pixelHeight=1200
内存占用:1600 × 1200 × 4 bytes (RGBA) = 7.68 MB ✓
###第3步:克隆 DOM 树
// 深克隆整个 DOM 树(包括所有子元素和事件监听器不会复制)
const clone = element.cloneNode(true) as HTMLElement | SVGElement
// 关键:内联所有外部 <img> 的 src
// 这是为了防止 Canvas 被"污染"(taint canvas)
for (const skipped of await inlineBrowserImages(clone)) {
options.onSkippedImage?.(skipped)
}
为什么要 inlineBrowserImages?
这是整个函数最关键的部分!详见下面的完整解析。
###第4步:规范化克隆的位置
// 移除所有影响位置的样式
clone.style.position = 'relative' // 防止 absolute 影响渲染
clone.style.left = '0'
clone.style.top = '0'
clone.style.transform = 'none' // 移除旋转、缩放等
clone.style.margin = '0' // 移除外边距
// 如果克隆的是 SVG 元素,需要包装在 div 中
const root =
clone.namespaceURI === 'http://www.w3.org/2000/svg'
? document.createElement('div') // 创建 HTML 容器
: clone // HTML 元素直接用
if (root !== clone) root.appendChild(clone) // 如果是 SVG,加入 div
root.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
为什么要包装 SVG?
原始:<svg viewBox="0 0 100 100">...</svg>
需要变成:<div xmlns="http://www.w3.org/1999/xhtml">
<svg viewBox="0 0 100 100">...</svg>
</div>
原因:SVG 的 <foreignObject> 内容必须是 XHTML
###第5步:序列化为 XML 字符串
// XMLSerializer 是浏览器 API,将 DOM 转换为 XML 字符串
const markup = new XMLSerializer().serializeToString(root)
// 输出例如:
// "<div xmlns=\"http://www.w3.org/1999/xhtml\">
// <svg viewBox=\"0 0 100 100\" style=\"...\">
// <path d=\"M 10 10 L 90 90\" fill=\"none\" />
// </svg>
// </div>"
它做了什么?
- 遍历 DOM 树
- 转换为 XML 字符串表示
- 包含所有属性和样式(内联)
###第6步:包装在 SVG 的 <foreignObject> 中
const svg = `<svg xmlns="http://www.w3.org/2000/svg"
width="${width}"
height="${height}"
viewBox="0 0 ${width} ${height}">
<foreignObject width="100%" height="100%">
${markup}
</foreignObject>
</svg>`
// 最终输出:
// "<svg xmlns="http://www.w3.org/2000/svg"
// width="800"
// height="600"
// viewBox="0 0 800 600">
// <foreignObject width="100%" height="100%">
// <div xmlns="http://www.w3.org/1999/xhtml">
// <svg viewBox="0 0 100 100" style="...">
// <path d="..." />
// </svg>
// </div>
// </foreignObject>
// </svg>"
这是核心技巧!为什么?
| 问题 | 为什么需要 <foreignObject> |
|---|---|
| HTML 不能直接光栅化 | <img src="data:text/html"> 不存在,浏览器不支持 |
| SVG 可以被渲染 | 浏览器知道如何渲染 SVG 为像素 |
<foreignObject> | SVG 中的特殊元素,允许嵌入 HTML/XHTML 内容 |
| 结果 | 浏览器渲染:SVG → 光栅化像素 → Canvas 可以读取 |
Without <foreignObject>:
HTML → ❌ 无法直接转为图片
With <foreignObject>:
HTML → wrapped in SVG → ✅ 可以转为图片
###第7步:创建 Blob URL
// 将 SVG 字符串转换为 Blob
const url = URL.createObjectURL(
new Blob([svg], { type: 'image/svg+xml' })
)
// url = "blob:http://localhost/a1b2c3d4-e5f6-..."
// MIME type 告诉浏览器这是 SVG
为什么用 createObjectURL?
- 将数据转换为可以像真实网络资源一样加载的 URL
- 浏览器的图片加载器会解析这个 SVG
###第8步:加载 SVG 为 Image 对象
try {
// 异步加载 SVG,浏览器会渲染它
const image = await loadBrowserImage(url)
// loadBrowserImage 的实现:
function loadBrowserImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image) // ✓ 加载成功
image.onerror = () => reject(new Error('...')) // ✗ 加载失败
image.src = src // 触发加载
})
}
**关键时刻!**此时浏览器会:
- 解析 SVG
- 渲染其中的
<foreignObject>内容 - 将整个内容光栅化为像素图像
- 将结果存储在
image对象中
###第9步:创建 Canvas 并绘制
// 创建 Canvas
const canvas = document.createElement('canvas')
canvas.width = pixelWidth // 物理像素宽度
canvas.height = pixelHeight // 物理像素高度
// 获取 2D 渲染上下文
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas export context is unavailable')
// 缩放上下文以匹配像素比
// 如果 pixelRatio=2,所有绘制操作都会放大 2 倍
context.scale(pixelRatio, pixelRatio)
// 重要:这里 width 和 height 是**逻辑**尺寸(不包括像素比)
context.drawImage(image, 0, 0, width, height)
// 浏览器实际绘制的是:
// - 逻辑坐标 (0, 0) 到 (width, height)
// - 但由于 scale(pixelRatio, pixelRatio),实际占用 pixelWidth × pixelHeight 像素
绘制过程的数学:
例:width=800, height=600, pixelRatio=2
pixelWidth=1600, pixelHeight=1200
context.scale(2, 2)
现在所有坐标都被乘以 2
context.drawImage(image, 0, 0, 800, 600)
逻辑坐标:(0,0) 到 (800×600)
实际像素:(0,0) 到 (1600×1200) ← 因为乘以 2
结果:Retina 屏幕上显示清晰的图像 ✓
###第10步:转换为 PNG Data URL
try {
return canvas.toDataURL('image/png')
// 输出:
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA..."
} catch {
// Canvas 被污染了(被来自其他源的资源污染)
throw new Error(
'The browser refused to read the capture back because part of this design is loaded from another site.',
)
}
} finally {
// 清理 Blob URL,释放内存
URL.revokeObjectURL(url)
}
}
toDataURL 可能失败的原因:
// ✓ 安全的图片来源
<img src="/api/asset/123"> // 同源
<img src="data:image/png;base64,"> // 数据 URL
// ✗ 污染 Canvas 的图片来源
<img src="https://other-site.com/image.png"> // 跨域
<img src="https://cdn.example.com/pic.jpg"> // CORS 没有开启
// 结果:toDataURL() 抛出错误
这就是为什么 inlineBrowserImages 非常重要!
##🔴 深度分析:inlineBrowserImages 函数
这是整个转换过程中最复杂的部分。它的目标是:
将所有外部
<img>URL 转换为 data: URLs,这样 Canvas 就不会被污染。
export async function inlineBrowserImages(root: Element) {
// 第1步:收集所有 <img> 标签
const images = [
...root.querySelectorAll('img'),
...(root.tagName === 'IMG' ? [root as HTMLImageElement] : []),
]
// 第2步:按 src 分组(避免重复加载相同的图片)
const imagesBySource = new Map<string, HTMLImageElement[]>()
for (const image of images) {
// srcset 会引入远程候选项,必须移除
image.removeAttribute('srcset')
const source = image.getAttribute('src') ?? ''
// 跳过空的和已经是 data: 的 URL
if (!source || source.startsWith('data:')) continue
const matching = imagesBySource.get(source) ?? []
matching.push(image)
imagesBySource.set(source, matching)
}
const entries = [...imagesBySource]
const skipped: string[] = []
let cursor = 0
// 第3步:并发加载图片(最多 4 个并发)
// 为什么限制并发?防止同时向浏览器请求太多资源
const IMAGE_INLINE_CONCURRENCY = 4
await Promise.all(
Array.from(
{ length: Math.min(IMAGE_INLINE_CONCURRENCY, entries.length) },
async () => {
while (cursor < entries.length) {
const [source, matching] = entries[cursor++]!
const image = matching[0]!
try {
// 第4步:获取图片二进制数据
const response = await fetch(image.src, {
credentials: 'same-origin', // 包含 cookie(同源)
})
if (!response.ok) {
throw new Error(`Image responded ${response.status}`)
}
// 第5步:转换为 data: URL
const dataUrl = await blobToDataUrl(await response.blob())
// 第6步:更新所有相同 src 的 <img> 标签
for (const target of matching) {
target.setAttribute('src', dataUrl)
}
} catch {
// 加载失败的处理:
// - 记录失败的 URL
skipped.push(...matching.map(() => source))
// - 用 1×1 透明 GIF 替代(防止污染 Canvas)
const BLANK_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
for (const target of matching) {
target.setAttribute('src', BLANK_IMAGE)
}
}
}
},
),
)
return skipped
}
###blobToDataUrl 如何工作?
async function blobToDataUrl(blob: Blob) {
// 第1步:获取字节数据
const bytes = new Uint8Array(await blob.arrayBuffer())
// 第2步:转换为二进制字符串
// ⚠️ 为什么分块?fromCharCode 有参数数量限制(栈溢出)
let binary = ''
for (let index = 0; index < bytes.length; index += 0x8000) {
// 0x8000 = 32,768 字节 / 块
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
}
// 第3步:Base64 编码
const b64 = btoa(binary) // binary to ASCII
// 第4步:生成 data: URL
return `data:${blob.type || 'application/octet-stream'};base64,${b64}`
// 例如:
// data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA...
}
例子:
// 输入:PNG 图片
blob.type = 'image/png'
blob.arrayBuffer() = Uint8Array([137, 80, 78, 71, ...])
// 输出:
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
###为什么需要这么复杂?
| 问题 | 解决方案 |
|---|---|
| 跨域图片污染 Canvas | 必须将所有 URL 转换为 data: URLs |
| data: URL 太大 | 使用 Base64 编码,嵌入在 URL 中 |
| Base64 编码很慢 | 预先转换,不用在每次导出时重新编码 |
| srcset 会引入远程链接 | 移除 srcset 属性 |
| 多个图片使用同一个 src | 使用 Map 分组,只加载一次,结果重用 |
| 加载太多图片会卡顿 | 限制并发为 4 |
| 无法加载的图片 | 用 1×1 透明 GIF 替代,而不是留下 URL |
##📊 完整流程图(包含所有细节)
renderElementToPng(element, options)
│
├─ 第1步:验证环境
│ └─ typeof document !== 'undefined' ? ✓ : ✗ throw
│
├─ 第2步:计算尺寸
│ ├─ bounds = element.getBoundingClientRect()
│ ├─ width, height 从 options 或 bounds 中来
│ └─ pixelRatio 从 options 或 window.devicePixelRatio 中来
│
├─ 第3步:安全检查
│ └─ pixelWidth × pixelHeight ≤ 32,000,000 pixels ? ✓ : ✗ throw
│
├─ 第4步:克隆 DOM
│ └─ clone = element.cloneNode(true)
│
├─ 第5步:内联图片 ⭐ 关键
│ └─ inlineBrowserImages(clone)
│ ├─ 收集所有 <img> 标签
│ ├─ 按 src 分组
│ ├─ 并发加载(最多 4 个)
│ │ └─ fetch(image.src) → Blob → Base64 → data: URL
│ └─ 失败的用 1×1 GIF 替代
│
├─ 第6步:规范化位置
│ ├─ clone.style.position = 'relative'
│ ├─ clone.style.transform = 'none'
│ └─ 如果是 SVG,包装在 <div> 中
│
├─ 第7步:序列化为 XML
│ └─ markup = new XMLSerializer().serializeToString(root)
│
├─ 第8步:包装在 SVG 中
│ └─ svg = `<svg ...><foreignObject>...</foreignObject></svg>`
│
├─ 第9步:创建 Blob URL
│ └─ url = URL.createObjectURL(new Blob([svg]))
│
├─ 第10步:加载为 Image ⭐ 浏览器渲染发生在这里
│ └─ image = await loadBrowserImage(url)
│ └─ 浏览器:渲染 SVG → 光栅化为像素
│
├─ 第11步:创建 Canvas
│ ├─ canvas.width = pixelWidth
│ ├─ canvas.height = pixelHeight
│ └─ context = canvas.getContext('2d')
│
├─ 第12步:绘制图像
│ ├─ context.scale(pixelRatio, pixelRatio)
│ └─ context.drawImage(image, 0, 0, width, height)
│
├─ 第13步:转换为 PNG
│ └─ return canvas.toDataURL('image/png')
│
└─ 第14步:清理
└─ URL.revokeObjectURL(url)
##🧪 测试案例分析
###测试 1:跨域图片污染
it('blanks an image it cannot read rather than tainting the capture', async () => {
globalThis.fetch = (async (input) => {
// 模拟 CORS 错误
if (String(input).includes('cdn.example.com')) throw new Error('CORS')
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'Content-Type': 'image/png' },
})
})
const host = document.createElement('div')
host.innerHTML =
'<img src="https://cdn.example.com/hero.png" srcset="...">'+ // ✗ 无法读取
'<img src="/api/asset/a1">' // ✓ 可以读取
const skipped = await inlineBrowserImages(host)
expect(skipped).toEqual(['https://cdn.example.com/hero.png'])
// 无法读取的图片被替换为 BLANK_IMAGE
expect(images[0].src).toMatch(/^data:image\/gif;base64,/)
// 可以读取的图片被转为 data: URL
expect(images[1].src).toMatch(/^data:image\/png/)
})
###测试 2:重复的图片只加载一次
it('fetches a repeated image once before reusing its data URL', async () => {
let fetches = 0
globalThis.fetch = async () => {
fetches += 1
return new Response(...)
}
const host = document.createElement('div')
host.innerHTML = '<img src="/api/asset/a1"><img src="/api/asset/a1">'
await inlineBrowserImages(host)
// 同一个 URL 只加载一次
expect(fetches).toBe(1)
// 两个 <img> 都被更新
const sources = [...host.querySelectorAll('img')].map(img => img.src)
expect(sources[0]).toBe(sources[1]) // 相同的 data: URL
})
###测试 3:限制并发
it('bounds simultaneous image reads during a capture', async () => {
let active = 0
let maximum = 0
globalThis.fetch = async () => {
active += 1
maximum = Math.max(maximum, active)
await new Promise(resolve => setTimeout(resolve, 0))
active -= 1
return new Response(...)
}
const host = document.createElement('div')
// 12 个不同的图片
host.innerHTML = Array.from(
{ length: 12 },
(_, i) => `<img src="/api/asset/${i}">`,
).join('')
await inlineBrowserImages(host)
// 最多同时 4 个请求
expect(maximum).toBe(4)
})
###测试 4:尺寸限制
it('refuses a PNG allocation large enough to exhaust browser memory', async () => {
const host = document.createElement('div')
await expect(
renderElementToPng(host, {
width: 10_000,
height: 10_000,
pixelRatio: 2,
// 总像素:10000 × 10000 × 2 × 2 = 400,000,000 > 32,000,000 ✗
}),
).rejects.toThrow('too large')
})
##⚡ 关键性能优化
###1. 缓存已加载的图片
// 相同 src 的图片只加载一次
const imagesBySource = new Map<string, HTMLImageElement[]>()
###2. 并发加载限制
// 最多 4 个并发请求,防止浏览器过载
const IMAGE_INLINE_CONCURRENCY = 4
###3. 分块编码
// Base64 编码时分块处理,防止栈溢出
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
}
###4. 尺寸限制
// 防止内存溢出
const MAX_BROWSER_PNG_PIXELS = 32_000_000 // ~128 MiB
const MAX_BROWSER_PNG_DIMENSION = 8_192
###5. 资源清理
// 完成后释放 Blob URL
finally {
URL.revokeObjectURL(url)
}
##🎯 总结:为什么这个设计这么聪明?
| 方面 | 为什么这样设计 |
|---|---|
SVG + <foreignObject> | HTML 无法直接光栅化,但 SVG 可以。<foreignObject> 允许在 SVG 中嵌入 HTML。 |
| XMLSerializer | 将动态 DOM 转换为静态字符串,避免每个浏览器重新渲染。 |
| Blob URL | 让字符串数据成为可加载的资源,触发浏览器的渲染流程。 |
| Image 对象 | 浏览器在加载 Image 时自动渲染 SVG,转换为像素数据。 |
| Canvas.drawImage | 将光栅化的 Image 绘制到 Canvas,可以转换为 PNG。 |
| Canvas.toDataURL | 最后一步,转换为 Base64 编码的 PNG。 |
| 内联图片 | 防止跨域图片污染 Canvas,使转换可靠。 |
| pixelRatio 支持 | 在高 DPI 屏幕上生成清晰的 PNG。 |
##🚀 使用示例
import { renderElementToPng } from '@loora/canvas'
// 简单用法
const element = document.querySelector('[data-loora-page]')!
const png = await renderElementToPng(element)
// → "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
// 高级用法
const png = await renderElementToPng(element, {
width: 1920,
height: 1080,
pixelRatio: 2, // Retina 屏幕
onSkippedImage: (src) => {
console.warn(`Could not inline image: ${src}`)
}
})
// 下载 PNG
const a = document.createElement('a')
a.href = png
a.download = 'design.png'
a.click()
这个函数是 Loora 整个架构的一个完美体现:利用现代浏览器 API,聪明地解决复杂问题,而无需任何外部库或 Canvas 黑魔法! 🎨✨
END OF POST