1. 从零开始用 react-resizable 实现基础列宽拖拽大家好我是老张一个在前后端摸爬滚打了十多年的老码农。今天想和大家聊聊一个在后台管理系统里几乎绕不开的需求Antd Table 的列宽拖拽。这功能听起来简单不就是让用户能拉一拉表格列的边框调整宽度嘛。但真做起来尤其是想做得流畅、稳定还能应对复杂的业务场景里面的坑还真不少。我自己就踩过不少从最开始的“能用就行”到后来被性能问题折磨得焦头烂额再到最后找到相对优雅的解决方案整个过程可以说是一波三折。这篇文章我就把自己这趟“实战演进”的心路历程和具体代码分享出来希望能帮你少走弯路。我们先从最基础的实现讲起。Ant Design 的 Table 组件本身不提供列宽拖拽功能它的列宽完全依赖于columns配置里每个对象的width属性。这个设计在数据固定、展示规整时没问题但一旦遇到内容长度不确定或者用户想自定义查看体验时固定的width就显得力不从心了。这时候我们就需要自己动手丰衣足食。社区里最常用的方案是借助react-resizable这个库。它的思路很清晰劫持 Table 的表头渲染把普通的th替换成react-resizable提供的可拖拽容器。具体怎么做呢我画个简单的流程图帮你理解安装与准备首先你得把react-resizable请进项目里。打开终端在你的项目根目录下运行npm install react-resizable或者yarn add react-resizable。创建可拖拽表头组件这是核心的一步。我们需要新建一个组件比如叫ResizableTitle.tsx在这个组件里我们用react-resizable的Resizable组件包裹住原本的th。Resizable组件会提供一个可拖拽的把手handle并监听拖拽事件。改造 Antd TableAntd Table 提供了一个非常强大的components属性允许我们重写其内部渲染的组件。我们要做的就是把 Table 的header.cell指定为我们刚刚创建的ResizableTitle组件。动态更新列宽当用户拖拽表头时react-resizable会触发onResize事件。我们需要在这个事件回调里计算出新的宽度然后更新columns配置中对应列的width值。由于 React 的状态更新会触发重新渲染表格的列宽就会随之变化。听起来是不是挺直接的下面我贴一段最精简的实现代码你可以直接复制到项目里跑起来看看效果。这里我创建了一个MyTable组件来封装所有逻辑。// MyTable/index.tsx import { Table } from antd; import { useState } from react; import ResizableTitle from ./ResizableTitle; import ./index.css; // 一些必要的样式 export default function MyTable() { // 1. 定义表格的列配置和数据 const [columns, setColumns] useState([ { title: 日期, dataIndex: date, width: 200 }, { title: 金额, dataIndex: amount, width: 100 }, { title: 类型, dataIndex: type, width: 100 }, { title: 备注, dataIndex: note, width: 100 }, { title: 操作, key: action, render: () a删除/a }, ]); const dataSource [ { key: 0, date: 2023-01-01, amount: 120, type: 收入, note: 转账 }, // ... 更多数据 ]; // 2. 为每一列注入拖拽所需的 props const mergedColumns columns.map((col, index) ({ ...col, onHeaderCell: (column) ({ width: column.width, onResize: (_, { size }) { // 拖拽结束时更新对应列的宽度 const newColumns [...columns]; newColumns[index].width size.width; setColumns(newColumns); // 触发重新渲染 }, }), })); // 3. 使用自定义的表头组件渲染表格 return ( Table components{{ header: { cell: ResizableTitle, // 关键替换表头单元格 }, }} columns{mergedColumns} dataSource{dataSource} / ); }接下来是自定义表头组件ResizableTitle的实现// MyTable/ResizableTitle.tsx import { Resizable } from react-resizable; import react-resizable/css/styles.css; // 引入基础样式 const ResizableTitle (props) { const { onResize, width, ...restProps } props; // 如果没有宽度例如操作列直接渲染普通 th if (!width) { return th {...restProps} /; } return ( Resizable width{width} height{0} // 高度不变只拖拽宽度 handle{ // 拖拽把手我们放在单元格右侧 span classNamereact-resizable-handle onClick{(e) e.stopPropagation()} // 防止与单元格点击事件冲突 / } onResize{onResize} draggableOpts{{ enableUserSelectHack: false }} th {...restProps} / /Resizable ); }; export default ResizableTitle;最后一点点 CSS 让拖拽把手出现在正确的位置并且看起来像个可拖拽的竖条/* MyTable/index.css */ .react-resizable { position: relative; } .react-resizable-handle { position: absolute; right: -5px; /* 把手位于单元格右侧边框处 */ bottom: 0; top: 0; z-index: 1; width: 10px; cursor: col-resize; background: transparent; /* 默认透明hover时可加颜色 */ } .react-resizable-handle:hover { background-color: #1890ff; /* Antd 主色拖拽时更明显 */ }把上面三段代码组合起来一个具备基础列宽拖拽功能的 Antd Table 就诞生了。用户现在可以鼠标悬停在列与列之间的缝隙看到光标变成左右箭头然后拖拽调整宽度了。这个方案对于简单的表格、数据量不大的管理后台来说已经完全够用而且代码清晰易于理解。1.1 基础实现的原理与局限虽然上面的代码跑起来了但我们得明白它到底是怎么工作的这样才能为后面解决复杂问题打下基础。核心原理在于React 的重新渲染机制。当用户拖拽松开鼠标时onResize回调被触发我们调用setColumns生成了一个全新的columns数组注意这里一定要是新数组遵循不可变数据原则。React 检测到状态变化于是重新渲染整个MyTable组件。Table组件接收到新的columns配置每一列的宽度width已经更新因此渲染出的表格列宽就发生了变化。这个过程可以类比成我们在一张纸上画表格。第一次画我们按照初始的宽度标尺来画。现在要改某一列的宽度我们不是拿橡皮擦只擦那一列而是把整张纸撕了换一张新纸按照新的标尺重新画整个表格。在数据量小、表格简单的时候这个“重画”的过程非常快用户感知不到延迟拖拽体验就是流畅的。但是这个方案的局限性也非常明显它埋下了性能问题的种子全量渲染任何一列宽度的改变都会导致整个 Table 组件及其所有子组件每一行、每一个单元格重新渲染。即使其他列的数据和宽度都没变。状态提升宽度状态管理在 Table 的最外层组件。对于深层嵌套的复杂表格组件状态更新可能会引发不必要的父组件渲染。副作用触发如果表格列里渲染了复杂的自定义组件比如包含权限判断、数据请求的按钮每次拖拽都会导致这些组件重新执行所有逻辑消耗巨大。我当时在第一个用到这个功能的中型项目里就感觉拖拽有点“涩”不那么跟手。但因为表格数据最多也就几百条电脑性能也好所以没太在意。直到后来遇到了真正的“性能杀手”场景才意识到这个基础方案是多么的脆弱。接下来我们就聊聊我是怎么撞上这堵性能墙的。2. 性能瓶颈初现当拖拽遇上复杂业务场景基础功能上线后平稳运行了一段时间。直到我们接手了一个新的后台系统这个系统的数据报表模块表格那叫一个复杂。我印象最深的一个页面表格有将近30列每一页展示100行数据这还不算完关键问题出在其中的几列上。有一列是“操作”列里面不是简单的文本而是封装了一个叫ActionButtonGroup的组件。这个组件内部干了些什么呢它要根据当前登录用户的权限动态计算显示哪些按钮查看、编辑、删除、审核等。权限计算涉及一次异步请求拿到权限列表后还要做一堆逻辑判断。按照我们上面的基础实现每一次拖拽列宽都会导致整个表格重绘这个ActionButtonGroup组件就会随之重新渲染重新执行权限获取和判断逻辑。你可以想象一下这个场景用户只是想调整一下“备注”列的宽度让他能看清长文本。在他按下鼠标并拖动的过程中鼠标的mousemove事件会高频触发可能每秒几十次react-resizable的onResize回调也随之高频调用。虽然我们只在拖拽结束时才更新columns状态但一些优化不佳的写法或者库的内部实现可能会在拖拽过程中就频繁触发更新。更糟糕的是即使我们只在结束时更新一次由于ActionButtonGroup的渲染成本极高一次更新就足以让页面“卡住”半秒钟体验直接崩坏。我当时遇到的情况就是一拖拽列宽页面帧率骤降操作反馈延迟极高用同事的话说就是“卡成了PPT”。通过 Chrome 的 Performance 工具录制性能时间线问题一目了然大量的时间都消耗在 React 的 Reconciliation协调和组件渲染上尤其是那个复杂的权限组件。这暴露了基础方案的两个致命弱点渲染粒度太粗牵一发而动全身。一列动全表动。对复杂子组件不友好表格列内嵌的组件如果包含重逻辑计算、副作用、异步请求会被拖拽行为反复触发造成资源浪费和页面卡顿。2.1 问题定位与初步优化尝试遇到问题首先要定位。我最初的排查思路是是否是react-resizable的锅我尝试换用其他拖拽库如react-draggable甚至自己用原生事件写了一个简单的拖拽逻辑发现卡顿依旧。这说明问题不在拖拽库本身而在于我们响应拖拽事件后的更新策略。是否是 Antd Table 渲染慢我构建了一个极简表格只有5列10行纯文本数据用同样的拖拽逻辑发现异常流畅。这证实了是我们表格的复杂性与全量更新机制共同导致了性能问题。使用 React 开发者工具打开Highlight updates when components render选项拖拽时整个 Table 组件、每一行、每一个单元格都在高亮闪烁证实了全量渲染的猜测。基于这个分析我的第一个优化思路是减少不必要的渲染。既然问题出在ActionButtonGroup这种昂贵组件被反复渲染那我能不能用React.memo或者useMemo把它缓存起来我尝试对ActionButtonGroup组件用React.memo进行包裹希望在其props没变时跳过渲染。但很快发现行不通因为我们的columns配置在每次拖拽后都是一个全新的数组传给Table的columnsprop 每次都在变导致Table认为所有内容都变了其内部的子组件自然也无法命中memo的缓存。然后我又尝试把columns的定义用useMemo包裹依赖项设为空数组[]希望它只初始化一次。但这显然不行因为列宽必须能变columns不可能是静态的。这条路走不通我意识到问题的核心在于React 的更新模型。在 React 的范式里UI 是状态的函数。状态columns变了UITable就必须重新计算。只要我们还通过更新 React 状态来驱动宽度变化在复杂场景下就难以避免重渲染的开销。那么有没有一种方法可以绕过 React 的渲染流程直接改变最终的视觉效果呢就像我们不想重画整张纸只想用橡皮擦和笔局部修改某一列的宽度。答案是肯定的这就是前端领域一个经典的选择直接操作 DOM。这个想法让我既兴奋又忐忑兴奋的是它可能从根本上解决问题忐忑的是这似乎违背了 React“数据驱动”的哲学需要非常小心地处理。接下来的章节我就分享如何安全、高效地走这条“捷径”。3. 性能优化实战绕过React直接操作DOM当我们发现通过 React 状态更新来调整列宽在复杂场景下会成为性能瓶颈时就该考虑更底层的优化手段了。我们的新目标是在用户拖拽过程中直接修改表格对应列的 DOM 元素的宽度实现视觉上的实时更新而完全避免触发 React 的重新渲染。等用户拖拽结束鼠标松开时再将最终的宽度值同步回 React 状态用于持久化比如保存到本地存储或后端。这个思路的关键在于理解 Antd Table 的 DOM 结构。打开浏览器开发者工具检查一个渲染好的 Antd Table你会发现它的宽度控制其实是通过 HTML 的colgroup和col元素实现的。col元素可以定义表格列的样式包括宽度。Antd 会为每一列生成一个col元素并通过内联样式stylewidth: 100px;来设置其宽度。所以我们的优化方案流程就变成了拖拽开始记录初始宽度和鼠标的初始 X 坐标。拖拽中监听鼠标移动计算移动距离直接找到对应的col元素修改其style.width值。拖拽结束将最终计算出的宽度值更新到 React 的columns状态中完成状态的同步。这样做的好处是巨大的在拖拽这个高频交互过程中浏览器只需要进行代价极小的 DOM 属性修改style.width完全绕过了 React 的虚拟 DOM 对比和组件渲染生命周期。页面响应会变得极其跟手。3.1 改造ResizableTitle组件我们需要重写ResizableTitle组件让它不再仅仅传递onResize回调给react-resizable而是自己处理拖拽事件并直接操作 DOM。// MyTable/ResizableTitle.tsx (优化版) import { Resizable } from react-resizable; import { useRef } from react; import react-resizable/css/styles.css; const ResizableTitle (props) { const { onResize, width, columnKey, tableId, ...restProps } props; // 使用 ref 记录初始值避免在事件回调中闭包依赖旧值 const initialWidthRef useRef(0); const initialXRef useRef(0); if (!width) { return th {...restProps} /; } const handleResizeStart (event, data) { // 1. 记录初始宽度和鼠标位置 initialWidthRef.current width; initialXRef.current event.clientX; // 2. 为整个表格添加一个“正在拖拽”的样式类可能用于改变鼠标样式或禁用文本选择 const table document.getElementById(tableId); if (table) { table.classList.add(table-resizing); } }; const handleResize (event, data) { // 1. 计算鼠标移动距离 const deltaX event.clientX - initialXRef.current; // 2. 计算新宽度并设置最小宽度限制例如60px const newWidth Math.max(60, initialWidthRef.current deltaX); // 3. 关键步骤直接操作 DOM const table document.getElementById(tableId); if (!table) return; // 找到对应的 col 元素 // Antd Table 的 colgroup 可能在 thead 对应的表头区域和 tbody 对应的数据区域各有一个 // 我们需要同时修改它们以确保表头和表体列宽对齐 const colgroups table.querySelectorAll(colgroup); colgroups.forEach((colgroup) { // 这里需要一个方法根据 columnKey 找到正确的 col 索引 // 假设我们通过 props 传入了 columnIndex const cols colgroup.querySelectorAll(col); if (cols.length props.columnIndex) { cols[props.columnIndex].style.width ${newWidth}px; } }); // 4. 可以在这里触发一个自定义事件或回调让父组件知道宽度正在变化用于实时显示宽度值等 // onResizing?.(newWidth); }; const handleResizeStop (event, data) { // 1. 移除“正在拖拽”的样式类 const table document.getElementById(tableId); if (table) { table.classList.remove(table-resizing); } // 2. 计算最终宽度 const deltaX event.clientX - initialXRef.current; const finalWidth Math.max(60, initialWidthRef.current deltaX); // 3. 将最终宽度通过回调传给父组件更新 React 状态 if (onResize) { onResize(event, { size: { width: finalWidth } }); } }; return ( Resizable width{width} height{0} handle{ span classNamereact-resizable-handle onClick{(e) e.stopPropagation()} / } onResizeStart{handleResizeStart} onResize{handleResize} onResizeStop{handleResizeStop} draggableOpts{{ enableUserSelectHack: false }} th {...restProps} / /Resizable ); }; export default ResizableTitle;相应的父组件MyTable也需要做一些调整主要是为每一列生成唯一的key和传递tableId并修改onHeaderCell的回调逻辑现在它只在拖拽结束时被调用。// MyTable/index.tsx (优化版片段) const MyTable () { const tableIdRef useRef(table-${Math.random().toString(36).slice(2)}); const [columns, setColumns] useState([...]); // 同前 const mergedColumns columns.map((col, index) ({ ...col, key: col.dataIndex || col.key || col-${index}, // 确保每列有唯一key onHeaderCell: (column) ({ width: column.width, columnKey: column.key, // 传递唯一标识 columnIndex: index, // 传递索引 tableId: tableIdRef.current, // 传递表格ID用于定位DOM onResize: (_, { size }) { // 这个回调现在只在拖拽停止时触发 const newColumns [...columns]; newColumns[index].width size.width; setColumns(newColumns); // 这里可以加上持久化逻辑例如 saveToLocalStorage(newColumns); }, }), })); return ( Table id{tableIdRef.current} // 为Table设置ID components{{ header: { cell: ResizableTitle } }} columns{mergedColumns} dataSource{dataSource} / ); };3.2 处理带滚动条的复杂情况上面的代码在表格没有横向滚动条时工作良好。但是当列总宽度超过容器宽度出现横向滚动条时Antd Table 的 DOM 结构会变得更复杂。通常它会将表头thead和表体tbody分离到两个不同的滚动容器中以实现表头固定的效果。每个部分都有自己的colgroup。因此在handleResize函数中我们需要更精确地定位和修改 DOM。我们需要同时修改表头区域和表体区域对应的col元素否则会出现表头和表体列宽错位的诡异情况。代码需要增加对滚动场景的判断const handleResize (event, data) { // ... 计算 newWidth const table document.getElementById(tableId); if (!table) return; // 查找所有的 colgroup const headerColGroup table.querySelector(.ant-table-header colgroup); const bodyColGroup table.querySelector(.ant-table-body colgroup); const updateColWidth (colgroup, index) { if (!colgroup) return; const cols colgroup.querySelectorAll(col); if (cols.length index) { cols[index].style.width ${newWidth}px; } }; updateColWidth(headerColGroup, columnIndex); updateColWidth(bodyColGroup, columnIndex); // 如果表格有固定列可能还需要处理固定的左侧或右侧列的colgroup // 可以通过选择器 .ant-table-fixed-left colgroup, .ant-table-fixed-right colgroup 来查找 };经过这番改造再回到那个拥有复杂权限组件的报表页面进行测试。效果立竿见影拖拽过程变得丝般顺滑无论怎么拉拽列宽页面都再也没有出现卡顿ActionButtonGroup组件在拖拽过程中一次也没有重新渲染。只有在鼠标松开最终宽度同步回 React 状态时它才因columns状态更新而渲染了一次这次渲染是必要的为了状态同步和持久化。这种“拖拽中操作DOM拖拽后同步状态”的模式完美地平衡了交互性能和状态一致性。它就像是在画画时先用铅笔直接操作DOM快速打草稿、调整线条等确定无误后再用钢笔React状态描边定型。4. 进阶优化与最佳实践解决了核心的性能卡顿问题我们的列宽拖拽功能已经非常实用了。但在实际的大型项目中我们还需要考虑更多细节让这个功能变得更健壮、更易用。这里分享几个我踩过坑后总结的进阶优化点。4.1 宽度持久化与恢复用户辛辛苦苦调整好的列宽肯定不希望刷新页面后就恢复原样。所以我们需要将调整后的列宽配置保存起来。通常有两种选择本地存储LocalStorage和服务端存储。本地存储实现简单适合个人偏好设置。我们在onResize停止回调中将最新的columns数组包含更新后的width序列化后存入localStorage。const saveColumnsToLocalStorage (tableId, columns) { try { const config columns.map(col ({ key: col.key, width: col.width })); localStorage.setItem(table_columns_${tableId}, JSON.stringify(config)); } catch (e) { console.error(保存列配置失败, e); } }; // 在 onResize 回调中调用 onResize: (_, { size }) { const newColumns [...columns]; newColumns[index].width size.width; setColumns(newColumns); saveColumnsToLocalStorage(tableIdRef.current, newColumns); // 保存 }组件初始化时再从localStorage读取配置并合并到初始的columns中。const [columns, setColumns] useState(() { const defaultCols [...]; // 默认列配置 try { const saved localStorage.getItem(table_columns_${tableIdRef.current}); if (saved) { const savedConfig JSON.parse(saved); return defaultCols.map(col { const savedCol savedConfig.find(sc sc.key col.key); return savedCol ? { ...col, width: savedCol.width } : col; }); } } catch (e) { console.error(读取列配置失败, e); } return defaultCols; });服务端存储则更适合多端同步的场景比如用户在公司电脑和家里电脑上希望看到相同的表格布局。这时我们需要在拖拽结束后将配置通过 API 发送到后端保存。为了避免频繁请求可以加入防抖debounce逻辑比如在用户停止拖拽300毫秒后再发送请求。4.2 处理动态列与列显隐很多时候表格的列不是固定的可以通过勾选来显示或隐藏某些列。这给我们的拖拽逻辑带来了挑战列的索引columnIndex可能会变。如果我们仍然用索引去查找col元素当某一列被隐藏后后续列的索引全部错位拖拽就会乱套。解决方案是放弃使用不稳定的index转而使用列的唯一标识key。我们需要在生成columns配置时为每一列都赋予一个稳定且唯一的key可以是dataIndex或者手动指定。在拖拽时我们通过这个key来定位对应的列。在直接操作 DOM 时如何通过key找到col元素呢Antd Table 在渲染时会将column.key映射到col元素的data-col-key属性上或者类似的属性需要实际查看DOM结构确认。如果 Antd 没有提供一个变通的方法是在渲染ResizableTitle时将column.key也设置到th元素上比如作为一个data-key属性。然后在拖拽事件处理函数中我们可以先根据data-key找到th再通过 DOM 关系找到其对应的col元素。这需要更精细的 DOM 遍历逻辑但确保了列显隐变化时拖拽功能依然正确工作。4.3 封装与复用打造通用的拖拽Table组件经过这么多优化我们的代码已经有点复杂了。为了在项目中不同地方复用最好的办法是将其封装成一个独立的高阶组件或 Hook。我倾向于封装成一个Hook比如叫useResizableColumns。这个 Hook 接收初始的columns配置和一个可选的tableId返回处理好的、可用于 Antd Table 的columns数组、components配置以及必要的tableProps如id。// useResizableColumns.ts import { useMemo, useRef, useCallback } from react; const useResizableColumns (initialColumns, tableId) { const [columns, setColumns] useState(initialColumns); // ... 所有状态、DOM操作逻辑、事件处理函数都封装在这里 const { mergedColumns, tableComponents, tableProps } useMemo(() { // 合并逻辑 return { mergedColumns: processColumns(columns, tableId, handleResizeStop), tableComponents: { header: { cell: ResizableTitle } }, tableProps: { id: tableId }, }; }, [columns, tableId]); return { columns: mergedColumns, components: tableComponents, tableProps, setColumns, // 暴露出去以便外部可能需要的重置等功能 }; }; // 在业务组件中使用 import { Table } from antd; import useResizableColumns from ./useResizableColumns; const BusinessTable () { const dataSource [...]; const initialColumns [...]; const { columns, components, tableProps } useResizableColumns(initialColumns, my-business-table); return ( Table {...tableProps} columns{columns} components{components} dataSource{dataSource} / ); };这样封装之后业务开发人员只需要关心表格的数据和列定义无需再涉足复杂的拖拽实现细节真正做到了“开箱即用性能无忧”。同时核心的优化逻辑集中在一处也便于后续的维护和升级。回过头看这段从基础实现到深度优化的历程核心的体会是在追求用户体验和性能时我们不能被单一的技术范式束缚。React 的数据驱动是强大的声明式抽象但在面对极致性能要求的交互场景时适时地结合命令式的 DOM 操作往往能取得事半功倍的效果。关键是要把握好两者的边界让它们各司其职——DOM 操作负责高频的视觉反馈React 状态负责最终的数据同步和逻辑管理。这种混合模式在很多复杂的交互组件如富文本编辑器、图形绘制工具中都非常常见也是前端开发者能力进阶的必经之路。