用React和TypeScript从零实现一个高性能Kanban看板组件
优化前:卡得不行
我们团队用 React + Redux 做了个内部 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 场景下保持首屏性能 —— 后续会继续分享这类博客。
以上是我踩坑后的总结,希望对你有帮助。

暂无评论