用React和TypeScript从零实现一个高性能Kanban看板组件

程序猿书錦 工具 阅读 1,107
赞 24 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

我们团队用 React + Redux 做了个内部 Kanban 看板,支持拖拽、实时更新、百级卡片、多列滚动。上线两周后,产品跑来问我:“为啥我拖个卡片要等半秒才响应?切到‘测试中’列直接卡住两秒?”

用React和TypeScript从零实现一个高性能Kanban看板组件

我打开控制台一测,Chrome Performance 面板里帧率掉到 12fps,长任务动辄 300ms+,加载整页看板(含 8 列 × 平均 15 张卡片)平均耗时 5.2s —— 还是本地开发环境。

用户说“卡”,真不是客气。真实场景下,有同事在旧 MacBook Pro 上反馈“拖着拖着浏览器崩溃了”。我信。

找到痛点了!

先没急着改代码,开了 Chrome DevTools 的 Performance 录制 10 秒操作:滚动 + 拖一张卡 + 放下。导出 trace 后重点盯三块:

  • JS 主线程里 reconcile 占了 68% 时间 —— 明显是 React 渲染太多
  • Layout 阶段频繁触发(每帧 3–5 次),getBoundingClientRect() 调用堆叠在 scroll handler 里
  • 每次拖拽都触发全量卡片重渲染,哪怕只动了一张卡,useSelector 拿的是整个 boards 数组

顺手加了 why-did-you-render 插件,发现一个列组件里,CardList 每次父组件 props 变(哪怕只是全局 loading 状态变),它就全量 re-render —— 就算卡片数据根本没动。

还发现个隐藏坑:我们用了 react-beautiful-dnd,但没关掉它的 dev mode(process.env.NODE_ENV !== 'production' 下默认开 debug 日志),日志本身就把主线程拖慢了 100ms+。关掉后首屏快了 300ms,但离“流畅”还差得远。

核心优化:就这三刀,最狠

第一刀砍在渲染上 —— 把卡片列表从“全量重绘”变成“按需更新”

原来写法太 naive:

// ❌ 优化前:每次 rerender,所有卡片都走一遍 JSX 构建
const CardList = ({ cards }) => {
  return cards.map(card => <Card key={card.id} card={card} />);
};

改成 memo + key + 精准依赖:

// ✅ 优化后:卡片组件自己决定要不要重渲染
const Card = React.memo(({ card, onDragStart }) => {
  // card 是普通对象,但 id 和 status 不变时,不需要重绘 UI
  return (
    <div draggable onDragStart={() => onDragStart(card)} className="card">
      <h4>{card.title}</h4>
      <p>{card.desc}</p>
    </div>
  );
}, (prev, next) => {
  // 只有 id、title、desc、status 任一变化才更新
  return prev.card.id === next.card.id &&
         prev.card.title === next.card.title &&
         prev.card.desc === next.card.desc &&
         prev.card.status === next.card.status;
});

// 外层 CardList 也 memo 化,且只接收稳定数组引用
const CardList = React.memo(({ cards }) => {
  return cards.map(card => <Card key={card.id} card={card} onDragStart={onDragStart} />);
});

第二刀砍在状态订阅上 —— 不让一个列的更新触发其他列 rerender

原来所有列共用一个 useSelector(state => state.boards),改完后拆成按列取:

// ✅ 每个 Column 组件只订阅自己关心的那一列
const Column = ({ columnId }) => {
  const column = useSelector(state => state.boards.columns[columnId]);
  const cards = useSelector(state => 
    state.boards.cards.filter(card => card.columnId === columnId)
  );

  // 注意:这里不能用 state.boards.cards.find(...),因为 filter 返回新数组,会破坏 memo 效果
  // 我们改用 createEntityAdapter + selectIds + selectById 做归一化存储,让 cards 是稳定引用
};

第三刀砍在滚动性能上 —— 干掉 layout thrashing

原来滚动监听里写了:

// ❌ 每次 scroll 都触发 getBoundingClientRect → 强制同步 layout
window.addEventListener('scroll', () => {
  const rect = container.getBoundingClientRect(); // 🔥 这里卡
  if (rect.top < 0) setSticky(true);
});

改成:

// ✅ 用 IntersectionObserver 替代 scroll + getBoundingClientRect
const observer = new IntersectionObserver(
  ([entry]) => setSticky(!entry.isIntersecting),
  { threshold: 0.01, rootMargin: '-1px 0px 0px 0px' }
);
observer.observe(container);

顺手优化的几处细节

这些不关键,但加起来省了 200ms:

  • CSS 里给所有卡片加 will-change: transform,让 GPU 提前准备(仅对拖拽中卡片动态加)
  • 图片懒加载统一用 loading="lazy",去掉自研的 JS 懒加载逻辑(它反而阻塞了主线程)
  • API 接口把 https://jztheme.com/api/boards?include=cards 拆成两个请求,避免一次拉回 200+ 卡片导致 JSON.parse 卡顿
  • Redux store 里删掉所有没用的中间件日志(redux-logger 在 prod 环境已关,但有个 dev-only 的 custom logger 没关)

优化后:流畅多了

再跑一遍 Performance 录制:

  • 首屏加载:5.2s → 820ms(实测,取 5 次平均)
  • 拖拽响应延迟:平均 190ms → 23ms(基本跟手)
  • 滚动帧率:12fps → 稳定 58–60fps
  • 内存占用:峰值 320MB → 140MB

用户反馈也变了:“现在拖着像磁吸一样顺”“终于敢在周会上演示了”。

当然没完美 —— 在低配安卓机上,快速连拖三张卡偶尔还有 1–2 帧掉帧,但我看了下,是 react-beautiful-dnd 自身的动画插值计算开销,换 dnd-kit 会更好,但当前迭代周期不允许大改。先这样,够用。

性能数据对比

本地 Mac M1 / Chrome 125 测试,数据真实可复现(Lighthouse + 自定义脚本):

指标 优化前 优化后 提升
FCP(首次内容绘制) 4.1s 780ms ↑ 81%
LCP(最大内容绘制) 5.2s 820ms ↑ 84%
TTI(可交互时间) 6.3s 1.1s ↑ 83%
滚动卡顿率(>16ms/frame) 42% 3.7% ↓ 91%

注意:LCP 数据和首屏加载时间接近,是因为看板主体就是卡片列表,没有大图或视频干扰。

以上是我的优化经验,有更好的方案欢迎交流

这次优化没碰 Web Worker 或虚拟滚动(vlist),因为卡片高度固定、列数不多(最多 12 列),纯 React.memo + 精准 selector 已经压到极限。如果你们列数上百、卡片上千,那必须上虚拟滚动 —— 但我建议先测,别一上来就上重武器,我们试过 react-window,结果因为卡片内嵌富文本编辑器,反而更卡。

还有一个提醒:千万别信“只要用了 memo 就一定快”—— 我们最早加了 memo,但 key 写错了(用 index 当 key),结果拖拽时卡片顺序错乱,debug 了俩小时才发现。这里注意我踩过好几次坑:key 必须是稳定、唯一、业务语义化的 ID,不是数组索引。

这个技巧的拓展用法还有很多,比如怎么配合 Zustand 做更轻量的状态管理、如何在 SSR 场景下保持首屏性能 —— 后续会继续分享这类博客。

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

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论