使用 JavaScript 拼接与规范化 URL 片段:joinURL 正则实现详解

原创2026-10-03 12:02:17209 阅读
文章标签:教程文档

使用 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),与以下主题紧密相关,可互为补充:

适用场景与替代方案对比

joinURL 这类纯字符串 + 正则方案适合快速拼接结构已知、参数简单的 URL,不依赖 URL 全局对象,在旧浏览器与 Node.js 环境下都能稳定运行,代码紧凑、无副作用。它特别适用于:生成 API 请求地址、拼接静态资源路径、组合多个配置来源的 URL 片段。

如果你的场景需要严格编码查询参数、保留已有查询串或精细控制哈希,则推荐 URL 对象方案——详见仓库中的 construct-url 片段 与 edit-url-params 片段:new URL(...) 配合 url.searchParams.set() 会自动完成编码与定界符处理,更稳健但代码更冗长。实践中可以两者结合:用 joinURL 完成路径与协议的规范化,再用 URLSearchParams 负责参数编码,各取所长。

登录后查看全文
30-seconds-of-code