从零封装一套企业级表格组件库 - 基于 Layui 的实战教程

从零封装一套企业级表格组件库 - 基于 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 的省略号效果很容易实现,但是有几个问题:

  1. 用户看不到完整内容 - 省略号后面是什么?不知道
  2. 原生 title 太丑 - 而且有延迟,样式没法自定义
  3. 没法复制 - 想复制完整内容?对不起,选不中

4.2 设计思路

我的解决方案是:

  1. 鼠标悬浮时,只有内容真的溢出了,才显示 Tooltip
  2. Tooltip 的位置要跟随可见文字,而不是整个单元格
  3. 点击单元格可以一键复制完整内容,并显示复制成功提示

4.3 核心代码解析

第一步:判断内容是否溢出

javascript 复制代码
// 判断是否溢出
var isOverflowing = $el[0].scrollWidth > $el[0].clientWidth;

// 只在溢出时显示 tooltip
if ($el[0].scrollWidth <= $el[0].clientWidth) return;

这里用 scrollWidthclientWidth 的对比来判断。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);
};

这里有几个细节:

  1. 延迟显示 - 用 setTimeout 实现,避免鼠标快速划过时频繁显示
  2. 边界处理 - 如果 Tooltip 超出屏幕边缘,自动调整位置
  3. 上下翻转 - 如果顶部空间不够,自动翻转到下方显示

第四步:点击复制

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 解决方案

经过无数次尝试,我总结出了一套可行的方案:

  1. 使用 table-layout: fixed - 让列宽严格按照设置的值
  2. 给单元格加 overflow: hidden - 防止内容撑开列宽
  3. 动态计算表格最小宽度 - 保证固定列始终在最右边
  4. 留一列不设置宽度 - 让它自动填充剩余空间

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 设计思路

我设计了以下特性:

  1. 手风琴模式 - 同时只能展开一个,展开新的会自动收起旧的
  2. 平滑动画 - 展开收起有过渡效果,不是生硬的显示隐藏
  3. 状态记忆 - 可以记住哪些行是展开的,翻页后恢复状态

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);
        }
    }
};

这里的动画技巧是:

  1. 先获取内容的实际高度 scrollHeight
  2. max-height 从 0 过渡到实际高度
  3. 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 就是一个转圈圈,用户看到的是一片空白 + 一个圈。这种体验有几个问题:

  1. 不知道要等多久 - 圈圈转啊转,用户焦虑
  2. 页面跳动 - 数据加载完后,页面突然出现内容,很突兀
  3. 审美疲劳 - 转圈圈用了十几年了,太无聊了

骨架屏的好处是:

  1. 预知布局 - 用户能看到大概的内容结构
  2. 减少跳动 - 骨架屏和真实内容布局一致,过渡更平滑
  3. 感觉更快 - 心理学上,有内容比空白感觉更快

7.2 设计思路

我设计了三种加载模式:

  1. skeleton - 骨架屏,适合翻页、刷新
  2. spinner - 转圈圈,适合首次加载
  3. 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 为什么要做空状态

很多开发者对空状态不重视,数据为空就显示一行"暂无数据",甚至什么都不显示。这样的体验很差:

  1. 用户困惑 - 是没有数据,还是加载失败了?
  2. 页面空洞 - 一大片空白,很难看
  3. 没有引导 - 用户不知道下一步该干什么

好的空状态应该:

  1. 明确告知 - 清楚地告诉用户"没有数据"
  2. 区分场景 - 是真的没数据,还是搜索没结果?
  3. 提供引导 - 告诉用户可以做什么

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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

    // ========== 渲染函数 ==========
    
    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 代码结构说明

我习惯把代码分成几个区块:

  1. 配置区 - 常量配置,方便统一修改
  2. 状态区 - 页面状态管理,数据、分页等
  3. 组件初始化区 - 初始化各种组件
  4. 工具函数区 - 通用的工具函数
  5. 渲染函数区 - 负责渲染 HTML
  6. 数据加载区 - 负责请求数据
  7. 事件绑定区 - 绑定各种事件
  8. 初始化区 - 页面初始化逻辑

