调试一个带下载功能的页面时,真正让人返工的往往不是业务逻辑,而是 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?page=2&keyword=牛奶#top拆出来的结果是这样的:
| 属性 | 值 | 说明 |
|---|---|---|
protocol | https: | 协议,带冒号 |
hostname | shop.example.com | 主机名,不含端口 |
port | 8443 | 端口,默认端口时为空串 |
host | shop.example.com:8443 | hostname + port |
origin | https://shop.example.com:8443 | 协议 + host,只读 |
pathname | /admin/goods/list.html | 路径,以/开头 |
search | ?page=2&keyword=牛奶 | 查询串,带前导? |
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?page=2 // 写:跳转,会在历史记录里增加一条 window.location.href = 'https://shop.example.com:8443/admin/goods/detail.html?id=1001';这里有个特别重要的行为差异,直接决定了下载功能会不会“越点越乱”:
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('&')就会切错。第三,同名参数(?tag=a&tag=b)只能拿到第一个,拿不到全部。第四,如果参数名是id,而查询串里恰好有个userid,正则里的(^|&)保护了这一点,但如果是用indexOf手写的版本就没这个保护。
除了正确性,还有可读性。参数一多,代码里全是下标和正则,调试成本很高。而浏览器原生提供的URLSearchParams把这些全解决了,包括自动解码、+转空格、getAll取同名参数,它本来就是为这件事设计的。
3.2 URLSearchParams 的正确打开方式
最常用的几种姿势:
const params = new URLSearchParams(window.location.search); // 取单个值,不存在返回 null(注意不是空字符串) params.get('id'); // "1001" params.get('missing'); // null // 取同名参数的数组 // 地址:?tag=js&tag=css&tag=html 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=会返回'',而?other=1里取id会返回null。很多业务逻辑里“参数没传”和“参数传了但为空”含义不同,比如分页页码为空时应该用默认值 1,但如果用户手动清空了搜索框,那就是空字符串,这两种情况要分开处理。第二,Object.fromEntries(params)遇到同名参数只保留最后一个,如果你需要全部值,只能用getAll。
如果把整段查询串从别的字符串里解析(比如从 hash 里取参数),可以直接构造:
// 从 hash 路由里取参数:#/detail?type=video&id=7 const hashQuery = window.location.hash.split('?')[1] || ''; const hp = new URLSearchParams(hashQuery); hp.get('id'); // "7" // 从任意字符串构造 const p = new URLSearchParams('a=1&b=hello%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'))。我见过一个页面的“调试模式”开关,参数传debug=false反而打开了调试面板,排查了半天才定位到这个转换上。上面那份代码里用白名单的方式判断真值,就是为了堵住这个口子。
还有一个实践建议:取参数时永远给默认值。分页参数没给默认值,第一页就会拼出page=null这样的请求;状态筛选没给默认值,请求会把status=null当字符串发出去。这类问题在联调阶段特别浪费时间,因为前端以为是后端默认逻辑,后端以为是前端传的。
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;charset=utf-8,' + encodeURIComponent('\uFEFF' + content); window.location.href = dataUrl;那个\uFEFF是 BOM 头,加在 CSV 开头,Excel 打开时才不会中文乱码。这是个老问题,不加的话用户会拿着一张乱码表来找你。不过 Data URL 有长度限制,不同浏览器上限不同,文件一大就会被截断,所以只适合小文件。
三种方式的取舍我整理成了一张表:
| 方式 | 能否带鉴权头 | 支持 POST | 文件名控制 | 适合场景 |
|---|---|---|---|---|
直链location.href | 需靠 Cookie 或 URL 参数 | 否 | 由响应头决定 | 简单的 GET 导出 |
Blob +a.download | 可以自定义 header | 可以 | 完全前端控制 | 需要鉴权、需要 POST |
| Data 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写起来更清爽。文件名解析那段正则兼容了filename="x.xlsx"和filename*=UTF-8''%E4%B8%AD%E6%96%87.xlsx两种写法,后者是 RFC 5987 定义的扩展形式,后端返回中文文件名时通常用这种,如果只匹配第一种写法就会拿到一堆百分号编码。这个坑我调了挺久,因为后端和前端各说各话,谁都不觉得自己错了。
5. 实战:一个带参数的下载页完整落地
前面几节都是零件,这一节把它们装成一台能跑的机器。需求很典型:一个报表页,顶部有月份、类型、关键词三个筛选条件,筛选条件同步到 URL 参数里,点“导出”按钮下载对应条件的文件,同时页面能被分享出去后自动还原筛选状态。
5.1 页面结构与参数约定
先把参数约定写清楚,这是整个功能的契约。我一般会在代码注释里维护这么一小段,避免多人协作时各写各的:
| 参数名 | 含义 | 类型 | 默认值 | 备注 |
|---|---|---|---|---|
month | 报表月份 | 字符串 | 当前月 | 格式YYYY-MM |
type | 报表类型 | 字符串 | 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,两套各管各的,不互相读取。这条约定看着简单,却省掉了很多“参数到底从哪来”的沟通成本。