首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
Ajax异步请求中DOM元素显隐控制的工程实践
📅 2026/10/17 21:06:49
✍️ 爱科研究院
👁 阅读 3,247
这阵子一直在维护一个后台前端页面列表筛选、详情弹窗、导出按钮这些交互全都得等接口回包之后才能决定界面怎么展示。正好JS系列写到第三篇这次想认真聊一个看起来很小、但实际上很容易翻车的问题在ajax请求过程中怎么让DOM元素的hide和show和异步数据加载配合好。大多数人第一反应就是“发请求前先隐藏回调里再显示”。单次请求这么写确实没什么问题可一旦遇到多个请求并发、超时、用户重复点击那套直来直去的写法就撑不住了。这篇文章把我实际踩过的坑和最终落地的方案完整拆开从XHR到fetch、async/await都会覆盖到比较适合正在写前端交互逻辑或者准备自己封装加载组件的同学参考。1. 为什么要在Ajax里控制DOM显隐先搞清楚使用场景1.1 最常见的三个需求防重复、加载反馈、条件展示先说最常见的防重复。比如保存、查询、导出这类按钮用户点一下之后我们要在请求返回之前立刻禁用按钮或者让按钮进入一个“提交中”的状态。这个“禁用”表面上是改disabled属性本质上也属于把可操作状态隐藏起来防止用户在同一时间再次触发同一个请求。第二个是加载反馈。搜索按钮点击之后等接口返回可能需要几百毫秒甚至更久。如果用户看到的还是上一次的表格内容他没法判断这次操作到底有没有生效。比较合理的方式是请求一开始就把旧内容区隐藏把loading元素显示出来请求结束后再根据结果是成功还是失败切换到对应内容或错误提示。第三个稍微复杂一点是条件展示。接口收回来之后我们要根据数据状态来决定某个区域是否显示。比如详情接口里带了一个状态字段只有状态是“待审核”的时候审核按钮才显示状态变成“已通过”之后按钮要隐藏。这种显隐不是请求前预先设好的而是由异步返回的数据来驱动。这三个场景都指向同一个核心问题DOM操作是同步的但数据加载是异步的。你要在正确的时机执行不同的DOM操作才不至于让用户看到“旧的界面状态”和“新的请求结果”错位在一起。1.2 异步请求和DOM操作的顺序陷阱很多人写代码时会犯一个很微妙的错误以为把显隐逻辑写在fetch后面它就会“等请求回来之后再执行”。实际上完全不是这样。JavaScript是单线程的但网络请求是异步的fetch发出去之后后面的同步代码会立刻继续执行。我习惯用一个餐厅点菜的比喻你去吃饭菜单先翻开后厨在后边慢慢做菜菜端上来之后服务员再把菜单收走。你不会把翻菜单这个动作放到“等菜上齐”之后再做。ajax请求也一样“请求前隐藏旧内容”这一动作必须立刻执行“请求完成后显示新内容”则只能放进回调函数、await之后的代码或者Promise链里。知道这个顺序问题之后再看标题里的“及时完成”就很好理解了所谓及时不是盯着网速快不快而是确保DOM操作落在正确的时间点上。1.3 一个最直白的实现先写一个最简单但能跑的版本后面再逐步优化。const loadingEl document.getElementById(loading); const contentEl document.getElementById(content); const btn document.getElementById(queryBtn); btn.addEventListener(click, function () { loadingEl.style.display block; contentEl.style.display none; fetch(/api/list) .then((response) response.json()) .then((data) { renderList(data); contentEl.style.display block; }) .catch((err) { console.error(err); contentEl.style.display block; }) .finally(() { loadingEl.style.display none; }); });这个版本的逻辑是对的请求前隐藏内容、显示loading请求结束后恢复。但它有两个很明显的隐患第一内联样式写死了后续想切换成动画或者统一CSS方案会很麻烦第二每次请求都要在一个地方add、另一个地方remove只要中间某个分支忘记处理loading就会一直挂在页面上。2. 直接操作还是用样式类DOM显隐的几种办法2.1 display、visibility、opacity怎么选标题里的hide和show如果放在以前可能直接用jQuery现在原生JS里我们有几个选项最容易混的就是这三样。方式是否占位是否可点击是否触发重排适用场景display: none不占位不可点击触发重排彻底隐藏适合切换内容区visibility: hidden占位不可点击不触发重排想隐藏但不希望布局跳动opacity: 0占位仍可点击不触发重排配合过渡动画做淡出效果display: none是最直观的“隐藏”元素不占空间但要注意频繁切换会触发重排。页面上如果有一大块表格区域在hide和show之间来回切换视觉上会有跳动感。如果你希望内容不可见但位置还留着可以使用visibility: hidden。opacity: 0虽然视觉上看不见但元素仍然可以接收点击事件所以要做透明淡出时最好顺带把pointer-events: none加上否则用户可能点到看不见的按钮。还有一个容易踩的坑HTML自带的hidden属性。它的本质就是给元素加上display: none但这个样式的优先级很低。如果你的元素本身有display: flex或者display: block这类自定义样式hidden属性可能完全不生效。.box[hidden] { display: none !important; }用这样一条CSS把hidden属性的优先级抬上来之后再用box.hidden true或者box.hidden false来控制会干净很多。2.2 用class代替style状态更好维护大部分业务场景我建议不要直接修改style.display而是通过切换class来管理状态。原因是显隐状态往往不止一个元素在控制今天可能是loading区明天可能还要加错误区、空数据区。如果你把所有状态写进class视觉规则就统一收拢到CSS里了JS里只负责加class、减class。.is-hidden { display: none !important; }loadingEl.classList.remove(is-hidden); contentEl.classList.add(is-hidden);class的命名也要注意。不要叫show、hide这种动词因为class描述的是状态动作应该发生在JS逻辑里。我一般会用is-hidden、is-loading、is-error这种名词形式。看到is-hidden你就知道它表示一个“当前处于隐藏状态”的元素别人维护代码时也不需要去猜这个class是哪个函数给它加上去的。说到.is-hidden { display: none !important; }加了!important是因为很容易出现元素自己已经有display: flex的情况如果不加隐藏类会被覆盖掉。只要你的项目里没有滥用!important这一条是很安全的兜底写法。2.3 缓存DOM引用别在事件回调里不停查询在事件回调里写document.getElementById(xxx)虽然很方便但如果在高频点击或者列表很长的情况下每次都去DOM树里查一遍没什么必要。更好的做法是在页面初始化时就把要控制的元素缓存到一个对象里。const listBox { loading: document.getElementById(listLoading), content: document.getElementById(listContent), empty: document.getElementById(listEmpty), error: document.getElementById(listError) };之后所有请求逻辑都通过listBox.loading这种方式去访问代码简洁也好排查。但要注意一个陷阱如果你把某个节点的innerHTML整个替换掉了那之前缓存的引用指向的还是旧节点。这个旧节点已经不在页面里了你再去操作它页面上一丁点变化都不会发生。这个情况在列表重新渲染、弹窗内容动态生成的时候特别容易出现。所以缓存DOM引用时要么确认节点不会被替换要么在重新渲染后主动更新缓存。3. 实战请求中隐藏、完成后显示要怎么写3.1 用XHR时别把回调钩子挂错虽然现在fetch越来越常用但老项目里XHR还是很多。用XHR有个特别容易踩的点onreadystatechange会触发多次你如果把显隐逻辑直接写在里面它会在readyState从1走到4的每一个阶段都执行一遍。const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.timeout 10000; loadingEl.classList.remove(is-hidden); contentEl.classList.add(is-hidden); xhr.onload function () { if (xhr.status 200 xhr.status 300) { renderData(xhr.responseText); contentEl.classList.remove(is-hidden); } else { showError(请求失败); } }; xhr.onerror function () { showError(网络异常); }; xhr.ontimeout function () { showError(请求超时请稍后再试); }; xhr.send();把业务逻辑放在onload里而不是靠onreadystatechange去判断readyState 4至少能少犯一半错误。因为onload只在请求完全结束时触发一次语义清晰。如果你非要写onreadystatechange也一定要在函数开头判断xhr.onreadystatechange function () { if (xhr.readyState ! 4) return; // 到这里才做显隐和数据渲染 };另外XHR还有ontimeout和onabort。前者用于处理超时后者用于处理主动取消。这两个事件里也要考虑是否需要恢复页面状态否则用户点了一次取消之后页面可能一直卡在loading。3.2 用fetch async/await错误处理和finally现代项目我更推荐用fetch加async/await可读性高错误处理也更集中。一个比较完整的写法像下面这样async function loadList(params) { const btn document.getElementById(searchBtn); const loadingEl document.getElementById(listLoading); const contentEl document.getElementById(listContent); const errorEl document.getElementById(listError); btn.disabled true; loadingEl.classList.remove(is-hidden); contentEl.classList.add(is-hidden); errorEl.classList.add(is-hidden); try { const query new URLSearchParams(params).toString(); const resp await fetch(/api/list? query, { headers: { Accept: application/json } }); if (!resp.ok) { throw new Error(HTTP resp.status); } const data await resp.json(); // 很多后端即使业务失败也返回200所以业务code也要判断 if (data.code ! 0) { throw new Error(data.message || 接口返回异常); } renderList(data.data); contentEl.classList.remove(is-hidden); } catch (err) { errorEl.textContent err.message; errorEl.classList.remove(is-hidden); } finally { btn.disabled false; loadingEl.classList.add(is-hidden); } }这段代码里有几个关键点。第一resp.ok只判断HTTP状态码是不是2xx但很多后端会用200包业务错误所以业务层面的code必须单独判断否则界面上会显示“加载成功”但内容区什么都没有。第二finally里的收尾动作非常关键不管成功、失败、异常都要把按钮恢复、把loading撤掉。第三错误提示区在请求开始时要先隐藏否则上一次的错误提示会残留。3.3 超时与取消用AbortController收尾fetch默认没有超时机制你不等它它可能一直卡在那里。更麻烦的是用户已经切走了页面或者换了筛选条件前一个请求还在慢慢返回来这时候很容易出现状态混乱。AbortController就是来解决这个问题的const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 10000); try { const resp await fetch(/api/list, { signal: controller.signal }); // ... } catch (err) { if (err.name AbortError) { // 超时或主动取消这里可以区分处理 return; } // 真正的网络或业务错误 } finally { clearTimeout(timeoutId); }AbortController触发后fetch的Promise会reject错误名是AbortError。如果你把这当成普通错误去弹提示用户会看到一个非常莫名的“请求失败”尤其是在你主动取消旧请求时。所以一定要先判断err.name AbortError。4. 容易踩的坑并发请求导致显隐错乱4.1 两个请求交叉返回会怎样单次请求只要按上面写法一般没什么问题麻烦的是并发。举个很常见的场景列表页有个筛选条件用户快速点了一次“查询”发现没点对又马上改条件点了第二次。第一次请求因为后端处理比较慢迟迟没结果第二次请求快先返回了。这时候页面显示了第二次的数据loading关闭。结果没过多久第一次请求也返回了如果没有做保护它会把页面重新渲染成旧数据loading可能也会闪一下。这就是最典型的竞态问题也是标题里“异步加载”最容易暴露风险的地方。4.2 只保留最新请求请求序号保护解决竞态最常用的办法是维护一个自增序列号。每次发起新请求时序号加一并把这个值保存在局部变量里。请求返回后先检查这个值是否还是最新的如果不是就说明当前有更新的请求已经发起直接丢弃这个过期结果。let requestSeq 0; async function loadList(params) { const currentSeq requestSeq; const loadingEl document.getElementById(listLoading); const contentEl document.getElementById(listContent); const errorEl document.getElementById(listError); const btn document.getElementById(searchBtn); btn.disabled true; loadingEl.classList.remove(is-hidden); contentEl.classList.add(is-hidden); errorEl.classList.add(is-hidden); try { const data await fetchData(params); if (currentSeq ! requestSeq) { // 说明这次请求已经不是最新直接丢弃 return; } renderList(data); contentEl.classList.remove(is-hidden); } catch (err) { if (currentSeq ! requestSeq) return; errorEl.textContent err.message; errorEl.classList.remove(is-hidden); } finally { if (currentSeq requestSeq) { btn.disabled false; loadingEl.classList.add(is-hidden); } } }注意这里finally里也要判断序号。如果旧请求的finally先执行而新请求还在路上它会提前把按钮恢复、把loading关掉视觉上也会出一瞬间的状态异常。所以所有和界面恢复相关的逻辑都要保证“当前的请求确实是最新的”。这个方案够廉价、够可靠不需要额外引入复杂状态管理是目前业务代码里性价比最高的一种做法。4.3 更彻底的做法把旧请求取消掉请求序号保护只能保证“过期请求的结果不往界面渲染”但旧的请求还在后台跑着占用网络资源。更彻底的办法是用AbortController主动取消旧请求let activeController null; async function search(params) { if (activeController) { activeController.abort(); } const controller new AbortController(); activeController controller; try { const resp await fetch(/api/list? new URLSearchParams(params), { signal: controller.signal }); // ... } catch (err) { if (err.name AbortError) return; // ... } }取消旧请求的好处在于后端少处理一次无效请求前端也不会因为过多并发请求把内存和CPU拉满。不过要提醒一句abort()只是让前端不再等待后端如果已经处理完并且返回了资源一样消耗了所以不能完全依赖它来节流。实际项目里我经常把序号保护和AbortController合在一起用发起新请求时先取消旧请求同时用序号判断防止取消动作和返回动作之间有微小的时序空隙。4.4 简单场景用状态锁就够了如果业务场景本身就是“同一时间只能有一个请求”比如登录、提交表单、生成文件这一类就不需要序号那么复杂的方案直接用一个锁变量就行。let isSubmitting false; btn.addEventListener(click, async function () { if (isSubmitting) return; isSubmitting true; btn.disabled true; loadingEl.classList.remove(is-hidden); try { await submitData(); // ... } finally { isSubmitting false; btn.disabled false; loadingEl.classList.add(is-hidden); } });状态锁的逻辑是“忽略后续点击”而不是“用最新一次覆盖旧请求”。如果用户希望连续切换条件时只保留最后一次查询就应该用请求序号如果用户点击一次提交后第二次点击本来就是误操作那用锁就足够了。5. 常见问题速查与调试技巧实录5.1 现场排错速查表我把平时排查显隐问题时最常用的对照关系整理成一个表遇到相似故障可以直接按图索骥。现象常见原因处理方式loading一直不出现元素已有display: flex等样式hidden属性被覆盖或CSS里没有对应class用.is-hidden并确认优先级打开DevTools看computed样式内容区一直显示不出来只判断了resp.ok没判断业务code或resp.json()解析失败统一在catch里透出错误信息别吞异常按钮一直禁用、loading一直转fetch异常没被catch住或没有设置超时或finally没触发检查Promise链是否完整补上finally并加AbortController超时页面显隐时来回跳动用的是display: none频繁切换触发重排换成visibility: hidden或使用动画过渡多个请求返回后内容错乱缺少请求序号保护引入requestSeq只在当前序号匹配时更新界面loading闪了一下又消失旧请求的finally关掉了原本应该属于新请求的loading在finally里也判断序号是否最新5.2 用浏览器控制台模拟慢响应和打断点排查异步加载类的显隐问题最怕的就是“复现不了”。Chrome DevTools的Network面板里有不少好用的小功能。一是模拟慢网络。把网络速度调成Slow 3G或者添加一条自定义延迟就能很容易看到loading状态。二是直接Block掉某个请求URL模拟接口一直不返回这时候页面应该如何处理超时很快就能暴露出来。如果在某个时间点想确认“DOM操作到底在哪一步被执行了”可以在最关键的那一行前面加一个debuggerfunction setViewState(state) { debugger; // 在这里停下来观察当前是哪个请求触发的 loadingEl.classList.toggle(is-hidden, state ! loading); contentEl.classList.toggle(is-hidden, state ! data); errorEl.classList.toggle(is-hidden, state ! error); }还有一个技巧在Elements面板选中某个元素右键选择Break on - attribute modifications这样当元素的class属性被修改时会自动跳到对应的JS代码。这个方式查“某个元素的class到底被谁改了”特别快。5.3 一个可复现的“闪现”案例拿列表页举例。页面上同时有查询按钮和导出按钮查询会发起一个请求导出会先拿一次数据再触发文件下载。为了省事有人让查询和导出共用一个loading元素。结果你会发现一个奇怪的现象点导出之后表格区域先隐藏loading出现然后loading消失过了一会儿loading又突然出现一次表格又闪一下。这是因为导出流程里的两个请求都绑定了同一个loading状态第一个请求结束后关了一次loading第二步请求开始又把它打开了等整个导出流程真正结束才关闭。这个问题的本质不是显隐写法不对而是“业务动作”和“界面状态”没有一一对应。查询和导出是两个独立的业务动作就应该使用各自独立的loading元素或状态变量。导出按钮的loading只影响导出按钮本身不要去动列表区。列表区的隐藏和显示必须只由查询、筛选这些直接影响列表数据的动作来驱动。解决办法很简单先梳理清楚页面上一共有几个独立状态区域再给每个区域分配对应的DOM元素和状态变量。写请求逻辑的时候脑海里把每一个请求和它要控制的区域映射清楚就不会出现“一个loading管所有事情”的混乱局面。我个人在实际操作中的体会是DOM显隐看起来是样式问题真正出事的都在时序上。我写“开始请求”和“请求结束”两段逻辑时会刻意把隐藏和显示当成一对必须对称的操作来检查像检查成对括号一样。少了哪一边页面就会卡在半路状态。加上请求序号保护、finally收尾、AbortController超时这三件套之后大部分业务场景里的显隐问题都能被稳稳控制住。希望这篇内容能帮你少踩几个和异步加载有关的坑。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/17 21:06:49
IndexedDB从入门到实战:打破localStorage容量上限的完整存储方案
2026/10/17 21:06:49
色彩命名工具核心算法:Lab空间转换与最近邻匹配实践
2026/10/17 21:01:48
socket错误处理封装实战:从errno分类到统一重试机制
2026/10/17 21:57:24
滑板底盘拆解报告:Rivian R1T技术解析与工程避坑指南
2026/10/17 21:57:24
VOC XML标注可视化验证:从画框到数据质量防火墙
2026/10/17 21:57:24
豆瓣电影数据集清洗与推荐系统实战:从CSV到SQLite全流程
2026/10/17 21:57:24
MySQL冷数据存储利器TokuDB安装配置与踩坑全记录
2026/10/17 21:57:24
VSCode Remote SSH配跳板机连接内网服务器与Docker容器实践
2026/10/17 21:52:24
蓝桥杯智能阅读助手开发:结构感知分块与可解释推理实战
2026/10/17 0:02:58
NetSpot 实战:用信道分析与频宽优化解决 Wi-Fi 卡顿
2026/10/17 0:02:58
C++ split() 字符串分割的五种实现与性能实测
2026/10/17 0:02:58
R语言坐标轴范围设置:从xlim/ylim到coord_cartesian的完整指南
2026/10/17 10:00:11
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/17 10:16:23
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/17 10:16:02
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/17 10:15:50
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/16 18:46:48
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/16 18:46:46
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)