测试平台前端DOM实战:万行表格渲染、事件委托与动态表单
三年前接手这套数据库测试平台前端时,我做的第一件事是把项目里的 jquery 版本号翻出来看了一眼——1.8。那一刻心情有点复杂。这套平台的页面结构远比想象中复杂:集群节点树、上万行的用例表格、按用例类型动态拼出来的执行参数表单、SQL 编辑区、结果集左右对比面板,每一块都在大量直接操作 DOM。很多人一听到"DOM 操作"就默认这是老掉牙的话题,觉得上了组件化框架之后就再也不用碰 document.querySelector 了。真做过这类测试平台的人会笑一下:定制化需求一旦压下来,框架那层抽象经常兜不住,最后还是要伸手去摸真实的节点。
这篇内容聊的就是这套平台前端里 DOM 操作相关的实战经验。它适合三类人看:正在维护或改造类似测试平台的同学、想搞清楚"框架之外那一层到底怎么回事"的初中级前端、以及需要在前端做大量表格和树形交互的后端转前端开发者。下面不会讲 getElementById 有多少种写法那种教科书内容,全部围绕真实页面里会遇到的问题:怎么渲染一万行不卡、事件怎么绑才不炸、动态表单克隆出来为什么会互相干扰、第三方编辑器挂载失败该怎么查、以及一次真实的"点了没反应"是怎么定位到根的。
1. 为什么在这类测试平台上,DOM 基本功比框架 API 更值钱
1.1 平台前端的真实长相:表格、树、动态表单三件套
先把页面结构摊开说,不然聊技术选型都是空的。这套测试平台的核心页面大概就三类。第一类是用例管理页,一个巨大的表格,列有十几项,包括用例名、所属模块、SQL 类型、数据集、预期结果、最近执行状态、执行耗时。每行右边挂四个行内按钮:编辑、复制、执行、删除。顶部还有全选、批量执行、批量删除。这个表格在数据量大的项目里轻松过万行。
第二类是集群与节点管理页,一棵树。根是集群,往下是节点组,再往下是具体节点,每个节点上挂一个状态色点,点击节点右边联动展示详情面板。树节点要支持展开收起、搜索过滤后高亮命中路径、拖拽调整顺序。
第三类是执行配置弹层,一个动态表单。选了什么类型的用例,下面就出现什么输入项:可能是一组参数键值对、可能是一个 SQL 编辑器、可能是一组并发数和超时时间的数字输入。用户可以点"再加一行参数",也可以删掉某一行。
这三类页面有一个共同点:它们都是高度数据驱动的列表和层级结构,但又都有大量局部状态需要即时反馈——勾选、展开、悬停、临时编辑。这类"高频、局部、不需要持久化"的交互,恰好是直接操作 DOM 最划算的场景。
1.2 框架抽象兜不住的四个瞬间
我不反对上框架,这套平台后来也逐步引入了组件化方案。但有四个瞬间,你最终还是要跟真实节点打交道。
第一个瞬间是接第三方组件。SQL 编辑区用的编辑器有自己的 DOM 结构和事件体系,它不管你外面是 jQuery 还是什么框架,初始化时只认一个容器节点。容器什么时候存在、宽高是不是零、有没有被 display:none 的父元素包着,这些都得你自己盯。
第二个瞬间是结果集对比面板。左右两个表格,左边是基线结果,右边是本次执行结果,差异单元格要单独着色,行要按差异类型高亮,滚动要双向同步。这种逐单元格的粒度操作,用数据绑定去驱动会产生巨大的 diff 开销,直接拿到单元格节点改 class 反而干净利落。
第三个瞬间是大表格性能。上万行的表格如果完全交给数据层驱动重渲染,一次勾选全选就能让页面卡两秒。而如果只是切换行的 class,DOM 层的代价几乎可以忽略。
第四个瞬间是历史代码迁移期。老页面用 jQuery 写的,新组件是另一套方案,两者混在同一个 DOM 树里。这时候你必须清楚谁在管哪块节点,否则一个 remove() 就能让另一边的引用变成悬空指针。
1.3 一条判据:什么时候该直接碰 DOM,什么时候必须绕回数据层
这是我踩了多次坑之后总结出来的一条线,写成了表格,团队里新人上手时我直接甩给他们看。
| 操作类型 | 是否直接操作 DOM | 判断理由 |
|---|---|---|
| 行选中态、展开态切换 | 是 | 高频、临时、不入库,改 class 最快 |
| 列表数据增删改 | 否 | 必须回数据层,DOM 只是投影 |
| 单元格文本临时更新(如执行中状态) | 是 | 只影响一个节点,不必全表重渲染 |
| 表单提交值 | 视情况 | 受控场景走数据层,非受控可直接读 value |
| 第三方组件内部结构 | 严禁 | 版本升级即崩,只能调它的公开 API |
| 拖拽过程的视觉反馈 | 是 | 占位符、拖影都是纯视觉,不入数据 |
| 拖拽结束后的顺序落库 | 否 | 顺序是业务数据,必须回数据层 |
这条线背后其实就一句话:DOM 是数据的投影,凡是需要被记住、被同步、被持久化的东西,都归数据层管;凡是转瞬即逝的视觉反馈,交给 DOM。分不清这条线的人,写出来的代码要么满屏手动同步、要么状态和界面各说各话。
2. 用例列表页:上万行表格的渲染方式与性能账
2.1 字符串拼接、innerHTML 与 DocumentFragment 的实测差距
先说渲染。最初这个表格是这么写的:循环拼一个巨大的 HTML 字符串,最后一次 tbody.innerHTML = str。这种写法在几百行时看不出问题,上千行开始能感觉到明显停顿。
原因不复杂。字符串拼接本身不慢,慢在两件事:一是每次 innerHTML 赋值,浏览器都要重新解析这段 HTML、构建节点树、计算样式、布局;二是如果这个赋值发生在循环里,那就是 N 次完整的解析加布局,代价是灾难级的。即便只在循环外赋值一次,超大字符串的解析本身也有成本,而且这种方式绑不了事件——你只能在渲染完之后重新给每行绑,又是一轮开销。
我做过一轮简单的对比测试,在同一台机器、同样的行结构(每行六列加四个按钮)、同样的数据下,用三种方式渲染 5000 行:
| 渲染方式 | 大致耗时量级 | 主要问题 |
|---|---|---|
| 循环内逐行 appendChild | 明显偏慢 | 每行都触发一次布局计算 |
| 拼字符串后一次性 innerHTML | 中等 | 解析成本高,事件需二次绑定 |
| DocumentFragment 批量插入 | 最快 | 一次插入,一次布局 |
具体数字会随机器和浏览器版本浮动,我这里不写绝对值,因为环境一变就没参考意义了,但相对关系是稳定的:DocumentFragment 明显领先。它的原理是把所有节点先挂到一个游离的文档片段上,这个片段不在渲染树里,往里加节点不会触发任何布局;最后一次性把整个片段插进真实 DOM,只触发一次重排。
实际代码大概长这样:
function renderCaseRows(tbody, rows) { const frag = document.createDocumentFragment(); for (let i = 0; i < rows.length; i++) { const item = rows[i]; const tr = document.createElement('tr'); tr.dataset.caseId = item.id; tr.dataset.status = item.status; tr.innerHTML = '<td class="col-name">' + escapeHtml(item.name) + '</td>' + '<td class="col-module">' + escapeHtml(item.module) + '</td>' + '<td class="col-status">' + statusText(item.status) + '</td>' + '<td class="col-ops">' + '<button>tbody.addEventListener('click', function (e) { const btn = e.target.closest('button[data-action]'); if (!btn || !tbody.contains(btn)) return; const tr = btn.closest('tr'); const caseId = tr && tr.dataset.caseId; if (!caseId) return; const action = btn.dataset.action; switch (action) { case 'edit': openEditDialog(caseId); break; case 'copy': duplicateCase(caseId); break; case 'run': runCase(caseId, btn); break; case 'del': confirmDelete(caseId); break; } });这段代码有三个关键点。第一是 closest,它从实际点击的元素向上找最近的匹配祖先,这样即使用户点的是按钮里的图标或者文字,也能正确命中。第二是 tbody.contains(btn) 这个校验,看着多余,其实是在防一类边界情况:如果按钮被移出 tbody 又重新插入,或者有别的浮层复用了同样的>function updateRowStatus(caseId, status, costMs) { const tr = tbody.querySelector('tr[data-case-id="' + caseId + '"]'); if (!tr) return; tr.dataset.status = status; const statusCell = tr.querySelector('.col-status'); if (statusCell) { statusCell.textContent = statusText(status); statusCell.className = 'col-status status-' + status; } const costCell = tr.querySelector('.col-cost'); if (costCell) { costCell.textContent = costMs + ' ms'; } }
这里只动了两个单元格的文本和 class,浏览器需要重算的范围极小,用户完全无感。这就是前面那张表里说的"临时状态可以走 DOM"的典型例子——执行状态最终当然也要回到数据层,但界面上的即时反馈没必要等数据层驱动。
注意:querySelector 的属性选择器里拼接 caseId 时,如果 id 可能包含引号或特殊字符,会破坏选择器语法。稳妥的做法是把 id 再做一层白名单校验(只允许数字和字母),或者干脆维护一个 id 到节点的映射表。
2.4 表格重排之后丢失的三样东西
这一节是纯经验,文档里基本不会写。表格一旦发生重排,有三样东西会悄悄消失,而且这三样都是测试人员高频使用的。
第一样是滚动位置。用户滚到第五百行,点了个批量操作,代码把 tbody 清空重渲染,滚动条瞬间回到顶部。用户骂人的心都有了。解决方式是在重渲染前记下 scrollTop,渲染后还原。但要注意,如果新数据量比原来少,还原时要判断目标值是否超过了新的最大滚动高度,否则会卡在一个尴尬的位置。
第二样是勾选状态。批量操作最怕的就是勾选丢失。如果勾选状态只存在 DOM 的 class 上,重渲染后必然全丢。所以勾选必须以 caseId 集合的形式存在数据层,渲染时根据集合回填 class。这也是前面那条判据的具体应用:勾选是要被记住的,所以它属于数据层,DOM 上的选中样式只是投影。
第三样是焦点。如果用户正在某个单元格上编辑,重排之后焦点会落到 body 上,输入法直接断掉。处理方式是在重排前记录当前聚焦元素的标识,重排后重新 focus。但更根本的做法是:编辑态不要触发整表重排,编辑就是编辑,跟列表刷新解耦。
3. 集群节点树与拖拽排序:层级操作和高频事件的边界
3.1 树形结构里找节点:closest 够用就别写递归
树形结构的 DOM 一般是嵌套的 ul/li,或者嵌套的 div,每个节点上带一个>treeRoot.addEventListener('click', function (e) { const nodeEl = e.target.closest('[data-node-id]'); if (!nodeEl || !treeRoot.contains(nodeEl)) return; const nodeId = nodeEl.dataset.nodeId; const isLeaf = nodeEl.dataset.leaf === '1'; const expanded = nodeEl.classList.contains('is-expanded'); if (!isLeaf) { nodeEl.classList.toggle('is-expanded'); toggleChildren(nodeEl, !expanded); } selectNode(nodeId); });
closest 从点击位置向上找到最近一个带>function expandAncestors(nodeEl, rootEl) { let p = nodeEl.parentElement; while (p && p !== rootEl) { if (p.dataset && p.dataset.nodeId) { p.classList.add('is-expanded'); } p = p.parentElement; } }
这段代码看着朴素,但它比任何递归写法都好读,而且不会栈溢出。
3.2 拖拽排序:三个事件、一个占位符、一个插入位置
集群节点和用例都有拖拽排序需求。原生拖拽看起来简单,实际要处理的东西不少,核心就三个事件。
dragstart 里做两件事:给被拖元素加一个 dragging class 做视觉标识,同时把它的标识写进 dataTransfer。
dragover 里必须调用 preventDefault,否则 drop 事件根本不会触发——这是新手最常卡住的地方,代码看着都对,就是拖不动。同时要在这里计算插入位置:拿到鼠标当前所在的那一行,判断鼠标是在它的上半部分还是下半部分,据此决定占位符插在它前面还是后面。
drop 里做收尾:根据占位符位置把节点实际移动过去,然后清理所有临时状态。
list.addEventListener('dragover', function (e) { e.preventDefault(); const dragging = list.querySelector('.dragging'); if (!dragging) return; const overEl = e.target.closest('tr[data-case-id]'); if (!overEl || overEl === dragging) return; const rect = overEl.getBoundingClientRect(); const isAfter = (e.clientY - rect.top) > rect.height / 2; if (isAfter) { overEl.parentNode.insertBefore(dragging, overEl.nextSibling); } else { overEl.parentNode.insertBefore(dragging, overEl); } });注意这里没有用占位符节点,而是直接移动被拖元素本身。这种做法在简单列表里够用,视觉上也直观。但有个副作用:dragover 触发极其频繁(鼠标每移动一像素都可能触发),如果每次都在里面做重计算或者触发数据层排序,页面会明显卡顿。所以这里规定死一条:dragover 里只允许做 DOM 位置调整,不允许碰数据层。
3.3 顺序到底谁说了算:DOM 先行还是数据先行
这是拖拽里最容易出架构问题的地方。如果 dragover 期间就直接把数据层的顺序改了,那么用户拖了一半松手取消,数据就脏了。如果 drop 之后才改数据层,中间那段时间数据和 DOM 是对不上的。
我的做法是分两段。拖拽过程中,DOM 随便动,数据层一动不动,同时用一个内部标记标识"正在拖拽中",这个标记期间禁止其他任何刷新逻辑介入。drop 事件触发后,先扫描一遍 DOM 当前的顺序,生成新的 id 数组,再拿这个数组去调后端接口。接口成功,什么都不用做,DOM 已经是对的;接口失败,用旧顺序重新渲染一次,把 DOM 掰回去。
list.addEventListener('drop', function (e) { e.preventDefault(); const dragging = list.querySelector('.dragging'); if (dragging) dragging.classList.remove('dragging'); const newOrder = Array.from(list.querySelectorAll('tr[data-case-id]')) .map(function (tr) { return tr.dataset.caseId; }); saveOrder(newOrder) .then(function () { isDragging = false; showToast('顺序已保存'); }) .catch(function () { isDragging = false; reloadList(); }); });这套流程的好处是失败可回滚,坏处是用户会看到一次"闪回"。如果后端接口很快,闪回几乎察觉不到;如果接口慢,可以考虑先本地写入,失败时再提示并回滚。具体选哪种,看你们对顺序准确性的容忍度。
还有一个坑必须提:如果列表是按分页加载的,拖拽到当前页之外是不合法的,drop 逻辑里要判断拖拽源和目标是同一页,跨页的拖拽直接拒绝并给出提示。这个细节不做,用户把用例拖到页面边缘时会以为排序成功了,刷新一看全白干。
4. 动态表单与参数编辑:克隆、模板与节点回收
4.1 cloneNode 的深拷贝陷阱:id 重复、值残留、事件丢失
执行配置弹层里,用户可以动态增加参数行,一行是一个键输入加一个值输入加一个删除按钮。最开始我用 cloneNode(true) 克隆一个隐藏的模板行,用了没多久就出了三个问题。
第一个问题是 id 重复。模板行上如果写了 id,克隆出来的每一行都带着同一个 id,页面上一下子出现好几个相同 id 的元素,label 的 for 指向直接错乱,用 getElementById 拿到的永远是第一个。解决方式很干脆:模板里根本不要写 id,全部用 class 和>function addParamRow() { const row = tplRow.cloneNode(true); row.classList.remove('is-hidden'); row.querySelectorAll('input').forEach(function (input) { if (input.type === 'checkbox' || input.type === 'radio') { input.checked = false; } else { input.value = ''; } }); paramContainer.appendChild(row); return row; }
第三个问题最隐蔽:事件丢失。有些同事习惯在克隆前先把模板行的事件绑好,然后指望克隆出来的行带着事件。但 cloneNode 不会拷贝通过 addEventListener 绑的监听器,那些绑定在模板上的监听器,克隆出来一个都没有。所以事件必须绑在容器上做委托,或者克隆之后单独绑一次。
4.2 模板管理:用 template 标签,别用 display:none 的隐藏行
早期我用的是一个真实的 table 行,加上 display:none 藏在页面角落,克隆时把 class 去掉。这种做法能用,但不干净:这一行会参与布局计算(虽然不可见),会被全选逻辑误扫到,也会在打印或者导出时冒出来。
后来统一改成了 HTML 的 template 标签。浏览器会把它里面的内容当作惰性片段处理,不渲染、不参与布局、不会被任何选择器扫到,取的时候通过 content 属性拿:
<template id="tpl-param-row"> <div class="param-row"> <input type="text" class="param-key" placeholder="参数名"> <input type="text" class="param-val" placeholder="参数值"> <button type="button" class="param-del">删除</button> </div> </template>const tpl = document.getElementById('tpl-param-row'); function createParamRow() { const row = tpl.content.firstElementChild.cloneNode(true); return row; }这样干净很多,模板和真实列表彻底隔离。缺点是 template 的 content 里取子节点要注意 firstElementChild,直接 firstChild 可能拿到换行产生的文本节点。
4.3 内存泄漏:节点被移除了,监听器还活着
这是动态表单里最容易被忽略的地方。用户删掉一行参数,代码调 row.remove(),视觉上确实没了。但如果当初给这一行的输入框单独绑过监听器,而这些监听器里又闭包引用了外部的数据结构(比如整个参数对象、整个表格实例),那么这些引用会一直挂在那个已经脱离文档的节点上,垃圾回收不掉。
单次泄漏微不足道,但用户在一个弹层里反复增删几十行、再反复打开关闭弹层几十次,内存曲线就明显往上走了。表现出来就是页面用久了越来越卡,刷新一下又好了。
排查这个问题的方法很直接:在浏览器开发者工具的控制台里,选中那个元素,用 getEventListeners 看它身上挂了什么,再看这些回调里引用了什么。如果是委托在容器上的监听器,本来就不需要清理;如果是绑在行内元素上的,删除时必须配对地解绑。
function removeParamRow(row) { const inputs = row.querySelectorAll('input'); inputs.forEach(function (input) { input.oninput = null; input.onchange = null; }); row.remove(); }如果一开始就用事件委托绑在 paramContainer 上,这个清理动作完全不需要。所以我的建议很明确:动态增删的元素,事件一律绑在稳定的父容器上。这一条能省掉九成的泄漏排查工作。
5. 编辑区与结果对比面板:和第三方组件分权
5.1 挂载时机:容器没有尺寸,组件初始化必炸
SQL 编辑区用的第三方编辑器,初始化时需要读取容器的宽高来算行高和光标位置。如果容器此时还没插入文档,或者父级是 display:none 的弹层,宽高读出来是零,组件就会以错误尺寸初始化。用户第一次看到的现象通常是:编辑器渲染出来了,但只有一条缝那么高,或者光标位置完全错位,输入时光标跑到文字上面。
这类问题的根因不在组件,在挂载顺序。常见的错误写法是把初始化代码写在弹层隐藏的时候就执行。正确顺序是:先让弹层显示出来(哪怕是 visibility:hidden 只占位),等布局完成,下一帧再初始化。
function openSqlDialog() { dialog.classList.remove('is-hidden'); dialog.style.visibility = 'hidden'; requestAnimationFrame(function () { dialog.style.visibility = 'visible'; if (!editor) { editor = createEditor(dialog.querySelector('.editor-host')); } editor.refresh(); }); }用 requestAnimationFrame 是为了等一帧,让样式和布局都算完。这不是玄学,是很实在的一步:同步代码块里刚改完的 class,浏览器还没做布局计算,此时读 clientHeight 拿到的可能是旧值。
5.2 滚动同步:一个标志位解决循环触发
结果对比面板的左右两个表格要同步滚动。最直接的写法是给左边绑 scroll,把 scrollTop 赋给右边;再给右边绑 scroll,把 scrollTop 赋给左边。写完一跑,页面开始疯狂抖动,因为 A 的赋值触发了 B 的 scroll 事件,B 又把值写回 A,进入死循环。
解法是加一个锁标志:
let syncLock = false; leftBody.addEventListener('scroll', function () { if (syncLock) return; syncLock = true; rightBody.scrollTop = leftBody.scrollTop; rightBody.scrollLeft = leftBody.scrollLeft; requestAnimationFrame(function () { syncLock = false; }); }); rightBody.addEventListener('scroll', function () { if (syncLock) return; syncLock = true; leftBody.scrollTop = rightBody.scrollTop; leftBody.scrollLeft = rightBody.scrollLeft; requestAnimationFrame(function () { syncLock = false; }); });锁为什么要用 requestAnimationFrame 解锁,而不是立刻解?因为滚动事件的触发是异步的,赋值之后浏览器会在下一帧才派发目标元素的 scroll 事件。如果同步代码里立刻把锁放开,等事件真正派发时锁已经不存在了,死循环照旧。放到下一帧释放,正好卡在事件派发之后。
5.3 浮层、层级与事件冒泡的三方混战
平台里到处都是浮层:字段筛选下拉、行操作菜单、执行进度提示。这些浮层跟表格、跟弹层、跟第三方编辑器交织在一起时,最常出问题的就是 z-index 和事件冒泡。
z-index 的问题不在于谁大谁小,而在于层叠上下文。一个元素如果父级设置了 transform、filter 或者 opacity 小于 1,它就会创建新的层叠上下文,内部的 z-index 再大也跳不出父级这个"盒子"。所以经常出现的情况是:弹层的 z-index 写了 9999,结果还是被一个 z-index 只有 10 的元素盖住,原因是那个 10 的元素的祖先链上有一个 transform。
排查方法很土但很有效:在开发者工具里从上往下逐层选中,看屏幕上的高亮块,哪一层的盒子把弹层框住了,问题就在哪儿。修复原则是尽量让所有浮层挂到 body 下,避开父级层叠上下文。
事件冒泡的问题更常见。行操作菜单点开之后,点空白处要关掉,于是给 document 绑了一个 click 关闭。结果点击菜单里的按钮时,冒泡到 document,菜单先关了,按钮的 click 还没来得及执行。这种要用 stopPropagation,或者更稳妥的做法是在 document 的监听里判断点击目标是否在菜单内部:
document.addEventListener('click', function (e) { if (menu.contains(e.target)) return; closeMenu(); });用 contains 判断比 stopPropagation 好在不破坏事件流,其他需要冒泡的逻辑不受影响。
6. 排查实录:一次"用例表格按钮点了没反应"的完整定位
6.1 现象描述与第一轮误判
那次的问题很奇怪:用例表格里编辑、复制、执行三个按钮都正常,唯独删除按钮点了完全没反应,既不弹确认框,控制台也没报错。
第一反应是事件委托里的 switch 漏了 del 分支。翻代码一看,分支在,逻辑也对。第二反应是按钮的>tr.addEventListener('click', function (e) { if (e.target.closest('.col-ops')) return; toggleDetail(tr.dataset.caseId); });
改完之后,删除按钮恢复正常,整行展开也不受影响了。验证时我特意跑了三种点击路径:点操作列的空白处、点三个正常按钮、点删除按钮,三种行为都符合预期。
6.5 举一反三:一套通用的排查顺序
这次经历之后,我把这类"某个按钮失灵"的排查顺序固化了下来,团队里通用:
| 顺序 | 检查项 | 判断依据 |
|---|---|---|
| 1 | 控制台有无报错 | 有错先看错,别急着往下查 |
| 2 | 委托回调第一行加断点 | 没命中说明事件没到容器 |
| 3 | Event Listeners 面板看祖先链 | 找出谁在拦截事件 |
| 4 | 搜代码里的 stopPropagation | 定位阻断点 |
| 5 | 检查元素的 pointer-events、disabled | 有时是样式层的屏蔽 |
| 6 | 检查元素是否被遮挡 | 上面的透明浮层也能吃掉点击 |
第六项是最容易被忽略的一条。有一次查了半天,最后发现是一个透明遮罩层忘了隐藏,它没挡住视觉,但挡住了所有点击。查这种问题最快的办法是在页面上右键检查,看选中的元素是不是你以为的那个——如果不是,说明有东西盖在上面了。
7. 写在最后的一些零散体会
这类测试平台的前端有个共性:页面看起来都挺朴素,但交互密度极高,测试人员一天要在这个表格上点几百次。所以性能优化不能只看首屏加载,更要看那些会被重复上千次的小操作的代价。改一个 class 和重建一张表,在用户感知上是天壤之别。
另一个体会是,老技术栈不等于烂技术栈。这套平台用了很多年的 jQuery,很多地方写得确实不好看,但它在处理"局部状态即时反馈"这件事上依然高效。真正的问题是缺乏分层规范,DOM 操作和数据操作混在一起写,导致后来加需求时互相踩脚。后来我们做的一件很值的事,是在团队里立了一条规矩:任何一次 DOM 改动,先问一句这是数据还是视图。答案清楚了,代码写在哪一层也就清楚了。
最后一个提醒,关于测试平台上那些看似无害的"优化"。有一次为了提升表格滚动流畅度,我加了节流,结果测试人员反馈勾选框响应变迟钝了。把节流阈值从 100 毫秒降到 16 毫秒才勉强接受。这类参数没有标准答案,只能拿真实的操作频率去试。别拿理想环境下的测试机数据去下结论,去坐到一个真实测试人员旁边看他操作十分钟,你会推翻很多自己拍脑袋的判断。