这阵子一直在维护一个后台前端页面,列表筛选、详情弹窗、导出按钮这些交互,全都得等接口回包之后才能决定界面怎么展示。正好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操作到底在哪一步被执行了”,可以在最关键的那一行前面加一个debugger:
function 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超时这三件套之后,大部分业务场景里的显隐问题都能被稳稳控制住。希望这篇内容能帮你少踩几个和异步加载有关的坑。