
做前端这么久我越来越觉得“看似简单”的东西往往最容易翻车。就说表格随便找个刚入行的前端问多半觉得“不就是个table套上tr、td嘛有什么难的”。可真到了项目里列宽乱跳、边框对不齐、数据一多页面卡死、表头固定后滚动条跑偏……这些问题一冒出来才发现自己连一个“简易表格”都整不明白。这一篇我就把“制作简易表格”这件事从头到尾掰开揉碎讲一遍。不光是给新手看的基础写法更重要的是讲清楚原生表格的各种机制和坑再带一点进阶的交互实现和性能优化思路。无论你是刚接触Web前端的新人还是已经被表格虐过几次的开发者这篇都值得花几分钟读完至少能让你少走我当年走过的弯路。1. 为什么明明是个“简易表格”做起来却一点都不省心先说一个反直觉的结论表格是Web前端里最被低估难度的组件之一。表单好歹逻辑直观一个输入框对应一个值而表格是“一对多”的数据陈列同时又带着复杂的布局算法和交互语义稍微碰几个细节就容易出问题。你可以回想一下真实项目里的高频需求表格要自适应屏幕宽度要固定表头要合计行要合并单元格还要支持编辑、排序、筛选。每一个需求单独拎出来都不算复杂但叠加在一起问题就呈指数级上升。热搜词里那堆五花八门的提问比如“element-ui表格固定列和底部重叠”“word表格列宽无法拖动”“markdown表格转换excel”其实都指向同一个本质——表格的宽度、布局、滚动、渲染机制远比表面看上去复杂。在做任何表格之前我建议你先想清楚几个问题这能帮你避免后面大量的返工这个表格的列数是不是固定的还是由后端返回的动态字段数据量大概有多大几十行、几百行还是几万行甚至更多是否需要横向滚动多列超过容器宽度时怎么处理用户需不需要对单元格做编辑、选中、复制表格是纯粹的展示还是承担了录入功能很多人的误区是直接开写写完才发现“哎呀列宽怎么不受控制”“哎呀数据一多页面怎么卡成PPT”。花两分钟把上面的问题理顺了再决定用哪种方案反而省时间。2. 从零手写一个原生HTML表格基础骨架与语义标签2.1 先搭一个标准结构的table虽然现在前端框架满天飞但原生table依然是所有表格方案的基石。清不清楚table的完整结构决定了你后面能不能各种姿势扩展。一个规范的表格骨架长这样table caption2024年Q3销售数据/caption thead tr th产品名称/th th销量/th th单价/th th销售额/th /tr /thead tbody tr td无线耳机/td td1200/td td299/td td358800/td /tr tr td智能手表/td td860/td td1299/td td1117140/td /tr /tbody tfoot tr td colspan3合计/td td1475940/td /tr /tfoot /table这里我得特别强调一下caption、thead、tbody、tfoot这几个标签的作用。caption是表格的标题默认显示在表格上方对于屏幕阅读器来说它是理解表格内容的第一个锚点无障碍细节不能省。thead专门放表头行tbody放主体数据tfoot放汇总行。很多人会偷懒只写tr和td不区分表头和数据。短期看没问题但浏览器渲染表格时没有thead标签的表格表头行会被默认归入tbody这在做样式控制和后续的JS操作时会多很多麻烦。从一开始就把结构写规范后面的CSS和JS才能精准命中。2.2 th和td的区别不只是加粗居中th是表头单元格td是普通数据单元格。视觉上th默认加粗居中但这只是浏览器默认样式的表象。真正的区别在语义层th告诉辅助技术“这一格是数据的标签或分类”所以在做无障碍支持时清晰的th结构是一个硬性要求。另外th还有一个很实用的scope属性tr th scopecol产品名称/th th scopecol销量/th th scoperow无线耳机/th /trscopecol表示这是列标题scoperow表示这是行标题。对普通用户来说这个属性“看不见摸不着”但对于依赖屏幕阅读器浏览的群体它是理解数据关系的关键。既然做前端这种顺手就能加的语义属性建议养成习惯。3. table布局算法为什么你的列宽总是不听你的表格的列宽控制问题几乎可以排进“前端表格十大玄学”的前三名。你给td设置了width: 100px结果它偏要变成120px你明明想让第一列窄一点它却和其他列平分了整个表格。要解释这个问题得先讲清楚浏览器渲染表格的底层机制。3.1 table的一个核心机制automatic layout自动布局算法在默认情况下表格采用的是自动布局算法。它的核心逻辑是先看所有单元格的内容再根据内容估算每一列的最小宽度。也就是说列的最终宽度是由“内容撑出来的”你显式设置的width只是一个参考值不是最终值。举个例子假设表格容器宽度是800px有两列你给第一列设了width: 200px第二列没设。但第一列里有条很长的字符比如一长串URL导致它实际需要的宽度远超200px。这时浏览器会优先保证内容完整显示把第一列撑到比如500px第二列只能分到剩下的300px。如果你的第一列设了200px却只有短内容第二列有长内容反之亦然。总而言之自动布局算法永远优先满足内容需求而不是你的设宽意图。这个机制的存在是有历史原因的尤其对没有明确列定义的老式表格它能保证文字不被截断、内容不重叠。但它也是千千万万“列宽失控”问题的根源。如果你想彻底摆脱“内容撑列”的困扰就得切换到固定布局模式table { table-layout: fixed; width: 100%; }在固定布局模式下表格的列宽取决于colgroup中col元素的宽度或第一行单元格的宽度不再理会单元格内容的实际长度。内容太长就溢出或换行但列的宽度是稳定的。这对做后台管理系统里的数据表格尤其重要——你希望表格呈现的是“数据网格”的规整感而不是“Word里的文本表格”那种随意拉伸感。3.2 colgroup批量控制列宽的利器很多开发者不知道colgroup和col的存在结果只能逐个给每个td设宽度又啰嗦又容易漏。正确写法是这样的table classdata-table colgroup col stylewidth: 15%; col stylewidth: 25%; col stylewidth: 20%; col stylewidth: 40%; /colgroup thead.../thead tbody.../tbody /table列宽只写一遍所有行自动对齐而且优先于表格行内每个单元格的宽度设置。结合table-layout: fixed你就能像控制栅格系统一样控制表格的每一个列的百分比。我个人做表格项目时几乎都是“table-layout: fixedcolgroup”组合起步极少再去跟某个具体td的宽度较劲。3.3 表格的默认宽度行为auto和100%再说一个很多人忽略的细节table默认的width是auto也就是表格宽度由内容决定。如果你想做出“铺满容器”的表格必须显式设置width: 100%通常配合table-layout: fixed使用。还有一种常见场景是表格列数少、内容也不长但你仍希望它撑满整个卡片区域。此时width: 100%就是必须的。反之如果你做的是一个宽高都依赖内容的“小部件表格”保持auto反而更合适。这里想给你一个实操建议先确认你要的是“自适应内容”还是“自适应容器”。这是两种完全不同的设计目标别混为一谈。我见过很多新手在auto和100%之间反复横跳改来改去都不知道自己在往哪个方向调整。4. 表格必备CSS实践边框、间距、对齐这些基础但关键的细节基础样式反而是最见功力的地方。一个表格好不好看不在于用了多花哨的阴影和渐变而在于边框线是否统一、间距是否舒适、数字是否对齐、表头和数据是否层次分明。4.1 border-collapse: collapse会消失的单元格间隙表格的border-collapse属性和border-spacing属性直接影响边框的呈现方式。默认情况下表格边框是separate模式每个单元格都有自己的边框单元格之间有间距。这会导致两个问题相邻单元格边框之间存在空隙、边界处出现“双线”。table { border-collapse: collapse; }collapse模式会把相邻单元格的边框合并为一条表格看起来干净利落得多。绝大多数项目用到表格的地方都是collapse优先。border-collapse: separate也并非一无是处它配合border-spacing能做出单元格之间有间隙的“留白”效果而且border-collapse: separate是让border-radius在表格单元格上生效的前提条件。当你想做圆角表格或者单元格圆角卡片风时记住这一点。4.2 间距怎么控制padding优先别用空单元格撑高度单元格内边距直接决定了表格的可读性。padding太小表格会显得拥挤局促padding太大又显得松散。.data-table th, .data-table td { padding: 10px 14px; vertical-align: middle; }vertical-align也很容易被忽略。默认情况下表格单元格的内容是垂直居中的对表格单元格的默认vertical-align是middle但如果你在里面放了多行文本或者有多行结构它就可能变成顶部对齐视觉上就会很乱。如果你希望所有单元格统一垂直居中显式声明这个属性。4.3 文字对齐的潜规则数字右对齐文本左对齐很多人做表格时下意识全部左对齐其实一个阅读体验更友好的规则是文本左对齐数值右对齐。试想一列销售额的数字如果左边起头参差不齐人眼去比较大小就非常吃力。而右对齐后个位、十位、百位自动对齐一眼就能看出哪条数据大。.data-table td.num, .data-table th.num { text-align: right; font-variant-numeric: tabular-nums; }上面这段里font-variant-numeric: tabular-nums是个进阶技巧。中文网页大多默认使用比例数字字体数字“1”和“0”的宽度不同在列表里上下错落。tabular-nums会强制所有数字使用等宽形式让数字列在切换数据时列宽不抖动。表头对齐方式则建议跟着数据对齐方式走数据右对齐表头也右对齐数据左对齐表头也左对齐。这样视觉上有骨架感不会错位。4.4 长内容如何处理截断、换行还是横向滚动表格设计多半会遇到的矛盾——“内容放不下”。处理方式主要三种单元格内换行显示适合内容文本本来就比较长、且对阅读连续性有要求的场景。单行截断显示省略号适合紧凑型数据表格比如后台列表中的文章标题、订单号等。整列内容按最小宽度控制放不下就触发表格容器横向滚动。截断用省略号在表格里有一个经典写法.data-table td.ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }注意max-width在普通td上有时会失效最稳妥的做法是把这段样式同时配合table-layout: fixed使用并且给该列设定width或max-width浏览器才会老老实实执行截断。否则内容依然会“撑破”单元格设定。4.5 隔行变色和hover的高亮长表格阅读时行线如果太淡眼睛很容易串行。最常见的解决方案是隔行变色.data-table tbody tr:nth-child(even) { background: #f8f9fa; }同时加上行悬停的高亮反馈.data-table tbody tr:hover { background-color: #eef4fb; }这个一是让鼠标“跟随”有反馈二是大幅降低长表格的数据错读率。对于存在行点击交互的表格hover效果基本算是强制的——用户得知道目前指针落在哪一行否则都不知道自己会点到什么。5. 实现表格交互编辑单元格、增删行从静态表格到动态数据写到这里上面的内容都还是“展示型表格”。但在真正的Web前端项目中“简易表格”往往还要承担数据录入的任务。这也是热搜词里“让用户填写一些单元格其他单元格用户无法修改”这一类需求出现的原因。5.1 单元格内容可编辑contenteditable使用contenteditable实现可编辑单元格是比较轻量的方案td contenteditabletrue1200/td这一属性就能让用户在浏览器里直接改单元格内容。但要注意直接contenteditable会带来一个数据同步的问题。用户在页面上改了内容只是视觉上变了如果不做监听底层数据并不会更新。你需要监听input事件或blur事件把新值读取出来const editableCells document.querySelectorAll(td[contenteditabletrue]); editableCells.forEach(cell { cell.addEventListener(blur, (e) { const newValue e.target.textContent.trim(); // 这里可以把newValue同步到数据源 console.log(单元格新值, newValue); }); });还有个坑是contenteditable把内容插入混合节点比如文本里突然夹了个div这在处理复杂粘贴时会乱套。所以如果只是数字或短文本录入用contenteditable没问题但如果是大段富文本还是找个正经编辑器组件更稳妥。5.2 通过JS动态增删行列配合input做真正的录入表格如果要做一个真正的录入型表格——比如日常的工具表、清单表不适合用contenteditable更稳的做法是让单元格里放input再用JS控制数据收集。tbody tr tdinput typetext nameitem-name placeholder项目名称/td tdinput typenumber nameitem-qty placeholder数量 min0/td tdinput typenumber nameitem-price placeholder单价 min0/td td classrow-total0/td tdbutton typebutton classbtn-delete-row删除/button/td /tr /tbody借助name属性提交或导出时直接遍历表单控件取值function getTableData() { const rows Array.from(document.querySelectorAll(#myTable tbody tr)); return rows.map(row { const inputs row.querySelectorAll(input); const data {}; inputs.forEach(input { data[input.name] input.value; }); return data; }); }JS作为纯数据类型配合增删行的按钮就是一个非常实用的录入型表格容器了。在需要“部分可改、部分不可改”时方案也简单只允许编辑的单元格放input只读单元格直接放文本。5.3 合计行别写死动态计算才是靠谱的很多“简易表格”都有合计行的需求。初学者最容易犯的错误是把合计值算好之后硬编码到HTML里用户一改数据合计就变成错误数字。正确逻辑是每次数据变化后重新计算并更新合计单元格。针对上面input方案的动态合计function updateTotals() { const rows document.querySelectorAll(#myTable tbody tr); let sum 0; rows.forEach(row { const qty parseFloat(row.querySelector(input[nameitem-qty])?.value) || 0; const price parseFloat(row.querySelector(input[nameitem-price])?.value) || 0; const rowTotal qty * price; row.querySelector(.row-total).textContent rowTotal.toFixed(2); sum rowTotal; }); document.querySelector(.grand-total).textContent sum.toFixed(2); } // 监听所有输入框的input事件 document.querySelector(#myTable).addEventListener(input, updateTotals);注意我这里是靠事件委托来监听即使后续动态增删行也不用重新绑定事件代码会健壮很多。这种思路写下去录入表格就已经告别“静态页面”状态步入正经的Web应用范畴了。6. 新手上路最容易栽的五个坑和对应的排错方案这部分是我这几年实践中反复踩过的也是热搜词里高频出现的问题。我按排查思路写一下方便你遇到同样问题时照着走一遍流程。6.1 列宽设了不生效改table-layout如果你给td或th设了width或min-width却完全没反应先看看是不是被自动布局吃掉了。绝大多数情况直接改table { table-layout: fixed; width: 100%; }改完后再检查第一行的单元格或col是否明确设置了宽度。第一行的宽度定义在fixed模式下就是整个列的“宪法”。6.2 相邻td之间有白线缝隙这多半是border-collapse没设置默认的separate模式下单元格之间有border-spacing间隙表格显得“漏风”。解决table { border-collapse: collapse; border-spacing: 0; }如果你的场景需要圆角且必须用separate那你需要把border-spacing: 0加上才能让相邻边框紧贴。6.3 长单词/URL把表格撑破在一列里放一个超长的URL有时候表格能被撑出几百像素宽度。在fixed布局下加td { word-break: break-all; overflow-wrap: break-word; }word-break: break-all力度较强任何长字符都强行打断换行适合强制规整的网格overflow-wrap: break-word相对温和它只在单词超长时打断。建议数据类表格优先overflow-wrap: break-word如果还是撑破再加word-break: break-all。6.4 表头固定后横向滚动条和纵向滚动条错位固定表头是表格里典型的高频难点。最轻量的做法不是JS而是用position: sticky.data-table thead th { position: sticky; top: 0; z-index: 10; background: #f5f6f8; }这个方案的前提是整个表格必须放在一个可滚动的容器里并且容器有明确的max-height或height。比如div stylemax-height: 400px; overflow: auto; table classdata-table.../table /div用position: sticky时容易出的问题是thead的背景是透明的滚动时下面数据会透上来。所以务必给th一个不透明的background。另外如果表格列有横向滚动表头固定在容器顶部时横向滚动是由容器整体滚动的sticky的th会跟着容器横移不会错位这也是sticky方案比JS模拟少出岔子的原因。6.5 边框线出现在表格内容之上很多人在td上加了border-bottom又给表格容器加了overflow: auto滚动时发现数据从边框上“穿过去”。这通常是border-bottom的定位是盒内画的但background-color如果是透明的那就藏不住。给单元格补上不透明的background并把需要盖住的内容层级处理好一般就能解决。7. 进阶数据量大时迟早要面对的性能优化问题热搜词里有几条关于“Qt表格大数据卡顿优化”的讨论本质上Web前端也面临同样的处境。当你往表格里塞几千上万行数据时原生table的渲染瓶颈就来了——浏览器要为每个单元格创建DOM节点、维护样式和布局行数一多页面就会卡。7.1 什么时候该考虑“不做DOM”的方案我的建议是上千行的数据如果还要频繁排序、筛选、编辑就别硬上原生表格了。性能瓶颈的本质是“太多DOM节点同时存在于文档中”只要这个数量降不下来再怎么优化CSS、压缩JS都是杯水车薪。这时候你需要的是“虚拟滚动”类的方案只渲染视口内可见的行滚动时动态替换行内容。现在成熟的前端框架生态里都有对应的表格库比如Ant Design Vue的Table组件内置滚动和性能选项。Element Plus的Table在数据量大时也能通过配置优化。TanStack Table 配合虚拟滚动插件自由度更高。当然如果项目很轻数据量其实没到夸张的程度我建议还是先用原生方案不要为了“炫技”引入大型依赖。用不用框架表格库标准应该是“当前数据和交互是否已经逼近原生DOM的极限”而不是“别人都在用所以我也要用”。7.2 大数据表格的优化方向降渲染、降计算、防闪跳即使引入虚拟滚动仍有一些通用优化原则可以留给原生表格做参考减少不必要重渲染。单元格数据更新时只更新变化的节点不要整个表重排。单元格文本用文本节点而不是内嵌多个子元素。内容越简单重排成本越低。避免在行上绑太多事件委托。用事件委托到tbody比给每行每格绑事件性能等级提升明显。长列表按需分页或懒加载。如果业务允许分页永远是最简单的优化。这一块多说一句表格的数据操作逻辑和数据渲染逻辑分离是避免日后“需求一多就改不动”的关键。渲染层只负责展示数据源中的数据操作层负责改数据源改完再触发渲染。不要一边在DOM里改值一边又在一个对象里同步状态两者脱节早晚出事。8. 一个完整的可运行示例简易销售录入表格理论讲完我拼了一个小而完整的示例把上面提到的核心点全塞进去方便你直接拿去做参考也方便照抄运行。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title简易销售录入表格/title style body { font-family: PingFang SC, Microsoft YaHei, sans-serif; padding: 40px; background: #f4f5f7; } .card { max-width: 900px; margin: 0 auto; background: #fff; border-radius: 8px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } .data-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-top: 16px; } .data-table th, .data-table td { border: 1px solid #e3e6eb; padding: 8px 12px; text-align: left; vertical-align: middle; } .data-table thead th { background: #f7f8fa; font-weight: 600; } .data-table tbody tr:nth-child(even) { background: #fafbfc; } .data-table tbody tr:hover { background: #f0f5ff; } .data-table input { width: 90%; border: 1px solid #dcdfe6; border-radius: 4px; padding: 6px 8px; } .num { text-align: right; font-variant-numeric: tabular-nums; } .btn { border: 1px solid #d0d7e2; background: #fff; border-radius: 4px; padding: 6px 12px; cursor: pointer; } .btn-primary { background: #1677ff; border-color: #1677ff; color: #fff; } .btn-primary:hover { background: #4096ff; } .btn-danger { color: #cf222e; border-color: #f0c4c8; } .btn-danger:hover { background: #ffeef0; } .grand-total { font-size: 18px; font-weight: 700; } /style /head body div classcard h3简易销售录入表格/h3 div classtoolbar button classbtn btn-primary idaddRowBtn新增一行/button /div table classdata-table idsaleTable colgroup col stylewidth: 28%; col stylewidth: 18%; col stylewidth: 22%; col stylewidth: 22%; col stylewidth: 10%; /colgroup thead tr th项目名称/th th classnum数量/th th classnum单价/th th classnum小计/th th操作/th /tr /thead tbody tr tdinput namename value无线耳机/td tdinput nameqty typenumber value12 min0/td tdinput nameprice typenumber value299 min0/td td classrow-total num3588/td tdbutton classbtn btn-danger btn-del删除/button/td /tr tr tdinput namename value智能手表/td tdinput nameqty typenumber value8 min0/td tdinput nameprice typenumber value1299 min0/td td classrow-total num10392/td tdbutton classbtn btn-danger btn-del删除/button/td /tr /tbody tfoot tr td colspan3 styletext-align: right;合计/td td classgrand-total num13980/td td/td /tr /tfoot /table /div script const table document.getElementById(saleTable); const tbody table.querySelector(tbody); const grandTotalCell table.querySelector(.grand-total); function calcRow(row) { const qty parseFloat(row.querySelector(input[nameqty]).value) || 0; const price parseFloat(row.querySelector(input[nameprice]).value) || 0; return qty * price; } function updateTable() { const rows Array.from(tbody.querySelectorAll(tr)); let grandTotal 0; rows.forEach(row { const subtotal calcRow(row); row.querySelector(.row-total).textContent subtotal.toFixed(2); grandTotal subtotal; }); grandTotalCell.textContent grandTotal.toFixed(2); } table.addEventListener(input, updateTable); table.addEventListener(click, (e) { if (e.target.classList.contains(btn-del)) { const rows tbody.querySelectorAll(tr); if (rows.length 1) { alert(至少保留一行); return; } e.target.closest(tr).remove(); updateTable(); } }); document.getElementById(addRowBtn).addEventListener(click, () { const tr document.createElement(tr); tr.innerHTML tdinput namename placeholder项目名称/td tdinput nameqty typenumber value0 min0/td tdinput nameprice typenumber value0 min0/td td classrow-total num0.00/td tdbutton classbtn btn-danger btn-del删除/button/td ; tbody.appendChild(tr); updateTable(); }); updateTable(); /script /body /html这个示例里我把本章提到的table-layout: fixed、colgroup、隔行变色、数字列右对齐、事件委托、动态合计、防误删提示全演示了一遍。它有简单的录入能力、增删行能力同时代码量控制在极小范围内拿到真实项目也方便继续扩展比如导出JSON、批量保存、校验输入等。9. 工具链对比原生table、CSS display:table、divflex网格、组件库表格到了项目实操层面你可能还会纠结另外一个问题做表格到底用哪种方案“用table”只是最传统的一种实际上还有好几条路线。我整理一个比较主观但很实用的对比帮你按需选择。方案优点缺点适合场景原生table语义清晰、浏览器兼容性最好、SEO和辅助功能友好布局算法特殊列宽/边框/合并单元格有学习成本大多数数据展示、信息列表、简单录入CSSdisplay: table能用div做出table布局适合配合flex体系语义缺失读屏无法识别为表格代码冗余不太推荐除非是纯视觉需要div flex / grid布局自由、列宽控制直观语义差复杂表头/合并单元格需要大量手工处理视觉组件、卡片式列表、不规则表格布局组件库表格Element Plus、Ant Design Vue、Naive UI等功能齐全固定表头、虚拟滚动、排序筛选都有引入体积大、定制困难、心智负担重中后台管理系统、重交互数据表格如果要我用一句话总结取舍原则能用手写原生table解决的问题就不要上组件库组件库解决不了的性能或强交互问题再考虑虚拟滚动和自定义方案。组件库能帮你省掉70%的基础功但后面那30%的定制坑才是真正考验你表格基本功的地方。10. 我的实操心得做表格前先想清楚这几个问题可以少走80%的弯路最后说点比较私人的实操体会。这几年处理表格需求的经验告诉我表格项目返工率高十有八九不是技术问题而是需求没想清楚就动手了。我给自己列了一张“表格开工前问题清单”每次做之前过一遍后面基本很少翻车列是可变的还是固定的如果是动态列原生table需要你动态生成col和表头复杂度高不少。数据量上限大概是多少超过500行且有排序需求我基本会上带虚拟滚动的方案。单元格允许编辑吗编辑后需要校验吗是即时校验还是保存时校验这决定了你是用input还是contenteditable。表头要不要固定横向滚动、纵向滚动怎么触发这些在布局阶段就得定好而不是等页面写完再补。会不会有合计、合并单元格这类特殊行合并规则是固定的还是用户自定义的这些问题大多可以在纸面上先画一画答案画出来之后方案其实已经八九不离十了。还有一个细节我每次都会留意表格的空状态别忽略。数据为空时好歹要显示一行“暂无数据”并且让操作的按钮仍然可用。很多表格在空数据时直接只剩表头用户很容易误以为页面坏了。至于未来的扩展方向如果这篇里的基础表格你已经吃透了下一步可以去试试给单元格做拖拽排序做行内编辑的失焦校验或者把表格数据导出成Excel文件前端用xlsx这类库很快。有了扎实的原生基础和清晰的数据流思维这些功能不过是在这个骨架上逐块搭肌肉而已。做表格这活儿说难不难说简单也绝对不简单。但等你把布局算法、数据操作、样式细节这几块都啃下来之后再回头去看各种组件库的表格文档会发现读起来非常轻松——因为它们翻来覆去处理的就是我今天讲的这些基础问题的工程化封装罢了。