从零封装一套企业级表格组件库 - 基于 Layui 的实战教程
写在前面:最近在做一个企业级后台管理系统,表格是用得最多的组件,但是原生的表格功能太弱,市面上的组件又太重。索性自己封装了一套轻量级的表格工具库,包含 Tooltip、固定列、骨架屏加载、空状态等功能。今天就来分享一下整个封装过程,希望能给大家一些启发。
先看效果吧
效果1:单元格省略号 + 悬浮提示 + 点击复制
当单元格内容过长时,自动显示省略号。鼠标悬浮时显示完整内容,点击还能一键复制:

效果2:固定列 + 横向滚动
表格内容超出时,操作列固定在右侧,滚动时带阴影效果:

效果3:骨架屏加载
数据加载时显示骨架屏,比 loading 圈圈更优雅:

显示Spinner效果展示:

效果4:子表格展开收起
支持手风琴模式,展开子表格查看详情:

一、为什么要自己封装
说实话,一开始我也想直接用现成的。Layui 自带的 table 模块功能挺全的,但是有几个痛点:
第一个痛点是 Tooltip 不够智能。 原生的 title 属性太丑了,而且没法控制样式。layui 的 tips 又需要手动判断内容是否溢出,每个页面都要写一遍判断逻辑。
第二个痛点是固定列太难搞。 CSS 的 position: sticky 看起来简单,但是配合 table-layout: fixed 一起用的时候,各种宽度计算问题能让你怀疑人生。
第三个痛点是加载状态太单调。 一个转圈圈的 loading 用了十年了,用户早就审美疲劳了。骨架屏明显更现代,但是每个表格都要手写骨架屏 HTML,太累了。
后来项目里有二十多个表格页面,我实在受不了了,决定封装一下。封装完之后,三行代码就能搞定一个功能完善的表格,真香。
二、整体设计思路
在动手之前,我先梳理了一下需求,把表格相关的功能拆分成了 6 个独立的组件:
| 组件名 | 功能 | 使用场景 |
|---|---|---|
| TableTooltip | 省略号提示 + 点击复制 | 单元格内容过长时 |
| FixedColumnManager | 固定列 + 自动计算宽度 | 表格列数多需要横向滚动时 |
| ChildRowManager | 子表格展开/收起 | 需要查看详情或关联数据时 |
| TableLoading | 骨架屏/Spinner 加载 | 数据请求中 |
| TableEmptyState | 空状态展示 | 无数据时 |
| GeneralTooltip | 通用提示 | 非表格场景的提示 |
为什么要拆成这么多组件?
因为它们的职责完全不同,硬塞到一个类里会很臃肿。拆开之后,各司其职,你可以按需引入,用哪个就 new 哪个,代码也更清晰。
三、代码结构概览
先看一下整体的代码结构:
javascript
/**
* Table Component - 可复用的表格工具函数
*/
(function (global) {
'use strict';
// 1. 工具函数
function getVisibleTextAnchorRect(el) { /* ... */ }
// 2. TableTooltip - 省略号提示
function TableTooltip(options) { /* ... */ }
// 3. ChildRowManager - 子表格管理
function ChildRowManager(options) { /* ... */ }
// 4. GeneralTooltip - 通用提示
function GeneralTooltip(options) { /* ... */ }
// 5. FixedColumnManager - 固定列管理
function FixedColumnManager(container, options) { /* ... */ }
// 6. TableLoading - 加载状态
function TableLoading(container, options) { /* ... */ }
// 7. TableEmptyState - 空状态
function TableEmptyState(container, options) { /* ... */ }
// 暴露到全局
global.TableTooltip = TableTooltip;
global.ChildRowManager = ChildRowManager;
global.GeneralTooltip = GeneralTooltip;
global.FixedColumnManager = FixedColumnManager;
global.TableLoading = TableLoading;
global.TableEmptyState = TableEmptyState;
})(window);
这里用了 IIFE(立即执行函数表达式)来包裹整个代码。这样做的好处是:
- 避免污染全局命名空间,内部变量不会泄露出去
- 只暴露需要的接口,保持 API 的简洁
- 代码更容易维护,改内部实现不影响外部调用
四、TableTooltip 详解 - 让省略号不再是黑盒
4.1 痛点分析
先说说为什么要做这个组件。看下面这个场景:
html
<td style="width: 200px; overflow: hidden; text-overflow: ellipsis;">
这是一段很长很长的文字,超出部分会显示省略号...
</td>
CSS 的省略号效果很容易实现,但是有几个问题:
- 用户看不到完整内容 - 省略号后面是什么?不知道
- 原生 title 太丑 - 而且有延迟,样式没法自定义
- 没法复制 - 想复制完整内容?对不起,选不中
4.2 设计思路
我的解决方案是:
- 鼠标悬浮时,只有内容真的溢出了,才显示 Tooltip
- Tooltip 的位置要跟随可见文字,而不是整个单元格
- 点击单元格可以一键复制完整内容,并显示复制成功提示
4.3 核心代码解析
第一步:判断内容是否溢出
javascript
// 判断是否溢出
var isOverflowing = $el[0].scrollWidth > $el[0].clientWidth;
// 只在溢出时显示 tooltip
if ($el[0].scrollWidth <= $el[0].clientWidth) return;
这里用 scrollWidth 和 clientWidth 的对比来判断。scrollWidth 是内容的实际宽度,clientWidth 是可见区域的宽度。如果实际宽度大于可见宽度,说明内容被截断了。
第二步:获取可见文字的位置
这是整个组件最核心的部分。普通的做法是把 Tooltip 定位到单元格中心,但这样不够精确。我希望 Tooltip 能定位到文字的正上方:
javascript
function getVisibleTextAnchorRect(el) {
var range = document.createRange();
range.selectNodeContents(el);
var rects = range.getClientRects();
if (!rects.length) return null;
// 取最后一行(ellipsis 场景更稳)
var textRect = rects[rects.length - 1];
var style = window.getComputedStyle(el);
var paddingLeft = parseFloat(style.paddingLeft) || 0;
var paddingRight = parseFloat(style.paddingRight) || 0;
var visibleWidth = el.clientWidth - paddingLeft - paddingRight;
var anchorWidth = Math.min(textRect.width, visibleWidth);
return {
left: textRect.left,
top: textRect.top,
bottom: textRect.bottom,
width: anchorWidth
};
}
这段代码用 Range API 来获取文字的精确位置。getClientRects() 会返回文字每一行的位置信息,我们取最后一行作为锚点。
第三步:显示 Tooltip
javascript
TableTooltip.prototype.show = function ($el, text) {
var self = this;
var $ = layui.jquery;
if (!text) return;
// 只在省略时显示 tooltip
if ($el[0].scrollWidth <= $el[0].clientWidth) return;
clearTimeout(this.timer);
this.timer = setTimeout(function () {
self.$tooltip
.text(text)
.css({
visibility: 'hidden',
display: 'block',
maxWidth: self.options.maxWidth + 'px'
});
var tipWidth = self.$tooltip.outerWidth();
var tipHeight = self.$tooltip.outerHeight();
self.$tooltip.css({ visibility: 'visible' });
var anchor = getVisibleTextAnchorRect($el[0]);
if (!anchor) return;
// 计算位置:水平居中于文字
var left = anchor.left + (anchor.width / 2) - (tipWidth / 2);
var top = anchor.top - tipHeight - self.options.offset;
// 边界处理
if (left < 10) left = 10;
if (left + tipWidth > $(window).width() - 10) {
left = $(window).width() - tipWidth - 10;
}
// 顶部不够就翻到下面
if (top < 10) {
top = anchor.bottom + self.options.offset;
self.$tooltip.addClass('tooltip-bottom');
} else {
self.$tooltip.removeClass('tooltip-bottom');
}
self.$tooltip.css({ left, top }).addClass('show');
}, this.options.delay);
};
这里有几个细节:
- 延迟显示 - 用
setTimeout实现,避免鼠标快速划过时频繁显示 - 边界处理 - 如果 Tooltip 超出屏幕边缘,自动调整位置
- 上下翻转 - 如果顶部空间不够,自动翻转到下方显示
第四步:点击复制
javascript
TableTooltip.prototype.copyToClipboard = function (text, $el) {
var self = this;
// 优先使用现代 API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(function () {
self.showCopySuccess($el);
})
.catch(function () {
self.fallbackCopy(text, $el);
});
} else {
self.fallbackCopy(text, $el);
}
};
// 降级方案:使用 execCommand
TableTooltip.prototype.fallbackCopy = function (text, $el) {
var self = this;
var $temp = $('<textarea>').val(text).css({
position: 'absolute',
left: '-9999px'
}).appendTo('body');
$temp.select();
try {
document.execCommand('copy');
self.showCopySuccess($el);
} catch (e) { }
$temp.remove();
};
复制功能做了兼容处理:优先使用现代的 Clipboard API,不支持的浏览器降级到 execCommand。
4.4 使用方式
javascript
// 最简单的用法
var tooltip = new TableTooltip();
// 自定义配置
var tooltip = new TableTooltip({
delay: 300, // 显示延迟 300ms
maxWidth: 300, // 最大宽度 300px
offset: 4, // 距离文字 4px
selector: '.ellipsis-cell[data-tip]' // 自定义选择器
});
HTML 结构:
html
<td class="ellipsis-cell" data-tip="这是完整的内容,可能很长很长">
这是完整的内容...
</td>
就这么简单,一行 JS + 一个 data-tip 属性,搞定!
五、FixedColumnManager 详解 - 固定列的那些坑
5.1 痛点分析
固定列看起来简单,CSS 一行 position: sticky; right: 0; 就搞定了。但是实际用起来,坑多到你怀疑人生:
坑1:列宽会被内容撑开
css
table { table-layout: fixed; }
th { width: 200px; }
你以为设置了 width: 200px 就是 200px?太天真了。如果单元格内容超过 200px,整列都会被撑开。
坑2:固定列不在最右边
当表格总宽度小于容器宽度时,sticky 定位的列会跟着内容走,而不是固定在容器最右边。
坑3:min-width 不生效
在 table-layout: fixed 模式下,min-width 完全不生效。你设置 min-width: 160px,实际可能被压缩成 0。
5.2 解决方案
经过无数次尝试,我总结出了一套可行的方案:
- 使用
table-layout: fixed- 让列宽严格按照设置的值 - 给单元格加
overflow: hidden- 防止内容撑开列宽 - 动态计算表格最小宽度 - 保证固定列始终在最右边
- 留一列不设置宽度 - 让它自动填充剩余空间
5.3 核心代码解析
自动计算最小宽度
javascript
FixedColumnManager.prototype._calcMinWidth = function ($container) {
var $ = layui.jquery;
var self = this;
var $wrapper = $container.find('.table-wrapper');
var $table = $wrapper.find('table');
var $ths = $table.find('thead th');
if (!$ths.length) return;
var totalWidth = 0;
$ths.each(function () {
var $th = $(this);
var width = $th[0].style.width;
if (width && width.indexOf('px') > -1) {
// 有设置宽度的列,取其值
totalWidth += parseInt(width, 10);
} else {
// 没有设置宽度的列,使用默认最小宽度
totalWidth += self.options.defaultFlexColumnWidth;
}
});
// 设置 table-wrapper 的最小宽度
$wrapper.css('min-width', totalWidth + 'px');
};
这段代码会遍历所有的 <th>,把它们的宽度加起来,然后设置到 .table-wrapper 上。这样就保证了表格的最小宽度,固定列永远在最右边。
滚动状态检测
javascript
this.checkScroll = function () {
var $wrapper = $container.find('.table-wrapper');
if (!$wrapper.length) return;
var containerWidth = $container[0].clientWidth;
var wrapperWidth = $wrapper[0].scrollWidth;
var scrollLeft = $container[0].scrollLeft;
var hasScroll = wrapperWidth > containerWidth;
var isScrolledToEnd = scrollLeft + containerWidth >= wrapperWidth - 1;
// 只有需要滚动且没滚到底时,才显示阴影
if (hasScroll && !isScrolledToEnd) {
$container.addClass('is-scrollable');
} else {
$container.removeClass('is-scrollable');
}
};
这段代码检测表格是否需要滚动,以及是否已经滚到最右边。只有在需要滚动且没滚到底的时候,才给固定列加阴影效果。
5.4 CSS 配合
css
/* 固定列容器 */
.table-container.has-fixed-column {
position: relative;
overflow-x: auto;
}
.table-container.has-fixed-column .table-wrapper {
min-width: 100%;
}
/* 表格使用 fixed 布局 */
.custom-table.table-fixed-right {
border-collapse: separate;
border-spacing: 0;
width: 100%;
table-layout: fixed;
}
/* 单元格强制不溢出 */
.custom-table.table-fixed-right th,
.custom-table.table-fixed-right td {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 固定列 */
.custom-table.table-fixed-right .fixed-column {
position: sticky;
right: 0;
background-color: #fff;
z-index: 2;
}
/* 滚动时显示阴影 */
.table-container.has-fixed-column.is-scrollable .fixed-column {
border-left: 1px solid #e6e6e6;
box-shadow: -1px 0 8px rgba(0, 0, 0, 0.08);
}
5.5 使用方式
javascript
// 基础用法
var fixedCol = new FixedColumnManager('.table-container.has-fixed-column');
// 自定义默认列宽(没有设置 width 的列会用这个值)
var fixedCol = new FixedColumnManager('.table-container.has-fixed-column', {
defaultFlexColumnWidth: 200
});
HTML 结构(重点:固定列前一列不设置 width):
html
<div class="table-container has-fixed-column">
<div class="table-wrapper">
<table class="custom-table table-fixed-right">
<thead>
<tr>
<th style="width: 90px;">ID</th>
<th style="width: 200px;">Name</th>
<th style="width: 140px;">Company</th>
<th>Status</th> <!-- 不设置 width,自动填充 -->
<th class="fixed-column" style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
<!-- ... -->
</tbody>
</table>
</div>
</div>
关键点 :固定列前面那一列不要设置 width,让它自动填充剩余空间。这样无论容器多宽,固定列都会在最右边。
六、ChildRowManager 详解 - 优雅的子表格展开收起
6.1 使用场景
有时候表格的一行数据关联了很多子数据,比如:
- 订单列表 → 展开查看订单明细
- 客户列表 → 展开查看关联的公司
- 案件列表 → 展开查看案件详情
这时候就需要子表格功能了。
6.2 设计思路
我设计了以下特性:
- 手风琴模式 - 同时只能展开一个,展开新的会自动收起旧的
- 平滑动画 - 展开收起有过渡效果,不是生硬的显示隐藏
- 状态记忆 - 可以记住哪些行是展开的,翻页后恢复状态
6.3 核心代码解析
展开/收起动画
javascript
ChildRowManager.prototype.toggle = function (index) {
var $ = layui.jquery;
var $childRow = $('tr.child-row[data-parent="' + index + '"]');
var $content = $childRow.find('.child-content');
var $parentRow = $('tr[data-index="' + index + '"]');
var $expandBtn = $parentRow.find('.expand-btn');
var $icon = $expandBtn.find('.expand-icon');
var $btnText = $expandBtn.find('.expand-text');
var isExpanded = $content.hasClass('expanded');
// 手风琴效果:收起其他
if (this.options.accordion && !isExpanded) {
this.collapseAll(index);
}
if (isExpanded) {
// 收起动画
var currentHeight = $content[0].scrollHeight;
$content.css('max-height', currentHeight + 'px');
$content[0].offsetHeight; // 强制重绘
$content.css('max-height', '0').removeClass('expanded');
$icon.removeClass('rotated');
$parentRow.removeClass('expanded');
$btnText.text('Expand');
this.expandedRows[index] = false;
if (typeof this.options.onCollapse === 'function') {
this.options.onCollapse(index);
}
} else {
// 展开动画
$content.css('max-height', 'none');
var scrollHeight = $content[0].scrollHeight;
$content.css('max-height', '0');
$content[0].offsetHeight; // 强制重绘
$content.css('max-height', scrollHeight + 'px').addClass('expanded');
$icon.addClass('rotated');
$parentRow.addClass('expanded');
$btnText.text('Collapse');
this.expandedRows[index] = true;
if (typeof this.options.onExpand === 'function') {
this.options.onExpand(index);
}
}
};
这里的动画技巧是:
- 先获取内容的实际高度
scrollHeight - 把
max-height从 0 过渡到实际高度 - CSS 的
transition会自动产生动画效果
$content[0].offsetHeight 这行看起来没用,实际上是强制浏览器重绘,否则动画可能不生效。
6.4 使用方式
javascript
// 基础用法(手风琴模式)
var childRow = new ChildRowManager();
// 点击展开按钮时调用
$('.expand-btn').on('click', function() {
var index = $(this).data('index');
childRow.toggle(index);
});
// 自定义配置
var childRow = new ChildRowManager({
accordion: true, // 手风琴模式
onExpand: function(index) {
console.log('展开了第', index, '行');
// 可以在这里异步加载子表格数据
},
onCollapse: function(index) {
console.log('收起了第', index, '行');
}
});
// 其他方法
childRow.collapseAll(); // 收起所有
childRow.getExpandedRows(); // 获取已展开的行索引数组
childRow.restoreState(); // 恢复展开状态(翻页后调用)
childRow.clearState(); // 清除状态
HTML 结构:
html
<!-- 父行 -->
<tr data-index="0">
<td>001</td>
<td>张三</td>
<td>
<button class="expand-btn" data-index="0">
<i class="expand-icon">▶</i>
<span class="expand-text">Expand</span>
</button>
</td>
</tr>
<!-- 子行(紧跟在父行后面) -->
<tr class="child-row" data-parent="0">
<td colspan="3">
<div class="child-content">
<!-- 子表格内容 -->
<table class="child-table">
<thead>...</thead>
<tbody>...</tbody>
</table>
</div>
</td>
</tr>
七、TableLoading 详解 - 骨架屏让等待不再焦虑
7.1 为什么用骨架屏
传统的 loading 就是一个转圈圈,用户看到的是一片空白 + 一个圈。这种体验有几个问题:
- 不知道要等多久 - 圈圈转啊转,用户焦虑
- 页面跳动 - 数据加载完后,页面突然出现内容,很突兀
- 审美疲劳 - 转圈圈用了十几年了,太无聊了
骨架屏的好处是:
- 预知布局 - 用户能看到大概的内容结构
- 减少跳动 - 骨架屏和真实内容布局一致,过渡更平滑
- 感觉更快 - 心理学上,有内容比空白感觉更快
7.2 设计思路
我设计了三种加载模式:
- skeleton - 骨架屏,适合翻页、刷新
- spinner - 转圈圈,适合首次加载
- dots - 三个点跳动,适合轻量级加载
为什么首次加载用 spinner?因为首次加载时我们不知道有多少数据,骨架屏的行数不好确定。而翻页时我们知道总数和当前页,可以精确计算骨架屏的行数。
7.3 核心代码解析
自动检测列数
javascript
TableLoading.prototype._detectCols = function () {
var $ = layui.jquery;
// 从 thead 获取列数
var $thead = this.$table.find('thead tr:first');
if ($thead.length) {
this.cols = $thead.find('th').length;
}
// 如果没有 thead,尝试从第一行 td 获取
if (!this.cols) {
var $firstRow = this.$container.find('tr:first');
if ($firstRow.length) {
this.cols = $firstRow.find('td').length;
}
}
// 默认值
if (!this.cols) {
this.cols = 5;
}
};
这段代码会自动检测表格有多少列,这样生成骨架屏时就知道要生成多少个单元格了。
生成骨架屏 HTML
javascript
TableLoading.prototype._createSkeletonHtml = function (rowCount) {
var rows = rowCount || this.options.pageSize;
var cols = this.cols;
var widths = ['w-short', 'w-medium', 'w-full'];
var html = '';
for (var i = 0; i < rows; i++) {
html += '<tr class="table-skeleton-row">';
for (var j = 0; j < cols; j++) {
// 随机宽度,让骨架屏看起来更自然
var widthClass = widths[Math.floor(Math.random() * widths.length)];
html += '<td><div class="table-skeleton-cell ' + widthClass + '"></div></td>';
}
html += '</tr>';
}
return html;
};
骨架屏的每个单元格宽度是随机的(60%、80%、100%),这样看起来更像真实数据,不会太死板。
骨架屏动画 CSS
css
@keyframes skeletonShimmer {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
.table-skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200px 100%;
animation: skeletonShimmer 1.5s infinite;
border-radius: 4px;
}
这个动画效果是一道光从左到右扫过,很有科技感。
7.4 使用方式
javascript
// 初始化
var loading = new TableLoading('#tableBody', {
pageSize: 10
});
// 首次加载 - 显示 spinner
loading.show();
// 翻页加载 - 显示骨架屏(自动计算行数)
loading.show(totalCount, currentPage);
// 加载完成
loading.hide();
// 更新每页条数
loading.setPageSize(20);
实际使用示例:
javascript
function loadData() {
// 显示加载状态
loading.show(state.total, state.currentPage);
$.ajax({
url: '/api/list',
data: { page: state.currentPage, limit: 10 },
success: function(res) {
loading.hide();
state.total = res.total;
renderTable(res.list);
},
error: function() {
loading.hide();
layer.msg('加载失败');
}
});
}
八、TableEmptyState 详解 - 空状态也要有仪式感
8.1 为什么要做空状态
很多开发者对空状态不重视,数据为空就显示一行"暂无数据",甚至什么都不显示。这样的体验很差:
- 用户困惑 - 是没有数据,还是加载失败了?
- 页面空洞 - 一大片空白,很难看
- 没有引导 - 用户不知道下一步该干什么
好的空状态应该:
- 明确告知 - 清楚地告诉用户"没有数据"
- 区分场景 - 是真的没数据,还是搜索没结果?
- 提供引导 - 告诉用户可以做什么
8.2 设计思路
我设计了几种不同的空状态图标:
- default - 默认图标,表示没有数据
- search - 搜索图标,表示搜索无结果
- filter - 筛选图标,表示筛选无结果
- box - 空盒子图标,表示列表为空
8.3 核心代码解析
javascript
TableEmptyState.prototype.show = function (hasFilters) {
this._detectColspan();
// 根据是否有筛选条件,显示不同的空状态
if (hasFilters === true) {
this.options.icon = 'search';
this.options.title = 'No data found';
this.options.description = 'Try adjusting your search or filter criteria';
} else if (hasFilters === false) {
this.options.icon = 'default';
this.options.title = 'No data available';
this.options.description = 'There are currently no records to display';
}
var html = this._createHtml();
this.$container.html(html);
};
调用时传入 hasFilters 参数,组件会自动选择合适的图标和文案。
8.4 使用方式
javascript
// 初始化
var emptyState = new TableEmptyState('#tableBody');
// 显示空状态(自动判断)
emptyState.show();
// 有筛选条件时的空状态
emptyState.show(true);
// 无筛选条件时的空状态
emptyState.show(false);
// 自定义配置
var emptyState = new TableEmptyState('#tableBody', {
colspan: 8,
icon: 'search',
title: '没有找到相关数据',
description: '请尝试调整搜索条件'
});
九、GeneralTooltip 详解 - 通用提示的封装
9.1 使用场景
除了表格单元格,页面上还有很多地方需要提示,比如:
- 图标按钮的功能说明
- 状态标签的详细解释
- 表单字段的帮助信息
9.2 实现原理
这个组件比较简单,直接基于 layui 的 layer.tips 封装:
javascript
function GeneralTooltip(options) {
this.options = Object.assign({
selector: '[data-tip]',
excludeSelector: '.ellipsis-cell', // 排除表格单元格
tips: 1, // 1=上, 2=右, 3=下, 4=左
time: 0 // 不自动关闭
}, options || {});
this._init();
}
GeneralTooltip.prototype.bindEvents = function () {
var self = this;
var $ = layui.jquery;
$(document).on('mouseenter', this.options.selector, function () {
var $el = $(this);
// 排除已被 TableTooltip 处理的元素
if (self.options.excludeSelector && $el.is(self.options.excludeSelector)) {
return;
}
var tip = $el.data('tip');
if (tip) {
layui.layer.tips(tip, this, {
tips: self.options.tips,
time: self.options.time
});
}
});
$(document).on('mouseleave', this.options.selector, function () {
layui.layer.closeAll('tips');
});
};
9.3 使用方式
javascript
// 初始化
var tip = new GeneralTooltip();
// 自定义配置
var tip = new GeneralTooltip({
selector: '[data-tip]',
tips: 3, // 显示在下方
time: 2000 // 2秒后自动关闭
});
HTML:
html
<button data-tip="点击保存数据">
<i class="icon-save"></i>
</button>
<span class="status-tag" data-tip="该用户已通过实名认证">
已认证
</span>
十、CSS 样式设计要点
10.1 表格基础样式
css
/* 表格容器 */
.table-container {
overflow-x: auto;
overflow-y: visible;
}
/* 基础表格样式 */
.custom-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.custom-table th,
.custom-table td {
padding: 12px 11px;
text-align: left;
border-bottom: 1px solid #dddddd;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 表头样式 */
.custom-table > thead th {
background-color: #F4FAFF;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
}
/* 行 hover 效果 */
.custom-table > tbody > tr:hover {
background-color: #F8FAFC;
}
10.2 固定列样式
css
/* 固定列 */
.custom-table.table-fixed-right .fixed-column {
position: sticky;
right: 0;
background-color: #fff;
z-index: 2;
}
/* 表头的固定列层级要更高 */
.custom-table.table-fixed-right thead .fixed-column {
background-color: #F4FAFF;
z-index: 12;
}
/* hover 时固定列也要变色 */
.custom-table.table-fixed-right tbody tr:hover .fixed-column {
background-color: #F8FAFC;
}
/* 滚动时的阴影效果 */
.table-container.has-fixed-column.is-scrollable .fixed-column {
border-left: 1px solid #e6e6e6;
box-shadow: -1px 0 8px rgba(0, 0, 0, 0.08);
}
10.3 子表格样式
css
/* 子表格行 */
.child-row {
background: #F7F9FB !important;
}
.child-row > td {
padding: 0 !important;
border: none !important;
}
/* 展开状态的父行 */
.custom-table > tbody > tr.expanded {
background: #F7F9FB !important;
}
/* 子表格内容容器 - 动画 */
.child-content {
overflow: hidden;
transition: max-height 0.3s ease-out;
max-height: 0;
}
.child-content.expanded {
/* max-height 由 JS 动态设置 */
}
/* 展开图标旋转动画 */
.expand-icon {
transition: transform 0.3s ease;
transform: rotate(-90deg);
}
.expand-icon.rotated {
transform: rotate(0deg);
}
10.4 Tooltip 样式
css
.custom-tooltip {
position: fixed;
max-width: 300px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 13px;
line-height: 1.5;
border-radius: 4px;
z-index: 9999;
word-wrap: break-word;
white-space: normal;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.custom-tooltip.show {
opacity: 1;
}
/* 箭头 - 默认向下 */
.custom-tooltip::before {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent;
}
/* 箭头 - 向上(显示在下方时) */
.custom-tooltip.tooltip-bottom::before {
bottom: auto;
top: -6px;
border-width: 0 6px 6px;
border-color: transparent transparent rgba(0, 0, 0, 0.8);
}
10.5 省略号单元格的复制图标
css
/* 复制图标 - 默认隐藏 */
.ellipsis-cell[data-tip]:not([data-tip=""])::after {
content: '';
display: inline-block;
width: 14px;
height: 14px;
background-image: url("data:image/svg+xml,..."); /* 复制图标 SVG */
opacity: 0;
transition: opacity 0.2s;
vertical-align: middle;
margin-left: 4px;
}
/* hover 时显示 */
.ellipsis-cell[data-tip]:not([data-tip=""]):hover::after {
opacity: 0.6;
}
/* 溢出时,图标覆盖省略号 */
.ellipsis-cell[data-tip]:not([data-tip=""]).is-overflowing::after {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
}
十一、实战:从零搭建一个完整的表格页面
11.1 HTML 结构
html
<!DOCTYPE html>
<html>
<head>
<title>表格组件演示</title>
<link rel="stylesheet" href="layui/css/layui.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<section class="bg-white rounded-xl shadow-sm p-6">
<!-- 标题和操作按钮 -->
<div class="flex justify-between items-center mb-5">
<h2 class="text-h2">用户列表</h2>
<button class="btn btn-primary">添加用户</button>
</div>
<!-- 表格容器 -->
<div class="table-container has-fixed-column">
<div class="table-wrapper">
<table class="custom-table table-fixed-right" id="userTable">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 150px;">姓名</th>
<th style="width: 200px;">邮箱</th>
<th style="width: 120px;">手机号</th>
<th>状态</th> <!-- 不设置宽度,自动填充 -->
<th class="fixed-column" style="width: 140px;">操作</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- 数据由 JS 渲染 -->
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="table-pagination" id="pagination"></div>
</section>
<script src="layui/layui.js"></script>
<script src="js/table-component.js"></script>
<script>
// 业务代码
</script>
</body>
</html>
11.2 JavaScript 代码
javascript
layui.use(['laypage'], function() {
const { laypage, jquery: $ } = layui;
// ========== 配置 ==========
const CONFIG = {
PAGE_SIZE: 10,
API_URL: '/api/users'
};
// ========== 状态管理 ==========
const state = {
data: [],
total: 0,
currentPage: 1
};
// ========== 初始化组件 ==========
// 1. 省略号提示
const tooltip = new TableTooltip({
delay: 300,
selector: '.ellipsis-cell[data-tip]'
});
// 2. 固定列管理
const fixedColumn = new FixedColumnManager('.table-container.has-fixed-column');
// 3. 加载状态
const loading = new TableLoading('#tableBody', {
pageSize: CONFIG.PAGE_SIZE
});
// 4. 空状态
const emptyState = new TableEmptyState('#tableBody');
// ========== 工具函数 ==========
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ========== 渲染函数 ==========
function renderStatus(status) {
if (status === 1) {
return '<span class="status-item is-success">正常</span>';
} else {
return '<span class="status-item is-warning">禁用</span>';
}
}
function renderRow(item, index) {
return `
<tr data-index="${index}">
<td>${item.id}</td>
<td class="ellipsis-cell" data-tip="${escapeHtml(item.name)}">
${item.name}
</td>
<td class="ellipsis-cell" data-tip="${escapeHtml(item.email)}">
${item.email}
</td>
<td>${item.phone}</td>
<td>${renderStatus(item.status)}</td>
<td class="fixed-column">
<a class="text-link edit-btn" data-id="${item.id}">编辑</a>
<a class="text-link delete-btn" data-id="${item.id}">删除</a>
</td>
</tr>
`;
}
function renderTable() {
if (state.data.length === 0) {
emptyState.show();
return;
}
const html = state.data.map((item, i) => renderRow(item, i)).join('');
$('#tableBody').html(html);
}
function renderPagination() {
if (state.total === 0) {
$('#pagination').html('');
return;
}
laypage.render({
elem: 'pagination',
count: state.total,
limit: CONFIG.PAGE_SIZE,
curr: state.currentPage,
layout: ['prev', 'page', 'next'],
jump: function(obj, first) {
state.currentPage = obj.curr;
if (!first) {
loadData();
}
}
});
}
// ========== 数据加载 ==========
function loadData() {
// 显示加载状态
loading.show(state.total, state.currentPage);
$.ajax({
url: CONFIG.API_URL,
data: {
page: state.currentPage,
limit: CONFIG.PAGE_SIZE
},
success: function(res) {
loading.hide();
if (res.code === 0) {
state.data = res.data.list || [];
state.total = res.data.total || 0;
renderTable();
renderPagination();
} else {
layer.msg(res.msg || '加载失败');
}
},
error: function() {
loading.hide();
layer.msg('请求失败');
}
});
}
// ========== 事件绑定 ==========
$(document).on('click', '.edit-btn', function() {
const id = $(this).data('id');
// 编辑逻辑...
});
$(document).on('click', '.delete-btn', function() {
const id = $(this).data('id');
layer.confirm('确定删除吗?', function(index) {
// 删除逻辑...
layer.close(index);
});
});
// ========== 初始化 ==========
loadData();
});
11.3 代码结构说明
我习惯把代码分成几个区块:
- 配置区 - 常量配置,方便统一修改
- 状态区 - 页面状态管理,数据、分页等
- 组件初始化区 - 初始化各种组件
- 工具函数区 - 通用的工具函数
- 渲染函数区 - 负责渲染 HTML
- 数据加载区 - 负责请求数据
- 事件绑定区 - 绑定各种事件
- 初始化区 - 页面初始化逻辑
这样的结构清晰明了,后期维护也方便。
十二、性能优化与最佳实践
12.1 事件委托
不要给每个按钮单独绑定事件,用事件委托:
javascript
// ❌ 不推荐
$('.edit-btn').on('click', function() { ... });
// ✅ 推荐
$(document).on('click', '.edit-btn', function() { ... });
事件委托的好处是:
- 动态生成的元素也能响应事件
- 只绑定一个事件监听器,性能更好
12.2 防抖与节流
Tooltip 的显示用了防抖:
javascript
clearTimeout(this.timer);
this.timer = setTimeout(function() {
// 显示 tooltip
}, this.options.delay);
滚动检测可以加节流:
javascript
var throttledCheck = throttle(this.checkScroll, 100);
$container.on('scroll', throttledCheck);
12.3 及时销毁
组件不用了要销毁,避免内存泄漏:
javascript
tooltip.destroy();
fixedColumn.destroy();
12.4 HTML 转义
渲染用户输入的内容时,一定要转义:
javascript
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 使用
<td data-tip="${escapeHtml(item.name)}">${escapeHtml(item.name)}</td>
不转义的话,用户输入 <script>alert(1)</script> 就能 XSS 攻击了。
十三、常见问题 FAQ
Q1: 固定列不在最右边怎么办?
检查以下几点:
- 是否给
.table-wrapper设置了min-width(或者用FixedColumnManager自动计算) - 固定列前面那一列是否没有设置
width - 表格是否设置了
table-layout: fixed
Q2: Tooltip 位置不对怎么办?
可能是因为:
- 单元格有
padding,影响了文字位置计算 - 表格在滚动容器内,需要考虑滚动偏移
Q3: 骨架屏行数不对怎么办?
确保调用 show() 时传入了正确的参数:
javascript
// 首次加载,不传参数
loading.show();
// 翻页加载,传入总数和当前页
loading.show(totalCount, currentPage);
Q4: 子表格展开后高度不对怎么办?
可能是子表格内容是异步加载的,加载完后需要重新计算高度:
javascript
childRow.toggle(index);
// 异步加载完数据后
setTimeout(function() {
var $content = $('tr.child-row[data-parent="' + index + '"] .child-content');
$content.css('max-height', $content[0].scrollHeight + 'px');
}, 100);
Q5: 如何支持多级表头?
目前的 FixedColumnManager 只支持单级表头。如果需要多级表头,需要手动计算列宽,或者扩展组件。
十四、总结与展望
做了什么
这套组件库解决了表格开发中的几个痛点:
- 省略号提示 - 自动判断溢出,精确定位,支持复制
- 固定列 - 自动计算宽度,滚动阴影,兼容性好
- 骨架屏加载 - 自动检测列数,智能计算行数
- 子表格 - 手风琴模式,平滑动画,状态记忆
- 空状态 - 区分场景,提供引导
设计原则
- 单一职责 - 每个组件只做一件事
- 开箱即用 - 默认配置就能用,不需要复杂配置
- 可扩展 - 提供配置项,满足不同需求
- 低侵入 - 不修改原有 DOM 结构,只增强功能
后续计划
- 支持 TypeScript 类型定义
- 支持虚拟滚动,优化大数据量性能
- 支持列拖拽排序
- 支持列显示/隐藏切换
十五、完整代码获取
完整代码已经放在项目中:
- JS 文件 :
pub/algo/js/table-component.js - CSS 文件 :
pub/algo/css/style.css - 演示页面 :
pub/algo/js/demo/table-component-demo.html
欢迎 Star 和 Fork,有问题可以提 Issue。
写在最后:封装组件是一件很有成就感的事情。从最初的"能用就行"到现在的"好用且优雅",中间踩了无数的坑,但也学到了很多。希望这篇文章能帮到正在被表格折磨的你。如果觉得有用,点个赞再走呗~ 😄
参考资料:
本文为原创文章,转载请注明出处。