
1. 学习路线规划为什么Day3必须啃下异步请求和数据持久化前两天的内容如果跟下来了你应该已经能把页面里的DOM节点玩得比较溜事件绑定、节点增删、样式切换这些操作基本顺手。但到了真实项目里你会发现一个尴尬的问题页面不能只靠写死的静态数据撑场子。用户信息从哪来商品列表从哪加载表单提交之后怎么处理这时候就轮到Web APIs里最硬核的一批接口登场了。Day3我给自己定下的目标是三条主线Fetch API处理异步请求、Web Storage做前端数据持久化、FormData与文件上传接口。这三块内容放在一天里学节奏是比较紧凑的但配合度非常高。日常开发中入参、请求、存储——这是一条完整的数据链路拆开学反而容易断层。先说为什么优先级这么排。如果你做过Spring Boot项目的接口联调或者接手过企业级Web开发中的任何一个小模块就会意识到前端工程的核心从来不是“画页面”而是“管数据”。页面上的按钮点了之后要发请求请求回来要更新视图用户关掉浏览器再打开状态最好还能保留——这一整套动作依赖的就是今天要学的这批浏览器原生API。顺便说一句CTF赛题里Web方向的接口分析套路也跟这块内容脱不开关系。拿到一个web靶场第一步就是看网络请求、翻LocalStorage很多隐藏接口和调试信息就藏在里面。所以Day3学的东西实战价值和应急场景价值都很高。2. Fetch API实战请求接口的正确姿势2.1 从XMLHttpRequest到Fetch为什么说时代变了接触过老项目的同学对XMLHttpRequest那套肯定不陌生——回调地狱、手动处理状态码、代码量还特别大。新写的代码里我强烈建议直接用Fetch。它是浏览器原生提供的接口基于Promise设计写起来更简洁也更容易配合async/await使用。举个最简单的GET请求例子async function getUserInfo(userId) { try { const response await fetch(/api/user/${userId}); if (!response.ok) { throw new Error(HTTP error! status: ${response.status}); } const data await response.json(); return data; } catch (error) { console.error(请求失败, error); throw error; } }这里有几个重点要提醒你。第一fetch返回的Response对象在你调用response.json()之前并不会自动解析数据。很多新手在这里踩坑以为response就是数据本身结果打印出来一堆字段也看不懂。Response对象里真正有价值的是status、ok、headers这些元信息数据要通过对应的方法去取。第二HTTP状态码的坑。Fetch与老版XHR最大的区别是只要服务器有响应——哪怕返回的是500、404——fetch一样会走Promise的resolve而不是走进catch。所以一定要通过response.ok或者response.status来判断请求是否真正成功。我见过不止一个人因为这个特性页面挂了还在假装数据正常。第三请求超时的处理。Fetch原生没有内置超时机制需要用AbortController自己模拟。这个功能在企业级开发里几乎必用否则用户在弱网环境下点击按钮界面会一直处于加载中体验极差。function fetchWithTimeout(timeout 10000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { signal: controller.signal }) .finally(() clearTimeout(timer)); }2.2 POST请求与JSON数据提交开发中更常见的是POST请求尤其是登录、提交表单、保存配置这类操作。直接上代码async function submitForm(formData) { try { const response await fetch(/api/order/create, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ userId: U001, items: [sku_1001, sku_1002], address: 默认收货地址 }), }); const result await response.json(); if (result.code 0) { // 业务成功逻辑 } else { // 业务失败逻辑 } } catch (error) { // 网络错误或异常 } }这里最容易被忽略的是Content-Type与body的格式必须匹配。你声明了application/jsonbody就要用JSON.stringify序列化如果你不写Content-Type直接用FormData当body浏览器会自动生成multipart/form-data格式。前端传参格式和后端接口的定义对不上大概率会拿到一个“参数解析失败”的莫名其妙报错。另外和后端同学联调的时候建议先确认对方接口的返回结构。现在很多企业项目喜欢统一返回{ code, message, data }这种格式前端就可以封装一个统一的请求层。我在实际项目里通常会把fetch包一层把接口前缀、token注入、状态码拦截、错误提示都收拢到一个模块里这样业务代码里就不会到处散落if (response.status 401)之类的逻辑。2.3 并发请求与竞态处理写真实业务场景的时候经常遇到“两个接口都返回了才能渲染”的情况。Fetch本身的Promise特性让并发处理变得很优雅const [userInfo, productList] await Promise.all([ getJson(/api/user/info), getJson(/api/product/list), ]);有一个细节值得记住如果多个请求之间有依赖关系用await按顺序写即可如果相互不依赖一定要用Promise.all否则串行请求会白白浪费一半以上的时间。竞态问题则更适合用AbortController处理。比如列表页里用户快速切换筛选条件第一次请求还没回来第二次请求已经发出去了。这时候不把上一次请求中断页面可能被先反馈的后一个请求结果覆盖造成数据错乱。我看到很多团队的代码是用“请求序号变量”硬比虽然也能解决但远不如直接abort干净。3. Web Storage实战把数据留在用户浏览器里3.1 localStorage与sessionStorage的定位区分前端需要一个“轻量级存储”的时候基本上就是localStorage和sessionStorage两选一。程序员用 localStorage 也换不了钱这俩 API 就是纯粹的字典式存取而已但彼此定位却差很远。localStorage持久化存储浏览器关闭、重启都不会失效。适合存用户偏好设置、主题色、历史记录这类“下次打开还想看到”的数据。sessionStorage会话级别的存储标签页关闭即失效刷新页面还在。适合存一次会话内需要共享的临时状态比如“填写到一半的表单草稿”。这两个API的语法完全一致setItem(key, value)、getItem(key)、removeItem(key)、clear()。用法没什么难度难度在于值只能存字符串。这跟很多人“存对象直接丢进去”的直觉是反过来的// 存对象必须序列化 const userInfo { name: 小明, level: 5, lastLogin: Date.now() }; localStorage.setItem(userInfo, JSON.stringify(userInfo)); // 取出来记得反序列化 const cachedInfo JSON.parse(localStorage.getItem(userInfo) || null);JSON.parse一个格式不对的字符串会直接抛异常所以取数据的时候最好带个兜底默认值像上面这样写|| null或者用try/catch包住才不会把整个页面搞崩。3.2 购物车和页面状态的本地持久化方案我做练习的时候拿“购物车”当案例用户在商品列表页加购了几件商品页面刷新之后购物车不应该清空。这个需求用localStorage实现再自然不过。const CART_KEY shop_cart; function getCart() { try { return JSON.parse(localStorage.getItem(CART_KEY) || []); } catch { return []; } } function addToCart(sku) { const cart getCart(); cart.push(sku); localStorage.setItem(CART_KEY, JSON.stringify(cart)); renderCart(cart); }实现本身很简单但实际开发中还有几个隐藏点值得你注意。第一localStorage是有容量上限的大多数浏览器限制在5MB左右。如果你拿它存大量业务数据页面迟早会报QuotaExceededError。虽然这个错误可以用try/catch兜住但更好的做法是配合IndexedDB存储大体积数据。Day3阶段先把localStorage用明白知道它的边界在哪就够了。第二多个页面之间的同步问题。同一个浏览器里多标签页同时打开你的应用一个页面改了localStorage另一个页面不知道。浏览器其实提供了一个storage事件来广播变化监听之后可以实现跨页面同步window.addEventListener(storage, (event) { if (event.key CART_KEY) { renderCart(JSON.parse(event.newValue || [])); } });注意这个事件只在别的标签页触发当前页内自己改数据不会收到通知。第三安全边界要想清楚。localStorage里的数据对任何同源脚本都是可读的所以永远不要往里面放口令、令牌一类的高敏感信息。做CTF题目的时候你也会发现出题人喜欢往本地存储里塞点“隐藏标记”——反过来也提醒我们真正线上的项目千万不能把这当存储保险箱。3.3 带过期时间的缓存方案localStorage本身没有“过期时间”的概念但业务上经常需要“缓存数据10分钟内有效过期重新拉取”。这个小坑我在社区回复里看到无数人踩过。其实实现方式很简单存的时候把时间戳一起存进去取的时候判断差值。const CACHE_KEY price_cache; function setCache(key, value, ttl 10 * 60 * 1000) { localStorage.setItem(key, JSON.stringify({ data: value, expire: Date.now() ttl, })); } function getCache(key) { const raw localStorage.getItem(key); if (!raw) return null; try { const { data, expire } JSON.parse(raw); if (Date.now() expire) { localStorage.removeItem(key); return null; } return data; } catch { return null; } }这段代码在项目里被我拆成了一个独立的cacheUtil.js模块用到的地方直接import。比如商品列表的接口请求加一个10分钟的缓存用户反复进页面时体验马上提升一个档次——这不是技巧这是分内事。4. FormData与文件上传处理表单请求的完整链路4.1 用FormData组织表单字段以前提交表单页面要么手动document.getElementById一个个取值拼body要么依赖第三方库。其实浏览器原生的FormData接口就能把表单字段一把梭。form idorderForm input namename placeholder姓名 required / input namephone placeholder电话 required / select nameexpress option valuesf顺丰/option option valueyt圆通/option /select button typesubmit提交/button /formconst formEl document.getElementById(orderForm); formEl.addEventListener(submit, async (event) { event.preventDefault(); const formData new FormData(formEl); // 手动追加额外字段 formData.append(userId, getCurrentUserId()); const response await fetch(/api/order/submit, { method: POST, body: formData, }); const result await response.json(); console.log(result); });用FormData(formElement)构造的时候会自动采集表单里所有带name属性的控件值不用再写一行行取值代码。而且不需要手动设置Content-Type浏览器会自动设置带boundary的multipart/form-data格式服务器端解析也比较友好。表单里的校验也不能忘formEl.checkValidity()可以触发原生校验提示配合submit事件在合理时机拦截。4.2 图片上传与Base64预览文件上传是几乎所有管理系统都躲不掉的功能。之前我做了一个练习项目要把用户头像上传到服务器还要在本地预览。传统的做法是FileReader读成Base64但现代浏览器还有一条更轻的路——URL.createObjectURL。const fileInput document.getElementById(avatarInput); fileInput.addEventListener(change, (event) { const file event.target.files[0]; if (!file) return; const previewUrl URL.createObjectURL(file); const imgEl document.getElementById(avatarPreview); imgEl.src previewUrl; // 上传 const formData new FormData(); formData.append(avatar, file); fetch(/api/user/avatar, { method: POST, body: formData, }); });URL.createObjectURL生成的临时链接非常轻量性能比读Base64好很多。不过要注意用完记得调用URL.revokeObjectURL(previewUrl)释放资源否则大文件多来几次浏览器内存会明显上涨。另一个常见问题是文件类型和大小校验。前端校验写起来简单但永远不能取代后端校验。攻击者完全可以绕过前端直接构造请求。我处理文件上传的时候前端只是为了提示用户后端只要对不上白名单就直接拒绝。4.3 上传进度的监听方案小文件上传无所谓大视频、大包上传时必须给用户一个进度反馈。Fetch API原生不提供上传进度事件但我可以在低配版里用XMLHttpRequest来补这个能力function uploadWithProgress(file, onProgress) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/file/upload); xhr.upload.addEventListener(progress, (event) { if (event.lengthComputable) { const percent Math.round((event.loaded / event.total) * 100); onProgress(percent); } }); xhr.onload () resolve(xhr.response); xhr.onerror () reject(new Error(上传失败)); const formData new FormData(); formData.append(file, file); xhr.send(formData); }); }在实际项目里进度条组件配合这个函数基本能完成绝大多数上传场景。需要强调的一点服务端能不能正确处理这种上传取决于后端框架的配置大小限制。比如你用Spring Boot默认单文件上传上限是1MB老版本不对spring.servlet.multipart.max-file-size做调整的话传大文件会在后端直接给我个“文件大小超出限制”的报错。前后端联调时这个参数先说清楚。5. 其他值得顺手掌握的实用API5.1 URLSearchParams查询参数的序列化与解析很多人在拼接查询参数时习惯用模板字符串硬拼?id${id}name${name}。这样碰到参数里带了特殊字符比如、、中文很容易拼出非法的URL后端解析拿到的值还是错的。URLSearchParams可以接管这些脏活const params new URLSearchParams(); params.append(keyword, ESP32 开发); params.append(page, 2); params.append(tag, web实战); // 序列化结果 // keywordESP32%E5%BC%80%E5%8F%91page2tagweb%2B%E5%AE%9E%E6%88%98 fetch(/api/search?${params.toString()});从URL里解析现有的参数也很好用const searchObj new URLSearchParams(window.location.search); const id searchObj.get(id);页面之间跳转传参时这比你自己写正则靠谱得多。前几日在改一个旧项目的分页跳转逻辑稍微复杂一点儿的参数组合手写字符串拼接看得人头皮发麻顺手换成两个构造器整个人都神清气爽。5.2 地理位置与设备信息接口有一部分场景需要调用浏览器和底层设备的能力。比如运动打卡类的Web应用想拿到当前经纬度来做定位标记可以用navigator.geolocation.getCurrentPosition。不过这个API在实际场景里有几个点要注意用户必须授权页面必须HTTPS环境授权被拒时要做兜底提示。获取设备类型信息则简单得多const ua navigator.userAgent; const isMobile /Android|iPhone|iPad/i.test(ua);响应式设计、不同终端展示不同交互时这个判断代码在项目里出现频率还挺高的。5.3 IntersectionObserver实现滚动懒加载Day3如果时间充裕建议顺手把IntersectionObserver也认识一下。它可以在不改页面布局、不频繁监听滚动的状态下知道某个元素是否进入可视区域是列表页懒加载图片的利器。const io new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; io.unobserve(img); } }); }, { rootMargin: 50px }); document.querySelectorAll(img[data-src]).forEach((img) io.observe(img));比起传统的scroll事件里计算getBoundingClientRect()IntersectionObserver不用手动做节流性能显著更好。企业级管理系统里的长列表、图片瀑布流几乎可以无脑用这套方案。6. 从Day3到实战开发路上的几个提醒6.1 接口联调时的三个自查思路如果你第一次拿自己的页面去对接后端接口顺利通过的案例很少。报错之后先不要乱猜按照这个顺序自查看Network面板里的请求URL和Request Payload是否符合后端接口文档定义看响应状态码和Response Body里的具体错误信息看CORS报错是否属于跨域。跨域应该是新手遇到最多的拦路虎。如果项目直接前端起localhost:8080、后端在localhost:9090两边端口不一致浏览器默认会把请求拦下来。常规解决方案主要是后端配置CORS比如Spring Boot里用CrossOrigin或全局CorsFilter配置或者通过反向代理把两个服务放到同一个域下面。前端开发环境的话像Vite自己就支持配置proxy同样可以规避跨域问题。真要验证生产环境是否正常直接部署到同一台Web服务器的不同路径下测一遍。6.2 前端请求封装的经验清单实战项目中我一般会建议把请求层写成一个独立模块。以Fetch为例至少封装几个能力统一处理超时时间自动携带Authorization请求头统一判断HTTP状态码和业务状态码对网络异常和业务异常做统一提示支持请求取消的可选能力。一份精简的封装大概长这样// request.js const BASE_URL /api; async function request(url, options {}) { const controller new AbortController(); const timeout setTimeout(() controller.abort(), options.timeout || 10000); try { const response await fetch(${BASE_URL}${url}, { ...options, signal: controller.signal, headers: { Content-Type: application/json, Authorization: localStorage.getItem(token) || , ...options.headers, }, }); if (!response.ok) { throw new Error(请求失败${response.status}); } const data await response.json(); if (data.code ! 0) { throw new Error(data.message || 业务异常); } return data.data; } finally { clearTimeout(timeout); } } export const get (url) request(url); export const post (url, body) request(url, { method: POST, body: JSON.stringify(body), });这样业务代码里调用的时候会特别清爽也降低了每个页面里出现低级错误的概率。建议你从Day3开始就尝试建立自己的请求封装库后面复用起来会很舒服。6.3 调浏览器接口怎么练比较有效Day3内容学完一定要趁热打铁写个小项目把接口请求、本地存储、文件上传串起来。比较推荐的方式是做一个“个人备忘笔记”进入页面加载历史备忘列表Fetch GET添加备忘时要能上传一张封面图FormDataPOST备忘数据用localStorage缓存刷新后依然展示之前的数据。如果愿意还可以接一个看板形式的统计把备忘数量的变化用简单图表展示出来。一个小提醒练习的时候尽量用真实的接口地址不要只用mock数据。企业里上线前的联调尤其如此。另外每隔一段时间回来看看自己写的旧代码你大概率会发现当初很多地方写得不够优雅比如重复的try/catch、堆成山的useEffect、各种连环if判断。改进的过程本身就是成长的过程这比我写多少建议都管用。写在最后的一个实操心得回想我最早接触这些浏览器API的时候也是在本地起了一个简单的前端工程边看文档边敲代码。fetch当时最让我意外的是它默认不发Cookie同源下会发跨域时需要设置credentials后来联调第三方接口时折腾了很久才反应过来。另外Web Storage那块也是自己把“存对象直接存”的错误踩了一遍才彻底记住必须做序列化。所以如果你在新手阶段偶尔出现这种看似基础的问题不用懊恼全是正常节奏。看过文档和能处理真实报错之间差的正是这些问题积累的过程。Day3之后你对浏览器能做什么的理解应该已经上了一个台阶后面碰到的接口奇奇怪怪都可以用自己的方式接住这就是好事。