多人白板 — 使用 Konva 和 Yjs 构建实时协作画布
协作画布 — Figma、Miro、Excalidraw — 都区分两件事:所有人共享的文档,以及每个人 看到的绘图表面。Konva 是后者。它渲染文档并处理指针交互,但它不会为你同步 state。
本示例把共享的那一半放在 Yjs 中,这是一个 CRDT 库,无需中心
权威即可合并并发编辑。BroadcastChannel 在标签页之间传输更新,因此你不需要服务器
就能试用真正的协作。
操作说明: 在两个浏览器标签页中打开本页,然后在其中一个中拖动便笺。另一个标签页 会跟随。
在两个标签页中试用
import React, { useEffect, useRef, useState } from 'react';
import { Stage, Layer, Group, Rect, Text } from 'react-konva';
import * as Y from 'yjs';
const WIDTH = 760;
const HEIGHT = 400;
const REMOTE_ORIGIN = Symbol('broadcast-channel');
function useResponsiveWidth(maxWidth) {
const containerRef = React.useRef(null);
const [width, setWidth] = React.useState(1);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () =>
setWidth(Math.max(1, Math.min(maxWidth, container.clientWidth)));
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(container);
return () => observer.disconnect();
}, [maxWidth]);
return { containerRef, width, scale: width / maxWidth };
}
const initialNotes = [
{ id: 'note-1', x: 80, y: 90, text: 'Define the problem', color: '#fde68a', updatedBy: 'initial-data' },
{ id: 'note-2', x: 310, y: 190, text: 'Sketch the flow', color: '#bfdbfe', updatedBy: 'initial-data' },
{ id: 'note-3', x: 540, y: 80, text: 'List the risks', color: '#fecdd3', updatedBy: 'initial-data' },
];
export default function App() {
const {
containerRef,
width: displayWidth,
scale: displayScale,
} = useResponsiveWidth(WIDTH);
const [notes, setNotes] = useState(initialNotes);
const [selectedId, setSelectedId] = useState('note-1');
const [channelReady, setChannelReady] = useState(false);
const [clientName] = useState(() => `tab-${Math.floor(Math.random() * 900 + 100)}`);
const notesMapRef = useRef(null);
const docRef = useRef(null);
useEffect(() => {
const doc = new Y.Doc();
const notesMap = doc.getMap('notes');
const channel = new BroadcastChannel('konva-yjs-whiteboard-v1');
docRef.current = doc;
notesMapRef.current = notesMap;
const readNotes = () => {
const nextNotes = Array.from(notesMap.values()).sort((a, b) =>
a.id.localeCompare(b.id)
);
setNotes(nextNotes);
};
const publishLocalUpdate = (update, origin) => {
if (origin === REMOTE_ORIGIN) return;
channel.postMessage({ type: 'update', update });
};
channel.onmessage = (event) => {
if (event.data.type === 'sync-request') {
channel.postMessage({
type: 'update',
update: Y.encodeStateAsUpdate(doc),
});
return;
}
if (event.data.type === 'update') {
Y.applyUpdate(doc, new Uint8Array(event.data.update), REMOTE_ORIGIN);
}
};
notesMap.observe(readNotes);
doc.on('update', publishLocalUpdate);
readNotes();
channel.postMessage({ type: 'sync-request' });
const seedTimer = window.setTimeout(() => {
if (notesMap.size > 0) return;
doc.transact(() => {
initialNotes.forEach((note) => notesMap.set(note.id, note));
});
}, 120);
setChannelReady(true);
return () => {
window.clearTimeout(seedTimer);
notesMap.unobserve(readNotes);
doc.off('update', publishLocalUpdate);
channel.close();
doc.destroy();
notesMapRef.current = null;
docRef.current = null;
};
}, []);
const updateNote = (id, patch) => {
const notesMap = notesMapRef.current;
const doc = docRef.current;
if (!notesMap || !doc) return;
const current = notesMap.get(id);
if (!current) return;
doc.transact(() => {
notesMap.set(id, { ...current, ...patch, updatedBy: clientName });
});
};
const moveSelected = (dx, dy) => {
const selected = notes.find((note) => note.id === selectedId);
if (!selected) return;
updateNote(selected.id, { x: selected.x + dx, y: selected.y + dy });
};
const selectedNote = notes.find((note) => note.id === selectedId);
return (
<div style={{ fontFamily: 'sans-serif', maxWidth: 820 }}>
<p role="status" aria-live="polite">
{channelReady ? `Local channel ready as ${clientName}.` : 'Opening local channel.'}
{' '}
{selectedNote ? `${selectedNote.text} was changed by ${selectedNote.updatedBy}.` : ''}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{notes.map((note) => (
<button
key={note.id}
type="button"
aria-pressed={selectedId === note.id}
onClick={() => setSelectedId(note.id)}
>
{note.text}
</button>
))}
<button type="button" aria-label="Move selected note left" onClick={() => moveSelected(-10, 0)}>
←
</button>
<button type="button" aria-label="Move selected note right" onClick={() => moveSelected(10, 0)}>
→
</button>
<button type="button" aria-label="Move selected note up" onClick={() => moveSelected(0, -10)}>
↑
</button>
<button type="button" aria-label="Move selected note down" onClick={() => moveSelected(0, 10)}>
↓
</button>
</div>
<div ref={containerRef} style={{ width: '100%', maxWidth: WIDTH }}>
<Stage
width={displayWidth}
height={HEIGHT * displayScale}
scaleX={displayScale}
scaleY={displayScale}
style={{ background: '#f8fafc', boxShadow: 'inset 0 0 0 1px #cbd5e1' }}
onClick={(event) => {
if (event.target === event.target.getStage()) setSelectedId(null);
}}
>
<Layer>
{notes.map((note) => {
const selected = note.id === selectedId;
return (
<Group
key={note.id}
x={note.x}
y={note.y}
draggable
onClick={() => setSelectedId(note.id)}
onTap={() => setSelectedId(note.id)}
onDragEnd={(event) =>
updateNote(note.id, {
x: event.target.x(),
y: event.target.y(),
})
}
>
<Rect
width={150}
height={100}
fill={note.color}
stroke={selected ? '#2563eb' : '#475569'}
strokeWidth={selected ? 4 : 1}
shadowColor="black"
shadowOpacity={0.12}
shadowBlur={8}
/>
<Text
x={12}
y={14}
width={126}
text={note.text}
fontSize={16}
lineHeight={1.3}
fill="#0f172a"
listening={false}
/>
</Group>
);
})}
</Layer>
</Stage>
</div>
</div>
);
}
共享数据设计
Yjs 是共享文档模型。notes Y.Map 存储普通记录,React state 存储这些记录的视图。
Konva 节点不进入共享文档。每个客户端都根据相同的共享数据创建自己的节点。
本示例在每次位置变化时替换完整的便笺记录。如果用户可以并发更改不同的属性,请使用嵌套的 Y.Map 值。
将本地视口数据保留在 Yjs 外部。用户可以平移或缩放,而不会移动其他用户的视口。