首页 / 资讯中心 / 文章详情

JavaScript核心Web API操作指南:DOM、Fetch与浏览器存储

JavaScript核心Web API操作指南:DOM、Fetch与浏览器存储 ★ FEATURED ARTICLE
1. 先聊聊这本操作指南的定位说实话,JavaScript 的 WebAPI 一直是前端开发者最熟悉的陌生人。框架用久了,很多人对document.querySelector、fetch、localStorage这些 API 的认知停留在一个模糊的印象上:好像会用,但真要解决一个具体问题,还得临时查 MDN。我见过不少同事,React 玩得很溜,但让他用原生 API 实现一个文件拖拽上传,或者处理一个跨标签页通信需求,会卡很久。这篇文章就是写给这类朋友的:不管你是刚入门想补基础的新手,还是在框架里泡了两年想系统梳理一遍的进阶者,把浏览器提供的这些核心 API 吃透,以后排查问题、做性能优化、写工具函数都会顺手很多。这里的核心操作我框定在五个大块:DOM 操作、Fetch 网络请求、浏览器存储、文件与表单、以及几个容易被忽略但极其实用的小 API。这些基本覆盖了日常业务开发 90% 的浏览器交互场景。每个部分我不仅讲用法,还会讲为什么这么设计、底层是怎么流转的、实际项目中容易踩哪些坑。读完之后,你拿这些 API 去写原生页面、去做框架里的自定义指令、去封装通用工具库,思路都会比现在清晰。2. DOM 操作:最基础,也最讲究细节2.1 选择器到底该怎么选document.querySelector几乎是现在用得最多的 DOM 获取方式,因为传入的是 CSS 选择器,非常灵活:const container document.querySelector(.app-container); const firstItem document.querySelector(.list-item:first-child); const input document.querySelector(input[nameusername]);但很多人忽略的是,querySelector返回的是第一个匹配元素。如果你需要一组元素,得用querySelectorAll,它返回的是静态的NodeList。这里的静态是重点:如果你用getElementsByClassName拿到的是 HTMLCollection,它是动态的,文档里新增一个符合条件的节点,集合会自动更新。这个差异在某些场景下会让人困惑。比如你循环操作一个 HTMLCollection 并往 DOM 里加节点,很容易死循环,因为集合长度一直在变。我的习惯是:大部分场景用querySelectorAll,拿到 NodeList 之后,如果想要数组方法,先转一下:const items document.querySelectorAll(.item); const arr Array.from(items); // 或者 [...items]如果你要做的事件委托性能很高,查元素频率极高,还可以用Element.matches()配合closest()来判断目标节点。这两个 API 在事件委托里是黄金搭档,后面会细说。2.2 dataset 和属性操作的正确姿势操作元素的自定义属性,原生 API 也有一套讲究。HTML 结构里你设了>const card document.querySelector(.card); const id card.dataset.userId; // 读取 card.dataset.active true; // 写一个新属性,渲染结果是>const x Number(card.dataset.x);另一个高频操作是classList,它提供了add、remove、toggle、contains几个方法。值得一提的是toggle可以传第二个参数,强制添加或移除:el.classList.toggle(active, condition); // condition 为 true 时添加,false 时移除这个写法替代手动 if 判断,代码简洁很多。还有className属性,它会覆盖整个 class 字符串,用的时候小心别把其他类名冲掉。2.3 事件监听器的生命周期addEventListener是标准的绑定方式,但很多人绑完之后从来不清理。对于单页应用,页面长期不刷新,事件累积会导致内存膨胀、逻辑重复触发。所以我在团队里一直强调:凡是动态创建的元素绑定事件,就要在销毁时解绑。removeEventListener要求传入和绑定时间同一个函数引用,所以命名函数比匿名函数更适合需要解绑的场景:function handleClick(event) { console.log(clicked, event.currentTarget); } element.addEventListener(click, handleClick); element.removeEventListener(click, handleClick);另外addEventListener的第三个参数可以传一个选项对象,这里有几个容易忽略的能力:once: true:事件触发一次后自动解绑,适合初始化类的操作;passive: true:告诉浏览器监听器不会调用preventDefault,滚动类事件用这个可以提升滚动性能;signal:传入一个AbortSignal,通过AbortController统一解绑多个监听器。第三个signal选项很有用。比如你有五个事件绑在同一个元素上,想一次全清掉:const controller new AbortController(); const { signal } controller; element.addEventListener(click, handler1, { signal }); element.addEventListener(mousemove, handler2, { signal }); // 清理时 controller.abort();我开始用这个模式之后,管理组件销毁时的清理工作变得特别省心。另外提醒一句:事件委托的核心是判断currentTarget和target。绑定的元素是 currentTarget,实际点击的元素是 target,通过两者关系可以判断事件来自哪里。3. Fetch 网络请求:从会用到用好3.1 完整请求参数与请求头发送fetch比XMLHttpRequest好用得多,但默认行为和一些参数细节还是会让人踩坑。先给一个完整的请求示例:const response await fetch(/api/user, { method: POST, headers: { Content-Type: application/json, X-Requested-With: XMLHttpRequest }, body: JSON.stringify({ name: 张三, age: 30 }), credentials: same-origin });这里有几个关键点要讲清楚。第一,Content-Type得手动设置。fetch不像axios那样自动根据 body 类型调整,如果你发送的是 JSON 字符串,但没设置application/json,后端可能解析不到数据。我见过太多联调故障是这一行 header 引起的。第二,credentials默认值是same-origin,也就是说跨域请求默认不带 cookie。如果你们的接口是跨子域的,比如前端在app.example.com,接口在api.example.com,你需要设置credentials: include,同时后端要配合响应头Access-Control-Allow-Origin而不能是*。第三,请求头里的Content-Type有几种常见选择:application/json:JSON 字符串体;application/x-www-form-urlencoded:表单序列化字符串,适合简单的键值对;multipart/form-data:文件上传,配合 FormData 用,浏览器会自动加上boundary分隔符,这时候不要手动设置Content-Type,否则可能造成请求体解析失败。3.2 响应处理的完整流程fetch返回的Promise只有在网络出错时才会 reject。HTTP 状态码 404、500 这些它不会当成错误,所以每个请求都要手动判断response.ok:const response await fetch(/api/data); if (!response.ok) { const message 请求失败: ${response.status} ${response.statusText}; throw new Error(message); } const data await response.json();我在这个判断上吃过很直接的亏。当时写一个批量导入功能,接口 500 了,前端却把错误页面当成功数据处理,导致表格出现一堆 undefined。从那以后,我封装请求工具的第一件事就是先拦截非 2xx 状态。响应体按用途有几种读取方式:response.json()、response.text()、response.blob()、response.formData()、response.arrayBuffer()。注意同一个 response 只能读取一次,如果既想看错误文本又想解析 JSON,就得用response.clone()或者转成 text 再手动解析。const response await fetch(/api/data); const text await response.text(); try { const data JSON.parse(text); console.log(data); } catch { console.error(解析失败,原始内容:, text); }这种策略在调试接口返回非标准 JSON 时特别有用。比如有些网关会在正常响应前加一段提示文本,直接json()会抛异常,先拿 text 你能看清楚问题出在哪。3.3 超时控制与请求中止fetch没有默认的超时时间,网络一直挂起程序就一直等着。所以封装的时候一定要手动实现超时。常用的方案是配合AbortController:const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 10000); try { const response await fetch(/api/slow, { signal: controller.signal }); const data await response.json(); clearTimeout(timeoutId); return data; } catch (err) { if (err.name AbortError) { console.error(请求超时了); } else { console.error(其他错误, err); } }其实AbortController不止能配合 fetch,前面提到的 DOM 事件、甚至流式读取都能用同一套信号机制管理。我封装一个统一的请求函数时,会把组件卸载时触发的清理和请求自身的超时合并到一个控制器上,这样用户离开页面时不用等待请求结束,自动中止,省了很多无谓的资源开销。调用链也行得通:如果你有多个请求并发,可以用Promise.all配合一个共享的 AbortController 实现失败一个全部取消:const controller new AbortController(); const requests [ fetch(/api/a, { signal: controller.signal }), fetch(/api/b, { signal: controller.signal }) ]; try { const [a, b] await Promise.all(requests); } catch (err) { controller.abort(); }这个模式在数据仪表盘里常见,其中一个接口挂了,与其让其他请求继续空跑,不如全部取消。4. 浏览器存储:选型与边界4.1 localStorage 和 sessionStorage 的适用边界很多团队把 localStorage 当成万能存储,什么都往里塞,包括一些根本不适合的东西。它有几个硬限制需要摆到台面上:存储内容只能同步读写、容量通常在 5MB 左右、存储内容被所有同源标签页共享而且没有过期时间。还有一点,业务数据多了之后,同步解析大 JSON 会阻塞主线程,页面会卡顿。sessionStorage和 localStorage 的区别在于生命周期与会话绑定,标签页关闭就没了,而且每个标签页的数据互相隔离。这个特性其实很适合某些场景。比如我在做后台管理系统时,会把当前用户临时筛选条件放到 sessionStorage,切换标签页之间不共享,用户刷新还在,关掉页面自动消失,不需要清理逻辑。实际建议是:简单的用户偏好、登录令牌、非敏感配置:localStorage;标签页临时的 UI 状态、草稿:sessionStorage;结构化数据、大量数据、需要索引查询:IndexedDB;服务端需要自动随请求发送的数据:用 Cookie 而不是 localStorage。如果只是临时跨模块共享数据,还可以用BroadcastChannel或storage事件来做标签页同步。storage事件会在其他标签页修改 localStorage 时触发:window.addEventListener(storage, (event) { if (event.key userInfo) { console.log(用户信息在别的标签页被修改了:, event.newValue); } });这个机制在同源多标签页需要同步登录状态时非常好用,比如你一个页面登录了,希望能通知另外几个标签页刷新用户信息。4.2 IndexedDB 用起来没那么可怕说到 IndexedDB,不少人有心理障碍,觉得接口繁琐、概念复杂。但真正用起来,把核心流程梳理清楚,它就是一个带索引的本地数据库。这里给一个最精简的存取示例。首先打开数据库:const request indexedDB.open(my-db, 1); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(users)) { const store db.createObjectStore(users, { keyPath: id }); store.createIndex(name, name, { unique: false }); } }; request.onsuccess (event) { const db event.target.result; console.log(数据库打开成功, db); };新增数据:function addData(db, storeName, data) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); const store tx.objectStore(storeName); store.add(data); tx.oncomplete () resolve(); tx.onerror (e) reject(e.target.error); }); }查询数据:function getData(db, storeName, key) { return new Promise((resolve, reject) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const request store.get(key); request.onsuccess () resolve(request.result); request.onerror (e) reject(e.target.error); }); }如果你不想每次都手动包 Promise,可以直接用idb-keyval这类迷你库,整个 API 就剩下get、set、del几个方法。我在项目里用 IndexedDB 存过百万级的基础字典数据,加载速度比每次从接口拉要快一个量级。核心教训是:不要把所有数据塞到一个 store 里,按业务域拆开,配合索引去查,性能才能稳。4.3 Cookie 的使用注意事项虽然现在前端存储不依赖 Cookie 了,但登录态相关需求还是离不开它。用document.cookie读写有一点要记住:它返回的是所有 Cookie 拼接的字符串,需要自己解析。// 写入 document.cookie token${encodeURIComponent(token)}; path/; max-age86400; SameSiteLax; // 读取 function getCookie(name) { const match document.cookie.match(new RegExp((^|; )${name}([^;]*))); return match ? decodeURIComponent(match[2]) : null; }安全方面,HttpOnly 标识通常由后端设置,前端 JS 读不到,这是好事。HttpOnly 的 Cookie 能防止 XSS 攻击时令牌被脚本窃取。前端写入 Cookie 时,尽量加上SameSiteLax,防止 CSRF 风险,同时保留站内正常请求的携带。5. 文件、表单与剪贴板:交互大头全在这5.1 FileReader 实现文件预览文件上传功能里最常见的需求是选择图片后立刻预览。这块用FileReader非常成熟,流程是:拿到 File 对象,调用readAsDataURL,然后在onload回调里拿 base64 字符串塞给img.src。inputElement.addEventListener(change, (event) { const file event.target.files[0]; if (!file) return; const reader new FileReader(); reader.onload (e) { const img document.querySelector(#preview); img.src e.target.result; }; reader.readAsDataURL(file); });这里有几个细节很容易忽略。一个是input[typefile]重复选择同一个文件不会触发 change,需要在选择结束后手动把input.value清空:inputElement.addEventListener(change, (event) { // ...处理文件 event.target.value ; });另一个是图片最大的情况。base64 会比原文件大 30% 左右,如果一张图十几 MB,img 的 src 会非常长,内存开销不小。方案是先压缩,再预览。要压缩,可以用 Canvas 把图片绘制到指定尺寸再导出 dataURL:function compressImage(file, maxWidth) { return new Promise((resolve) { const reader new FileReader(); reader.onload (e) { const image new Image(); image.onload () { const scale Math.min(1, maxWidth / image.width); const canvas document.createElement(canvas); canvas.width image.width * scale; canvas.height image.height * scale; const ctx canvas.getContext(2d); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) resolve(blob), image/jpeg, 0.7); }; image.src e.target.result; }; reader.readAsDataURL(file); }); }toBlob是现在更推荐的处理方式,它直接得到压缩后的 Blob,上传时更节省流量。如果你需要的是 dataURL,用canvas.toDataURL即可。5.2 FormData:一步到位搞定上传传统表单上传过程和 Fetch 结合非常顺滑,核心就是 FormData。先把文件加进去:const formData new FormData(); formData.append(file, file, file.name); formData.append(uploadPath, /images/avatar);然后 fetch 上传:const response await fetch(/api/upload, { method: POST, body: formData });注意这里不要手动设置Content-Type,浏览器会自动设置成multipart/form-data并带上 boundary。手动设置反而会丢边界信息,后端解析不了。这个坑我遇到不下五次,每次联调上传都有人在这栽跟头。除了文件,FormData 还可以丢 Blob、字符串,甚至直接用顺序构造多层字段。进度条比较麻烦,fetch 原生不提供上传进度事件。如果你需要实时进度,要么用 XMLHttpRequest 的upload.onprogress,要么用第三方库。这个取舍要心里有数。5.3 Clipboard API 实现复制粘贴复制文本到剪贴板,现在推荐用异步的 Clipboard API:async function copyText(text) { try { await navigator.clipboard.writeText(text); console.log(复制成功); } catch (err) { console.error(复制失败, err); } }这里要注意:大多浏览器要求页面处于安全上下文(HTTPS 或 localhost)才开放这个 API。另外,调用时机要跟用户手势在同一次事件循环里,否则浏览器可能拦截。为了解决兼容性问题,我一般会兜底一个方案:function fallbackCopy(text) { const textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.opacity 0; document.body.appendChild(textarea); textarea.select(); document.execCommand(copy); document.body.removeChild(textarea); }document.execCommand虽然标记废弃,但在不少老浏览器里还能用,作为兜底没问题。读取剪贴板内容用navigator.clipboard.readText()也可以,但会触发权限询问,不建议在页面加载时自动调用。6. 几个能显著提升体验的实用 API6.1 IntersectionObserver:滚动加载与曝光埋点IntersectionObserver 用来监听元素是否进入视口,特别适合做无限滚动、懒加载、以及页面刷到了才上报的埋点逻辑。基本用法:const observer new IntersectionObserver((entries, observer) { entries.forEach((entry) { if (entry.isIntersecting) { console.log(元素进入视口, entry.target); // 触发加载 observe.disconnect(); // 如果只触发一次,就断开观察 } }); }, { rootMargin: 0px 0px 200px 0px, // 提前 200px 触发 threshold: 0.1 }); const target document.querySelector(.lazy-load-item); observer.observe(target);对比传统的scroll事件加getBoundingClientRect计算方法,IntersectionObserver 的异步回调机制对主线程更友好,不会因为滚动高频触发而卡顿。做无限滚动的时候,还可以配合一个哨兵元素放在列表底部,观察哨兵是否可见,来决定是否加载下一页。6.2 网络状态与页面可见性navigator.onLine可以判断是否在线,但它的准确性一般,而且只有在网络状态变化事件里最有用:window.addEventListener(offline, () { showToast(网络已断开); }); window.addEventListener(online, () { hideToast(); retryPendingRequests(); });在实现断网提示 自动重试时,这两个事件非常省事。我之前做过一个报表系统,断网期间用户输入了大量内容,恢复网络后我们用online事件触发一次自动保存,体验特别顺畅。页面可见性 API 则是处理后台标签页的利器。比如你将音乐播放器切到后台,音频还在播放,但动画可以暂停:document.addEventListener(visibilitychange, () { if (document.hidden) { document.title 页面在后台运行; pauseAnimation(); } else { document.title 回到页面了; startAnimation(); } });这里有个使用细节:不要在visibilitychange里做重任务,因为页面切到后台时 CPU 资源受限。合理的做法是只更新状态,具体逻辑交给定时器或 requestAnimationFrame 去判断。6.3 ResizeObserver 监听容器尺寸变化响应式布局时,很多时候需要监听某个容器的尺寸变化,但window.resize只在窗口变化时触发,容器内部变化它感知不到。ResizeObserver就是为这个场景设计的:const container document.querySelector(.chart-wrap); const ro new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; chart.resize(width, height); } }); ro.observe(container);图表库的自适应、侧栏折叠后内容区的重新绘制,基本都靠它。用的时候注意:回调里不要做特别重的同步计算,连续触发场景(拖动分割条)会频繁执行。可以配合requestAnimationFrame做节流,或者直接在里面用debounce。7. 高频问题速查与实战排查问题现象可能原因解决方案fetch 请求发出去,后端拿不到 POST 参数没设置Content-Type: application/json或 body 不是合法 String检查 headers 与 body 格式FormData 上传后,后端说 boundary 不对手动设置了 Content-Type删除手动 Content-TypelocalStorage 存着存着突然报 QuotaExceededError存储空间满了try/catch 捕获,提示用户清理事件绑定了但没生效元素是动态生成的,绑定时机太早用事件委托或改为生成后绑定监听器重复执行多遍组件多次挂载,事件一直在累加解绑旧监听器;用 once/signal 统一管理文件预览显示不了大图base64 太长或内存占用过高先用 Canvas 压缩再预览复制代码在非 HTTPS 环境失效Clipboard API 受安全上下文限制提供 execCommand 兜底页面滚动时懒加载闪烁监听 scroll 事件卡顿改用 IntersectionObserver实际排查时的思路,我推荐按先定位阶段,再看 API 行为来走。比如请求问题,先用浏览器的网络面板看请求头、请求体、响应内容,确认是发送端还是接收端的问题;再看代码里有没有手动设置不该设置的字段。DOM 问题优先在 Elements 面板查看绑定元素是否存在、事件源是否被遮挡。存储问题用 Application 面板看成是哪个 key 撑爆了。还有一个很值得做的通用实践:把常用的 WebAPI 封装成独立小工具库,比如 safeStorage、request、domUtil 这几个模块。每个模块都做好边界处理(QuotaExceededError、AbortError、JSON 解析异常),这样业务代码里只管调用,不会因为底层 API 的一些怪癖来回折腾。8. 关于这套 API 的几点复盘心得文档写了很多,但真正的经验都是踩坑踩出来的。我最早做前端时,也以为会查 MDN 就够了,后来在真实项目里被三次生产故障教训过:第一次是 fetch 409 没处理,导致用户看到崩溃页;第二次是 localStorage 存了大量日志数据,某天直接写入失败,导致表单没法保存;第三次是滚动加载用 scroll 事件,页面卡得没法操作,换成 IntersectionObserver 才缓过来。每一次问题的根源都不是 API 不会用,而是对 API 的边界条件不了解。所以这篇核心操作指南,与其说是列 API,不如说是在划边界。每个 API 都不是简单的功能函数,而是一套有前提、有成本、有陷阱的协议。理解它适合什么,不适合什么,比记住所有参数更重要。像 AbortController 这种统一信号机制,打通了事件、请求和中止管理,是这几年前端 API 设计的一个很出色的方向,值得在组件封装时深入应用。最后再分享一个小技巧:做原生 WebAPI 调试时,我会开一个独立的测试页面,上面只放一个按钮和一段日志区,专门用来验证某个 API 的实际行为。比如想确认querySelectorAll返回的 NodeList 是否包含隐藏元素、想确认resize事件在容器 flex 变化时到底会不会触发,都在这个页面里快速验证。不用每次改业务代码、跑整套工程,效率能高出一大截。这套小工具我已经用习惯,遇到不确定的兼容性或边界行为,第一时间在隔离环境里测清楚,比翻文档猜答案要稳得多。
阅读完成 · 觉得有帮助?
咨询建站