这样的结构清晰明了,后期维护也方便。


十二、性能优化与最佳实践

12.1 事件委托

不要给每个按钮单独绑定事件,用事件委托:

javascript 复制代码
// ❌ 不推荐
$('.edit-btn').on('click', function() { ... });

// ✅ 推荐
$(document).on('click', '.edit-btn', function() { ... });

事件委托的好处是:

  1. 动态生成的元素也能响应事件
  2. 只绑定一个事件监听器,性能更好

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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

// 使用
<td data-tip="${escapeHtml(item.name)}">${escapeHtml(item.name)}</td>

不转义的话,用户输入 <script>alert(1)</script> 就能 XSS 攻击了。


十三、常见问题 FAQ

Q1: 固定列不在最右边怎么办?

检查以下几点:

  1. 是否给 .table-wrapper 设置了 min-width(或者用 FixedColumnManager 自动计算)
  2. 固定列前面那一列是否没有设置 width
  3. 表格是否设置了 table-layout: fixed

Q2: Tooltip 位置不对怎么办?

可能是因为:

  1. 单元格有 padding,影响了文字位置计算
  2. 表格在滚动容器内,需要考虑滚动偏移

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 只支持单级表头。如果需要多级表头,需要手动计算列宽,或者扩展组件。


十四、总结与展望

做了什么

这套组件库解决了表格开发中的几个痛点:

  1. 省略号提示 - 自动判断溢出,精确定位,支持复制
  2. 固定列 - 自动计算宽度,滚动阴影,兼容性好
  3. 骨架屏加载 - 自动检测列数,智能计算行数
  4. 子表格 - 手风琴模式,平滑动画,状态记忆
  5. 空状态 - 区分场景,提供引导

设计原则

  1. 单一职责 - 每个组件只做一件事
  2. 开箱即用 - 默认配置就能用,不需要复杂配置
  3. 可扩展 - 提供配置项,满足不同需求
  4. 低侵入 - 不修改原有 DOM 结构,只增强功能

后续计划

  1. 支持 TypeScript 类型定义
  2. 支持虚拟滚动,优化大数据量性能
  3. 支持列拖拽排序
  4. 支持列显示/隐藏切换

十五、完整代码获取

完整代码已经放在项目中:

  • JS 文件 : pub/algo/js/table-component.js
  • CSS 文件 : pub/algo/css/style.css
  • 演示页面 : pub/algo/js/demo/table-component-demo.html

欢迎 Star 和 Fork,有问题可以提 Issue。


写在最后:封装组件是一件很有成就感的事情。从最初的"能用就行"到现在的"好用且优雅",中间踩了无数的坑,但也学到了很多。希望这篇文章能帮到正在被表格折磨的你。如果觉得有用,点个赞再走呗~ 😄


参考资料


本文为原创文章,转载请注明出处。

相关推荐
2501_944525544 小时前
Flutter for OpenHarmony 个人理财管理App实战 - 预算详情页面
android·开发语言·前端·javascript·flutter·ecmascript
打小就很皮...5 小时前
《在 React/Vue 项目中引入 Supademo 实现交互式新手指引》
前端·supademo·新手指引
C澒5 小时前
系统初始化成功率下降排查实践
前端·安全·运维开发
摘星编程5 小时前
React Native + OpenHarmony:自定义useFormik表单处理
javascript·react native·react.js
C澒5 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
pas1365 小时前
39-mini-vue 实现解析 text 功能
前端·javascript·vue.js
qq_532453535 小时前
使用 GaussianSplats3D 在 Vue 3 中构建交互式 3D 高斯点云查看器
前端·vue.js·3d
Swift社区6 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
2601_949833396 小时前
flutter_for_openharmony口腔护理app实战+我的实现
开发语言·javascript·flutter
雾眠气泡水@6 小时前
前端:解决同一张图片由于页面大小不统一导致图片模糊
前端