一、介绍
通过原生 JS + CSS 实现一个简单的表格组件,支持固定表头、左右固定列、三态排序、自定义单元格渲染、空数据与加载状态,零依赖、零构建,引入两个文件即可使用。
二、实现代码
class SimpleTable { constructor(selector, options = {}) { this.columns = options.columns || []; this.data = options.data || []; this.orginalData = [...this.data]; this.sortCallback = options.sortCallback; this.placeholder = options.placeholder || "-"; this.rootEl = document.querySelector(selector); this.initColumns(); this.init(); this.render(); } isEmpty(val) { return val === undefined || val === null || val === ""; } init() { this.rootEl.className += " simple-table"; this.rootEl.innerHTML = ` <div class="simple-table-header"> <table style="table-layout: fixed"><colgroup></colgroup><thead></thead></table> </div> <div class="simple-table-body"> <table style="table-layout: fixed"><colgroup></colgroup><tbody></tbody></table> <div class="no-data">暂无数据</div> </div> <div class="simple-table-loading" style="display: none">加载中</div>`; this.headWrapperEl = this.rootEl.querySelector(".simple-table-header"); this.bodyWrapperEl = this.rootEl.querySelector(".simple-table-body"); this.headColgroupEl = this.rootEl.querySelector( ".simple-table-header colgroup" ); this.bodyColgroupEl = this.rootEl.querySelector( ".simple-table-body colgroup" ); this.headEl = this.rootEl.querySelector("thead"); this.bodyEl = this.rootEl.querySelector("tbody"); this.loadingEl = this.rootEl.querySelector(".simple-table-loading"); this.syncScroll(); } syncScroll() { this.bodyWrapperEl.onscroll = () => { this.headWrapperEl.scrollLeft = this.bodyWrapperEl.scrollLeft; }; } initColumns() { let left = 0; let right = 0; const len = this.columns.length; for (let i = 0; i < len; i++) { const item = this.columns[i]; item.width = item.width || "auto"; item._className = ""; item._style = ""; if (item.fixed === "left") { item._style = `left:${left}px;`; left += parseInt(item.width); item._className = "fixed"; } const rightItem = this.columns[len - i - 1]; if (rightItem.fixed === "right") { rightItem._style = `right:${right}px;`; right += parseInt(rightItem.width); rightItem._className = "fixed"; } } } render() { this.renderHeader(); this.renderData(); } setData(data) { this.data = data; this.orginalData = [...data]; this.renderData(); } loading(val) { this.loadingEl.style.display = val ? "flex" : "none"; } renderHeader() { const colgroupHtml = this.columns .map((c) => `<col width="${c.width}">`) .join(""); const headHtml = `<tr>${this.columns .map( (c) => `<th class="${c._className} ${ c.sort ? "simple-table-sort" : "" }" style="${c._style}" data-key="${c.key}" ${c.sort ? `data-sort="${c._sort || ""}"` : ""}>${ c.title }</th>` ) .join("")}</tr>`; this.headColgroupEl.innerHTML = colgroupHtml; this.bodyColgroupEl.innerHTML = colgroupHtml; this.headEl.innerHTML = headHtml; this.headEl.querySelectorAll(".simple-table-sort").forEach((th) => { th.onclick = (e) => { const current = e.target.dataset.sort; const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc"; const key = e.target.dataset.key; this.headEl.querySelectorAll("th").forEach((t) => { t.classList.remove("asc", "desc"); t.dataset.sort = ""; }); e.target.dataset.sort = next; e.target.classList.add(next); if (typeof this.sortCallback === "function") { this.sortCallback({ key, sort: next }); } else { if (next === "asc") this.data.sort((a, b) => a[key] - b[key]); else if (next === "desc") this.data.sort((a, b) => b[key] - a[key]); else this.data = [...this.orginalData]; this.renderData(); } }; }); } renderData() { this.bodyEl.innerHTML = this.data .map( (row, index) => `<tr>${this.columns .map((col) => { const content = col.render ? col.render({ value: row[col.key], key: col.key, row, index, data: this.data, }) : this.isEmpty(row[col.key]) ? this.placeholder : row[col.key]; return `<td class="${col._className}" style="${col._style}">${content}</td>`; }) .join("")}</tr>` ) .join(""); this.rootEl.classList.toggle("simple-table-empty", !this.data.length); }}
.simple-table { overflow: hidden; display: flex; flex-direction: column; position: relative;}.simple-table .simple-table-header { overflow: hidden;}.simple-table .simple-table-body { overflow: auto; flex: 1;}.simple-table table { table-layout: fixed; border-collapse: collapse; width: 100%; font-size: 14px;}.simple-table th,.simple-table td { padding: 14px 16px; text-align: left;}.simple-table th { background-color: #f7f8fa; color: #999;}.simple-table th.fixed,.simple-table td.fixed { position: sticky; z-index: 1;}.simple-table-sort { position: relative; cursor: pointer;}.simple-table-sort::before,.simple-table-sort::after { content: ""; position: absolute; right: 2px; border-width: 4px; border-style: dashed;}.simple-table-sort::before { top: 50%; margin-top: -10px; border-bottom-style: solid; border-color: transparent transparent #999;}.simple-table-sort::after { top: 50%; margin-top: 2px; border-top-style: solid; border-color: #999 transparent transparent;}.simple-table-sort.asc::before { border-color: transparent transparent #e62b1c;}.simple-table-sort.desc::after { border-color: #e62b1c transparent transparent;}.simple-table .no-data { display: none; padding: 30px 0; text-align: center; color: #999;}.simple-table.simple-table-empty .no-data { display: block;}.simple-table-loading { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background-color: rgba(255, 255, 255, 0.8); font-size: 14px;}
三、调用示例
<link rel="stylesheet" href="SimpleTable.css" /><script src="SimpleTable.js"></script><div id="myTable" style="width: 800px; height: 300px"></div>
const table = new SimpleTable("#myTable", { columns: [ { title: "姓名", key: "name", width: "200px", fixed: "left" }, // 固定在左侧 { title: "分数", key: "score", width: "100px", sort: true }, // 开启排序 { title: "备注", key: "remark", width: "300px" }, { title: "操作", key: "op", width: "120px", fixed: "right", // 固定在右侧 render: ({ row }) => `<a href="javascript:;">编辑</a>`, }, ],});table.setData([ { name: "张三", score: 90, remark: "优秀" }, { name: "李四", score: 80 }, { name: "", score: 60, remark: null }, // 空值自动显示占位符 "-"]);
四、参数
| | |
|---|
columns | | [] |
data | | [] |
minWidth | | "100%" |
placeholder | | "-" |
emptyContent | | 暂无数据 |
loadingContent | | 加载中 |
sortCallback | 自定义排序回调,接收 { key, sort },常用于服务端排序 | |
tableClass / trClass / thClass / tdClass / headTrClass / bodyTrClass | | "" |
| | |
|---|
title | | |
key | | |
width | | "auto" |
fixed | | |
sort | | false |
render | 自定义渲染函数,参数为 { value, key, row, index, data } | |
class / thClass / tdClass | | "" |
| |
|---|
setData(data) | |
setOptions({ columns, data, sortCallback }) | |
loading(val) | |
setEmptyContent(content) | |
五、总结
通过双 table 拆分与滚动同步实现固定表头,position: sticky 配合 JS 计算偏移实现固定列,colgroup + table-layout: fixed 保证两套表格列宽严格对齐,再以模板字符串渲染数据、事件绑定实现三态排序。实现这个组件后,对各类表格组件(如 Element UI 的 Table)的底层原理会有更深入的了解。
该文章在 2026/10/19 10:01:12 编辑过