实现拖拽排序、虚拟滚动、富文本编辑器、实时协作、Canvas/WebGL 等高难度交互,给出可落地的方案和关键代码。
不要上来就自己实现,先评估:
场景推荐方案自己实现的条件简单拖拽排序(同列表)@dnd-kit/sortable几乎不需要复杂拖拽(跨容器、看板、树形)@dnd-kit/core 自定义有特殊约束时长列表虚拟滚动@tanstack/react-virtual几乎不需要富文本编辑器Tiptap / Slate.js极度定制化需求实时协作Yjs + 对应绑定几乎不需要Canvas 2D 交互Konva.js / Fabric.js性能极限场景3D / WebGLThree.js / React Three Fiber几乎不需要
自己实现的成本远超预期——拖拽涉及触摸事件、键盘无障碍、滚动容器、嵌套列表,富文本涉及光标、选区、撤销栈,这些库已经踩过所有坑。
标准实现(@dnd-kit):
tsx
import { DndContext, closestCenter } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
function SortableItem({ id, children }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}}
{...attributes}
{...listeners}
>
{children}
</div>
)
}
function SortableList({ items, onReorder }) {
return (
<DndContext
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(i => i.id === active.id)
const newIndex = items.findIndex(i => i.id === over.id)
onReorder(arrayMove(items, oldIndex, newIndex))
}
}}
>
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
{items.map(item => (
<SortableItem key={item.id} id={item.id}>{item.name}</SortableItem>
))}
</SortableContext>
</DndContext>
)
}
跨容器拖拽(看板)的关键点:
DragOverlay 渲染拖拽中的预览,避免原位置闪烁over.data.current 判断拖到了哪个容器onDragEnd 里做, onDragOver 只做视觉反馈拖拽上传的关键点:
dragover + drop 事件,不需要 dnd-kitdragover 必须 preventDefault() 才能触发 dropDataTransfer.items 处理文件夹递归上传何时需要虚拟滚动: 列表超过 500 条且有明显卡顿,或超过 2000 条无论是否卡顿。
标准实现(@tanstack/react-virtual):
tsx
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56, // 预估行高,影响滚动条比例
overscan: 5, // 视口外额外渲染的行数
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}> {/* 撑开滚动高度 */}
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
)
}
动态行高的处理:
tsx
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
// 测量实际渲染高度,自动修正
measureElement: (el) => el.getBoundingClientRect().height,
})
// 在每个 item 上加 ref
<div ref={virtualizer.measureElement} data-index={virtualRow.index}>
虚拟滚动 + 无限加载:
tsx
// 监听最后一个 item 进入视口
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualItems, items.length, hasNextPage, isFetchingNextPage])
选型决策:
Tiptap 快速上手:
tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
function RichTextEditor({ value, onChange }) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: '开始输入...' }),
],
content: value,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
})
return (
<div className="editor-wrapper">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
)
}
function Toolbar({ editor }) {
if (!editor) return null
return (
<div>
<button onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'active' : ''}>B</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
</div>
)
}
Yjs 的核心概念:
Y.Doc:共享文档,所有协作数据的容器与 Tiptap 集成:
tsx
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('wss://your-server.com', 'room-name', ydoc)
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }), // 禁用内置 history,用 Yjs 的
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: '张三', color: '#f783ac' },
}),
],
})
后端 WebSocket 服务器(y-websocket):
bash
# 最简单的方式:直接用官方服务器
HOST=localhost PORT=1234 npx y-websocket
Canvas 卡顿的常见原因:
原因解法每帧重绘整个画布分层 Canvas(静态背景一层,动态元素一层)在主线程做大量计算用 OffscreenCanvas + Web Worker频繁创建/销毁对象对象池(Object Pool)没有用 requestAnimationFrame用 rAF 驱动动画循环高 DPI 屏幕模糊canvas.width = width * devicePixelRatio
高 DPI 修复:
ts
function setupCanvas(canvas: HTMLCanvasElement) {
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
return ctx
}
分层 Canvas(低代码画布场景):
tsx
// 背景网格层(静态,只在 resize 时重绘)
<canvas ref={bgRef} style={{ position: 'absolute' }} />
// 元素层(拖拽时重绘)
<canvas ref={elementsRef} style={{ position: 'absolute' }} />
// 交互层(hover/选中高亮,频繁重绘)
<canvas ref={interactionRef} style={{ position: 'absolute' }} />
场景坑解法拖拽触摸设备不触发 mouse 事件dnd-kit 已处理,自己实现要加 touch 事件虚拟滚动滚动到底部时跳动用 measureElement 测量实际高度富文本粘贴时带入外部样式配置 transformPastedHTML 清理实时协作断线重连后数据不一致y-websocket 自动处理,确保 provider 有重连逻辑Canvas事件坐标偏移用 canvas.getBoundingClientRect() 转换坐标