
做前端的朋友大概率都撞到过这种场景页面里有个区域要用接口数据填充数据没回来之前它不该露出来等 ajax 请求成功之后再把内容塞进去、把 DOM 元素的 hide/show 状态切换到位。看起来就是一个 show() 和 hide() 的问题但真正写起来很容易出幺蛾子明明请求成功了元素却还是隐藏的或者旧内容闪了一下新的又没接上又或者请求没结束用户疯狂点击最后 UI 一团乱。这篇是这个系列的第3篇前两篇聊的是基础的 DOM 操作和事件机制这次正好接着往下走专门说说在 ajax 请求这种异步流程里怎么规划 DOM 元素的显示和隐藏时机以及如何封装一套不会出错的加载状态管理。适合刚写前端业务不久、经常被异步交互折磨的新手也适合想把自己项目里那堆混乱的 loading 逻辑整理干净的初级开发者。看完你至少能明白为什么 hide/show 不能随便写在 ajax 调用后面以及怎么把请求发起、请求成功、请求失败三个阶段和 UI 状态正确对应起来。1. 先聊聊那个最常见的闪一下bug1.1 第一版代码几乎都是这样写坏的我见过太多业务代码的开局是这样的按钮点击之后先把结果区域隐藏一下然后发 ajax成功回调里再把结果显示出来。时间一长问题就出现了结果区域会先闪烁一下旧内容然后才被隐藏或者更离谱的是ajax 还没回来区域先没了等数据到了又因为某些条件没重新显示页面就变成一片空白。先看一个典型错误写法$(#btn).click(function () { // 先发请求回来之后再展示 $.ajax({ url: /api/list, success: function (data) { $(#loading).hide(); $(#result).show(); $(#result).html(data); } }); // 又想把显示逻辑放在这里错了 // $(#loading).show(); });这种代码不会立刻报错但它隐含了一个顺序问题ajax 是异步的你写在 ajax 调用后面那些代码并不会等到请求返回后才执行它们会立刻执行。于是你会看到一种诡异的现象点击按钮loading 圈还没转起来结果区域一闪而过几秒后数据回来了loading 却不知道什么时候被隐藏了页面直接展示内容。根本原因就是没有理解发起 ajax 请求和ajax 成功回调之间的时间差。这段等待时间里浏览器不会停下等你它该干嘛干嘛。DOM 的 hide/show 如果放在错误的时间点就会出现操作执行了但不是你想要的那个顺序。1.2 异步流程下DOM 操作到底卡在哪一步要理清楚这个问题得先稍微看一眼 JavaScript 的异步原理。ajax 请求发出后浏览器会把请求交给网络层注册一个回调函数等到响应返回后这个回调才会被放进任务队列等待当前同步代码执行完毕再被调用。举个生活例子你进一家餐厅点了菜发请求服务员说做好了叫你注册回调。你不可能坐在那儿干等该玩玩该喝喝执行后面的代码。只有后厨把菜端出来服务员喊你回调触发你才可以动筷子操作 DOM。所以及时完成 DOM 元素的 hide/show里的及时不是指代码写得快而是指把 DOM 操作放在正确的回调阶段里。请求之前做一次状态切换请求成功之后再做一次请求失败再做一次。这三个阶段缺一不可否则 UI 就会处于错误状态。2. 正确时机请求前、成功后、失败后2.1 请求发起前先把 UI 切换到加载态很多新手觉得反正接口速度快等回来再显示也行。但实测下来哪怕是本地 mock 接口也有几十毫秒的延迟用户完全能感觉到点击后没反应。正确的做法是在点击按钮、发出请求的那一刻立刻把当前区域切换成加载中状态。所谓切换加载态通常包含两步把旧内容隐藏把 loading 占位显示出来。以 jQuery 为例$(#btn).click(function () { // 1. 先隐藏结果区显示加载占位 $(#result).hide(); $(#loading).show(); // 2. 再发请求 $.ajax({ url: /api/list, method: GET, success: function (data) { // 成功处理... } }); });注意一个细节我习惯先操作 DOM再发 ajax。原因是让用户的视觉反馈第一时间出现而不是等函数执行完再一次性变化。这两行代码执行间隔几乎为零看起来是连续的但语义上很重要——它意味着用户点击动作被接受了正在处理中。这里有个隐藏的小技巧如果旧内容里有表单输入或者用户滚动到了某个位置直接 hide 可能会导致滚动位置丢失。更好的做法是把整个区域包在一个容器里对这个容器做加载态切换而不是把容器里的每个子元素都逐个 hide/show。2.2 成功回调里先更新内容再显示元素到了 success 回调拿到数据这时候才轮到内容填充 显示这两个操作。顺序也很讲究先更新 HTML 结构再让元素显示出来。success: function (data) { // 1. 先渲染数据 $(#result).html(renderList(data)); // 2. 隐藏 loading $(#loading).hide(); // 3. 最后显示结果区 $(#result).show(); }如果顺序反过来先 show 再 html用户会在一瞬间看到旧的空白区域甚至看到正在变化的 DOM 结构产生闪烁。这其实是浏览器渲染机制导致的DOM 修改未必立刻重绘但一旦触发重绘屏幕上的中间状态就会被人眼捕捉到。另外渲染内容本身也可能很耗时。如果数据量大需要拼接大量字符串尽量在 show 之前用 fragment 或者模板字符串一次性构造完整结构再挂载到 DOM 上。这样浏览器只需要做一次布局计算不会反复触发 reflow。2.3 失败回调别忘了恢复现场否则 UI 直接卡死请求成功率不可能 100%失败场景被戏称为前端必修课不是没道理的。很多业务代码里success 处理得很漂亮error 里只写一个 console.log然后 loading 就永远停在那里页面像死了一样。所以失败回调里至少要做两件事隐藏 loading恢复结果区域或者展示错误信息。具体看你业务怎么设计。轻量一点的做法是沿用原来的容器展示错误提示error: function (xhr, status, error) { $(#loading).hide(); $(#result).html(p classerror加载失败请稍后重试/p).show(); }更严谨的做法是连完整回调 complete 里做兜底因为不管成功失败complete 都会执行非常适合用来隐藏 loading 这类无论如何都要结束的状态。但要注意complete 里的结束状态清理不要覆盖 success/error 里已经设置好的 UI 状态。顺序上一般是 success 先执行、error 也先执行然后才到 complete。3. 封装一个不会越用越乱的请求状态工具3.1 每次手写三个阶段的隐藏点迟早会漏说实话上面那种三个步骤手写一次两次没问题但项目里页面多了每个请求都要写一遍隐藏、显示、成功、失败很容易有一处漏掉。比如某个接口不需要 loading另一个接口需要局部 loading第三个接口又要全屏 loading复制粘贴改来改去迟早出乱子。我建议把请求期间 UI 如何切换这件事抽象出来封装成一个简单的函数。核心思想是调用方只管传一个返回 Promise 的函数和一组 UI 切换的回调剩下的状态管理交给工具函数处理。3.2 用 Promise finally 写一个基础版本现代浏览器环境里axios、fetch 这些库返回的都是 Promise配合 async/await 写起来非常清爽。先看一个基础封装function withLoading(uiHandlers, task) { // uiHandlers: { showLoading, hideLoading } uiHandlers.showLoading(); return Promise.resolve() .then(task) .catch(function (e) { console.error(e); throw e; }) .finally(function () { uiHandlers.hideLoading(); }); }用的时候大概是这样的function loadList() { return withLoading( { showLoading: function () { $(#loading).show(); $(#result).hide(); }, hideLoading: function () { $(#loading).hide(); } }, function () { return fetch(/api/list).then(function (res) { return res.json(); }); } ).then(function (data) { $(#result).html(render(data)).show(); }); }这样 loading 的出现和消失被收拢到一处无论任务成功还是失败finally 都会隐藏 loading。不会出现某个分支忘了隐藏的问题。但是注意成功和失败的具体 UI 处理还是要在调用方做这没问题因为不同页面成功后的渲染逻辑差异很大不该强行统一。3.3 更通用的状态工具显式传入状态标志如果只有一个 loading 还好但业务复杂之后我们需要的其实是多个状态loading、success、error、empty。四态之间存在互斥关系任何时候只能有一个生效。这时候再用散落的 show/hide 就非常难维护。我倾向于用一个更通用的函数专门负责切换容器状态function setViewState(root, state) { // root 是容器节点内部约定有>div idlistContainer div>$(#btn).click(function () { var $btn $(this); $btn.prop(disabled, true); setViewState(container, loading); fetch(/api/list) .then(renderData) .finally(function () { $btn.prop(disabled, false); }); });如果需要支持允许并发但是以最后一次请求的结果为准就得用一个请求序号锁。发请求前递增一个计数器回调里比较当前的序号是不是自己发的那个不是就直接忽略。代码不复杂但能避免很多灵异渲染问题。4.2 请求完成时DOM 节点已经被移除了比如你在列表页发起请求请求期间用户点击某个按钮跳到了详情页列表页的 DOM 被框架清理了。等请求回来回调里调用$(#result).show()结果这个节点已经不存在。如果用的是原生 DOM还会遇到Cannot read property style of null之类的报错。解决办法是在回调开头先判断节点是否还存在于文档中。凡是异步回调里要操作的 DOM都建议做健壮性检查var resultNode document.getElementById(result); if (!resultNode || !document.body.contains(resultNode)) { return; // 节点已被销毁不再处理 UI }如果整个页面是单页应用更推荐用路由参数或组件卸载标记来取消无用的回调更新。框架里通常会提供销毁前的取消订阅钩子原生开发就要自己维护一个当前组件是否卸载的标志位。4.3 hide/show 和动画效果打架jQuery 的hide()和show()本质是把元素设置为display: none或默认 display 值。如果你之前用slideUp()做了一个收起动画还没执行完就调用hide()动画会被中断元素瞬间隐藏。反过来也是一样fadeIn()动画开始前元素还是display: none如果中间有人调用了show()动画效果就会变得很奇怪。要避免这种冲突最好统一显示/隐藏的实现方式。要么全部用 CSS 类切换加上transition动画要么全部走同一套动画函数最忌讳的是这里用show/hide那里用fadeIn/fadeOut两套逻辑互相覆盖。如果确实需要在动画播放过程中切换状态建议先用.stop(true, true)清掉动画队列再操作。4.4 连续触发导致 loading 闪烁还有一个特别常见但容易被忽视的问题接口速度快得离谱loading 刚显示出来请求就完成了loading 马上消失。用户看到的是页面快速闪一下非常刺眼。这种时候不一定要显示 loading或者给 loading 设置一个最小展示时间。我的做法是给 loading 加一个 250 毫秒的最低保底时间function showLoading() { loadingStart Date.now(); loadingEl.hidden false; } function hideLoading() { var elapsed Date.now() - loadingStart; var remain Math.max(0, 250 - elapsed); setTimeout(function () { loadingEl.hidden true; }, remain); }这样接口即使瞬间返回页面也不会像帕金森一样抖一下。当然这个最小值要结合产品体验来定如果请求真的很快甚至可以完全不用 loading。4.5 局部刷新时隐藏了外层容器导致其他区域布局跳动有时候我们只是想刷新列表区域的数据结果因为使用了$(#page).hide()这种粗粒度操作整个页面都消失了一下。新手最容易犯这个错看到一个容器里既有 loading 又有内容图省事就把整个容器隐藏再显示布局跟着跳。正确的做法是让 loading 层和内容层在同一个容器内但在不同位置或者用 position: absolute 覆盖。总之hide/show 的范围一定要精确到会变化的那一小块而不是把整个页面甚至整个 section 都动一遍。这里多花一点心思后面 UI 抖动问题会少很多。5. 进阶异步请求下的 UI 状态机设计5.1 把页面区域当成一个四状态状态机回到标题里那件事的本质上ajax 请求期间要及时完成 DOM 元素的 hide 和 show其实就是把一块区域的 UI 状态随着异步过程在不同状态之间切换。状态无非这几种初始态、加载态、成功态、失败态。再加一个空态。初始态什么都没有或者隐藏。 加载态显示进度反馈隐藏结果。 成功态显示数据。 失败态显示错误提示。 空态显示暂无数据。如果把这种状态映射成代码每个状态就是一组 DOM 元素的显示/隐藏组合。上一节写的setViewState就是状态机的雏形。真正项目里我建议把状态名定义成常量避免字符串到处飘var VIEW_STATE { IDLE: idle, LOADING: loading, SUCCESS: success, ERROR: error, EMPTY: empty };然后所有异步请求的回调里只执行setViewState(container, VIEW_STATE.SUCCESS)这种语义明确的代码而不是散落一地的show()和hide()。代码可读性会直线上升排查问题的时候你只需要看状态切换是否符合预期。5.2 异步数据加载的完整闭环示例把上面所有思路合并起来可以写一个很清晰的示例。用原生 fetch 加 async/awaitasync function loadData() { var container document.getElementById(listContainer); setViewState(container, VIEW_STATE.LOADING); try { var response await fetch(/api/data); if (!response.ok) { throw new Error(HTTP response.status); } var data await response.json(); if (!data.items || data.items.length 0) { setViewState(container, VIEW_STATE.EMPTY); return; } renderItems(data.items); setViewState(container, VIEW_STATE.SUCCESS); } catch (e) { console.error(e); setViewState(container, VIEW_STATE.ERROR); } }这个函数里没有任何一个多余的手动 hide/show 调用所有区域切换全部交给setViewState统一处理。而且每个分支语义都很清楚数据为空就走空态接口报错就走错误态正常了就走成功态。这比在回调里纠结$(#a).hide()、$(#b).show()要可靠得多。5.3 配合防抖让高频触发不再撕裂 DOM异步请求不只来自按钮点击也可能是搜索框输入、滚动分页加载这种高频事件。在这种场景里请求的发起时机和 DOM 更新时机需要额外加一道保险——防抖或节流。搜索框里每输入一个字符就发一次请求回调返回顺序一旦乱掉结果区就会反复横跳。这时候 hide/show 做得再快也没用因为数据本身就是错的。正确做法是给请求加上防抖比如停止输入 300 毫秒后才发请求。如果框架支持取消请求还要在下次触发时取消前一个请求避免资源浪费。即便不做防抖至少也要处理竞态。用请求序号是成本最低的办法var requestSeq 0; function search(keyword) { var currentSeq requestSeq; setViewState(container, VIEW_STATE.LOADING); fetch(/api/search?q keyword) .then(function (res) { return res.json(); }) .then(function (data) { if (currentSeq ! requestSeq) { return; // 说明这是旧请求丢弃 } renderResults(data); setViewState(container, VIEW_STATE.SUCCESS); }); }这套逻辑我几乎在每个项目里都会写一遍哪怕只是两行代码就能避免后返回的数据覆盖先返回的新数据。6. 最后分享两个我在实际开发里养成的习惯第一所有涉及异步请求的 DOM 操作我绝对不会写在回调外面。哪怕这个接口固定返回 0 延时我也会把 UI 切换放进 then 或者回调里万一以后接口变慢了代码逻辑依然成立。第二我习惯给 loading 层加一个aria-busy之类的无障碍标识虽然很多团队不注意这个但屏幕阅读器用户是能感知到页面正在加载的。维护好这个属性比单纯 show/hide 多走一小步但体验差距很大。这个小系列下一期我可能会聊聊更复杂的场景多个异步请求并行、串行、甚至依赖关系那时候我们再做状态管理的整体设计。这次的内容已经足够你应付日常业务里 90% 的请求期间显示/隐藏元素需求了。