用React和TypeScript从零实现一个可拖拽的Scrum看板

司徒长春 工具 阅读 2,941
赞 40 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

我们团队用 React 写了个内部 Scrum 看板,一开始也就 3 列、20 来张卡片,跑得飞快。结果上生产不到两周,产品经理开始往里塞需求:列数涨到 8 列,每列平均 40+ 卡片,还加了拖拽排序、实时评论预览、状态变更动画……某天晨会我打开看板,点了下「加载更多」按钮,页面直接卡住 5 秒——浏览器标签页都弹出「此页面无响应」提示。用户反馈里写得挺实在:「点一下要等半分钟,我以为网断了」。

用React和TypeScript从零实现一个可拖拽的Scrum看板

不是夸张,真测过:首屏渲染耗时 4.8s(Lighthouse),交互延迟峰值 1200ms,滚动帧率跌到 12fps。最离谱的是,拖动一张卡片,Chrome DevTools 里 LayoutPaint 那两栏直接红成一片,像是被泼了番茄酱。

找到瘼颈了!

先用 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 ? &#039;dragging&#039; : &#039;&#039;}}
    >
      <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 = &lt;h3&gt;${item.title}&lt;/h3&gt;&lt;p&gt;${item.desc}&lt;/p&gt;;
        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-markdownuseEffect + requestIdleCallback 懒解析,避免长文本卡主线程。如果你也在搞类似看板系统,尤其遇到拖拽+大量卡片的场景,欢迎评论区聊聊你用的方案。比如:你们用的是 dnd-kit 还是 react-dnd?虚拟滚动怎么处理 drag preview 的位置?有没有踩过 getBoundingClientRect 在缩放页面下失准的坑?

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
程序员红辰
作者的分享让我对项目的未来发展有了新的规划,更有方向感了。
点赞 3
2026-02-10 19:25