ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

DOM与XMLHttpRequest:从数据请求到页面渲染的完整链路

DOM与XMLHttpRequest:从数据请求到页面渲染的完整链路 每次调接口前端最绕不开的就是两个家伙DOM 和 XMLHttpRequest。一个是页面骨架的操作入口一个是浏览器里最原始的数据请求通道。虽然现在大家张口闭口都是 fetch、axios但 XHR 这套老底子只要还在浏览器里跑一天你就绕不开它——尤其你一旦碰上老项目维护、上传进度条、或者面试官冷不丁甩你一句“讲讲 XMLHttpRequest 和 DOM 的关系”没有点底层认知是真的会卡壳。这篇文章不打算给你搞那种“复制粘贴就能用”的玩具代码而是把 DOM 操作和 XHR 请求这条链路上的关键细节从头到尾捋一遍为什么 XHR 有那么多反直觉的坑readyState 到底怎么理解responseText 和 responseXML 什么时候能直接用动态渲染时一旦用到 innerHTML 又是怎么把页面搞出 DOM 型 XSS 的。适合刚入门前端想打牢基础的同学也适合写过不少业务代码、但一直没时间把这块地基补上的老手。看完你可以直接照着里面的封装思路改造自己的项目也能拿去应付绝大多数浏览器原理层面的面试题。1. 项目解析为什么 DOM 和 XMLHttpRequest 总被放在一起聊1.1 两者在浏览器里的真实分工先说最基础的关系。浏览器拿到 HTML 之后会把它解析成一棵节点树这棵树就是 DOMDocument Object Model。你想要页面上的按钮变色、列表刷新、弹窗出现本质上都是对 DOM 树做增删改查。而 XMLHttpRequest 呢它是浏览器提供的一个 API 对象专门用来在页面不刷新的情况下向服务器发 HTTP 请求、拿数据。一个管“画”一个管“要数据”这两者单独看都不复杂但一旦把它们串起来就构成了现代 Web 页面最核心的运作方式通过 XHR 获取数据再把数据渲染到 DOM 上。我见过很多初学者把这两件事混着学以为 XHR 的返回值可以直接“贴”到页面上。实际上 XHR 拿到的只是一段文本或者二进制数据它跟页面上那个 div 之间没有任何自动关联。你必须在拿到数据之后手动用 JavaScript 去创建元素、填充文本、插入 DOM或者直接把字符串塞进某个容器节点的 innerHTML。这个“手动”的过程恰恰就是前端工程师绝大部分日常工作的本质。1.2 热词背后真正值得关注的两个延伸点围绕“DOM XMLHttpRequest”这个标题最近网络上高频出现的几个相关词很有信息量dom 型 xss、dom 元素、虚拟 dom 和 diff 算法面试。这些词其实是在提醒你单纯会用 XHR 不够你得理解数据和 DOM 结合的边界在哪里——比如数据不可信时渲染到 DOM 就可能被注入脚本数据量大时频繁操作 DOM 会很卡这才催生了虚拟 DOM 和 diff 算法这类优化方案。面试里考“虚拟 DOM 和 diff 算法”绝大多数答案都会提到一个理由直接操作 DOM 太慢所以先在 JS 里做 diff最后统一更新。但如果你追问一句“为什么直接操作 DOM 慢”很多人就答不上来了。这里面绕不开 XHR 和 DOM 的关系XHR 异步拿到大量数据后如果逐条 append 到 DOM每一次 append 都可能触发布局计算和重绘这是性能瓶颈的根源。所以这篇文章后面我会专门用一个章节来说这个事帮你在面试现场把这条链路讲完整。2. XMLHttpRequest 的底层机制拆解2.1 readyState 和 status 是不是一回事这是新手最容易混淆的一组概念。我面试别人的时候每次问到 XHR 都有不少人把 readyState 等于 4 和 status 等于 200 混在一起说。它们俩完全是两个维度readyState描述的是请求生命周期的状态从 0 到 4。0 是未初始化1 是已调用 open 还没 send2 是已 send 且服务器响应头已收到3 是正在接收响应体4 是响应体接收完成。status描述的是 HTTP 响应状态码比如 200、404、500。它描述的是“服务器那端给的业务结果”跟请求有没有传完是两回事。判断一个请求成功必须同时满足readyState 4数据接收结束和 status 在 200~299 之间响应正常。很多时候你发现 console 里有响应数据可页面就是没渲染出来八成就是只判断了 readyState没判断 status 就贸然把 responseText 拿去用了——比如服务器返回一个 500 的 HTML 错误页也被当成正常数据渲染进了页面。2.2 open 和 send 到底干了什么new XMLHttpRequest() 创建出来的对象只是一个空壳真正的“网络请求发射”是从调用 open 开始的但这时候它还没发射只是完成了“瞄准”。open(method, url, async) 的三个参数里async 默认是 true表示异步发送。这里有个细节如果你显式传 false请求会变成同步模式主线程会被完全阻塞按钮点了没反应、页面卡死直到服务器返回响应。现代浏览器对主线程上的同步 XHR 已经给出了强烈警告甚至在某些场景直接禁用比如页面卸载时的 sendBeacon 替代所以除非你在处理极特殊的兼容逻辑否则别碰同步模式。send(body) 才是真正把请求发出去。这里的 body 可以传字符串、FormData、Blob 等。没内容时传 null 就好但要注意调用 send 之后服务器的响应不是立刻返回的而是通过事件机制触发。事件驱动的核心就是你得在 send 之前把 onreadystatechange 或 onload 这些监听函数挂好——挂晚了某些浏览器可能因为状态已经推进而错过回调。2.3 XHR 的事件模型和回调陷阱onreadystatechange 是 XHR 最传统的回调方式它在每个 readyState 变化时都会被触发所以你会在回调里看到状态一路从 1 走到 4。比较现代的做法是直接监听 load 事件只在请求成功完成时触发代码更简洁const xhr new XMLHttpRequest() xhr.open(GET, /api/users, true) xhr.onload function() { if (xhr.status 200 xhr.status 300) { const data JSON.parse(xhr.responseText) renderUserList(data) } } xhr.onerror function() { console.error(网络异常) } xhr.send(null)这里有个很容易踩的坑load 事件只代表请求传输完毕不代表业务成功。HTTP 200 也可能返回业务错误码HTTP 500 也可能在 load 里触发。所以 onload 里必须校验 status而真正网络层面断了才会走 onerror——比如断网、DNS 解析失败、跨域被拦。把业务错误和网络错误混为一谈是很多线上 bug 的来源。3. DOM 和 XHR 结合的完整方案设计3.1 前后端数据格式与 DOM 渲染的衔接思路拿数据是为了渲染。但这中间有一个关键的“翻译”环节XHR 的 responseText 是字符串你得先把它变成 JavaScript 对象再根据对象结构去创建 DOM。最常见的结构是从后端拿到 JSON 数组然后渲染成一个列表。在设计渲染方案时你要提前想清楚三件事一是列表项的结构长什么样是简单的文本节点还是要带嵌套元素二是数据量级大概多大几十条和几千条的渲染策略完全不同三是用户会不会高频刷新数据如果会你就得考虑是清空重绘还是局部更新。这一层思考决定了你用 createElement 逐个构建节点还是用字符串拼 HTML 后一次性插入 innerHTML。3.2 两种渲染方式的对比与选择我先把两种方式摆出来给你看再告诉你什么时候用哪个。第一种是纯 DOM API 方式function renderUserList(users) { const container document.getElementById(user-list) container.innerHTML const fragment document.createDocumentFragment() users.forEach(user { const li document.createElement(li) li.className user-item const nameSpan document.createElement(span) nameSpan.textContent user.name const ageSpan document.createElement(span) ageSpan.textContent user.age 岁 li.appendChild(nameSpan) li.appendChild(ageSpan) fragment.appendChild(li) }) container.appendChild(fragment) }第二种是 innerHTML 方式function renderUserList(users) { const container document.getElementById(user-list) const html users.map(user li classuser-item span${user.name}/span span${user.age}岁/span /li ).join() container.innerHTML html }两种方式都对但适用场景不同。纯 DOM 方式安全系数高因为 textContent 会自动转义不会把数据里的 HTML 标签或脚本当代码执行渲染过程可控适合需要给每个节点绑事件、或者要做局部更新的场景。缺点是代码啰嗦几千条数据时如果没配合 DocumentFragment性能会肉眼可见地拉胯。innerHTML 方式写起来爽适合结构固定、数据量大但一次性的渲染场景。但它的风险也很明确如果数据里混了img srcx onerroralert(1)这种字符串直接拼接进 innerHTML 就执行了。这就是“DOM 型 XSS”最常见的入口。所以用 innerHTML 有个铁律业务数据必须经过转义函数处理之后再拼进去。后面我在常见问题里会专门给一个转义函数。3.3 三要素辅助函数把 XHR 封装成好用的小工具原生 XHR 写多了以后你会发现套路高度重复创建对象、open、配事件、send、判断状态、解析数据。所以我建议直接封装成一个通用函数业务代码里一行调用省心很多。我平时项目里会放一个这样的基础版本function request(options) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest() const method (options.method || GET).toUpperCase() const url options.url const async options.async ! false xhr.open(method, url, async) if (options.headers) { Object.keys(options.headers).forEach(key { xhr.setRequestHeader(key, options.headers[key]) }) } xhr.responseType options.responseType || text xhr.onreadystatechange function() { if (xhr.readyState ! 4) return if (xhr.status 200 xhr.status 300) { let response xhr.response if (xhr.responseType text) { response xhr.responseText } resolve(response) } else { reject(new Error(HTTP ${xhr.status}: ${xhr.statusText})) } } xhr.onerror () reject(new Error(Network Error)) let body null if (options.data) { if (options.data instanceof FormData) { body options.data } else if (typeof options.data object) { body JSON.stringify(options.data) if (!options.headers || !options.headers[Content-Type]) { xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8) } } else { body options.data } } xhr.send(body) }) }调用方式就清爽了request({ url: /api/users, method: GET }) .then(data renderUserList(data)) .catch(err console.error(err))封装的时候有几个细节值得注意。responseType 的设置很讲究默认 text 时拿 responseText设置为 json 时浏览器会自动帮你解析response 直接是对象省去手动 JSON.parse但兼容性在个别老浏览器上会有问题。setRequestHeader 必须在 open 之后、send 之前调用这是硬性顺序要求。Content-Type 的设置也要小心application/x-www-form-urlencoded和multipart/form-data的提交格式完全不同传 JSON 字符串时忘了设置报文头服务端大概率解析不出来。4. 手把手实操从请求数据到完成 DOM 渲染的完整链路4.1 设计一个带搜索和分页的用户列表纸上谈兵差不多够了现在来一个能直接跑的真实案例。需求是这样页面上有一个输入框、一个搜索按钮、一个用户列表容器还有一个“加载更多”按钮。用户在输入框输入关键词点击搜索后前端通过 XHR 请求/api/users?keywordxxxpage1拿到第一页用户数据渲染到列表里点击“加载更多”时请求下一页把新数据追加到列表底部。这个场景覆盖了 XHR 的核心操作、DOM 渲染、拼接式更新数据和状态维护是一个很典型的前端数据交互闭环。我把 HTML 骨架先写出来div idapp input typetext idsearch-input placeholder输入用户名搜索 / button idsearch-btn搜索/button ul iduser-list/ul button idload-more styledisplay:none;加载更多/button /div这个骨架很简单但你注意load-more按钮初始是隐藏的——因为还没搜索你不知道后面还有没有更多数据。这个状态设计我在后面会详细讲。4.2 串联 XHR 请求流程与 DOM 更新逻辑接下来是最核心的 JS 逻辑。我倾向于把“请求数据”和“渲染 DOM”拆成两个函数中间用数据状态连接而不是在回调里直接写一坨 DOM 操作。这样职责清晰排查问题也好定位。const state { keyword: , page: 1, pageSize: 10, hasMore: true } function fetchUsers(reset true) { if (reset) { state.page 1 state.hasMore true } const url /api/users?keyword${encodeURIComponent(state.keyword)}page${state.page}pageSize${state.pageSize} request({ url, method: GET }) .then(response { const data JSON.parse(response) if (reset) { document.getElementById(user-list).innerHTML } renderUsers(data.list) state.hasMore data.hasMore document.getElementById(load-more).style.display state.hasMore ? block : none }) .catch(err console.error(加载失败, err)) } function renderUsers(users) { const container document.getElementById(user-list) const fragment document.createDocumentFragment() users.forEach(user { const li document.createElement(li) li.className user-item li.innerHTML span classuser-name/span span classuser-age/span li.querySelector(.user-name).textContent user.name li.querySelector(.user-age).textContent user.age 岁 fragment.appendChild(li) }) container.appendChild(fragment) }这里我给了一个很有意思的组合innerHTML 用来生成稳定的结构模板textContent 用来填充不可信数据。这样既省掉了繁琐的 createElement 代码又避免了 XSS 注入。很多有经验的前端都会用这种“模板结构 安全填充”的混合写法。你在面试里如果能讲出这个细节比干巴巴背一个“不要用 innerHTML”要有说服力得多。搜索按钮和加载更多的逻辑挂在事件里document.getElementById(search-btn).addEventListener(click, function() { state.keyword document.getElementById(search-input).value.trim() fetchUsers(true) }) document.getElementById(load-more).addEventListener(click, function() { state.page 1 fetchUsers(false) })4.3 参数构造和防抖踩坑记录这个案例里有一个容易被忽略的点URL 参数拼接。keyword 从输入框里直接拿过来如果用户输入了中文或者特殊符号不经过 encodeURIComponent 直接拼 URL轻则参数丢失重则请求直接 400。我在项目里见过太多因为、、#之类字符导致的线上 bug。所以凡是用户输入的参数一律 encodeURIComponent这个习惯务必养成。另外一个实际体验问题搜索按钮如果被用户连续快速点击就会连续发起相同请求旧响应后回来还会覆盖新响应造成渲染错乱。解决思路有两个低配版是发请求前禁用按钮拿完数据再恢复高配版是加 abort 控制把上一次未完成的 XHR 请求 cancel 掉let currentXhr null function fetchUsers(reset true) { if (currentXhr) { currentXhr.abort() } currentXhr request({ url, method: GET }) currentXhr.then(data { currentXhr null // 渲染逻辑... }) }abort 之后之前的 onreadystatechange 不会再触发到 readyState 4也就不会产生覆盖渲染。这个技巧在搜索框自动补全、Tab 切换加载数据时非常有用比单纯加 loading 锁体验好很多。5. 常见问题与排查技巧实录5.1 请求成功但页面空白responseType 与 DOM 操作的坑有一种情况很诡异Network 面板里能看到响应数据控制台也没报错但页面就是空白。我排查过几次最后发现原因多半出在 responseType 和 DOM 更新时机的配合上。比如你把 responseType 设成了 json然后又在代码里用了 xhr.responseText此时 responseText 是空字符串——因为浏览器在 responseType 不是 text 的时候不会填充 responseText数据都在 xhr.response 里。还有一类情况跟 DOM 相关你渲染数据的容器本身的 id 写错了或者渲染函数执行时容器还没挂载到文档里。虽然 script 放在 body 底部可以在很大程度上避免“找不到元素”的问题但如果你的代码是动态插入的模块执行时机没控制好document.getElementById 就有概率返回 null然后你还在 null 上操作 appendChild页面自然就空了一截。这种问题最简单的定位方式是在渲染函数入口处加一行 console.log(container)别急着怀疑数据链路。5.2 DOM 型 XSSinnerHTML 注入案例与防御方案刚才提到过 DOM 型 XSS这里必须展开讲。本质上它就是你往 innerHTML、document.write、outerHTML 这类“能解析 HTML 的赋值接口”里塞了不可信的字符串浏览器把其中的script标签或事件属性当成了可执行代码。XHR 拿到的响应数据天然是“不可信”的哪怕它来自你自己的后端——因为后端可能被第三方污染数据库可能被注入脏数据甚至中间环节被篡改。一个典型的注入案例用户搜索的关键词是img srcx onerroralert(document.cookie)如果你的渲染代码直接container.innerHTML p keyword /p那这段代码就会执行。防御方案最直接的就是把拼接的字符串全部转义function escapeHtml(str) { const div document.createElement(div) div.appendChild(document.createTextNode(str)) return div.innerHTML }这招的原理是 createTextNode 创建的永远是文本节点浏览器不会把其中的标签当 HTML 解析然后我们再取这个文本节点的 innerHTML得到的自然就是转义后的字符串。用它把 keyword 转义后再拼进 innerHTML注入就失效了。如果你用的是 Vue 或 React 这类框架它们默认的插值语法已经做了转义但 v-html / dangerouslySetInnerHTML 仍然是风险面规则一样非绝对可信数据永远不进 HTML 解析接口。5.3 跨域报错、缓存干扰和状态码误判实际项目里 XHR 最常见的两大报错一个是跨域一个是缓存。跨域的典型表现是 Network 面板里请求显示红色Console 报 “Access-Control-Allow-Origin” 相关错误。处理方式要么后端配置 CORS 头要么走同域代理转发。如果你是纯前端本地调试本地起一个 proxy 服务或者用脚手架自带的代理配置都能绕开。这里有个值得记的细节跨域写在 XHR 请求失败时会触发 onerror而错误信息里不一定能看到 HTTP 状态码因为浏览器直接拦截了响应status 会是 0。所以排查时可以先用 curl 直接测接口来判断问题到底出在服务端还是浏览器侧。缓存干扰也是个隐形杀手。默认情况下 XHR 遵循浏览器的 HTTP 缓存策略如果你的 GET 接口返回的响应头没有设置 Cache-Control浏览器在某些场景下会直接命中缓存导致你改了后端代码前端还是旧数据。排查办法是临时给 URL 加个时间戳参数url ?_t Date.now()。也可以在 XHR 上手动加上Cache-Control: no-cache请求头虽然这不是所有场景都有效但配合时间戳基本能解决开发期 99% 的缓存问题。状态码误判也多说一句。很多人只处理 200结果后端返回 201、204 或者 304 的时候就走了错误分支。正确写法是判断xhr.status 200 xhr.status 300把 2xx 整个区间都视为成功304 根据场景单独判断是否有内容可渲染。我自己封装的那版 request 就是这么处理的用下来省了很多沟通成本。6. 性能与面试延伸从 XHR 渲染到虚拟 DOM 和 diff 算法6.1 直接操作 DOM 慢在哪儿这是一个被面试官反复蹂躏、但很多人只背结论不会展开的点。直接操作 DOM 慢具体慢在三个环节一是 DOM 结构变更后会触发样式计算Style Recalc、布局Layout、绘制Paint复杂页面里这是一套重量级流程二是在循环里逐个修改节点会连续触发这个过程比如在 for 循环里一个个 appendChild三是频繁的读写操作会打乱浏览器的渲染优化机制造成强制同步布局Forced Synchronous Layout。拿我们的搜索列表来说如果服务端返回 1000 条数据你在 forEach 里逐条 appendChild 而没有使用 DocumentFragment浏览器就要在每一条插入后重新计算列表容器的尺寸和位置这种开销在小列表时无感数据一上量就卡。XHR 异步拿数据的场景天然就是“一次性大量数据涌入”的高发区所以这块知识跟 XHR 是强相关的。6.2 虚拟 DOM 是怎么解决这个问题的虚拟 DOM 的思路说白了就是先在 JS 对象层面模拟出一棵“虚拟的 DOM 树”数据变化时不直接动真实 DOM而是新生成一棵新的虚拟树和旧虚拟树做对比这个对比就是 diff找出最小差异集合最后统一把差异一次性提交到真实 DOM。对比 JSON 对象属性的开销要比触发浏览器的布局和重绘小得多所以数据量大、变更频繁时虚拟 DOM 能显著减少真实 DOM 操作次数。diff 算法面试的考点一般集中在同层对比、key 的作用、O(n) 复杂度的原因。你回答时可以带着 XHR 的业务场景讲比如列表数据刷新时如果给每一条列表项一个稳定的 key比如用户 iddiff 算法就能精确识别哪些是新增、哪些是删掉、哪些只是顺序变了从而只做最小的节点操作。如果没有 key 或者用了 index 当 key列表重排时 diff 的成本会升高甚至造成状态错位——比如输入框内容串行。这个例子一说面试官基本就知道你是真懂不是背的。6.3 原生 XHR 还在哪些场景有不可替代性有人可能会问现在 axios 满地走原生 XHR 还有必要掌握吗坦诚讲业务代码里直接裸写 XHR 的情况越来越少但有三类场景它仍然有不可替代的江湖地位。第一类是上传进度的精确控制axios 底层用的就是 XHR它的 onprogress 事件可以拿到已上传字节数fetch 的 upload 进度到现在都还是“半残”状态。第二类是请求的中断和超时控制xhr.abort() 和 xhr.timeout 是稳定的多年 APIfetch 的 AbortController 虽然也能用但在兼容性和细节上不如 XHR 来得皮实。第三类是适配老代码库很多存量系统或者低版本浏览器环境只认 XHR。所以我的建议是你可以不用原生 XHR但你得有能力随时揭开 axios 那层封装看到底下是什么。尤其是维护老项目、或者在一些特殊网络环境下排障时打开控制台看到那堆 XMLHttpRequest 相关的信息如果你脑子里没这张图会非常被动。7. 个人实战经验总结最后分享几个我对这个主题最真实的体会。一是写 XHR 请求之前先把数据结构和渲染容器想清楚再动手代码不是越少越好是越明确越好我在多个人项目里发现最痛苦的不是请求失败而是请求成功后不知道数据往哪儿放。二是 DOM 操作守一条底线所有外部输入和接口返回的数据默认当成“危险品”处理能用 textContent 绝不直接拼 HTML真要用 innerHTML 先过一遍转义函数这个习惯能替你挡掉至少五成以上的安全问题。三是一定要敢用 XHR 的进阶能力onprogress、timeout、abort 这些方法看着老实则在处理大文件上传、搜索防抖、并发竞态这些场景里异常好用比你在外面找的各种封装库反而更稳。XHR 和 DOM 这对组合说穿了就是数据与呈现的关系。你把它俩的原理吃透再看任何现代前端框架——不管是虚拟 DOM 还是各种请求库——都会有一种“原来都是从这里长出来”的通透感。项目里多用几次踩过几个真实的坑这些东西就会变成你身体里的肌肉记忆遇到问题不用翻文档就能下意识地排除掉一片可能性。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表