1995 年:原始的 HTML 表格
网页里只有<table>、<tr>、<td>。后台系统还没出现,表格就是用来展示一些静态数据的。
<tableborder="1"><tr><td>张三</td><td>90</td></tr><tr><td>李四</td><td>85</td></tr></table>没有任何高级名词,一切都赤裸裸的 DOM。
2005 年:数据变多了,手写 HTML 行不通了
痛点:后台系统需要展示 500 条员工记录,手写 500 行<tr>不现实。
解决方案:用 JavaScript 循环数组,动态生成 DOM。
constdata=[/* 500 条数据 */];consttbody=document.querySelector('tbody');data.forEach(item=>{consttr=document.createElement('tr');tr.innerHTML=`<td>${item.name}</td><td>${item.score}</td>`;tbody.appendChild(tr);});出现的概念:数据驱动渲染。表格不再是静态的,而是由数据生成的。
2010 年:列太多,横向滚动时关键列消失了
痛点:员工表有 30 列,屏幕只能显示 10 列。用户向右滚动时,最左边的“姓名”列滚出了视野,导致不知道每一行是谁的工资。
尝试用 CSS 解决:position: sticky。但当时浏览器支持差,而且一旦表头有合并单元格,sticky 的计算经常错位。
最终方案:多表叠加(渲染三张独立的表格)。
具体怎么做?
- 左侧固定表格:只包含需要固定的列(比如姓名),宽度固定,绝对定位在左边,禁止横向滚动。
- 右侧固定表格:只包含右侧需要固定的列(比如操作列),绝对定位在右边。
- 中间滚动表格:包含所有列,宽度设为所有列宽之和,放在一个
overflow-x: auto的容器里。
垂直滚动同步:用户滚动中间表格的垂直滚动条时,通过 JS 将scrollTop值同步设置给左右两张固定表格的容器。
// 监听中间表格的滚动容器middleWrapper.addEventListener('scroll',()=>{constscrollTop=middleWrapper.scrollTop;leftWrapper.scrollTop=scrollTop;rightWrapper.scrollTop=scrollTop;});行高对齐:因为三张表格独立渲染,同一行的高度可能因为内容不同而不一致。所以需要在每次渲染后,用ResizeObserver或getBoundingClientRect获取每一行的真实高度,然后强制同步三张表中对应行的高度。
// 简化的对齐逻辑:获取所有行高,取最大值应用到三张表的对应行constleftRows=leftTable.querySelectorAll('tr');constmiddleRows=middleTable.querySelectorAll('tr');for(leti=0;i<leftRows.length;i++){constmaxHeight=Math.max(leftRows[i].getBoundingClientRect().height,middleRows[i].getBoundingClientRect().height);leftRows[i].style.height=maxHeight+'px';middleRows[i].style.height=maxHeight+'px';}出现的新名词:固定列(Fixed Columns)、多表叠加、滚动同步、行高对齐。
2015 年:数据量爆炸,一次性渲染 10 万行直接卡死
痛点:运营需要查看 10 万条日志。如果用 2005 年的循环渲染方法,浏览器会生成 10 万个<tr>节点,内存占用数百 MB,页面直接白屏或卡死。
解决方案:虚拟滚动(Virtual Scrolling)。
核心思想:既然用户屏幕一次只能看到 30 行,那我就只渲染这 30 行,其余的行用两个空白的<tr>占位,撑出滚动条的高度。
具体实现步骤(以定高模式为例):
设定固定行高:比如每行高度
rowHeight = 48像素。计算可视区能容纳的行数:
constvisibleCount=Math.ceil(container.clientHeight/rowHeight);监听滚动事件,计算起始索引:
constscrollTop=container.scrollTop;conststartIndex=Math.floor(scrollTop/rowHeight);constendIndex=startIndex+visibleCount;只渲染
startIndex到endIndex的数据。用占位行撑开滚动条高度:
- 在渲染的真实行上方,放一个
<tr>,其高度为startIndex * rowHeight。 - 在真实行下方,放一个
<tr>,其高度为(totalCount - endIndex) * rowHeight。
- 在渲染的真实行上方,放一个
这样,滚动条的总高度等于totalCount * rowHeight,用户感觉就像真的滚动了 10 万行,但实际上浏览器里只有 30 多个<tr>。
出现的新名词:虚拟滚动、视口裁剪、起始索引、占位行。
2018 年:表头需要像 Excel 一样多层嵌套
痛点:财务要求表头为“2024 年第一季度”,下面分“1月”、“2月”、“3月”,每月再分“收入”、“支出”。如果用原生<th>手动写colspan和rowspan,不仅计算繁琐,而且一旦列顺序调整,所有合并数字都要重算。
解决方案:列配置树 + 自动计算跨度。
只需用嵌套标签描述表头结构,组件内部自动算出每个<th>的colspan和rowspan。
具体算法:
将嵌套的列配置拍平为一维数组(深度优先遍历 DFS),同时记录每个节点的层级(
level)。自底向上计算
colspan:- 叶子节点(没有子列)的
colspan = 1。 - 父节点的
colspan= 其所有子节点colspan之和。 - 递归实现,从最深层往回算。
- 叶子节点(没有子列)的
自顶向下计算
rowspan:- 先算出整棵树的最大深度
maxDepth。 - 叶子节点的
rowspan = maxDepth - 当前层级。 - 非叶子节点的
rowspan = 1。
- 先算出整棵树的最大深度
生成表头二维网格:创建一个
maxDepth行的二维数组,根据每个节点的level、colspan、rowspan放入对应位置,并用一个occupied布尔数组标记已被合并占用的格子,避免重复放置。
出现的新名词:多级表头、深度优先遍历(DFS)、自底向上/自顶向下计算、占位标记。
2020 年:跨页全选的带宽和内存危机
痛点:用户想全选所有“已离职”员工(共 5000 人)并发送问卷。初级做法是把 5000 个 ID 全传到前端存储。如果数据是 50 万呢?内存和带宽都会爆炸。
解决方案:差量状态管理(只存例外)。
具体做法:
前端状态设计:
interfaceSelectState{mode:'none'|'page'|'all';// 全选模式filters:Record<string,any>;// 当前筛选条件total:number;// 符合条件的总数据量exceptions:Set<number>;// 取消勾选的 ID 集合}用户点击全选时:设置
mode = 'all',记录当前filters,清空exceptions。用户手动取消某行(ID=123)时:
exceptions.add(123)。提交给后端的数据结构:
{"operation":"delete","scope":"filtered","filters":{"status":"离职"},"excludeIds":[123,456]}后端直接执行 SQL:
DELETEFROMusersWHEREstatus='离职'ANDidNOTIN(123,456);
带宽消耗从传输 5000 个 ID(约 100KB)降为几十字节。
出现的新名词:跨页全选、差量状态管理、例外集合、条件批量操作。
总结:表格技术演进地图
| 时间 | 痛点 | 解决方案 | 核心名词 |
|---|---|---|---|
| 1995 | 无,静态展示 | <table>标签 | DOM |
| 2005 | 数据量大,手写 HTML 繁琐 | JS 循环生成 DOM | 数据驱动 |
| 2010 | 列太多,横向滚动丢失上下文 | 三张表格叠加 + 滚动同步 | 固定列、多表叠加 |
| 2015 | 数据量极大(10 万+),渲染卡死 | 只渲染可视区 + 占位行 | 虚拟滚动、视口裁剪 |
| 2018 | 表头复杂,手动计算跨度易错 | 树形配置 + 自动计算 colspan/rowspan | 多级表头、DFS 拍平 |
| 2020 | 跨页全选大数据量时带宽内存爆炸 | 差量状态管理,只传例外集合 | 跨页全选、差量模式 |