
调试一个带下载功能的页面时真正让人返工的往往不是业务逻辑而是 URL 这一层没处理干净域名判断错了导致接口拼错查询参数里带了个#结果后半截被吞掉window.location.href指向下载地址却什么都没发生。这些坑我都踩过而且踩得很难看。这篇就围绕 js、window.location.href、当前域名、相对路径、参数这几个点把前端拿 URL 信息这件事从头到尾捋一遍重点讲清楚两件事怎么准确地把当前域名、完整 URL、相对路径、查询参数以及某个指定参数取出来怎么用window.location.href稳稳地把文件下载下来。内容偏向实战适合已经会写基本 JS、但在真实项目里被 URL 和下载折腾过的人也适合刚开始接触前端、想把这一块基础打牢的读者。1. window.location 到底是什么先把这对象拆开看很多人用window.location用了好几年其实只用了href和search两个属性剩下的全靠字符串切割硬拼。真正把浏览器提供的这套 URL 模型搞清楚之后你会发现大部分手写解析都是多余的。这一节先把对象本身讲透后面所有取值和下载操作都建立在这上面。1.1 location 的八个属性各自管哪一段浏览器在打开一个页面时会把地址栏里那一长串字符按标准规则拆成若干段然后挂到location对象上。理解拆分规则比死记属性名有用得多。假设当前地址是https://shop.example.com:8443/admin/goods/list.html?page2keyword牛奶#top拆出来的结果是这样的属性值说明protocolhttps:协议带冒号hostnameshop.example.com主机名不含端口port8443端口默认端口时为空串hostshop.example.com:8443hostname portoriginhttps://shop.example.com:8443协议 host只读pathname/admin/goods/list.html路径以/开头search?page2keyword牛奶查询串带前导?hash#top锚点带前导#href完整地址上面所有部分的拼接结果这张表里有两个容易忽略的细节。第一port在地址没显式写端口的时候是空字符串不是80或443所以不能拿它做数字比较。第二search和hash都带着前导符号search带?hash带#如果你直接拿去做字符串比较记得把这一个字符处理掉我见过不止一个同事写了if (location.hash detail)然后一脸疑惑为什么永远不成立。origin这个属性值得单独说一句。它是只读的而且是从protocol和host推导出来的。做跨域请求判断、拼接接口地址的时候用origin比用protocol // host手拼要可靠因为手拼很容易在端口那一层出错。至于document.domain那个属性已经被现代浏览器标记为废弃就不要在新项目里用了。1.2 href 的特殊之处它不只是读还能写location上其他属性大多是可读可写的但只有href承担了“跳转”这个职责而且它是双向的。读它拿完整地址写它就等于告诉浏览器“去这个新地址”。// 读 console.log(window.location.href); // https://shop.example.com:8443/admin/goods/list.html?page2 // 写跳转会在历史记录里增加一条 window.location.href https://shop.example.com:8443/admin/goods/detail.html?id1001;这里有个特别重要的行为差异直接决定了下载功能会不会“越点越乱”location.href url产生新的历史记录用户点返回键能回到当前页。location.assign(url)效果和上面完全一样只是写法更语义化。location.replace(url)替换当前历史记录用户点返回会跳回上一页的上一页。location.reload()重新加载当前文档。location.hash #detail只改锚点不会重新加载页面触发hashchange事件。下载场景里我一般用location.href因为用户下载完之后很可能还想回到列表页继续点第二个文件保留历史记录体验更好。但如果是在一个中间跳转页做自动下载跳完就没用了那就该用replace否则用户按返回键会被弹回来形成“返回死循环”这个坑挺烦人的。顺带说一个直观的类比location对象像是浏览器的“地址栏遥控器”href是那个总开关其他属性是分项按钮。你按总开关所有分项跟着变你按分项按钮比如只改hash总开关显示的字符串也跟着变。它们是同一个东西的不同视角不是两份数据。2. 获取当前域名、URL 与相对路径的几种写法知道了属性含义接下来是实际怎么用。这一节我把域名、完整 URL、相对路径这三类需求分别拆开讲最后给一个能直接抄走的工具函数。之所以要分开讲是因为这三类需求在真实项目里的“正确取值”并不一样混着用非常容易出错。2.1 域名相关的三个取值场景origin、hostname、host先说结论再讲为什么。拼接口地址用origin做域名白名单判断用hostname需要带端口做完整比对时才用host。// 场景一拼接后端接口基础地址 const API_BASE window.location.origin /api; // https://shop.example.com:8443/api // 场景二多环境判断只看域名不看端口 const host window.location.hostname; // shop.example.com const isTest /^test-/.test(host); // 场景三确实需要带端口比对 if (window.location.host shop.example.com:8443) { // ... }为什么拼接口优先用origin因为它把协议一起带上了。开发环境经常出现前端跑在http://localhost:3000、后端在同一台机器的http://localhost:8080这种情况如果你手写成// host /api在 https 页面下会拼出一个相对协议地址浏览器会按当前页面的协议去请求行为看似正确但调试时很迷惑。直接用origin就得明明白白。还有一个常见的翻车点不要把hostname当成“域名后缀”来用。hostname返回的是完整主机名包含所有子域。如果你的判断写成了hostname example.com而当前实际是shop.example.com条件永远不会成立。要做后缀匹配就用endsWith或者正则并且注意endsWith(.example.com)和endsWith(example.com)的差别后者会误匹配到fakeexample.com这种恶意构造的域名这个安全细节在处理多站点共享脚本时非常重要。port在本地开发时特别有用因为端口往往决定了当前连的是哪个后端。但记住那个坑生产环境 https 默认端口时port是空串。稳妥的写法是给个兜底const currentPort window.location.port || (window.location.protocol https: ? 443 : 80);2.2 相对路径的真相它不是 location 的一个属性标题里提到“相对路径”但有个事实很多人没意识到location对象里没有“相对路径”这个属性pathname给的是绝对路径以/开头。所谓相对路径是相对于某个基准 URL 计算出来的结果。那基准是谁就是当前文档的地址准确说是“当前文档地址去掉最后一段之后的部分”。举个具体例子当前地址https://shop.example.com/admin/goods/list.html 基准目录https://shop.example.com/admin/goods/ 相对路径./detail.html → https://shop.example.com/admin/goods/detail.html 相对路径../index.html → https://shop.example.com/admin/index.html 相对路径/api/data → https://shop.example.com/api/data在页面里写a href./detail.html时浏览器就是按这个规则解析的。但 JS 里字符串拼接不会自动帮你解析所以如果你拿到一个相对路径字符串想把它变成完整 URL最靠得住的办法是用URL构造器// 把相对路径解析成绝对地址基准用当前页面 const abs new URL(./detail.html, window.location.href).href; // https://shop.example.com/admin/goods/detail.html // 也可以手动指定基准 const abs2 new URL(../img/logo.png, https://cdn.example.com/assets/css/main.css).href; // https://cdn.example.com/assets/img/logo.png这里有个细节要注意new URL(相对路径, 基准)的基准如果指向的是一个“看起来像文件”的地址最后一段带扩展名它会自动把最后一段当文件名去掉。所以把location.href当基准是安全的。但如果你传的基准是https://cdn.example.com/assets/css/以斜杠结尾那就不会有“去最后一段”的动作。这两种行为差异在拼接资源路径时经常导致图片 404特别是在 CDN 目录结构下。另外页面里如果写了base href...标签所有相对路径的解析基准都会被它覆盖这时候 JS 里用new URL(x, location.href)得到的结果和浏览器实际解析资源的结果可能不一致。我的建议是新项目尽量别用base非要用的化JS 里的相对地址一律用基于base元素的document.baseURI来解析const resolved new URL(./detail.html, document.baseURI).href;document.baseURI会自动考虑base标签是比location.href更准的基准。2.3 一个能直接抄走的取值工具函数讲完分散的知识点给一份我在项目里反复用的小工具。它不依赖任何库纯原生覆盖了域名、完整地址、路径、参数这几类最常见的需求// url-kit.js —— 无依赖直接复制可用 const UrlKit { // 当前完整地址 full() { return window.location.href; }, // 来源协议 主机 端口用于拼接口 origin() { return window.location.origin; }, // 主机名不含端口 hostname() { return window.location.hostname; }, // 路径以 / 开头 path() { return window.location.pathname; }, // 相对当前文档的目录路径例如 /admin/goods/ dir() { const p window.location.pathname; return p.slice(0, p.lastIndexOf(/) 1); }, // 参数对象 query() { return Object.fromEntries(new URLSearchParams(window.location.search)); }, // 取指定参数支持默认值 get(key, defaultValue null) { const v new URLSearchParams(window.location.search).get(key); return v null ? defaultValue : v; } }; export default UrlKit;这个文件的好处是把“我要什么”和“怎么算”分开了。业务代码里只写UrlKit.get(id, 0)哪天要换成从 hash 里取参数比如用了 hash 路由只改这一个文件不用全项目搜索替换。我在一个后台项目里就是这么干的从 history 路由切到 hash 路由时改动量控制在了 20 行以内。注意Object.fromEntries在极老的环境里不存在如果你的项目要兼容很古老的浏览器把它换成手写的循环。新项目不用管这个。3. 查询参数解析从手写 split 到 URLSearchParams参数解析是 URL 处理里最容易被轻视、也最容易出 bug 的一块。很多人觉得不就是切字符串吗两行代码搞定。等到线上出现“中文关键词搜不出结果”或者“同名参数只拿到最后一个”时才发现那两行代码埋了雷。这一节把参数解析讲透顺带给你一套带默认值和类型转换的取参方案。3.1 为什么该放弃 split(?)[1].split()先看看那段经典的“祖传代码”// 反例不建议使用 function getQuery(name) { const reg new RegExp((^|) name ([^]*)(|$)); const r window.location.search.substr(1).match(reg); if (r) return decodeURIComponent(r[2]); return null; }这段代码在简单场景下能跑但它有一串问题。第一参数值是 URL 编码过的号在查询串里代表空格decodeURIComponent不会把转成空格你必须手动replace(/\/g, )很多搜索功能在这个点上出错用户搜“C 教程”拿到的是乱码。第二如果参数值本身包含编码后的也就是%26正则里[^]*是安全的但如果用了split()就会切错。第三同名参数?tagatagb只能拿到第一个拿不到全部。第四如果参数名是id而查询串里恰好有个userid正则里的(^|)保护了这一点但如果是用indexOf手写的版本就没这个保护。除了正确性还有可读性。参数一多代码里全是下标和正则调试成本很高。而浏览器原生提供的URLSearchParams把这些全解决了包括自动解码、转空格、getAll取同名参数它本来就是为这件事设计的。3.2 URLSearchParams 的正确打开方式最常用的几种姿势const params new URLSearchParams(window.location.search); // 取单个值不存在返回 null注意不是空字符串 params.get(id); // 1001 params.get(missing); // null // 取同名参数的数组 // 地址?tagjstagcsstaghtml params.getAll(tag); // [js, css, html] // 判断是否存在 params.has(keyword); // true / false // 遍历 for (const [key, value] of params) { console.log(key, value); } // 转成普通对象同名参数会只保留最后一个 Object.fromEntries(params);用URLSearchParams时有两个行为要留意。第一get返回null和返回空字符串是两件事?id会返回而?other1里取id会返回null。很多业务逻辑里“参数没传”和“参数传了但为空”含义不同比如分页页码为空时应该用默认值 1但如果用户手动清空了搜索框那就是空字符串这两种情况要分开处理。第二Object.fromEntries(params)遇到同名参数只保留最后一个如果你需要全部值只能用getAll。如果把整段查询串从别的字符串里解析比如从 hash 里取参数可以直接构造// 从 hash 路由里取参数#/detail?typevideoid7 const hashQuery window.location.hash.split(?)[1] || ; const hp new URLSearchParams(hashQuery); hp.get(id); // 7 // 从任意字符串构造 const p new URLSearchParams(a1bhello%20world); p.get(b); // hello world自动解码URLSearchParams 的浏览器兼容性已经很好了主流的现代浏览器全支持维护期结束的老版本 Edge 和 IE 不支持。如果你的项目还在兼容 IE只能退回手写解析但记得把号处理加上。3.3 指定参数获取默认值、类型转换与数组参数实际业务里“拿指定参数”这句话背后通常还藏着三个需求取不到时要有默认值取到的是字符串但业务要数字或布尔可能是个多选要数组。我把这三种情况统一封装成一个getParam用起来最省心function getParam(key, options {}) { const { type string, defaultValue null, multiple false } options; const params new URLSearchParams(window.location.search); if (multiple) { const list params.getAll(key); return list.length ? list : (defaultValue ?? []); } const raw params.get(key); if (raw null || raw ) return defaultValue; switch (type) { case number: { const n Number(raw); return Number.isNaN(n) ? defaultValue : n; } case boolean: { // 只认这几个真值避免 false 被判成 true return [1, true, yes, on].includes(raw.toLowerCase()); } case json: { try { return JSON.parse(raw); } catch { return defaultValue; } } default: return raw; } } // 用法 const page getParam(page, { type: number, defaultValue: 1 }); const isDebug getParam(debug, { type: boolean, defaultValue: false }); const tags getParam(tag, { multiple: true, defaultValue: [] }); const filter getParam(filter, { type: json, defaultValue: {} });关于布尔参数的坑我要专门说一句。JS 里Boolean(false)是true因为非空字符串都是真值。所以绝对不能写Boolean(params.get(debug))。我见过一个页面的“调试模式”开关参数传debugfalse反而打开了调试面板排查了半天才定位到这个转换上。上面那份代码里用白名单的方式判断真值就是为了堵住这个口子。还有一个实践建议取参数时永远给默认值。分页参数没给默认值第一页就会拼出pagenull这样的请求状态筛选没给默认值请求会把statusnull当字符串发出去。这类问题在联调阶段特别浪费时间因为前端以为是后端默认逻辑后端以为是前端传的。4. window.location.href 触发下载原理、坑与替代方案前面都在取信息这一节讲怎么“用出去”。window.location.href 文件地址是最简单的下载触发方式简单到只有一行但也正因为简单很多边界情况它处理不了。我把几种触发下载的方式、各自适用场景、以及为什么“点了没反应”这件事拆开来讲。4.1 三种触发下载的路径直链、Blob、Data URL第一种直链跳转。这是最省事的// 后端已经提供了一个返回文件的接口 const fileUrl /api/export/report?month${month}token${token}; window.location.href fileUrl;它的工作方式是浏览器发现当前导航的目标是一个它无法在页面里渲染的资源比如响应头里带了Content-Disposition: attachment就转成下载而不是跳转。整个过程不需要 JS 参与兼容性无敌。缺点也很明显只支持 GET参数只能挂在 URL 上长度受 URL 长度限制而且如果后端没设那个响应头浏览器会直接在页面里打开文件页面就被“顶掉”了。第二种Blob 下载。当你需要带鉴权头、需要 POST 参数、或者文件是先在前端生成的比如导出的 CSV就走这条路async function downloadByBlob(url, filename, payload) { const res await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer getToken() }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(下载失败 res.status); const blob await res.blob(); const objectUrl URL.createObjectURL(blob); const a document.createElement(a); a.href objectUrl; a.download filename || download; document.body.appendChild(a); a.click(); a.remove(); // 关键释放否则这块内存会一直占着 setTimeout(() URL.revokeObjectURL(objectUrl), 1000); }这段代码里有三处值得说的细节。a.download属性只在同源地址上生效如果你把一个跨域的https://cdn.xxx.com/a.pdf直接赋给a.href再加download文件名会被忽略、行为退化成普通导航。Blob URL 之所以能保住自定义文件名就是因为它是同源的。URL.revokeObjectURL那句不能省我做过的页面里有连续导出二三十次的场景不释放的话内存曲线一路往上爬。最后a.remove()之后再click()是不行的必须先插入 DOM有些浏览器要求元素在文档里触发完再移除。第三种Data URL。适合非常小的文本文件比如导出一个几百字节的配置const content name,age\n张三,28\n李四,32; const dataUrl data:text/csv;charsetutf-8, encodeURIComponent(\uFEFF content); window.location.href dataUrl;那个\uFEFF是 BOM 头加在 CSV 开头Excel 打开时才不会中文乱码。这是个老问题不加的话用户会拿着一张乱码表来找你。不过 Data URL 有长度限制不同浏览器上限不同文件一大就会被截断所以只适合小文件。三种方式的取舍我整理成了一张表方式能否带鉴权头支持 POST文件名控制适合场景直链location.href需靠 Cookie 或 URL 参数否由响应头决定简单的 GET 导出Blob a.download可以自定义 header可以完全前端控制需要鉴权、需要 POSTData URL不涉及不涉及前端控制极小的纯文本4.2 点了没反应先把这五种原因排一遍“我用location.href指向了下载地址但是页面刷新了一下就没了”——这是我被问得最多的一类问题。按我的排查经验原因基本逃不出下面五种。第一种浏览器把它当成了导航而不是下载。判据是响应头。如果后端返回的Content-Type是application/json或者text/html浏览器一定会尝试在页面里渲染结果就是整个页面被替换掉。这种情况你可以打开开发者工具的 Network 面板点开那个请求看 Response Headers没有Content-Disposition: attachment就是后端的问题前端改不了。第二种被弹窗/下载拦截规则拦了。现代浏览器对“非用户手势触发的下载”比较敏感。如果你的下载是在setTimeout里触发的、或者经过了多个await之后才调用浏览器可能认为这不是用户主动行为而拦截。解决办法是把触发时机尽量靠近点击事件或者在拦截后给用户一个明显的“点击此处下载”的兜底入口。第三种接口返回了一个 JSON 错误页。后端出错时经常返回{code:500,msg:导出失败}但你用location.href跳过去浏览器会把这个 JSON 当页面渲染用户看到白屏加一行花括号。这种体验非常糟糕。稳健的做法是先用fetch请求一次检查res.ok和响应类型确认是文件再走 Blob 下载不是文件就弹提示。这就是我上面那个downloadByBlob存在的意义。第四种URL 上的参数被截断。如果参数值里含有#它后面的内容会被当成锚点丢掉。参数值里带、、空格、中文都必须encodeURIComponent// 错误keyword 里如果有 或 # 就出事 const url /api/export?keyword${keyword}; // 正确 const url /api/export?keyword${encodeURIComponent(keyword)};第五种文件接口返回 200 但没有内容。有时候是权限问题接口静默返回了空文件浏览器照样下载一个 0 字节的文件用户以为下载成功了。这种情况建议在 Blob 下载时加一个大小校验blob.size 0就提示“文件为空请检查筛选条件”。4.3 带进度和文件名解析的下载封装把上面所有经验揉在一起得到一份我目前在用的下载工具。它处理了鉴权、错误响应、文件名解析、进度回调这几个点/** * 通用文件下载 * param {string} url 接口地址 * param {object} options { method, body, headers, filename, onProgress } */ function download(url, options {}) { const { method GET, body, headers {}, filename, onProgress } options; return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(method, url, true); xhr.responseType blob; Object.keys(headers).forEach(k xhr.setRequestHeader(k, headers[k])); if (onProgress) { xhr.onprogress e { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; } xhr.onload () { if (xhr.status 200 || xhr.status 300) { return reject(new Error(下载失败状态码 xhr.status)); } const blob xhr.response; if (!blob || blob.size 0) { return reject(new Error(文件内容为空)); } // 从响应头里解析后端指定的文件名 const disposition xhr.getResponseHeader(Content-Disposition) || ; const matched /filename\*?(?:UTF-8)??([^;])?/i.exec(disposition); let finalName filename || download; if (!filename matched) { finalName decodeURIComponent(matched[1]); } const objectUrl URL.createObjectURL(blob); const a document.createElement(a); a.href objectUrl; a.download finalName; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() URL.revokeObjectURL(objectUrl), 1000); resolve({ size: blob.size, filename: finalName }); }; xhr.onerror () reject(new Error(网络异常)); xhr.send(body ? JSON.stringify(body) : null); }); }这里之所以用XMLHttpRequest而不是fetch唯一的理由是进度fetch标准里没有暴露上传/下载进度的能力想拿百分比只能用 XHR。如果不需要进度用fetch写起来更清爽。文件名解析那段正则兼容了filenamex.xlsx和filename*UTF-8%E4%B8%AD%E6%96%87.xlsx两种写法后者是 RFC 5987 定义的扩展形式后端返回中文文件名时通常用这种如果只匹配第一种写法就会拿到一堆百分号编码。这个坑我调了挺久因为后端和前端各说各话谁都不觉得自己错了。5. 实战一个带参数的下载页完整落地前面几节都是零件这一节把它们装成一台能跑的机器。需求很典型一个报表页顶部有月份、类型、关键词三个筛选条件筛选条件同步到 URL 参数里点“导出”按钮下载对应条件的文件同时页面能被分享出去后自动还原筛选状态。5.1 页面结构与参数约定先把参数约定写清楚这是整个功能的契约。我一般会在代码注释里维护这么一小段避免多人协作时各写各的参数名含义类型默认值备注month报表月份字符串当前月格式YYYY-MMtype报表类型字符串all枚举值keyword搜索关键词字符串空需要编码page页码数字1导出时忽略页面结构大致是筛选区 表格区 导出按钮。真正的技巧在于“参数怎么在 UI 和 URL 之间双向同步”。5.2 参数回填与 URL 同步的两段核心代码第一段页面初始化时从 URL 回填表单import UrlKit from ./url-kit.js; function initFormFromUrl() { const month UrlKit.get(month, getCurrentMonth()); const type UrlKit.get(type, all); const keyword UrlKit.get(keyword, ); document.getElementById(month).value month; document.getElementById(type).value type; document.getElementById(keyword).value keyword; return { month, type, keyword }; } function getCurrentMonth() { const d new Date(); return d.getFullYear() - String(d.getMonth() 1).padStart(2, 0); }第二段筛选项变化时把状态写回 URL。这里用history.replaceState而不是location.href因为改筛选条件并不想产生一条新历史记录否则用户每改一次下拉框就要按好几次返回键才能离开这个页面function syncUrlToState(state) { const params new URLSearchParams(); params.set(month, state.month); params.set(type, state.type); if (state.keyword) params.set(keyword, state.keyword); const newUrl window.location.pathname ? params.toString(); // 只改地址栏不刷新页面不新增历史记录 window.history.replaceState(null, , newUrl); }params.toString()会自动做编码处理所以这里不需要手动encodeURIComponent这也是用URLSearchParams而不是手拼字符串的好处之一。另外注意if (state.keyword)这个判断参数为空时不写进 URL能让分享出去的链接干净很多只带真正有意义的筛选条件。5.3 导出按钮的完整处理流程导出这一下要处理的分支比看起来多。下面是我实际用的写法document.getElementById(exportBtn).addEventListener(click, async function () { const btn this; const state readFormState(); btn.disabled true; btn.textContent 导出中 0%; try { const query new URLSearchParams({ month: state.month, type: state.type, keyword: state.keyword || }); const result await download(/api/report/export? query.toString(), { method: GET, headers: { Authorization: Bearer getToken() }, onProgress: p { btn.textContent 导出中 p %; } }); console.log(下载完成, result.filename, result.size 字节); } catch (err) { alert(导出失败 err.message); } finally { btn.disabled false; btn.textContent 导出; } });这段代码里有几个防御性的点。按钮在请求期间被禁用避免用户连点五次导致五个并发请求进度文本让用户知道系统在干活长导出时体验差别很大finally里恢复按钮状态保证即使请求抛错按钮也不会永远卡在禁用态。我以前写过一个没加finally的版本接口超时后按钮永久变灰只能刷新页面被用户投诉过。还有个细节是关键词为空时也显式传了keyword。有些后端对“参数缺失”和“参数为空”处理逻辑不同缺失时会用全库查询为空时也会用全库查询但有的会报参数校验错误。跟后端确认一次比猜要省事得多。6. 常见问题排查速查表与踩过的坑写到这里URL 取值和下载这两块的主线已经走完了。最后一节我把自己实际碰到过的问题整理成速查表再补几条只在真实项目里才会遇到的教训方便你遇到问题时直接对照。6.1 问题与排查方向速查现象最可能的原因处理方向hostname判断永远不成立用了完整域名比较实际带子域改用endsWith或严格的全等比较search比较永远为假忘了前导?用URLSearchParams而不是比字符串中文参数取出来是乱码编码了两次或没解码检查服务端是否二次编码前端用URLSearchParams自动解码号参数变成空格查询串中代表空格这是标准行为需要保留字面量就编码成%2B同名参数只拿到一个用了get或Object.fromEntries改用getAll布尔参数false变true直接Boolean(字符串)用真值白名单判断location.href下载后白屏响应不是文件被当页面渲染检查Content-Disposition改用 fetch Blob下载文件名是乱码后端用了 RFC 5987 编码前端解析filename*再做decodeURIComponent下载文件名被忽略跨域地址用了a.download同源限制改用 Blob多次下载后页面变卡Blob URL 没释放加URL.revokeObjectURL导出按钮变灰不恢复finally没写或抛错未捕获补try/finally这张表里的每一条我都亲自遇上过至少一次其中“布尔参数反转”和“Blob 未释放”这两条最隐蔽因为功能表面上是正常的只有用户长时间使用或者特定参数组合下才会暴露。6.2 几条只在真实项目里才学到的经验第一URL 参数不要承担状态管理的全部职责。一开始我把筛选条件、页码、排序方向、列宽全塞进了 URL结果分享出去的链接有二十多个参数用户拿到手完全看不懂而且任何一处 UI 微调都要同时改 URL 同步逻辑。后来的做法是只把“别人打开链接后需要看到同样内容”的参数放进 URL比如报表月份和关键词纯个人偏好列宽、每页条数放本地存储。这个边界划清之后同步逻辑少了一半bug 也明显少了。第二取值一律走统一入口。项目里但凡出现location.search.split()这种写法就一定会有人复制粘贴到别的文件里然后某一天编码规则改了你要找十个地方改。统一入口不只是为了少写代码更是为了让“规则变更的影响面可控”。我在一个中台项目里推动这件事花了两周后来一次查询参数结构调整改动只花了半小时。第三下载功能一定要有失败反馈。这个功能太容易被当成“一行location.href就完事”的事情结果就是失败时静默无感知。用户点了没反应就会再点点到超时。我的经验是任何下载操作都要有明确的开始、进度、成功或失败状态哪怕只是按钮文字从“导出”变成“导出中”再变回来体验差别也是肉眼可见的。第四测试时一定要覆盖特殊字符。参数值里带上、#、、空格、中文、emoji各跑一遍。我做过一个搜索功能测试数据全是英文上线后第一个真实用户搜了带#的词就出了白屏。从那以后我在所有涉及参数拼接的地方都会加一组特殊字符的用例成本很低把很多问题拦在了上线前。第五注意协议和端口的组合变化。开发、测试、预发、生产四个环境的域名和端口各不相同如果代码里写死了origin的某个形态或者用端口做环境判断很容易在某一个环境上翻车。稳妥的做法是只从构建时注入的配置里读环境标识运行时的location只用来拼相对路径不要用来判断环境。我踩过的最惨的一次是本地开发用localhost:8080判断“是开发环境”结果同事把后端也跑在 8080 上请求全打到本机去了排查了整整一个下午。如果你的页面里既有 hash 路由又有查询参数还要特别留意location.search和 hash 里那一段参数的优先级问题。我的处理原则很朴素谁离业务近用谁但绝不混着用。路由层面的参数当前在哪个页面走 hash 或者 path业务筛选参数统一走search两套各管各的不互相读取。这条约定看着简单却省掉了很多“参数到底从哪来”的沟通成本。