☰
DOM与XMLHttpRequest:从数据请求到页面渲染的完整链路
2026/10/9 6:31:32 网站建设 项目流程

每次调接口,前端最绕不开的就是两个家伙:DOM 和 XMLHttpRequest。一个是页面骨架的操作入口,一个是浏览器里最原始的数据请求通道。虽然现在大家张口闭口都是 fetch、axios,但 XHR 这套老底子只要还在浏览器里跑一天,你就绕不开它——尤其你一旦碰上老项目维护、上传进度条、或者面试官冷不丁甩你一句“讲讲 XMLHttpRequest 和 DOM 的关系”,没有点底层认知是真的会卡壳。

这篇文章不打算给你搞那种“复制粘贴就能用”的玩具代码,而是把 DOM 操作和 XHR 请求这条链路上的关键细节从头到尾捋一遍:为什么 XHR 有那么多反直觉的坑,readyState 到底怎么理解,responseText 和 responseXML 什么时候能直接用,动态渲染时一旦用到 innerHTML 又是怎么把页面搞出 DOM 型 XSS 的。适合刚入门前端想打牢基础的同学,也适合写过不少业务代码、但一直没时间把这块地基补上的老手。看完你可以直接照着里面的封装思路改造自己的项目,也能拿去应付绝大多数浏览器原理层面的面试题。

1. 项目解析:为什么 DOM 和 XMLHttpRequest 总被放在一起聊

1.1 两者在浏览器里的真实分工

先说最基础的关系。浏览器拿到 HTML 之后,会把它解析成一棵节点树,这棵树就是 DOM(Document 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 还没 send,2 是已 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 class="user-item"> <span>${user.name}</span> <span>${user.age}岁</span> </li> `).join('') container.innerHTML = html }

两种方式都对,但适用场景不同。纯 DOM 方式安全系数高,因为 textContent 会自动转义,不会把数据里的 HTML 标签或脚本当代码执行;渲染过程可控,适合需要给每个节点绑事件、或者要做局部更新的场景。缺点是代码啰嗦,几千条数据时如果没配合 DocumentFragment,性能会肉眼可见地拉胯。

innerHTML 方式写起来爽,适合结构固定、数据量大但一次性的渲染场景。但它的风险也很明确:如果数据里混了<img src=x onerror=alert(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;charset=UTF-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?keyword=xxx&page=1,拿到第一页用户数据渲染到列表里;点击“加载更多”时请求下一页,把新数据追加到列表底部。这个场景覆盖了 XHR 的核心操作、DOM 渲染、拼接式更新数据和状态维护,是一个很典型的前端数据交互闭环。

我把 HTML 骨架先写出来:

<div id="app"> <input type="text" id="search-input" placeholder="输入用户名搜索" /> <button id="search-btn">搜索</button> <ul id="user-list"></ul> <button id="load-more" style="display: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 class="user-name"></span> <span class="user-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 型 XSS:innerHTML 注入案例与防御方案

刚才提到过 DOM 型 XSS,这里必须展开讲。本质上它就是你往 innerHTML、document.write、outerHTML 这类“能解析 HTML 的赋值接口”里塞了不可信的字符串,浏览器把其中的<script>标签或事件属性当成了可执行代码。XHR 拿到的响应数据天然是“不可信”的,哪怕它来自你自己的后端——因为后端可能被第三方污染,数据库可能被注入脏数据,甚至中间环节被篡改。

一个典型的注入案例:用户搜索的关键词是<img src=x onerror=alert(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(比如用户 id),diff 算法就能精确识别哪些是新增、哪些是删掉、哪些只是顺序变了,从而只做最小的节点操作。如果没有 key 或者用了 index 当 key,列表重排时 diff 的成本会升高,甚至造成状态错位——比如输入框内容串行。这个例子一说,面试官基本就知道你是真懂,不是背的。

6.3 原生 XHR 还在哪些场景有不可替代性

有人可能会问,现在 axios 满地走,原生 XHR 还有必要掌握吗?坦诚讲,业务代码里直接裸写 XHR 的情况越来越少,但有三类场景它仍然有不可替代的江湖地位。第一类是上传进度的精确控制,axios 底层用的就是 XHR,它的 onprogress 事件可以拿到已上传字节数,fetch 的 upload 进度到现在都还是“半残”状态。第二类是请求的中断和超时控制,xhr.abort() 和 xhr.timeout 是稳定的多年 API,fetch 的 AbortController 虽然也能用,但在兼容性和细节上不如 XHR 来得皮实。第三类是适配老代码库,很多存量系统或者低版本浏览器环境只认 XHR。

所以我的建议是,你可以不用原生 XHR,但你得有能力随时揭开 axios 那层封装看到底下是什么。尤其是维护老项目、或者在一些特殊网络环境下排障时,打开控制台看到那堆 XMLHttpRequest 相关的信息,如果你脑子里没这张图,会非常被动。

7. 个人实战经验总结

最后分享几个我对这个主题最真实的体会。一是写 XHR 请求之前,先把数据结构和渲染容器想清楚再动手,代码不是越少越好,是越明确越好,我在多个人项目里发现,最痛苦的不是请求失败,而是请求成功后不知道数据往哪儿放。二是 DOM 操作守一条底线:所有外部输入和接口返回的数据,默认当成“危险品”处理,能用 textContent 绝不直接拼 HTML,真要用 innerHTML 先过一遍转义函数,这个习惯能替你挡掉至少五成以上的安全问题。三是一定要敢用 XHR 的进阶能力,onprogress、timeout、abort 这些方法看着老,实则在处理大文件上传、搜索防抖、并发竞态这些场景里异常好用,比你在外面找的各种封装库反而更稳。

XHR 和 DOM 这对组合,说穿了就是数据与呈现的关系。你把它俩的原理吃透,再看任何现代前端框架——不管是虚拟 DOM 还是各种请求库——都会有一种“原来都是从这里长出来”的通透感。项目里多用几次,踩过几个真实的坑,这些东西就会变成你身体里的肌肉记忆,遇到问题不用翻文档就能下意识地排除掉一片可能性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询