
表格这东西放到Web前端里真是又爱又恨。往小了说一个table标签配上几个td就能出个简单表格往大了说行合并、列合并、固定表头、跨页、全选、合计行、导出每一项都能让人折腾半天。这篇东西就是想聊聊“制作简易表格”这件事——从原生HTML结构讲到JS动态创建再讲到el-table这类组件里的合并和全选把我实际操作中验证过、踩过的坑一起整理出来适合刚入门前端、正在被表格折磨的初学者也适合写了几年业务代码但始终没系统梳理过表格逻辑的开发者拿来当一份查漏补缺的参考。1. 一点背景为什么“简易表格”值得单独写一篇1.1 “简易”这两个字的真实分量很多人看到“简易表格”四个字脑海里第一反应是“这有什么好写的”。我在接手第一个管理后台项目之前也是这么想的结果真上手才发现表格从来不是简单的标签堆砌它的复杂度往往取决于周围业务的需求。举个很常见的例子你要做一个出入库表格。听起来很简单对不对就是物料名称、入库数量、出库数量、结存、时间这几列。但一旦加上“本月合计”“本年累计”“按供应商筛选”“单元格可编辑”“某列需要合并相同的订单号”这些需求原本那个规规矩矩的table就变得不那么听话了。你会发现你不仅要处理数据的展示还要处理数据的结构、行列关系、样式适配甚至还要考虑当数据超过一屏时用户怎么浏览。每一层都是独立的坑。这也是我为什么想写这篇文章的原因。很多网上教程只教最基础的table标签语法却没人告诉你表格在真实项目里是怎么一步步“长大”的。这篇东西的定位是从零开始把一张简易表格做到能用、好看、能应对常见业务需求同时把背后的逻辑讲清楚。1.2 表格开发的能力地图如果你把表格开发当作一项独立技能来拆解你会发现它其实横跨了前端的几个基础领域HTML语义table、thead、tbody、tfoot每种结构是干什么的为什么要区分这决定了表格的语义正确性和可维护性。CSS布局与样式边框、间距、斑马纹、悬停高亮、响应式处理。很多初学者在这里第一次接触到border-collapse这种“不那么直觉”的属性。JavaScript DOM操作用createElement还是insertRow事件委托怎么做动态创建出来的表格如何绑定行点击、删除、编辑数据处理把接口返回的JSON数组渲染成表格本质上是“数据到视图”的映射。这一步做得好不好直接决定你的表格是“静态死表”还是“活表”。组件化思维当你切换到React或Vue生态表格的使用方式又会变成el-table、antd Table这种配置式组件这时核心难点从“怎么画格子”变成了“怎么配置列、怎么控制合并、怎么处理分页状态”。这四个层面的能力在一张“简易表格”身上全部都能体现。换句话说把表格做透你等于把前端基础功练了大半。2. 动手之前先把表格的数据结构想清楚2.1 数据先行表格的本质是“二维数据”我见过很多刚入行的同事拿到需求就打开编辑器开始写table写到一半才发现列数和接口返回的字段对不上或者合计行不知道加在哪里。根本原因是没有先梳理数据。表格的本质是二维数据展示。横向是列字段纵向是数据记录。所以动手前先把数据结构定下来后续所有逻辑都会顺很多。一个最简单的用户信息表数据结构一般是这样的const users [ { id: 1, name: 张伟, department: 技术部, age: 28 }, { id: 2, name: 李娜, department: 产品部, age: 32 }, { id: 3, name: 王强, department: 设计部, age: 26 } ];对应的表格列配置就是const columns [ { key: id, title: 序号 }, { key: name, title: 姓名 }, { key: department, title: 部门 }, { key: age, title: 年龄 } ];把数据和列配置分开是你做出一个“可维护表格”的第一步。因为后续所有的增删改查、筛选排序、导出全部建立在“数据数组 列配置数组”这个双数组结构上。如果一上来就把数据写死在HTML里后面接手的人会很痛苦你自己过一个月回来看也会很痛苦。有时候接口返回的数据结构会比较“脏”字段名是下划线风格的user_name而前端用的是驼峰userName或者某个字段在接口里是null前端需要展示成“–”。这些映射逻辑不应该散落在模板判断里而是在数据进表格之前先做一层归一化统一成表格组件能识别的干净结构。这也是我后来转向组件化方案的原因之一把脏活提前处理掉表格只负责展示。2.2 不要忽视thead和tbody的语义很多早期教程里表格是这样写的table border1 tr td姓名/td td部门/td /tr tr td张伟/td td技术部/td /tr /table这种写法不是不能显示而是有三个问题。第一表头和内容混在一起语义不清晰第二后续想做“表头固定”或“首列固定”时没有结构基础第三屏幕阅读器和SEO对表格的解析会出问题。正确的结构是结构分离表头进thead内容进tbody汇总行放tfoottable thead tr th姓名/th th部门/th /tr /thead tbody tr td张伟/td td技术部/td /tr /tbody tfoot tr td colspan2总计1人/td /tr /tfoot /tableth和td的区别也要记住表头单元格用th默认加粗居中内容单元格用td。这种语义差别虽然肉眼看起来只差一个加粗但它关联到浏览器的默认样式、辅助技术的读屏逻辑以及你后面用CSS选择器控制表头样式时的便利性。2.3 合并单元格背后的“栅格思维”合并单元格是表格开发里绕不开的操作。理解rowspan和colspan并不难colspan管横向跨列rowspan管纵向跨行。但难的是反向推导——当数据结构动态变化时你到底该给哪些单元格加上合并属性。这时候我建议你用“栅格思维”来思考把表格想象成一个坐标系每个单元格占据一定的行数和列数。当你合并单元格时其实是在告诉浏览器“这个位置由前面的单元格延伸过来占用”。动态数据场景下我们最喜欢用的手法是先算好哪些行的某一列相同再决定从哪一行开始合并、跨越几行。这个逻辑如果放到数据量大的表格里建议单独抽一个函数来处理不要写在渲染循环里。3. 从静态到动态JS创建表格的两条路线3.1 路线一模板字符串拼接快但要注意转义最早在项目里做动态表格时我习惯用模板字符串拼接HTML。因为思路直接map遍历数据拼出tr和td最后一次性innerHTML进容器。function renderTable(container, columns, data) { const headHtml thead tr${columns.map(col th${col.title}/th).join()}/tr /thead; const bodyHtml tbody ${data.map(row tr ${columns.map(col td${row[col.key] ?? —}/td).join()} /tr ).join()} /tbody; container.innerHTML table${headHtml}${bodyHtml}/table; }这段代码跑起来很爽几行就能渲染一张完整的表。但问题也随之而来最大的坑是数据中包含HTML字符时会被当作标签解析。比如用户的备注里写了script或者b开头的内容页面直接就被“注入”了。后来的经验是凡是渲染用户输入型数据务必先做转义或者尽量不用innerHTML拼接改用textContent逐节点填充。另外模板字符串的嵌套层级一多阅读性和调试性都会下降。一旦某一行数据有误报错信息定位不到具体是哪个字段出了问题。所以这个方法适合快速原型、数据可信度高的场景但不适合作为生产方案长期使用。3.2 路线二DOM API逐个创建可控但写起来繁琐另一个路线是用document.createElement或者table.insertRow这类DOM API来构建节点。代码量大一些但每一步都可控数据里的特殊字符会被自动当作纯文本处理没有注入风险。一个典型的实现思路是这样的function renderTable(container, columns, data) { const table document.createElement(table); const thead document.createElement(thead); const trHead document.createElement(tr); columns.forEach(col { const th document.createElement(th); th.textContent col.title; trHead.appendChild(th); }); thead.appendChild(trHead); table.appendChild(thead); const tbody document.createElement(tbody); data.forEach(row { const tr document.createElement(tr); columns.forEach(col { const td document.createElement(td); td.textContent row[col.key] ?? —; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); container.appendChild(table); }对比模板拼接这个方法的代码量差不多翻了一倍但好处是你没有把数据“翻译”成HTML字符串再交给浏览器解析而是直接构建DOM节点性能和安全性都更好。更重要的是你随时可以在某个td创建后、插入前追加额外的属性、样式或事件监听。在实际项目里我更倾向于折中外层结构用模板字符串数据行部分用DOM API创建。这样兼顾了开发效率和安全性。简单说静态框架可以用字符串动态内容用节点这个习惯能帮你避开很多动态渲染的坑。3.3 动态增删行核心是数据先行做表格的动态增删最直接的办法是用deleteRow(index)接口。当年我看文档的时候有一瞬间觉得奇怪为什么是deleteRow而不是removeChild后来才明白HTMLTableElement接口本身提供了基于行索引的操作方法这样你不用自己去遍历子节点找目标行。但真正决定增删逻辑是否稳定的是数据源。增删行不能只动DOM要同步维护背后的数据数组。删掉一行data里对应对象也要移除。因为后续排序、导出、统计都要基于数据源来做DOM只是渲染结果。如果只删DOM不删数据很快你就会发现“表格显示的数据和统计数据对不上”这种诡异问题。给行加删除按钮时强烈建议用事件委托。不要把click监听器一个个绑到每个按钮上而是监听tbody的点击事件判断event.target是不是按钮再读取>tr td colspan5没有更多数据了/td /tr这个情况比较简单没有太多脑力成本。麻烦的是rowspan动态合并相同内容的单元格。比如一个订单表格同一个订单号可能占用两行分别显示两种商品需求是让订单号单元格纵向合并。处理思路是这样的先把数据按订单号分组记录每一组的起始索引和跨度然后在渲染时只对每个分组的第一个数据行输出订单号单元格并加上rowspan属性其余数据行跳过该列。// 假设数据已经按 orderId 排好序 function buildRowspanMap(data, columnKey) { const map new Map(); let current null; let startIndex 0; data.forEach((row, index) { const value row[columnKey]; if (value ! current) { if (current ! null) { map.set(startIndex, index - startIndex); } current value; startIndex index; } }); if (current ! null) { map.set(startIndex, data.length - startIndex); } return map; }核心逻辑不复杂遇到值变化就结算上一组的跨度。渲染时判断当前行索引是否在map里如果在输出一个自带rowspan的单元格如果不在就不输出该列单元格。这个“跳过单元格”的细节特别容易踩坑——如果你不跳过表格的列数会对不齐布局直接乱掉。4.2 JS动态创建后的合并怎么做在原生JS里实现动态合并常见的笨办法是渲染完成后用DOM操作去“事后处理”。比如先把表格全部渲染出来然后从第二行开始判断某一列的值和上一行相同就把当前行的单元格删除同时给上一行对应单元格的rowspan加1。这种“后处理”方式确实能跑但代码很脆稍不注意就会漏掉连续三段相同值的情况。我更推荐“渲染前计算、渲染时决策”的方式也就是前面说的buildRowspanMap。在生成tr的时候就已经知道该行是否要输出合并单元格这样DOM结构和数据是完全对应的不会有事后修补带来的错位问题。如果你用的是组件库比如el-table或antd Table动态合并的思路也类似组件通过一个span-method或cellMerge回调接收当前行、列信息返回合并的行列数。阿里和字节的组件在API上略有差异但背后的“栅格坐标”逻辑是通用的。在掌握原生表格合并之前我建议不要直接上手组件的高级合并功能否则你根本看不懂报错信息里那个rowspan是从哪冒出来的。4.3 合计行怎么加才不破坏数据结构“自定义表格合计行”是业务后台里特别常见的需求。产品经理通常会希望表格底部有一行展示金额合计、数量合计。最不该做的方案是往数据数组里push一个特殊对象然后渲染时判断这个对象是否是合计行。这样会把汇总逻辑混进业务数据里后面做筛选、排序时还要时刻记得把它踢出去。正确的做法是渲染时单独生成一个tfoot或者渲染一个额外的行与数据数组完全解耦。比如tfoot tr td合计/td td${totalQuantity}/td td${totalAmount}/td /tr /tfoot这样合计行是独立的不污染数据。日后再要导出Excel数据源还是原来的数组合计行的计算逻辑单独复用即可。从维护角度讲这个分离极其重要。4.4 表格自适应宽度别再纠结“列宽怎么调都拖不动”“表格自适应宽度”其实是几个子问题的集合列宽怎么定、容器变化时要不要响应、内容超长时怎么办。先说table-layout属性。默认值是auto浏览器会根据单元格内容自动计算列宽好处是内容不会被截断坏处是列宽不可控。如果你想要“列宽说了算”用table-layout: fixed再配合colgroup里的col标签设置宽度table { table-layout: fixed; width: 100%; } col:nth-child(1) { width: 80px; } col:nth-child(2) { width: 2fr; }固定布局下浏览器会严格按照你给定的宽度渲染列不再受内容影响。你可能会遇到“列宽怎么拖都拖不动”的情况——那时候先检查一下是不是table-layout是auto而内容撑开了单元格。在Word里拖不动列宽可以怀疑是锁定了宽度但在网页里先看CSS再谈其他。内容超长时最稳妥的三件套是td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }同时给td加上max-width避免表格被撑破。移动端适配的话可以考虑把过长的列隐藏或者改用横向滚动容器包裹表格。响应式不是简单地把表格宽度变成百分比就完事还要考虑内容优先级。4.5 表头固定与跨页续表来自Word的灵感在长表格里固定表头原生方案是给thead设置position: sticky; top: 0;并且给表头单元格背景色避免滚动时露出下层内容。这个方案比早年用两份表格模拟“假表头”的做法优雅得多。thead th { position: sticky; top: 0; background: #fff; z-index: 1; }有一点要注意position: sticky是相对于最近的滚动容器生效的。如果表格外面的某个div设置了overflow: auto那top: 0会相对于这个div滚动而不是页面滚动。很多同学在页面上测试正常一放进弹窗或滚动的容器里就失效原因就在这里。“跨页续表”这个词是从Word表格里传过来的表格很长跨到下一页时需要重复表头。在Web端最常见的对应方案就是固定表头让滚动时表头始终可见。如果在打印场景CSS里thead默认会在每页重复显示这倒是一个原生支持不用额外处理。5. 组件化时代的表格el-table与antdesignvue实战笔记5.1 为什么最终都会走向组件化当你用原生表格处理了十几个业务需求之后大概率会走上组件化这条路。原因很简单表格的交互复杂度已经超过了原生DOM操作的舒适区。分页、排序、筛选、多选、逐行展开、树形数据、拖拽列宽每一个功能自己手写都需要大量时间而且容易写出风格不统一的代码。组件化不是“偷懒”而是把复杂的表格交互抽象成配置。比如el-table只需要你提供columns数据和行数据框架自己处理虚拟滚动、多选状态、固定列这些底层逻辑。引入组件化之后我的表格开发效率提升了不止一倍但代价是必须理解组件提供的配置API尤其是那些跟合并、全选相关的边界情况。5.2 行合并列合并两个框架的配置差异我之前在同一时期接手过一个Vue2项目和一个React项目分别是el-table和antdesignvue的表格。两个组件都支持行合并但实现方式完全不同。el-table提供的是span-method方法每次渲染单元格时都会调用返回{ rowspan, colspan }或{ rowspan: 0, colspan: 0 }来隐藏单元格。这个方法非常灵活但性能上要注意数据量一大每个单元格都走一次回调很容易成为性能瓶颈。我的做法是预先计算好合并映射表在span-method里直接查表返回把计算量前置。const spanMap computed(() buildRowspanMap(props.tableData, orderId)); function spanMethod({ rowIndex, columnIndex }) { if (columnIndex 0 spanMap.value.has(rowIndex)) { return { rowspan: spanMap.value.get(rowIndex), colspan: 1 }; } if (columnIndex 0 !spanMap.value.has(rowIndex)) { return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }antdesignvue的Table则是通过customCell或者onCell返回属性对象的方式让某个单元格携带rowSpan或colSpan属性。React的开发模型把“单元格属性”当成纯数据对象返回组件内部负责把它们映射到底层DOM。说实话React版的合并更符合数据驱动的直觉但Vue版的回调在使用上更顺手。核心结论两个框架的合并底层都是rowspan和colspan只是包装方式不同。你只要吃透原生表格的合并逻辑换框架只是查一下API文档的事。5.3 跨页全选和自定义合计组件文档不会告诉你的细节“el-table表格第一页全选不影响其他页”是我搜了很久才找到答案的一个问题。el-table自带头部的全选复选框但默认行为是全选当前页。如果你希望它在跨页时只选择当前页不做全量选择做法是使用selection-change事件配合手动控制“全选”状态或者在header-cell-class-name里给全选列添加样式再监听select-all事件并阻止默认的跨页选择逻辑。具体方案取决于产品需求。如果产品要求“把当前筛选条件下的所有数据选中”那得用ref调用toggleAllSelection但要注意每次请求新数据后手动清空上一批的选择状态。这个过程细节很多稍不留神就会出现“明明选了50条提交时却只带走了第一页的10条”这种事故。自定义合计行在组件里其实不难el-table的show-summary自带一个合计行但如果你要“自定义合计行的文案、样式、某些列不参与合计”优先级比较高的做法是自己用表格组件外的div渲染一行自定义合计而不是依赖内置summary-method去改。原因很简单内置合计行的布局和样式自由度低改起来容易踩到组件内部样式的各种坑。5.4 组件表格导出Excel数据流要闭环很多管理后台做完表格展示之后下一步就是“导出Excel”。有几种方案最传统的是后端根据查询条件导出文件也有纯前端的方案用xlsx库或SheetJS把表格数据导出成Excel文件。这里值得强调一个原则导出不能从DOM去摘数据而是要从数据源导出。有的同学图省事遍历表格DOM取每个单元格的textContent再拼成二维数组。数据量小的时候看不出问题一旦遇到图片、自定义渲染、合并单元格导出的内容就会和页面显示不符。正确的做法是维护一套与表格展示一致的数据模型导出时直接基于数据模型生成Excel。前端导出的代码路径大概是import * as XLSX from xlsx; function exportToExcel(columns, data, filename) { const sheetData data.map(row { const obj {}; columns.forEach(col obj[col.title] row[col.key]); return obj; }); const worksheet XLSX.utils.json_to_sheet(sheetData); const workbook XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, Sheet1); XLSX.writeFile(workbook, ${filename}.xlsx); }这种方案还能顺便把合并单元格、图片链接转图片这些需求在导出层做处理。不过我要提醒一下涉及大批量数据导出时纯前端方案容易内存溢出或卡死页面尽量让后端承担导出任务。前端导出只适合轻量数据场景。6. 常见问题与排查技巧实录6.1 一张问题速查表我把这几年做表格遇到的高频问题整理成一张速查表方便你查漏补缺现象常见原因一句话解决办法表格列宽拖不动table-layout默认auto内容撑开了列设置table-layout: fixed并配合col宽度合并后表格错位只加了rowspan没跳过被合并单元格分组渲染时跳过非起始行对应列表头固定失效position: sticky的容器不是滚动容器检查祖先的overflow设置动态表格XSS数据里的HTML片段被innerHTML解析改用textContent或转义合计行被排序带跑合计对象混入了数据数组用tfoot或独立渲染合计行跨页全选错乱没有在请求新数据前清空选择状态在新数据渲染前手动clearSelection()表格数据无效粘贴表格内容包含隐藏字符或复制来源格式异常等粘贴后清洗数据或用clipboardData手动处理合并单元格导出Excel错乱导出时没考虑合并映射导出层保留合并所属关系用sheet_add_aoa时按映射处理这张表是现象到原因的快速映射实际排查时还是要结合具体代码一步步定位。6.2 我踩过的三个坑第一个坑是“动态表格无法合并”。早期我直接用innerHTML把表格字符串插入容器然后在插入后试图通过遍历tr来修改rowspan。结果每次都因为表格结构已经被浏览器解析成了固定的DOM树导致修改逻辑写起来非常别扭。后来我改成了先计算合并映射、再在生成HTML字符串时直接带上rowspan属性问题迎刃而解。那次经历让我明白表格开发里“先算后画”比“画完再改”靠谱得多几乎所有合并、跨页、合计的棘手问题都可以通过提前计算和结构调整来解决而不是事后修补。第二个坑是“WPS/Excel表格粘贴后格式乱掉”。很多后台系统都支持把Excel内容粘贴到一个表格组件里但用户在表格里看到的单元格内容和后端收到的数据往往有出入。最典型的问题是Excel的0开头的订单号粘贴过来后变成数字前导零全没了。解决方案是在paste事件里用event.clipboardData获取纯文本解析成二维数组并且把所有列都强制当作文本字符串处理必要时用正则把单元格内的换行和制表符先拆好再做单元格内容映射。这样虽然底层还是在做表格但你处理的其实是“剪贴板数据流”。第三个坑是“列宽百分比还是固定像素”。我之前很激进所有列都用百分比试图做全自适应布局。结果在高分屏上表格被拉得特别开用户看着非常难受。后来我意识到自适应不等于“等宽拉满”合理的做法是固定关键列序号、操作宽度内容列用百分比或min-width必要时在表格外层套一个overflow-x: auto的容器。数据宽就让它横向滚动但表头固定不动这样用户既能看清结构也不会被拉伸变形。6.3 测试表格功能时的几条经验给表格做测试不要只看正常数据。至少要把以下几类边界情况跑一遍空数据、超长文本、特殊字符引号、尖括号、换行、数值为0、数值为null、字段缺失。我遇到过好几次“表格没数据时不渲染表头”、或者“某字段为0时被当成空值用—代替”这类问题原因都是数据校验逻辑写了短路条件。稳健的做法是接口返回数据后统一走一层格式化函数把null转为空字符串、数值0保持为0、字符串首尾空格去掉再交给表格渲染。这个小习惯可以省掉后面大量纠纷。6.4 我个人的表格开发习惯最后分享几个我自己坚持的小习惯不一定适合所有人但至少在多次项目里帮我省了不少事。第一数据归一化前置。接口数据进表格前先做字段名映射和默认值填充表格组件内部永远只认一套干净的数据结构。第二合并映射表和合计行计算永远独立成纯函数。不写在组件render里方便单元测试也方便在NewTab里快速验证输出。第三给表头和内容分别维护样式类名。避免在th和td上直接写行内样式至少为后续主题定制留出口。第四能用事件委托就绝不逐个绑定事件。表格行多了以后事件监听器的数量直接影响页面流畅度这个道理在原生DOM阶段成立在组件化阶段依然成立。表格在Web前端里既基础又复杂。你在把“简易表格”做成“能用表格”的过程中其实已经把HTML结构、CSS布局、JavaScript逻辑、数据处理和组件配置这条链路完整走了一遍。如果你现在正被某个合并需求卡住或者正在纠结表格组件里的全选逻辑希望这篇东西能帮你理清思路。下次再遇到“做个表格而已”的需求可以多留一个心眼它可能没有嘴上说的那么简单但你也已经有能力把它拆解明白了。