← 中文资源

简体中文版本

用于缓存键的 URL 规范化器

移除默认端口与片段、排序查询参数并过滤追踪参数,为 HTTP URL 生成稳定缓存键。

阅读英文原文

功能说明

该示例把语义等价但字面不同的 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;
}