上一篇把增删行、取值、遍历、简单样式切换这些基础操作过了一遍,代码单独拿出来跑都没问题。但只要把这批 jQuery table 操作放进真实的后台页面,组合起来用,第一天就会冒出一堆问题:全选之后一筛选,半选状态不对了;点表头排完序,行内编辑的输入框丢了焦点;合并单元格的表删掉中间一行,整张表错位半格。这些都不是 jQuery 的锅,是操作顺序、DOM 复用方式和数据来源没设计好。
这篇接着往下写,聚焦的是"表格从只能看变成能改之后"的那一层。适合已经写过至少一个带勾选、排序、行内编辑的列表页,但被各种状态同步折磨过的前端同学;也适合刚接手一个祖传表格组件、准备重构又不敢动的人。下面提到的方案都在 jQuery 1.9 到 3.x 区间验证过,跟具体框架无关,原生 JS 混着写也能用。
1. 表格进入"可编辑"阶段后暴露的三类连锁问题
基础操作阶段,表格是只读的,展示什么就是什么,逻辑是单向的。一旦允许用户勾选、排序、编辑、删除,表格就变成一个有多份状态副本的系统:DOM 是一份,jQuery 的 data 缓存是一份,你业务里的数组又是一份。三份状态只要有一处没同步,用户看到的就是错的。
第一类问题是状态归属混乱。比如勾选状态到底存在哪里?很多人直接读$('input:checked'),这看起来很省事,但它默认了"DOM 就是唯一数据源"。一旦你做了分页、筛选、虚拟滚动,DOM 里只挂着一部分数据,读出来的勾选集合就是不完整的。正确的做法是业务数据里维护一个selectedIds集合,DOM 只是它的投影,勾选时改集合,重渲染时按集合回填。
第二类问题是节点生命周期被误判。jQuery 里append、prepend、detach移动的是同一个 DOM 节点,节点上通过.data()存的值、通过.on()绑的直绑事件都还在;而html()、replaceWith()、remove()之后重建出来的是新节点,一切归零。排序、筛选、分页这些操作到底会打断哪种状态,取决于你用的是移动还是重建。这条分界线没搞清楚,就会出现"排完序按钮点不动了"这种玄学 bug。
第三类问题是读写的时机错位。表格里最常见的性能塌方不是选择器写得慢,而是读布局和写布局交替进行。你在循环里先读一次offsetHeight再改一次style,浏览器就被迫同步布局一次,一千行下来就是几千次强制回流。批量操作必须把读和写分成两段,中间不夹任何 DOM 修改。
提示:判断一段表格代码有没有隐患,有个土办法——把这段代码连续执行两遍,看结果是否一致。行内编辑、全选半选、合并单元格这三块,绝大多数 bug 在第二次执行时就会现形。
这三类问题在后面几节里会反复出现,因为它们不是孤立的技术点,而是同一套状态管理思路在不同功能上的投影。理解了这个前提,下面的具体写法才有意义,否则只是抄了几段代码,换个场景又不会了。
1.1 为什么这篇不按"功能"分节而是按"故障模式"分节
按功能讲,每节都是"怎么做全选""怎么做排序",读起来很顺,但学完还是不会排错。因为真实项目里的问题从来不按功能划分,它是"我勾了三行,排了个序,然后删了一行,勾选没了"这种混合场景。所以这篇的组织方式是按故障模式来:先讲状态同步,再讲节点复用,再讲取值路径,最后讲性能与排查。每节都会带上它最容易牵连的其他功能。
2. 全选、半选与禁用行的三态联动
全选框的indeterminate是表格交互里最容易被忽略的一个属性。它不是一个 HTML 属性,而是一个 DOM 属性,只能通过element.indeterminate = true或者 jQuery 的.prop('indeterminate', true)来设置,用.attr()完全无效。而且它和checked是两套独立的状态:一个半选的复选框,视觉上是一条横杠,但.is(':checked')返回false,$(':indeterminate')能选中它但很少有人这么写。
这带来一个必须处理的分支:用户点击全选框的时候,如果它当前是半选状态,浏览器会先把它置成checked = true并触发change,你的处理函数里读到的this.checked是true,语义上正好是"从部分选中变成全选",符合直觉。所以只要在change里无条件按this.checked全量同步行复选框,再回头重算一次全选框状态,逻辑就闭环了。
var $grid = $('#grid'); function syncCheckState() { var $boxes = $grid.find('tbody .js-row-check').not(':disabled'); var total = $boxes.length; var checked = $boxes.filter(':checked').length; $('#checkAll') .prop('checked', total > 0 && checked === total) .prop('indeterminate', checked > 0 && checked < total); } $grid.on('change', '.js-row-check', syncCheckState); $('#checkAll').on('change', function () { var on = this.checked; $grid.find('tbody .js-row-check').not(':disabled').prop('checked', on); syncCheckState(); });这段代码里有三个细节值得单独说。第一,事件用委托绑在$grid上,后面动态插入的行自动生效,不需要每次插行都重新绑。第二,not(':disabled')把禁用行排除在计数之外,否则只要有一行禁用,全选框永远到不了"全选"状态。第三,syncCheckState在change处理函数末尾又被调用了一次,这一步不能省,因为prop('checked', true)不会触发change事件,半选状态不会自动归位。
2.1 筛选隐藏的行算不算"全选范围内"
这是产品需求层面必须先问清楚的问题。常见的两种口径:一是"全选只针对当前可见行",筛选后全选,隐藏的行保持原来的勾选状态;二是"全选针对所有数据",筛选只是视图层的过滤。两种做法代码差别不大,但数据结构完全不同。
第一种做法可以直接读 DOM,用:visible过滤,但:visible在大表上很慢,因为它要逐个读offsetWidth。更实用的方式是自己维护一个filtered类,筛选时给行加减这个类,计数时用.not('.is-filtered'),一次选择器就搞定,没有布局读取。
第二种做法必须回到数据层,用一个selectedIds对象或Set,行上的 checkbox 只是它的渲染结果。这时候syncCheckState要改成先算数据里的交集,再回填 DOM。
// 数据驱动版本 var selected = {}; // { id: true } function renderRows(rows) { var html = rows.map(function (r) { var on = selected[r.id] ? ' checked' : ''; return '<tr>var wasIndeterminate = false; $('#checkAll').on('mousedown', function () { wasIndeterminate = this.indeterminate; }).on('change', function () { var on = wasIndeterminate ? false : this.checked; $grid.find('tbody .js-row-check').not(':disabled').prop('checked', on); syncCheckState(); });用mousedown记录点击前的状态,因为change触发时状态已经被浏览器改了。这个技巧在键盘操作(空格键切换)下不生效,需要再补一个keydown处理。实际项目里,我建议直接用浏览器默认语义,别自己造规则,否则用户在两套系统之间切换会很不适应。
3. 排序与筛选叠加导致的行序丢失
表格排序的经典错误写法是这样的:读所有行,用数组的sort排完,然后$tbody.html('')清空再逐行append回去,或者干脆拼字符串塞innerHTML。第一种写法把行从 DOM 里摘了出来,但因为你没有用detach,如果中途走了remove,节点上的.data()和直绑事件全没了。第二种写法更彻底,所有状态直接归零。
正确的做法是把"排序"理解成节点搬家,不是重建。行节点本身不动,只是顺序变了,用detach()取出来、排好、再一次性插回去。
function sortTable($tbody, colIndex, type, dir) { var rows = $tbody.children('tr').detach().get(); // detach 保留 data 与事件 rows.sort(function (a, b) { var va = readSortValue($(a), colIndex, type); var vb = readSortValue($(b), colIndex, type); var r; if (type === 'string') { r = String(va).localeCompare(String(vb), 'zh-Hans-CN', { numeric: true }); } else { r = va - vb; } // 相等时回退到原始序号,保证多列排序和重复值稳定 if (r === 0) { r = (parseInt($(a).data('origIndex'), 10) || 0) - (parseInt($(b).data('origIndex'), 10) || 0); } return dir === 'desc' ? -r : r; }); // 用 DocumentFragment 一次性插入,避免 N 次布局 var frag = document.createDocumentFragment(); rows.forEach(function (tr) { frag.appendChild(tr); }); $tbody[0].appendChild(frag); }这里有几个决策需要解释。为什么用detach而不是remove:remove会顺带清理 jQuery 缓存在该节点上的数据和事件,detach不会。虽然detach之后节点已经离开文档,视觉上看不见,但中间这段时间如果发生异常,节点会彻底消失在内存里,所以要在try/finally里保证插回。
为什么要回退到原始序号:Array.prototype.sort在现代引擎里是稳定排序,但它的稳定性依赖比较器返回 0 时保持原序,而你的数组顺序是被上一次排序改过的。用户点第二次升序再点降序,相同值的行就会来回跳。存一个>function readSortValue($tr, colIndex, type) { var raw = $.trim($tr.children('td').eq(colIndex).text()); if (type === 'number') { var n = parseFloat(raw.replace(/[,\s¥$€%]/g, '')); return isNaN(n) ? -Infinity : n; } if (type === 'date') { // 统一成 yyyy/MM/dd 再解析,各浏览器兼容性最好 var t = Date.parse(raw.replace(/-/g, '/')); return isNaN(t) ? 0 : t; } return raw; }
百分号这里有个坑:85%和0.85在同一个列里混着出现,去掉%之后一个变成 85,一个还是 0.85,排序结果完全错乱。这类列必须在数据层统一单位,展示层再做格式化,不能指望排序函数现场猜。
日期同理。2024-03-05这种 ISO 格式字符串可以直接比较大小,因为字典序等于时间序;但2024/3/5就不行,2024/10/1会排在2024/3/5前面,因为1比3小。要么在渲染时就输出补零的 ISO 格式,要么排序时统一转时间戳。
还有一个隐蔽的坑:单元格里有嵌套标签,text()会把所有后代文本拼在一起,包括徽标里的"新"字、按钮里的图标文字。取值要用>function tableToMatrix($table, fillWithRef) { var matrix = []; $table.find('tr').each(function (r) { matrix[r] = matrix[r] || []; var c = 0; $(this).children('td, th').each(function () { while (matrix[r][c] !== undefined) c++; // 跳过被上方 rowspan 占住的位置 var rs = parseInt(this.getAttribute('rowspan') || 1, 10); var cs = parseInt(this.getAttribute('colspan') || 1, 10); var val = fillWithRef ? this : $.trim($(this).text()); for (var i = 0; i < rs; i++) { matrix[r + i] = matrix[r + i] || []; for (var j = 0; j < cs; j++) { matrix[r + i][c + j] = val; } } c += cs; }); }); return matrix; }
while (matrix[r][c] !== undefined) c++这一行是整个函数的核心:它跳过被上面行rowspan占掉的格子。没有这一步,第二行的第一个td会从第 0 列开始填,覆盖掉上面那个跨行单元格的信息,后面的列全部错位。
fillWithRef参数是为了区分两种用途:传true时矩阵里存的是 DOM 引用,用于给单元格绑定事件、加高亮;传false时存文本,用于导出和统计。同一个函数两种用法,比写两份代码维护成本低。
4.2 删行时同步收缩 rowspan
第二个方向是不改变渲染方式,只在删行时修正rowspan。思路是:删之前先定位这一行里有哪些单元格是"跨行覆盖到本行"的,把这些单元格的rowspan减一;如果某行同时是某个跨行单元格的起始行,那么删掉之后要把这个单元格整体挪到下一行去。
function removeRowSafely($tr) { var $tbody = $tr.parent(); var rowIndex = $tbody.children('tr').index($tr); // 找出所有会受影响的跨行单元格:起始行号 <= rowIndex < 起始行号 + rowspan $tbody.children('tr').each(function (r) { if (r >= rowIndex) return; // 只处理上方的行 $(this).children('td[rowspan]').each(function () { var rs = parseInt($(this).attr('rowspan'), 10); if (r + rs > rowIndex) { if (r + rs - 1 === rowIndex && rs === 1) return; if (r + rs - 1 === rowIndex && rs > 1) { // 本行是这个跨行单元格的最后一行,收缩即可 $(this).attr('rowspan', rs - 1); } else if (rs > 1) { $(this).attr('rowspan', rs - 1); } } }); }); $tr.remove(); renumberRows($tbody); }上面这段代码里判断分支写得比较啰嗦,实际项目里可以简化成"只要r < rowIndex < r + rs,就把rowspan减一",因为删掉的这一行必然在它的覆盖范围内。真正麻烦的是起始行被删的情况:那个td跟着行一起消失了,下面的行需要一个新的单元格来补位,否则整体左移一列。这种场景我建议直接放弃手动修补,回到数据层重渲染整表。
这里就引出我在实际项目里的一条铁律:带合并单元格的可编辑表格,不要做局部 DOM 更新,任何删改都走"改数据 + 全量重渲染"。合并单元格的数量通常很少(分组表头、分类列),全量重渲染的成本可以接受,而手动修补rowspan的代码复杂度和出错概率远超收益。
如果表格数据量确实大到不能重渲染,那就说明它不该用rowspan来表达层级关系。改成每行都重复父级字段、配合 CSS 隐藏重复值,或者干脆用两级表头加分组行,从数据模型上绕开合并单元格。
4.3 合并单元格里取值的正确姿势
不管用哪种方案,正文取值都有一条必须遵守的规则:不要用列索引定位字段,用 真正容易出问题的是退出编辑态的绑定。 只要表格要发生结构性变化(排序、筛选、分页、重渲染),当前正在编辑的单元格必须先提交或取消。原因很直接: 做法是在这些操作入口统一调一次 更进一步,如果提交涉及异步请求(比如调接口保存),要让它变成同步阻塞的语义——要么禁用排序按钮直到保存回调完成,要么在提交时把值先写回本地数据,请求失败再回滚。后者体验更好,但需要一套完整的乐观更新和回滚逻辑。 带勾选的表格里,点击行经常会触发行选中(有些设计是点击行任意位置都能切换勾选)。这时候点单元格进入编辑,会连带把勾选状态翻掉,用户很难受。 消解的办法是把两种手势分开:勾选只响应复选框本身和行首的特定区域,单元格点击只进入编辑。代码上就是事件处理里判断 注意这里用了 行数上去之后,性能问题的来源和很多人想象的不一样。选择器本身不是瓶颈, 先看渲染。逐行拼接的写法是这样的: 每次 这个技巧的适用边界要说清楚:它优化的是脚本执行期间的布局开销,对网络请求、图片加载引起的重排无效;而且如果表格本身有滚动位置、焦点状态, 有个经验阈值可以参考:单页渲染行数在 500 以内,全量渲染加委托事件完全够用;500 到 2000 行之间,取决于单元格复杂度,纯文本表格能撑住,带图标、进度条、多选控件的会明显卡顿;超过 2000 行,别犹豫,分页或者可视区渲染(虚拟滚动)选一个。 分页的实现成本最低,但它改变了交互语义,勾选跨页要保持、排序要作用于全量数据而不是当前页、序号要连续,这些都要在数据层解决。虚拟滚动体验最好但坑最多,尤其是行高不固定、表格有合并单元格的情况下,实现难度陡增。 我的取舍是:后台管理类页面优先分页,面向终端用户的展示型页面考虑虚拟滚动。前者用户习惯了翻页,实现简单不易出错;后者用户更在意流畅度,值得投入。带合并单元格的表格几乎只能分页,因为虚拟滚动的回收机制和 表格做到最后基本都会加导出功能,这块看起来最没技术含量,实际上坑很集中。 第一是中文乱码。Excel 打开 UTF-8 编码的 CSV 默认按本地编码解析,中文全变问号。解决办法是在内容最前面加一个 BOM 头 第二是内容里含逗号、引号、换行。字段必须用双引号包裹,内部的引号要翻倍转义,否则一列数据会被拆成好几列。 第三是公式注入。如果单元格内容以 导出用的数据源应该是 下面这些是我在不同项目里反复踩到、文档里基本不提、但只要做表格就绕不开的点。每一条都配了判断方法和处理方式,可以直接当排查清单用。 手写的 HTML 里如果没有 正确的选择器是 判断方法很简单:在控制台执行 jQuery 1.6 之后, 统一规则:布尔类状态一律用 替代方案是给每行一个稳定的 排序、删行、筛选之后都要调一次。这一步漏掉,用户看到的就是"删了第三行,序号变成 1、2、4、5"。 在 处理方式有两种:一是在容器显示之后再初始化表格,用 这个轮询看着很土,但在容器显示时机不确定的场景下最可靠。要注意加一个最大重试次数,防止容器永远不显示导致死循环。 动态加行的场景里,很多人用 建议模板行只用 表格放在 解决办法是在输入框的 如果不方便全局拦,至少要在行内编辑的输入框上处理,并在按回车时显式调用提交逻辑,保证行为一致。 最后分享一个我在实际项目里养成的小习惯:给表格组件的每个对外方法都加一个"重置视图"的入口,内部把所有行内编辑态取消、所有临时类名清掉、所有排序标记归位。听起来有点笨,但它能兜住 90% 因为操作顺序导致的脏状态,尤其是在用户快速连点表头、边编辑边筛选这类真实操作路径下,比事后一个个排查划算得多。><tr>var EDITABLE = 'td[data-editable]'; $grid.on('click', EDITABLE, function (e) { var $td = $(this); if ($td.find('.cell-editor').length) return; // 已在编辑态,忽略重复点击 if ($(e.target).is('input, a, button')) return; // 点到内部控件不进入编辑 var oldHtml = $td.html(); var oldVal = $.trim($td.text()); $td.data({ oldHtml: oldHtml, oldVal: oldVal, phase: 'edit' }); var $input = $('<input type="text" class="cell-editor">').val(oldVal); $td.empty().append($input); $input[0].focus(); $input[0].select(); });if ($td.find('.cell-editor').length) return;这一句防的是"编辑态下再点自己",没有它会不断把输入框替换掉,光标和已输入的内容全丢。$(e.target).is('input, a, button')防的是单元格里有链接或者小图标按钮时误触发编辑,这个判断条件要按实际单元格内容补全。blur事件不冒泡,jQuery 的委托对它支持有限(内部做了兼容但行为不完全一致),所以监听失焦要用focusout,它是blur的冒泡版本。$grid.on('focusout', '.cell-editor', function () { commitCell($(this).closest('td')); }); $grid.on('keydown', '.cell-editor', function (e) { if (e.keyCode === 13) { // Enter 提交 e.preventDefault(); $(this).blur(); } else if (e.keyCode === 27) { // Esc 取消 e.preventDefault(); cancelCell($(this).closest('td')); } }); function commitCell($td) { if ($td.data('phase') !== 'edit') return; // 防止重复提交 var $input = $td.find('.cell-editor'); if (!$input.length) return; var val = $.trim($input.val()); var old = $td.data('oldVal'); $td.data('phase', 'display'); if (!validate(val, $td)) { // 校验失败,回到编辑态 $td.data('phase', 'edit'); $input[0].focus(); return; } $td.text(val); if (val !== old) { $grid.trigger('cell:change', [$td, old, val]); } } function cancelCell($td) { $td.data('phase', 'display').html($td.data('oldHtml')); }phase这个标记位不能省。focusout和blur()会互相触发:用户在编辑态按 Esc,代码里调用了cancelCell直接还原 DOM,但还原动作本身会让输入框从文档里移除,从而触发一次focusout,如果没有phase判断,就会再走一次提交逻辑,把刚取消的值又写回去。这种"操作引发事件、事件又触发操作"的循环是行内编辑最常见的 bug 源。5.1 排序、筛选、重渲染之前必须先收口编辑态
detach会把输入框一起带走,焦点丢失,focusout触发时机变得不可预测,用户刚输入的内容可能悄无声息地消失。flushEditing():function flushEditing() { $grid.find('.cell-editor').each(function () { commitCell($(this).closest('td')); }); }5.2 编辑态与行选中的冲突消解
e.target:$grid.on('click', 'tbody tr', function (e) { if ($(e.target).closest('td').is(EDITABLE)) return; // 编辑单元格不切勾选 if ($(e.target).is('input[type=checkbox]')) return; // 复选框自己处理 var $cb = $(this).find('.js-row-check').not(':disabled'); $cb.prop('checked', !$cb.prop('checked')).trigger('change'); });trigger('change')手动触发,因为prop赋值不会触发原生change事件,不手动触发的话,全选框的半选状态就不会更新。这个细节在使用数据驱动方案时容易被忽略,表现就是"点了行,行勾上了,但全选框没反应"。6. 千行级表格的批量操作与渲染开销
$('#grid tbody tr')在千行规模下也就几毫秒。真正拖慢的是三类操作:逐行append导致的多次布局、读写交替导致的强制同步布局、以及每次操作都重新构造大量 jQuery 对象。// 慢:N 次 DOM 插入 rows.forEach(function (r) { $tbody.append('<tr><td>' + r.name + '</td></tr>'); });append都会让浏览器重新计算布局,一千行就是一千次。改成字符串拼接加一次html(),或者用DocumentFragment,能把这部分开销压到几乎为零。// 快:一次解析,一次插入 var html = rows.map(function (r) { return '<tr>function batchUpdate(fn) { var $tbody = $grid.find('tbody').detach(); // 离开文档,无布局开销 try { fn($tbody); // 期间随便改 } finally { $grid[0].appendChild($tbody[0]); // 保证一定插回去 } }try/finally不是形式主义。detach之后节点在文档外,如果fn里抛异常,节点就彻底丢了,表格直接空白,而且没有任何报错提示指向这里。我踩过一次,排查了半小时才发现是某个格式化函数对空值抛了错。detach期间会丢失,插回后要手动恢复。行数在五百以内,这个优化带来的收益其实感知不到,不要为了优化而优化。6.2 什么时候该放弃全量渲染改成分页
rowspan天然冲突。6.3 CSV 导出时那几个不起眼但必踩的坑
\uFEFF。=、+、-、@开头,Excel 会当成公式执行,这在用户可控内容的场景下是真实风险。简单处理是在前面加一个单引号或者空格。function toCsvCell(v) { var s = String(v == null ? '' : v); if (/^[=+\-@]/.test(s)) s = "'" + s; // 防公式注入 return '"' + s.replace(/"/g, '""') + '"'; // 引号翻倍 } function exportCsv(matrix, filename) { var csv = matrix.map(function (row) { return row.map(toCsvCell).join(','); }).join('\r\n'); // 行分隔用 CRLF,兼容性最好 var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename || 'export.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); }tableToMatrix的结果,而不是直接读可见行——这样隐藏的列、合并单元格都能正确展开。revokeObjectURL加个延迟是为了兼容部分浏览器在下载启动前就释放 URL 导致下载失败的情况。7. 只在真机上才会暴露的几个细节
7.1 浏览器偷偷给你加了个 tbody
<tbody>,浏览器解析时会自动补一个。这导致$('#grid > tr')什么都选不到,$('#grid tr')又会把thead里的表头行一起选进来。表现就是"遍历行的时候莫名其妙多了一行,取到的数据全是表头文字"。$('#grid > tbody > tr')或者$grid.find('tbody').children('tr')。如果你的表格有多个tbody(用分组或者分页占位),要注意children只取第一层的,跨tbody的操作得用find('tbody > tr')。$('#grid').children().prop('tagName'),看看是不是多了个TBODY。7.2 attr 和 prop 在复选框上的分水岭
attr('checked')读的是 HTML 属性(初始值),prop('checked')读的是当前状态。用attr('checked', true)设置勾选,页面第一次渲染能生效,后续再改就没反应了,因为改的只是那个不会再生效的初始属性。prop,包括checked、disabled、selected、readonly;自定义数据用data;普通属性用attr。这条规则记住,能省掉大量莫名其妙的"设了没反应"。7.3 index() 在筛选和分组之后就不准了
$tr.index()返回的是它在同级兄弟中的位置,一旦中间插入了分组行、空状态行,或者你筛选时删掉了节点,索引就全乱了。用索引做业务映射是最脆弱的做法。>function renumberRows($tbody) { $tbody.children('tr').each(function (i) { $(this).children('[data-field="seq"]').text(i + 1); }); }7.4 隐藏容器里量不出表格宽度
display: none的容器里(未激活的 tab、未展开的折叠面板、弹窗未显示时),所有元素的offsetWidth都是 0。如果你的表格做了列宽自适应、固定表头对齐、列宽拖拽,这些计算在容器显示之前执行,结果全是 0,等显示出来就发现表头和内容对不齐。shown.bs.tab这类事件或者MutationObserver监听;二是初始化时先测量,测量不到就延迟到requestAnimationFrame里重试。function initWhenVisible($el, fn) { if ($el.is(':visible') && $el.width() > 0) { fn(); return; } requestAnimationFrame(function () { initWhenVisible($el, fn); }); }7.5 克隆行时 id 重复与事件丢失
$tr.clone()复制模板行。这里有两个坑:一是模板行上有id,克隆出来就是重复 id,document.getElementById只能拿到第一个,后面的行为全不可预测;二是clone()默认不带事件,clone(true)才带,但带事件往往又不是你想要的(因为闭包捕获的是原行的数据)。class,不用id;克隆之后手动重算所有需要唯一的标识;事件全部走委托,从根上就不依赖克隆时复制事件。7.6 回车键把整页表单提交了
<form>里,行内编辑的输入框按回车,浏览器会触发默认的表单提交,页面直接跳走。这个问题在测试环境往往发现不了,因为测试时很少真的按回车。keydown里preventDefault()。如果整个表单都不需要回车提交,可以直接在form上拦:$('#myForm').on('keydown', 'input', function (e) { if (e.keyCode === 13) e.preventDefault(); });