使用 JavaScript 拼接与规范化 URL 片段:joinURL 正则实现详解
使用 JavaScript 拼接与规范化 URL 片段:joinURL 正则实现详解
在 JavaScript 中程序化地拼接 URL 片段(如协议、路径、查询参数、哈希)是前端与 Node.js 开发中的高频需求。看似简单的字符串拼接,实际隐藏着大量边界情况:多余斜杠、协议分隔符、参数分隔符、哈希片段等,稍不注意就会生成畸形 URL。本文以 30-seconds-of-code 仓库中的 join-url-segments 片段 为骨架,逐层拆解 Array.prototype.join() 与正则表达式链式规范化方案,带你掌握一套可直接复用的 joinURL 实现,并理解每一行正则背后的设计意图。
为什么拼接 URL 片段并不简单
当我们在代码里动态构建 URL(请求资源、跳转页面、生成分享链接)时,通常会拿到多个零散片段:可能是带协议的域名、带前导或尾随斜杠的路径、以 ? 开头的查询参数、以 # 开头的哈希片段。直接使用模板字符串或简单拼接会出现一系列问题:
- 片段之间产生多余斜杠(
/a//b/cd); - 协议的
//被误伤(http://被压缩成http:/); - 参数与路径之间残留多余斜杠(
/cd/?foo=123); - 多个
?参数拼接后产生错误分隔符(?foo=123?bar=foo); - 哈希片段(
#)与路径之间出现多余斜杠。
因此,一个健壮的拼接方案必须分两步走:先拼接,再规范化。
第一步:用 Array.prototype.join() 完成基础拼接
最朴素的做法是收集所有片段,然后用 / 作为分隔符把它们连接起来。Array.prototype.join() 天然支持任意数量的片段参数:
const segments = ['http://www.google.com', 'a', '/b/cd', '?foo=123', '?bar=foo'];
segments.join('/');
// 'http://www.google.com/a//b/cd/?foo=123/?bar=foo'
可以看到,join('/') 只负责把片段粘在一起,结果中存在大量需要后续处理的问题:a//b 的多余斜杠、路径与 ? 之间的斜杠、多个 ? 参数等。这正是第二步——正则规范化要解决的。
第二步:用正则表达式链式规范化 URL
规范化 URL 的核心思路,是通过一系列 String.prototype.replace() 调用,配合正则表达式逐一修复各类格式问题。整个过程包括:去除多余斜杠、为协议补回斜杠、处理 file: 协议特例、移除参数与哈希前的斜杠、统一参数分隔符、恢复首个参数定界符。
1. 合并连续斜杠
第一个正则把任意数量的连续斜杠折叠为单个斜杠:
.replace(/[\/]+/g, '/')
这里 [\/]+ 匹配一个或多个 /,全局替换为单个 /。'a//b/cd/' 会变成 'a/b/cd/'。注意:这一步也会把协议里的 http:// 变成 http:/,这需要紧接着的步骤来修复。
2. 为协议补回正确的斜杠
协议被上一步误伤后,需要用正则重新补回 ://:
.replace(/^(.+):\//, '$1://')
^(.+):\/ 匹配字符串开头到第一个 :/ 之间的内容((.+) 捕获协议名,如 http),并将其替换为 $1://。于是 http:/www.google.com 被还原成 http://www.google.com。该正则在 ^ 锚定下只会影响 URL 开头,不会误伤路径中的斜杠。
3. 特例:file: 协议保持单斜杠
file: 协议是个例外:file:// 写法并不规范,正确形式是 file:/path(或带空主机的 file:///path)。因此第三步用 ^file: 前缀匹配,把 file: 强制规范为 file:/:
.replace(/^file:/, 'file:/')
这一步必须放在第二步之后执行,用它覆盖协议恢复逻辑对 file: 的特殊处理,确保文件协议始终只保留单个斜杠。
4. 移除参数与哈希前的多余斜杠
接下来清理路径与查询参数、片段标识符之间的斜杠:
.replace(/\/(\?|&|#[^!])/g, '$1')
\/(\?|&|#[^!]) 匹配紧跟在 / 后面的 ?、& 或 #(#[^!] 要求 # 后紧跟一个非 ! 的字符),并替换为捕获组 $1——即只保留符号、去掉斜杠。于是 /cd/?foo=123 变成 /cd?foo=123。
其中 #[^!] 的限定是刻意的:形如 #!(hashbang,常用于 #!/route 这类单页应用路由)的片段前通常需要保留斜杠,因此被排除在匹配之外。
5. 将多余的 ? 统一为 &
经过上一步,路径与参数之间只剩一个 ?,但如果调用方传入了多个带 ? 的片段(如 ?foo=123 与 ?bar=foo),结果里会出现多个 ?。这一步把所有 ? 全部替换为 &:
.replace(/\?/g, '&')
于是 ?foo=123?bar=foo 变成 &foo=123&bar=foo。
6. 恢复首个参数定界符
最后一步把第一个 & 换回 ?,作为查询字符串的正式起点:
.replace('&', '?')
注意这里传入的是字符串而非正则,因此只替换第一次出现的 &,恰好把 &foo=123&bar=foo 修正为 ?foo=123&bar=foo。至此 URL 完全规范化。
完整实现:joinURL 函数
把上述六步串联起来,就得到 30-seconds-of-code 仓库中 join-url-segments 片段 的完整实现:
const joinURL = (...args) =>
args
.join('/')
.replace(/[\/]+/g, '/')
.replace(/^(.+):\//, '$1://')
.replace(/^file:/, 'file:/')
.replace(/\/(\?|&|#[^!])/g, '$1')
.replace(/\?/g, '&')
.replace('&', '?');
joinURL('http://www.google.com', 'a', '/b/cd', '?foo=123', '?bar=foo');
// 'http://www.google.com/a/b/cd?foo=123&bar=foo'
逐字演变过程
以上述调用为例,函数内部每一步处理后的中间结果如下:
| 步骤 | 处理 | 结果 |
|---|---|---|
| 拼接 | args.join('/') |
http://www.google.com/a//b/cd/?foo=123/?bar=foo |
| 1 | 合并连续斜杠 | http:/www.google.com/a/b/cd/?foo=123/?bar=foo |
| 2 | 恢复协议 :// |
http://www.google.com/a/b/cd/?foo=123/?bar=foo |
| 4 | 移除参数前斜杠 | http://www.google.com/a/b/cd?foo=123?bar=foo |
| 5 | ? 统一为 & |
http://www.google.com/a/b/cd&foo=123&bar=foo |
| 6 | 首个 & 恢复为 ? |
http://www.google.com/a/b/cd?foo=123&bar=foo |
最终输出完全符合规范,可直接用于请求或跳转。
边界情况与设计考量
- 哈希片段:
joinURL('http://a.com', '/x', '#hash')会先移除#前的斜杠,输出http://a.com/x#hash;而#!hashbang 前的斜杠会被#[^!]排除规则保留。 file:协议:joinURL('file:', '/tmp/x')经协议恢复后再被file:特例覆盖,输出file:/tmp/x,符合文件 URL 规范。- 参数拼接:多个
?片段会被合并为单个?+&分隔的多参数查询串。 - 不做编码:该方案是纯字符串与正则操作,不会对查询参数中的特殊字符做
encodeURIComponent编码。若参数可能包含空格、中文、&等字符,应改用URL/URLSearchParams方案(见下文替代方案)。
在仓库中的定位:URL 合集与相关片段
joinURL 片段位于 content/snippets/js/s/join-url-segments.md,frontmatter 中标注了 language: javascript、tags: <a href="https://link.gitcode.com/i/0667cdfb6c8c2cae4ea187a94949e311" target="_blank">string, regexp]、listed: true,最后更新于 2024-08-10。在仓库的内容体系中,每个片段文件都是一篇带结构化元数据的 Markdown 文档,由 [src/models/snippet.js 解析 title、shortTitle、content、tags、dateModified 等字段并驱动站点渲染。
该片段被收录进 URLs in JavaScript 合集(content/collections/js/url.yaml),与以下主题紧密相关,可互为补充:
- is-absolute-url 片段:用正则判断 URL 是否包含协议(
^[a-z][a-z0-9+.-]*:),是拼接前校验片段合法性的好搭档; - construct-url 片段:讲解用
URL对象构造 URL,自动处理编码与分隔符,强调模板字符串拼接的编码缺陷; - edit-url-params 片段:用
URLSearchParams增删改查询参数; - get-base-url-or-url-params 片段:从 URL 中提取基础地址与参数对象;
- window-location-cheatsheet 片段:
window.location各属性(protocol、host、pathname、search、hash等)速查。
适用场景与替代方案对比
joinURL 这类纯字符串 + 正则方案适合快速拼接结构已知、参数简单的 URL,不依赖 URL 全局对象,在旧浏览器与 Node.js 环境下都能稳定运行,代码紧凑、无副作用。它特别适用于:生成 API 请求地址、拼接静态资源路径、组合多个配置来源的 URL 片段。
如果你的场景需要严格编码查询参数、保留已有查询串或精细控制哈希,则推荐 URL 对象方案——详见仓库中的 construct-url 片段 与 edit-url-params 片段:new URL(...) 配合 url.searchParams.set() 会自动完成编码与定界符处理,更稳健但代码更冗长。实践中可以两者结合:用 joinURL 完成路径与协议的规范化,再用 URLSearchParams 负责参数编码,各取所长。