Day25-20260121,这个看起来像打卡签到的编号,其实是我给自己定的“连续25天小项目驱动”计划的第25次记录。日期落在2026年1月21日,所以完整标记就成了这样。今天这个项目没去追什么新框架,而是老老实实用原生JavaScript做了一个“纯前端表格筛选组件”,支持多字段组合筛选、拼音首字母查询、排序、分页,目标是让一万行数据在前端筛选时依然能保持流畅,不依赖任何第三方库。这篇文章就把从设计、编码到调优、踩坑的完整过程记录下来,适合正在做数据展示、表格处理或前端性能优化的朋友参考。
1. 项目背景与整体设计思路
1.1 这个日期标记背后是什么
熟悉我的人都知道,我每隔一段时间就会给自己定一个“连续写项目”的挑战,不追求宏大,只求每天能拿出一个形状完整、能跑通、能复用的东西。Day25是第25天,20260121是日期,两者组合在一起就成了这个看起来无关紧要的编号。它其实是一个刻意给自己留的记录锚点,方便以后回看某一天到底做了什么。
说回项目本身。我做的是一个很常见但又很刚需的东西:表格。尤其在后台管理系统、数据报表、运营看板这类场景里,表格几乎是绕不开的。很多团队遇到这种需求会直接引入重型组件库,但在一些特殊环境,比如内网、离线环境、纯静态页面、低代码平台里,外部依赖反而成了负担。这次的项目X就是在“零依赖”的前提下,复刻一个表格组件最常用的能力:搜索、筛选、排序、分页。
这类项目的难点不在于把功能做出来,而在于做出来之后是否还敢继续往里塞数据。市面上大部分表格插件在几千行数据时表现正常,一旦到两三万行,不做虚拟滚动的话,输入一个关键词都能卡顿半天。所以在设计之初,我就把“大数据量下的可用性”定为核心指标,而不是先堆功能。
1.2 为什么选定原生JavaScript
选原生JavaScript不是为了炫技,而是基于一个很直接的判断:这个表格组件的使用场景大概率是被嵌入到各种宿主环境中,有可能是老旧浏览器,有可能是客户自研框架,也有可能是完全静态的HTML页面。如果用框架写,发布时还得考虑构建链路的兼容性,使用者还得学一套自定义渲染方式。而原生JS加一份HTML结构,拿到任何地方都能直接跑,门槛最低,出问题也最方便排查。
这个思路其实和写工具类代码是一个道理。工具的第一要务是“能被用起来”,其次才是“用起来舒服”。所以我在命名、接口设计上都尽量贴近直觉,比如调用方式就设计成一个函数:
// 初始化表格筛选组件 const table = createFilterableTable({ container: '#app', dataSource: rawData, columns: [ { key: 'name', label: '姓名', filterable: true }, { key: 'city', label: '城市', filterable: true }, { key: 'amount', label: '金额', sortable: true } ] });这个设计说起来简单,但它决定了后面所有模块的走向。比如为了让筛选和排序能组合执行,我把整个数据流设计成了“管道式”处理:原始数据进来,先执行关键词过滤,再执行字段级过滤,再排序,最后分页。每一层都是独立的纯函数,这样既好测试,也好扩展。
1.3 目标场景和硬指标
做之前我先给项目定了一个硬指标:在普通笔记本上,一万条数据渲染到DOM里,关键词搜索响应时间不能超过300毫秒。这个目标写出来容易,真做起来涉及的细节很多,比如怎么监听输入、怎么按需更新DOM、怎么避免每次输入都全量重绘。
最初我也考虑过用虚拟滚动来彻底解决渲染问题,但虚拟滚动会带来行高计算、滚动条占位、焦点管理等一系列复杂度,对于这个项目的定位来说有点用力过猛。所以我采取了一个更务实的策略:默认只渲染每页可见的数据,比如每页50条,真正落到DOM上的节点始终是可控的,原始大数据只存在于JavaScript数组中。这样页面上的实时节点数量被锁死,性能消耗就主要集中在了“数据筛选”和“DOM更新”这两个环节上。
2. 核心功能实现与关键细节解析
2.1 数据模型与筛选状态管理
这个组件的核心数据模型分两层。第一层是原始数据数组,在整个生命周期内保持不变,避免误操作导致数据污染;第二层是筛选后的数据集,每次用户操作都会重新计算一次。这里有个很多人容易忽视的点:筛选后的数据集虽然不需要真正创建一个新数组存起来,但为了让逻辑清晰,我建议还是先产出中间结果,再交给视图层渲染。
筛选状态我用一个普通对象来表示:
const state = { keyword: '', fieldFilters: {}, sortKey: '', sortOrder: 'asc', page: 1, pageSize: 50 };每个属性都对应一个用户可操作的动作。这样设计的好处是,所有状态都能序列化成URL参数,以后做分享链接、页面刷新后恢复状态会非常省事。实际项目中我已经顺手加了一个restoreState方法,可以从URL查询参数里把表格状态还原回来,对运营同事来说特别好用。
筛选逻辑我采用了“柯里化”的思路,把过滤器和排序器拆成独立函数:
function keywordFilter(data, keyword, columns) { if (!keyword) return data; const lowerKeyword = keyword.toLowerCase(); return data.filter(row => columns.some(col => String(row[col.key]).toLowerCase().includes(lowerKeyword)) ); } function fieldFilter(data, fieldFilters) { const entries = Object.entries(fieldFilters).filter(([, v]) => v !== '' && v != null); if (!entries.length) return data; return data.filter(row => entries.every(([key, value]) => String(row[key]) === String(value)) ); } function sortData(data, key, order) { if (!key) return data; const factor = order === 'asc' ? 1 : -1; return data.slice().sort((a, b) => { const av = a[key]; const bv = b[key]; if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * factor; } return String(av).localeCompare(String(bv), 'zh-Hans-CN') * factor; }); }localeCompare在这里有一个容易被忽略的好处:传入zh-Hans-CN之后,中文字符的排序会按照拼音顺序排列,而不是按照Unicode编码拍脑袋排序。如果漏掉这个参数,那么“张三”和“阿强”这种名字排序出来会非常别扭,用户体验直接打折扣。
2.2 拼音首字母查询的实现与模糊策略
表格搜索框里最常见的需求之一就是拼音首字母搜索。用户记不住完整名字,但知道“zhang”或“z”就能搜到“张三”。之前我见过有人直接把拼音索引表写死进业务代码,这种做法在小数据量下没问题,但不够通用。这次我直接在组件内部集成了一套小型的拼音索引机制。
实现上我没有引入整个拼音库,而是只内置了常用汉字的拼音首字母映射表。具体做法是准备一个由“Unicode区间到字母”的映射集合,因为常用汉字在Unicode编码上大体连续,可以压缩成几百个区间表,再用二分查找快速命中。这样既覆盖了常用情况,又不会让代码体积膨胀太多。
搜索时,把输入值处理成两种形态:一种是原文模糊匹配,一种是拼音首字母匹配。匹配逻辑可以这样理解:
function isMatch(text, query) { const t = String(text).toLowerCase(); const initials = getInitialLetters(t); // 转成首字母串,如“张三” -> “zs” return t.includes(query) || initials.includes(query); }这种做法对应的用户预期是:输入“zs”能搜出“张三”,输入“z"也能搜出”张三“,输入完整的“张三”也能精确搜到。很多商用表格组件都没有这种细节,但实际业务里又经常有人提,所以这次我顺手把它做进去了。整体代码量不多,效果却很惊艳。
2.3 防抖与增量渲染的配合
搜索框的输入频率远高于筛选下拉框,所以必须给输入事件加防抖。防抖时间我通常设180毫秒,这个值在“响应速度”和“避免频繁计算”之间比较平衡。太短会导致输入稍微停顿就触发计算,太长会让人觉得迟钝。
防抖的本质很简单,就是延迟执行,取消上一次的未执行操作。代码可以只用一个计时器变量:
let debounceTimer = null; searchInput.addEventListener('input', (event) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { state.keyword = event.target.value.trim(); state.page = 1; // 搜索后回到第一页 renderTable(); }, 180); });另一个配合的细节是增量DOM更新。常规做法是每次筛选后直接innerHTML清空重建,这在数据量小时没毛病,但当表格结构复杂、列数多、有行内按钮时,全量重建的成本会高起来。所以我在更新表格主体时,选择只重新生成tbody里的行,表头保持不动。如果再进一步优化,还可以用DocumentFragment先把新行拼好,再一次性挂载到DOM上,减少浏览器重排次数。
function updateTableBody(rows) { const frag = document.createDocumentFragment(); rows.forEach(rowData => { const tr = document.createElement('tr'); // 生成单元格逻辑... frag.appendChild(tr); }); tbody.replaceChildren(frag); }replaceChildren这个API比反复removeChild和appendChild简洁,兼容性在现代浏览器里已经没问题。用上它以后,一万行数据的表格在筛选时,肉眼基本感觉不到顿挫。
3. 实操过程与核心环节实现
3.1 先搭一个可运行的最小骨架
任何项目都怕一上来就陷入细节。我的习惯是先搭一个能运行的最小骨架,把输入、表格、翻页按钮都摆出来,哪怕所有数据都是写死的,也要先让视觉链路通起来。这一步的目的不是为了体验,而是确保后续每次改动都能在一个可观察的环境里进行。
页面骨架我用了最简单的HTML结构:
<div id="app"> <div class="toolbar"> <input type="text" id="keyword" placeholder="输入关键词或拼音首字母" /> <select id="cityFilter"> <option value="">城市不限</option> <option value="北京">北京</option> <option value="上海">上海</option> <option value="广州">广州</option> </select> <span id="recordCount">共 0 条</span> </div> <table id="dataTable"> <thead>...</thead> <tbody></tbody> </table> <div class="pagination"> <button id="prevPage">上一页</button> <span id="pageInfo">1 / 1</span> <button id="nextPage">下一页</button> </div> </div>这已经是我习惯的“先跑通再润色”策略。工具栏、表格、分页三个区域分得清清楚楚,后续写筛选逻辑时,只需要关注状态变化和渲染函数,不用在DOM结构里纠结。
3.2 渲染函数与事件绑定的落地方案
我负责这个组件的渲染函数分了三层,各管各的,不越界。第一层renderTable负责总调度:先走一遍过滤、排序、分页管道,拿到当前页数据后,同时更新表头、表体、分页信息、记录数。第二层renderHeader处理表头,包括排序指示器(上升/下降箭头)的切换。第三层renderBody专门产出数据行。
事件绑定上也做了区域划分。事件委托是关键:我给整个tbody绑一个click事件,根据点击元素的>headerCell.addEventListener('click', () => { const key = headerCell.dataset.sortKey; if (state.sortKey !== key) { state.sortKey = key; state.sortOrder = 'asc'; } else if (state.sortOrder === 'asc') { state.sortOrder = 'desc'; } else { state.sortKey = ''; state.sortOrder = ''; } state.page = 1; renderTable(); });
这里把排序状态直接存在组件内部,没有引入任何全局变量,回调函数也不会产生闭包混乱。从我这次的实践看,用“状态驱动渲染”这种思路来做组件,后续真的要加功能时会轻松很多。
3.3 一万条数据的压测与调优
写完核心功能后,我用脚本生成了一万条模拟数据,字段包括姓名、城市、金额、日期、状态,尽量贴近真实业务形态。压测环境就是我自己常用的笔记本,没有做任何特殊优化,想看看裸跑状态下这个组件到底水平如何。
第一轮压测结果其实不太理想。输入关键词时,肉眼能感觉到搜索框有明显延迟,大概在500毫秒左右。我用性能分析工具打点一看,发现一个隐蔽的问题:拼音首字母转换函数在每次过滤时都对整列数据重新计算一遍首字母串,而一万条数据里很多名字是重复的,导致大量重复计算。
修复思路很直接:给原始数据增加一个缓存字段,把姓名转成的拼音首字母串计算好,存放在数据的隐藏属性里。这样过滤时直接读缓存,不再反复调用转换函数。别看这是个小改动,修复后搜索耗时从500毫秒降到了200毫秒以内。之后又把所有匹配逻辑从toLowerCase()调用里节省了一些重复数据转换,最终压测稳定在120到180毫秒之间,达成了最初设定的指标。
3.4 数据流管道里的顺序问题
筛选状态多了以后,容易犯的一个错误是搞混过滤顺序。很多人的第一反应是“先排序还是先过滤?”,其实顺序非常简单:先过滤掉不需要的数据,再对剩余数据排序,最后分页。如果反过来先排序再过滤,那么排序消耗的时间会被白白浪费掉,尤其原始数据量大时影响非常明显。
实际代码里我是这样组织的:
function getProcessedData() { let result = keywordFilter(state.rawData, state.keyword, filterableColumns); result = fieldFilter(result, state.fieldFilters); result = sortData(result, state.sortKey, state.sortOrder); const total = result.length; const start = (state.page - 1) * state.pageSize; const pageRows = result.slice(start, start + state.pageSize); return { total, pageRows }; }这也顺带引出一个容易忽略的点:当筛选条件变化时,当前页码必须重置为1。比如用户在第10页输入了新关键词,如果不重置页码,很可能第10页已经没内容了,或者会滑到一个奇怪的位置。这个边界问题我在第三轮自测时就抓住了,拿到真实项目里也几乎一定会出现。
4. 常见问题与排查技巧实录
4.1 几个典型的Bug和定位过程
开发这个项目时踩了很多坑,挑几个有代表性的记录一下。
第一个坑是中文排序不生效。一开始我直接对中文使用sort(),结果“上海”排在“北京”前面,因为Unicode编码和拼音顺序完全是两码事。排查时我先在浏览器控制台里分别打印了几对城市的排序结果,发现localeCompare默认参数在部分环境下表现不一致,加上zh-Hans-CN后就稳定了。这个坑很典型:不是代码逻辑不对,而是“环境差异”造成的排序结果漂移。
第二个坑是分页信息对不上。数据显示“共N条”,但翻到后几页表体空白。排查下来发现是state.page超过了总页数。场景是这样的:用户在某一页把筛选条件改得极其严格,结果剩余数据只有5条,但当前页码还是之前的第10页,切片自然就拉不出数据。解决方案就是在每次更新筛选条件时重置页码,同时在渲染前做一个边界保护:
const totalPages = Math.max(1, Math.ceil(total / state.pageSize)); if (state.page > totalPages) state.page = totalPages;第三个坑和拼音搜索有关。输入“zhangsan”时能搜到“张三”,但输入“zhangs”时匹配不到的,因为索引只存了首字母“zs”。这个问题让我意识到用户的输入习惯往往是完整拼音,不一定是首字母缩写。于是我在匹配规则里又加了“包含全拼片段”的对策,逻辑统一改成了“原文命中、首字母命中、全拼命中,三种满足一种即算命中”。全拼数据同样通过预计算缓存解决,没有额外性能压力。
4.2 排查效率翻倍的几条心得
老实说,这个项目本身不算难,真正花时间的场景是在几万条数据下去复现边界问题。几次打交道下来,我总结出几个特别有用的排查习惯。
第一,不要猜,要用性能分析工具看耗时分布。我之前一直靠人肉感觉判断“哪段代码慢”,直到有一次发现一个不起眼的函数占了60%的执行时间,才彻底改变习惯。现在写数据密集型代码,压测时一定会先看火焰图。
第二,给每个状态变更打日志。这个习惯在排查分页问题时帮了大忙。我在state的page、keyword、fieldFilters这些字段上统一封装了setState方法,方法里会临时输出变更前后的值。真出问题时,看一眼控制台就知道是哪一步把状态搞坏的。
第三,准备一份自测用例集。我并不是等到出问题才临时想数据,而是提前准备了一份“边界数据”:名字是空的、城市是空字符串、金额是负数、日期格式不一致、超长字符串等等。每次改完代码都会用这份数据过一遍,很多隐藏问题在提交之前就被兜住了。
4.3 一个可以抄走的避坑清单
把这次涉及的知识点整理成一张速查表,方便以后直接用:
| 场景 | 常见坑 | 建议方案 |
|---|---|---|
| 中文排序 | 默认排序与拼音不符 | 使用带zh-Hans-CN区域的localeCompare |
| 搜索输入 | 每次击键都全量计算 | 设置180毫秒左右防抖 |
| 拼音搜索 | 首字母、全拼、原文匹配不一致 | 预计算缓存全拼与首字母串 |
| 分页切换 | 筛选条件变化后页码溢出 | 每次筛选后重置页码并做边界保护 |
| 大表格渲染 | 全部节点重建导致卡顿 | 只重建tbody,使用DocumentFragment |
| 多字段筛选 | 条件覆盖关系混乱 | 统一使用“过滤器数组,全部满足才保留” |
| 原始数据安全 | 筛选过程中误改原始数据 | 善用slice、map等不可变操作 |
这张表说白了就是把经验变成了可迁移的知识,下次做类似场景时,我只需要先过一遍表上的问题,基本能避开八成重复的坑。
5. 后续扩展与一些个人心得
5.1 接下来我打算怎么做扩展
Day25这个项目到现在还不算完全收尾,我给它留了几个可扩展的接口。比如当前的分页是前端本地分页,但如果数据源变成后台接口数据集,只需要把rawData改成异步获取,再把分页动作改成重新拉取数据,组件内部结构不用大改。
另一个想加的能力是列显隐控制。现在是固定列,想隐藏某些信息只能改代码。更好的做法是把列的显示状态也纳入state,在表头行做一个下拉勾选面板,这样在真实业务里会非常受欢迎。还有导出功能,可以直接把筛选后的数据导出成CSV文件,用Blob加URL.createObjectURL就能实现,不需要任何第三方库。
这些扩展之所以敢说“不难”,是因为当前组件的核心数据流足够清晰。只要筛选、排序、分页这条管道不被破坏,任何新增功能都只是往管道里增加一个处理节点,或者改变一下视图层的展示方式而已。这也是我当初坚持用“管道式”设计的重要原因。
5.2 这次实操最值得记录的个人体会
如果让我只保留一句心得,那一定是:性能问题不要靠猜,要靠建模和测量。这次项目最耗时的并不是写出这些功能,而是确定“什么才是被真正消耗的时间”。当你把每一次输入、每一次筛选、每一次排序的耗时都量化出来,优化就不再是玄学,而是纯粹的体力活。
还有一个小技巧特别值得分享:给数据加隐藏缓存字段时,不要破坏原始对象的可序列化性。比如我现在用的方案是给行对象添加一个Symbol类型的属性来缓存拼音串,这样即使用JSON.stringify序列化数据,缓存字段也不会被带出去。这个细节在对接后端、上报日志的时候尤其重要,不会把一堆内部字段暴露给外部。
Day25的代码量不算多,核心JS文件压缩后大概20K左右,没有依赖任何东西,拷到任意HTML文件里都能跑。对我个人而言,它最大的价值是再次验证了一个观点:很多时候我们以为需要重型框架才能解决的问题,实际上用基础技术加明确的设计约束就能做到。希望这篇记录能给你在做表格类功能时提供一个不同的参考思路。