功能说明
该示例把语义等价但字面不同的 HTTP URL 归一化为同一个字符串,便于做缓存键、去重与签名。URL 构造器本身会处理主机小写、百分号编码与路径点段,函数在此基础上移除默认端口与片段、补齐根路径、可选去除末尾斜杠,并重新排序查询参数;dropQuery 可过滤 utm 等与内容无关的追踪字段。重复查询键会保留并按值排序。
关键代码
函数只接受字符串并限制协议为 http 或 https,避免 file、data 等协议进入缓存层。默认端口通过协议到端口映射判断后清空。查询参数先展开为键值对数组,过滤指定键后按键、再按值做确定性排序,最后清空 search 并逐项 append,因此同名多值参数的相对顺序也被规范化。末尾斜杠选项只处理长度大于一的路径,根路径始终保持斜杠。此外 urlCacheKey 把输入与关键选项拼成指纹并缓存规范化结果,同一逻辑 URL 只解析一次即可复用为稳定的缓存键,把标题所述的缓存键用途落实在代码里。
使用注意
规范化不等于安全校验,不能仅凭结果判断 URL 是否可信或是否越权,仍需域名白名单与路径校验。不同站点可能把查询参数顺序视为语义的一部分,例如签名 URL,排序前应确认兼容性。过滤追踪参数会改变实际请求内容,只应在缓存键计算阶段使用,不要拿结果直接发起请求。片段默认移除,因为浏览器不会把它发送给服务器。
验证方式
用带默认端口、大写主机、点段路径与乱序查询的 URL,断言输出稳定;测试重复键排序与 utm 参数过滤;比较 keepTrailingSlash 开关的结果;传入非 http 协议、空串与畸形 URL,分别验证 TypeError;同一输入多次调用应返回完全相同的字符串。
代码示例
const DEFAULT_PORTS = new Map([
["http:", "80"],
["https:", "443"],
]);
export function canonicalizeUrl(input, options = {}) {
if (typeof input !== "string" || input.trim() === "") {
throw new TypeError("input must be a non-empty URL string");
}
const {
stripFragment = true,
sortQuery = true,
dropQuery = [],
keepTrailingSlash = true,
} = options;
if (!Array.isArray(dropQuery)) throw new TypeError("dropQuery must be an array");
const dropped = new Set(dropQuery.map((key) => String(key)));
let url;
try {
url = new URL(input.trim());
} catch {
throw new TypeError("invalid URL: " + input);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new TypeError("only http and https URLs are supported");
}
if (stripFragment) url.hash = "";
if (DEFAULT_PORTS.get(url.protocol) === url.port) url.port = "";
if (url.pathname === "") url.pathname = "/";
if (!keepTrailingSlash && url.pathname.length > 1 && url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
}
if (sortQuery) {
const pairs = [...url.searchParams.entries()].filter(([key]) => !dropped.has(key));
pairs.sort((left, right) => {
if (left[0] !== right[0]) return left[0] < right[0] ? -1 : 1;
if (left[1] === right[1]) return 0;
return left[1] < right[1] ? -1 : 1;
});
url.search = "";
for (const [key, value] of pairs) url.searchParams.append(key, value);
}
return url.toString();
}
const urlKeyCache = new Map();
// Memoize canonical results so one logical URL maps to one cache key.
export function urlCacheKey(input, options = {}) {
const fingerprint = [input, options.sortQuery ?? true, options.stripFragment ?? true, options.keepTrailingSlash ?? true, (options.dropQuery ?? []).join(',')].join('|');
let key = urlKeyCache.get(fingerprint);
if (key === undefined) {
key = canonicalizeUrl(input, options);
urlKeyCache.set(fingerprint, key);
}
return key;
}