L2 · React 手写画布
节点是有业务逻辑的真 React 组件(受控表单、计数器、播放器)、需要状态管理、但不需要白板全家桶 —— 这一档不要引入任何画布库,React + 一个状态方案 + 手写 transform 就是正解。本页 demo 的核心示范只有一件事:transform 放 useRef 直写 DOM,别放 React state。
试这几下:
- 每个节点右上角有 renders 计数 —— 使劲拖空白平移、滚轮缩放,计数纹丝不动:视口变换全程没有触发 React 渲染
- 拖节点标题移动节点:拖拽中同样直写 DOM,松手那一刻才 sync 回状态(计数 +1)
- 节点里的输入框和按钮是货真价实的受控组件 —— 这就是 L2 相对 L4 的意义:节点想长什么样就长什么样
为什么 transform 必须走 ref
画布拖拽是 60fps 高频事件。如果 transform 放 React state,每帧 setState 都触发整个组件树 diff —— 节点一多必卡。正确姿势:
const transformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
const worldRef = useRef<HTMLDivElement>(null);
const applyTransform = () => {
const t = transformRef.current;
worldRef.current!.style.transform =
`translate(${t.x}px, ${t.y}px) scale(${t.scale})`;
};
// 高频 move 只动 ref + DOM;松手才让 React 知道最终位置
const onPointerUp = () => setViewport({ ...transformRef.current });
React 只负责它擅长的事(节点内容、选中态、增删),60fps 的活交给浏览器合成器。
要点
- 文档态和视口态分家:节点数组走 React 状态(不可变更新,为将来接撤销/持久化留路);
transform走 ref。节点拖拽同理——move 直写style.left/top,up 才setNodes - viewport.ts 不到 30 行:
screenToWorld/worldToScreen/applyZoom(锚点缩放公式与 L1 同一条),纯函数可单测 - wheel 必须原生绑定:React 合成事件拿不到
{ passive: false },preventDefault会失效,useEffect里addEventListener一次 - 节点内部的交互元素(input/button)记得
stopPropagation,否则点按钮变成拖节点 - 推荐目录(骨架长这样,状态用 zustand 或 useState 皆可):
src/canvas/
├── Canvas.tsx # 容器:鼠标事件、框选、拖拽
├── viewport.ts # 坐标转换 + 缩放,纯函数
└── nodes/
├── NodeWrapper.tsx # 通用外壳:选中态、拖拽 handle
└── XxxNodeView.tsx # 业务节点
src/store/canvasStore.ts # nodes / viewport / selection
L2 做到产品级要补什么
L2 架构撑得起完整产品,但 tldraw 白送的能力都得自己建,按需取用:
| 能力 | 低成本自建方案 |
|---|---|
| 撤销/重做 | 不可变更新 + 快照栈(见 L4 demo 的 80 行实现) |
| 持久化 | 防抖 diff → PATCH,本地 IndexedDB 读缓存 |
| 协同 | 写锁 + 乐观锁 version + 服务端推送通道(见总览) |
| 导出 | DOM 渲染的红利:html2canvas 直接截图;jspdf/pptxgenjs 动态 import() |
| 视口剔除 | 只渲染与视口相交的节点,节点多时再加 |
何时升级
源码(折叠)
demo 源码:assets/demo-l2-react.html(React 18 + htm,站内 vendor,无构建)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L2 · React 手写画布 — useRef 持有 transform</title>
<style>
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; }
.viewport {
position: relative; width: 100%; height: 100vh; overflow: hidden;
background: #f7f8fa; cursor: grab; touch-action: none;
}
.viewport.panning { cursor: grabbing; }
.grid {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(30,41,59,.18) 1px, transparent 1px);
background-size: 28px 28px;
}
.world { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }
.node {
position: absolute; width: 230px; background: #fff;
border: 1px solid #dbe1ea; border-radius: 12px;
box-shadow: 0 2px 8px rgba(15,23,42,.08); user-select: none;
}
.node.selected { border-color: #5b8def; box-shadow: 0 0 0 2px rgba(91,141,239,.25); }
.node header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border-bottom: 1px solid #eef1f6; cursor: grab;
font-size: 13px; font-weight: 600; color: #0f172a;
}
.node header .renders { font-size: 10px; font-weight: normal; color: #94a3b8; font-variant-numeric: tabular-nums; }
.node .body { padding: 10px 12px; display: grid; gap: 8px; }
.node .body p { font-size: 12px; color: #64748b; line-height: 1.5; }
.node input {
font: inherit; font-size: 12px; padding: 5px 8px;
border: 1px solid #cbd5e1; border-radius: 6px; width: 100%;
}
.node button {
font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer;
border: 1px solid #cbd5e1; border-radius: 6px; background: #f8fafc; color: #334155;
}
.node button:hover { background: #eef2f7; }
.hud {
position: absolute; left: 12px; bottom: 12px; z-index: 5;
display: flex; gap: 10px; align-items: center; font-size: 12px; color: #475569;
}
.hud .stat {
background: rgba(255,255,255,.9); border: 1px solid #e2e8f0;
border-radius: 8px; padding: 5px 10px; font-variant-numeric: tabular-nums;
}
.hud .stat b { color: #16a34a; }
.tip {
position: absolute; right: 12px; top: 12px; z-index: 5;
font-size: 11.5px; color: #94a3b8; background: rgba(255,255,255,.85);
border: 1px solid #e2e8f0; border-radius: 8px; padding: 6px 10px; line-height: 1.7;
}
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
const html = htm.bind(React.createElement);
const { useState, useRef, useEffect, useCallback } = React;
/* ============ viewport.ts:坐标转换 + 锚点缩放(<30 行) ============ */
const screenToWorld = (t, sx, sy) => ({ x: (sx - t.x) / t.scale, y: (sy - t.y) / t.scale });
function applyZoom(t, ax, ay, adjust, min = 0.2, max = 3) {
const next = Math.min(max, Math.max(min, t.scale * Math.exp(adjust)));
if (next === t.scale) return t;
const ratio = 1 - next / t.scale;
return { x: t.x + (ax - t.x) * ratio, y: t.y + (ay - t.y) * ratio, scale: next };
}
/* ============ 业务节点:真正的 React 组件(表单/计数器) ============ */
/* 右上角 renders 徽标 = 本组件累计渲染次数。拖画布平移/缩放时它不动 —— 因为
transform 走 ref 直写 DOM,没有触发 React 渲染。 */
function TaskNode({ node, selected, onPointerDown, onPatch }) {
const renders = useRef(0);
renders.current++;
return html`
<div class="node ${selected ? 'selected' : ''}"
style=${{ left: node.x + 'px', top: node.y + 'px' }}
data-id=${node.id}>
<header onPointerDown=${(e) => onPointerDown(e, node.id)}>
<span>${node.title}</span>
<span class="renders">renders: ${renders.current}</span>
</header>
<div class="body">
<p>${node.desc}</p>
<input value=${node.note} placeholder="备注(受控输入,状态在 store)"
onPointerDown=${(e) => e.stopPropagation()}
onChange=${(e) => onPatch(node.id, { note: e.target.value })} />
<button onPointerDown=${(e) => e.stopPropagation()}
onClick=${() => onPatch(node.id, { count: node.count + 1 })}>
业务动作 × ${node.count}
</button>
</div>
</div>`;
}
function App() {
/* 文档态:节点数组走 React 状态(不可变更新) */
const [nodes, setNodes] = useState([
{ id: 'n1', x: 110, y: 90, title: '调研竞品', desc: '节点是完整的 React 业务组件:受控输入、按钮、任意状态。', note: '', count: 0 },
{ id: 'n2', x: 450, y: 200, title: '写原型', desc: '拖标题栏移动我;拖空白平移画布,对比两侧 renders 计数。', note: '', count: 0 },
{ id: 'n3', x: 180, y: 360, title: '排期评审', desc: '平移/缩放期间 renders 不涨 —— transform 没进 React。', note: '', count: 0 },
]);
const [selectedId, setSelectedId] = useState(null);
/* 视口态:走 ref,不进 React —— 本 demo 的核心示范 */
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
const worldRef = useRef(null);
const gridRef = useRef(null);
const viewportRef = useRef(null);
const zoomStatRef = useRef(null);
const drag = useRef(null); // { kind:'pan'|'node', ... }
const applyTransform = useCallback(() => {
const t = transformRef.current, GRID = 28;
if (worldRef.current)
worldRef.current.style.transform = `translate(${t.x}px, ${t.y}px) scale(${t.scale})`;
if (gridRef.current) {
gridRef.current.style.backgroundSize = `${GRID * t.scale}px ${GRID * t.scale}px`;
gridRef.current.style.backgroundPosition = `${t.x % (GRID * t.scale)}px ${t.y % (GRID * t.scale)}px`;
}
if (zoomStatRef.current)
zoomStatRef.current.textContent = Math.round(t.scale * 100) + '%';
}, []);
const patchNode = useCallback((id, patch) =>
setNodes(ns => ns.map(n => n.id === id ? { ...n, ...patch } : n)), []);
/* 节点拖拽:高频 move 直写 DOM,pointerup 才 sync 回 React 状态 */
const onNodePointerDown = useCallback((e, id) => {
e.stopPropagation();
viewportRef.current.setPointerCapture(e.pointerId);
setSelectedId(id);
const t = transformRef.current;
const el = e.currentTarget.parentElement;
drag.current = {
kind: 'node', id, el,
startWorld: screenToWorld(t, e.clientX, e.clientY),
origin: { x: el.offsetLeft, y: el.offsetTop },
last: null,
};
}, []);
const onPointerDown = useCallback((e) => {
if (e.target.closest('.node')) return; // 节点自己处理
viewportRef.current.setPointerCapture(e.pointerId);
viewportRef.current.classList.add('panning');
setSelectedId(null);
drag.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
}, []);
const onPointerMove = useCallback((e) => {
const d = drag.current;
if (!d) return;
if (d.kind === 'pan') {
const t = transformRef.current;
t.x += e.clientX - d.lastX; t.y += e.clientY - d.lastY;
d.lastX = e.clientX; d.lastY = e.clientY;
applyTransform(); // 直写 DOM,零 React 渲染
} else {
const t = transformRef.current;
const w = screenToWorld(t, e.clientX, e.clientY);
const x = d.origin.x + (w.x - d.startWorld.x);
const y = d.origin.y + (w.y - d.startWorld.y);
d.el.style.left = x + 'px'; d.el.style.top = y + 'px'; // 拖拽中同样直写
d.last = { x, y };
}
}, [applyTransform]);
const onPointerUp = useCallback(() => {
const d = drag.current;
if (d && d.kind === 'node' && d.last)
patchNode(d.id, d.last); // 松手才 sync 到状态(触发一次渲染)
viewportRef.current.classList.remove('panning');
drag.current = null;
}, [patchNode]);
/* wheel 必须 passive:false 才能 preventDefault,React 合成事件做不到 → 原生绑定 */
useEffect(() => {
const vp = viewportRef.current;
const onWheel = (e) => {
e.preventDefault();
const rect = vp.getBoundingClientRect();
transformRef.current = applyZoom(
transformRef.current,
e.clientX - rect.left, e.clientY - rect.top,
e.deltaY * -0.002,
);
applyTransform();
};
vp.addEventListener('wheel', onWheel, { passive: false });
applyTransform();
return () => vp.removeEventListener('wheel', onWheel);
}, [applyTransform]);
return html`
<div class="viewport" ref=${viewportRef}
onPointerDown=${onPointerDown} onPointerMove=${onPointerMove}
onPointerUp=${onPointerUp} onPointerCancel=${onPointerUp}>
<div class="grid" ref=${gridRef}></div>
<div class="world" ref=${worldRef}>
${nodes.map(n => html`
<${TaskNode} key=${n.id} node=${n} selected=${n.id === selectedId}
onPointerDown=${onNodePointerDown} onPatch=${patchNode} />`)}
</div>
<div class="tip">拖空白平移 · 滚轮缩放 · 拖节点标题移动<br/>盯住各节点的 renders 计数</div>
<div class="hud">
<span class="stat">缩放 <b ref=${zoomStatRef}>100%</b></span>
<span class="stat">平移/缩放期间 React 渲染次数:<b>0</b>(transform 在 ref 里)</span>
</div>
</div>`;
}
ReactDOM.createRoot(document.getElementById('root')).render(html`<${App} />`);
</script>
</body>
</html>