URL 拼接函数最容易写出来的 bug,大概长这样。

https://example.com
        ↓
https:/example.com

起因往往也很朴素。把几段字符串用 / 连起来,再把连续斜杠压成一个。代码可能只有几行,https:// 也跟着被压坏了。

我维护 @anys/url-join 时,经常会想起这种反差。我们把它叫「小工具」,只是因为源码不多,不是因为它承担的语义真的少。

斜杠长得一样,身份却完全不同

协议后的 //、路径中间重复的 /、开头表示绝对路径的 /,肉眼看都是斜杠,处理规则却不能共用。

工具必须先认出协议边界,再决定哪些路径斜杠需要归一化。调用者关闭 normalize 时,原始双斜杠又应该留下。到了尾部,trailingSlash 要加在路径末尾,不能越过查询字符串跑到整条 URL 的最后。

这类 bug 麻烦的地方是,最常见的示例往往照样通过。

urlJoin('api', 'users') // api/users,看起来很好

直到输入变成完整协议、绝对路径或带查询的地址,工具才露出它其实没有理解斜杠,只是在替换字符。所谓 Protocol Safe,不是给 README 加一个好听的词,而是一组以后不能退化的测试。

undefined 应该消失,数字 0 不应该

业务里的路径片段很少全是手写常量。更多时候,它们来自可选参数。

urlJoin('api', 'users', userId, 'profile')

userIdundefined 时,这一段通常应该被忽略;它是数字 0 时,却可能代表一条完全合法的记录。这里如果顺手写一个 filter(Boolean)undefined 的确没了,0 也一起没了。

所以「空值」必须成为公开契约,而不是依赖 JavaScript 的真假规则。nullundefined 和空字符串不生成路径段,字符串与数字参与拼接。规则看起来啰嗦一点,调用结果却更可预测。

我越来越觉得,基础工具最危险的偷懒就是把语言习惯误当成业务语义。0 在条件判断里是假,不等于它在 URL 里没有意义。

路径拼完以后,查询参数才刚刚开始

查询参数使用的是另一套编码规则。字符串、数字、布尔值要怎么转,空格和特殊字符要怎么编码,同一个键的数组值如何展开,nullundefined 是否忽略,原 URL 已有参数时又怎样合并。

这些问题如果全靠字符串拼接,很快会长出一套似是而非的编码器。我更愿意沿用平台的 URLSearchParams 语义。数组按约定展开成重复键,新增参数接在已有查询之后,路径归一化也在查询字符串出现之前完成。

关键不是所有人都必须喜欢同一种数组格式,而是一旦工具选定规则,就要把它写进文档和测试。基础函数可以很小,行为不能靠猜。

一个小包变胖,通常从「顺便支持」开始

URL 相关需求很容易继续往里加。顺便解析域名,顺便合并 hash,顺便支持模板,顺便识别文件路径,再顺便兼容几个非标准协议。

每个需求单独看都合理,放进同一个函数以后,调用者反而越来越难判断输入会被改成什么样。

@anys/url-join 的边界因此比较克制。它拼接路径片段,过滤约定的空值,可选地归一化斜杠,控制尾斜杠,并追加查询参数。它不是完整 URL 解析器,也不打算替代浏览器的 URL API

零依赖也是同一个取舍。对于这样一个底层工具,依赖带来的安装面和供应链变化,可能比核心逻辑本身还复杂。语言和平台能稳定提供的能力,就没有必要再引入一层。

责任越集中,测试越不能只看正常输入

复杂应用出错时,大家知道要谨慎。小工具因为看起来简单,反而很容易只测一条顺利路径。

我会把测试按语义拆开,覆盖协议、绝对路径、重复斜杠、空值、数字、尾斜杠、已有查询、数组查询和编码。新增一个选项时,也不只测它自己,还要看它与 normalize、查询参数和尾斜杠组合后会发生什么。

写完这些测试,源码可能还是不长,但「几段字符串连起来」已经有了一份可以相信的契约。

小工具并不是问题小。很多时候,它只是把一堆项目都会遇到的问题,集中到了一个很短的函数里。

也正因为短,才更没有地方藏含糊。