用React和TypeScript从零实现一个可拖拽的Scrum看板
优化前:卡得不行
我们团队用 React 写了个内部 Scrum 看板,一开始也就 3 列、20 来张卡片,跑得飞快。结果上生产不到两周,产品经理开始往里塞需求:列数涨到 8 列,每列平均 40+ 卡片,还加了拖拽排序、实时评论预览、状态变更动画……某天晨会我打开看板,点了下「加载更多」按钮,页面直接卡住 5 秒——浏览器标签页都弹出「此页面无响应」提示。用户反馈里写得挺实在:「点一下要等半分钟,我以为网断了」。
不是夸张,真测过:首屏渲染耗时 4.8s(Lighthouse),交互延迟峰值 1200ms,滚动帧率跌到 12fps。最离谱的是,拖动一张卡片,Chrome DevTools 里 Layout 和 Paint 那两栏直接红成一片,像是被泼了番茄酱。
找到瘼颈了!
先用 Chrome 的 Performance 面板录了一次典型操作:从进入页面 → 滚动到底部 → 点击「加载更多」→ 拖动一张卡片。导出 trace 后放大一看,问题集中在三块:
- 首屏渲染阶段,
render()调用次数超 360 次,其中 217 次是重复渲染同一批卡片(因为状态更新没做防抖,而且所有卡片共用一个父级useState) - 每次拖拽,都触发全量卡片的
shouldComponentUpdate(我们还在用 class 组件过渡期…别笑,历史包袱) - 「加载更多」后,新卡片插入时,整个
BoardContainer组件重新 mount —— 因为用了key={Date.now()}来强制刷新(我写的,现在看到这行代码就想删库跑路)
另外,React DevTools 的 Profiler 显示,Card 组件的 render 时间占比 68%,但它的 props 实际只变了 1 个字段(isDragging)。说明:没 memo,没 shouldComponentUpdate,更没用 React.memo。
试了几种方案,最后这个效果最好
我试过拆组件、用 useMemo 包整个卡片列表、甚至想上虚拟滚动(后来发现不现实——拖拽必须真实 DOM 位置,虚拟滚动会断掉坐标映射)。折腾两天后,收效最大的其实是三个改动,按优先级排:
1. 卡片层彻底 memo 化 + 精确依赖控制
原来每个 Card 是这样写的:
function Card({ data, onDragStart }) {
return (
<div draggable onDragStart={onDragStart}>
<h3>{data.title}</h3>
<p>{data.desc}</p>
{/* 一堆子组件 */}
</div>
);
}
问题在于:父组件传进来的 data 是一个对象,每次父组件重 render,哪怕只是 data.status 改了,data 引用也变了,导致 Card 必然重渲染。
改法很土,但亲测有效:把真正影响 UI 的字段显式拆出来,再用 React.memo 锁死:
const Card = React.memo(function Card({
id,
title,
desc,
status,
isDragging,
onDragStart
}) {
return (
<div
draggable={true}
onDragStart={onDragStart}
className={card ${isDragging ? 'dragging' : ''}}
>
<h3>{title}</h3>
<p>{desc}</p>
<span className="status">{status}</span>
</div>
);
}, (prevProps, nextProps) => {
// 只有这几个字段变了才更新
return (
prevProps.id === nextProps.id &&
prevProps.title === nextProps.title &&
prevProps.desc === nextProps.desc &&
prevProps.status === nextProps.status &&
prevProps.isDragging === nextProps.isDragging
);
});
这里注意我踩过好几次坑:不能只比 id,因为 isDragging 是拖拽态的关键视觉变量;也不能用 JSON.stringify 去比整个 data,性能反而更差。
2. 把「拖拽中」的状态从全局挪到单卡片内
原来我们有个 globalDraggingId 存在 context 里,每张卡片都要订阅这个值,一变就全员重 render。改成卡片自己管自己的拖拽态:
function Card({ id, ...rest }) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = useCallback((e) => {
setIsDragging(true);
// 其他逻辑...
}, []);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
return <CardImpl {...rest} id={id} isDragging={isDragging} />;
}
注意:这里 CardImpl 是上面那个 memo 化的纯展示组件,Card 只负责状态和事件绑定,职责彻底分离。
3. 加载更多?别 reload 整个列表了
原来「加载更多」是这样干的:
// ❌ 错误示范
setCards(prev => [...prev, ...newItems]);
// 然后在 BoardContainer 里 key={Math.random()}
改成只 append DOM 节点,不触发父组件重 render:
// ✅ 正确做法:用 ref 直接追加
const listRef = useRef(null);
function loadMore() {
fetch('https://jztheme.com/api/cards?offset=' + offset)
.then(res => res.json())
.then(newItems => {
const fragment = document.createDocumentFragment();
newItems.forEach(item => {
const el = document.createElement('div');
el.className = 'card';
el.innerHTML = <h3>${item.title}</h3><p>${item.desc}</p>;
fragment.appendChild(el);
});
listRef.current.appendChild(fragment);
setOffset(prev => prev + 20);
});
}
当然,这只是保底方案(我们最终还是切到了 React 18 + useTransition,但这个 ref 方案在灰度期间救了命)。
优化后:流畅多了
改完上线第二天,我蹲着看监控数据:
- 首屏渲染时间:从 4.8s → 820ms
- 拖拽平均响应延迟:从 1200ms → 95ms
- Lighthouse 性能分:42 → 91
- 滚动帧率稳定在 58~60fps(MacBook Pro M1)
最直观的是:测试同学说「现在能边拖边说话,以前拖一下得等它喘口气」。
当然,还有小瑕疵:当同时拖两张卡(极少数场景),偶尔会丢一帧。但我们评估了投入产出比,决定先上线——毕竟 95% 用户根本不会这么玩。
性能数据对比
这是压测环境跑三次取的平均值(设备:MacBook Pro M1, Chrome 124):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| FCP(首内容绘制) | 3240ms | 680ms | 4.8x |
| TBT(总阻塞时间) | 1840ms | 210ms | 8.8x |
| 拖拽操作平均耗时 | 1170ms | 92ms | 12.7x |
数据看着漂亮,但说实话,核心就那三招:拆状态、锁引用、绕开 React 更新链。没有魔法,全是土办法。
以上是我的优化经验,有更好的方案欢迎交流
这个 Scrum 看板还在迭代,比如下一步打算把卡片内的富文本渲染换成 react-markdown 的 useEffect + requestIdleCallback 懒解析,避免长文本卡主线程。如果你也在搞类似看板系统,尤其遇到拖拽+大量卡片的场景,欢迎评论区聊聊你用的方案。比如:你们用的是 dnd-kit 还是 react-dnd?虚拟滚动怎么处理 drag preview 的位置?有没有踩过 getBoundingClientRect 在缩放页面下失准的坑?
以上是我踩坑后的总结,希望对你有帮助。
