《互联网产品的客户端架构·端形态与体验基础》6.1 URL 即状态

从「回声」的订单详情页出发,把路由当成状态的投影:区分该进 URL、内存、本地存储与永远不该出现的四类状态,给出一个不依赖 DOM 的 TypeScript 解析器,覆盖参数校验、查询串归一化与规范 URL 生成,并复盘一次把草稿和令牌写进分享链接的事故。

订单页的七种状态

「回声」的订单详情页同时承载七种状态:订单号、来源渠道、当前 Tab、筛选条件、滚动位置、未提交的备注草稿、登录令牌。产品经理希望用户把链接分享出去后,对方打开能看到同一页;工程师则要回答:这七种状态里,哪几种必须写入 URL?先给边界:可分享、可刷新、可被外部应用引用的状态进 URL;只影响本机观感的状态留在内存;跨会话要恢复的草稿留在本地存储;任何凭证类状态永远不进 URL,也不进日志。这个划分不是洁癖,而是由 URL 的三个事实决定的:它会被复制到聊天窗口,会进入浏览器历史,会被 CDN 与日志系统原样记录。

哪些状态该进 URL

状态例子归属理由
资源标识id=42URL 路径分享后必须指向同一条订单
来源渠道ref=pushURL 查询串分享后保留进入路径
视图选择tab=logisticsURL 查询串刷新与分享后保持同一视图
排序与分页page=3&sort=amountURL 查询串可回退、可收藏、可比较
输入草稿draft=...本地存储可能很长且含隐私,不该出现在地址栏
滚动位置与弹层scroll=480内存只对当前会话有意义
登录令牌token=...内存泄露即等于交出账号

表中前三类进入 URL,但它们也有优先级:路径标识资源,查询串描述“同一资源的哪一种看法”。分页、排序、筛选都应该能被默认值消掉,否则 /search?q=echo&page=1&sort=relevance 与 /search?q=echo 会变成两个 URL,缓存、埋点和分享统计都会被拆散。规范 URL 的目标是“一种状态只有一个地址”:只写与默认值不同的部分,并按固定顺序排列,这样两个逻辑上等价的链接可以直接做字符串比较,缓存键也不需要额外的归一化层。

解析器:匹配、校验、归一化

下面的解析器不依赖 DOM、History API 或任何框架。它接收路径与路由表,输出匹配结果或明确的失败原因。路由按静态段数量排序,所以 /orders/new 永远优先于 /orders/:id,不会因为定义顺序不同而把 new 当成订单号。

// file: src/router/url-state.ts
// tier: L1
// verify: node --test test/router/url-state.test.ts

export type ParamType = "string" | "number" | "boolean";
export type ParamValue = string | number | boolean;

export interface RouteDefinition {
  name: string;
  pattern: string;
  params?: Record<string, ParamType>;
  /** 允许出现在查询串的键;未列出的键会被归一化掉 */
  query?: string[];
}

export interface RouteMatch {
  route: RouteDefinition;
  params: Record<string, ParamValue>;
  query: Record<string, string>;
}

export type ParseOutcome =
  | { ok: true; match: RouteMatch; canonical: string }
  | { ok: false; reason: "bad-path" | "not-found" | "bad-param" | "bad-query"; detail: string };

/** 状态应该待在哪里;secret 永远不允许进入 URL。 */
export type StateClass = "url" | "memory" | "storage" | "secret";

export interface BuiltUrl {
  url: string;
  /** 因为只属于内存或本地存储而被跳过的键 */
  omitted: string[];
  /** 因为属于敏感信息而被拒绝写入的键 */
  refused: string[];
}

const SECRET = /(^|\.)(token|password|passwd|secret|session|otp|cookie)$/i;
const STORAGE = /(^|\.)(draft|scroll|lastVisit|recent|sessionState)$/i;
const MEMORY = /(^|\.)(modal|hover|focus|menu|toast|expanded)$/i;

/** 判断状态归属;先判敏感,再判持久化,最后判瞬时 UI 状态。 */
export function classifyState(key: string): StateClass {
  if (SECRET.test(key)) return "secret";
  if (STORAGE.test(key)) return "storage";
  if (MEMORY.test(key)) return "memory";
  return "url";
}

/** 去掉查询串与哈希,合并重复斜杠,去掉末尾斜杠(根路径除外)。 */
export function normalizePath(input: string): string {
  const path = (input.split("#")[0] ?? "").split("?")[0] ?? "";
  const withSlash = path.startsWith("/") ? path : `/${path}`;
  const collapsed = withSlash.replace(/\/{2,}/g, "/");
  return collapsed === "/" ? "/" : collapsed.replace(/\/+$/, "");
}

function segmentsOf(pattern: string): string[] {
  return normalizePath(pattern).split("/").filter(Boolean);
}

function decode(raw: string): string | undefined {
  try {
    return decodeURIComponent(raw.replace(/\+/g, " "));
  } catch {
    return undefined;
  }
}

function match(
  segments: string[],
  route: RouteDefinition,
): Record<string, string> | undefined {
  const pattern = segmentsOf(route.pattern);
  if (pattern.length !== segments.length) return undefined;
  const captured: Record<string, string> = {};
  for (let i = 0; i < pattern.length; i += 1) {
    const expected = pattern[i] as string;
    const actual = segments[i] as string;
    if (expected.startsWith(":")) {
      if (actual.length === 0) return undefined;
      captured[expected.slice(1)] = actual;
    } else if (expected !== actual) {
      return undefined;
    }
  }
  return captured;
}

function convert(raw: string, type: ParamType): ParamValue | undefined {
  if (type === "string") return raw;
  if (type === "number") {
    if (!/^-?\d+$/.test(raw)) return undefined;
    const value = Number(raw);
    return Number.isSafeInteger(value) ? value : undefined;
  }
  if (raw === "true") return true;
  if (raw === "false") return false;
  return undefined;
}

function readQuery(
  raw: string,
  allowed: string[],
): { ok: true; value: Record<string, string> } | { ok: false; detail: string } {
  const value: Record<string, string> = {};
  if (raw.length === 0) return { ok: true, value };
  const allowedSet = new Set(allowed);
  for (const chunk of raw.split("&")) {
    if (chunk.length === 0) continue;
    const eq = chunk.indexOf("=");
    const key = decode(eq === -1 ? chunk : chunk.slice(0, eq));
    const decoded = decode(eq === -1 ? "" : chunk.slice(eq + 1));
    if (key === undefined || decoded === undefined) {
      return { ok: false, detail: `查询串编码损坏:${chunk}` };
    }
    if (classifyState(key) === "secret") {
      return { ok: false, detail: `敏感状态 ${key} 不允许出现在 URL 中` };
    }
    if (!allowedSet.has(key)) continue;
    const trimmed = decoded.trim();
    if (trimmed.length > 0) value[key] = trimmed;
  }
  return { ok: true, value };
}

/**
 * 解析路径与查询串:静态段优先匹配,再做类型校验与查询归一化。
 * 失败时返回具体原因,调用方据此渲染 404 或降级页面。
 */
export function parseUrl(input: string, routes: RouteDefinition[]): ParseOutcome {
  if (input.includes("://")) {
    return { ok: false, reason: "bad-path", detail: "只接受路径,不接受完整 URL" };
  }
  const path = normalizePath(input);
  const segments: string[] = [];
  for (const raw of path.split("/").filter(Boolean)) {
    const decoded = decode(raw);
    if (decoded === undefined) {
      return { ok: false, reason: "bad-path", detail: `路径编码损坏:${raw}` };
    }
    segments.push(decoded);
  }

  const ordered = [...routes].sort((a, b) => {
    const ap = segmentsOf(a.pattern);
    const bp = segmentsOf(b.pattern);
    const statics = (list: string[]) => list.filter((s) => !s.startsWith(":")).length;
    return statics(bp) - statics(ap) || bp.length - ap.length;
  });

  for (const route of ordered) {
    const captured = match(segments, route);
    if (!captured) continue;
    const params: Record<string, ParamValue> = {};
    for (const [name, raw] of Object.entries(captured)) {
      const converted = convert(raw, route.params?.[name] ?? "string");
      if (converted === undefined) {
        return { ok: false, reason: "bad-param", detail: `参数 ${name}=${raw} 类型不符` };
      }
      params[name] = converted;
    }
    const qIndex = input.split("#")[0]?.indexOf("?") ?? -1;
    const rawQuery = qIndex === -1 ? "" : (input.split("#")[0] ?? "").slice(qIndex + 1);
    const query = readQuery(rawQuery, route.query ?? []);
    if (!query.ok) return { ok: false, reason: "bad-query", detail: query.detail };
    return {
      ok: true,
      match: { route, params, query: query.value },
      canonical: buildUrl(route, params, query.value).url,
    };
  }
  return { ok: false, reason: "not-found", detail: `没有匹配 ${path} 的路由` };
}

/**
 * 生成规范 URL:敏感键拒绝、内存/存储状态省略、等于默认值的查询参数不写。
 */
export function buildUrl(
  route: RouteDefinition,
  params: Record<string, ParamValue>,
  query: Record<string, string> = {},
  defaults: Record<string, string> = {},
): BuiltUrl {
  const used = new Set<string>();
  const path = segmentsOf(route.pattern)
    .map((segment) => {
      if (!segment.startsWith(":")) return segment;
      const name = segment.slice(1);
      used.add(name);
      if (!(name in params)) throw new Error(`缺少路径参数 ${name}`);
      return encodeURIComponent(String(params[name]));
    })
    .join("/");
  for (const name of Object.keys(params)) {
    if (!used.has(name)) throw new Error(`路由 ${route.name} 不认识参数 ${name}`);
  }

  const omitted: string[] = [];
  const refused: string[] = [];
  const pairs: string[] = [];
  for (const key of Object.keys(query).sort()) {
    const value = query[key] as string;
    const home = classifyState(key);
    if (home === "secret") refused.push(key);
    else if (home !== "url") omitted.push(key);
    else if (value.length > 0 && defaults[key] !== value) {
      pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
    }
  }
  return {
    url: `/${path}${pairs.length > 0 ? `?${pairs.join("&")}` : ""}`,
    omitted,
    refused,
  };
}

/** 稳定序列化查询状态,便于比较两个 URL 是否等价。 */
export function queryKey(query: Record<string, string>): string {
  return Object.keys(query)
    .sort()
    .map((key) => `${key}=${query[key]}`)
    .join("&");
}

有三处设计值得单独说明。第一,失败原因分成 bad-path、not-found、bad-param、bad-query 四类,调用方可以据此决定渲染 404、参数错误页还是直接丢弃链接;把错误压成一个布尔值,线上排查时只能靠猜。第二,查询串走白名单,未列出的键被静默忽略,但敏感键会直接报错,因为“忽略”会让令牌看起来没被使用,实际上已经出现在地址栏和日志里。第三,buildUrl 返回 omitted 与 refused 两个列表,调用方可以记录“哪些状态没有进 URL”,而不是假设它们已经生效。

defaults 参数负责消掉默认值。它看起来只是让 URL 变短,实际影响的是缓存命中率与分享链接的可读性:同一个筛选状态如果有时带 page=1、有时不带,服务端缓存与前端路由都会把它们当成两个页面。

测试与真实输出

测试文件覆盖七组场景:动态参数解析、路径归一化、参数类型错误、查询串白名单与重复键、敏感键拒绝、状态归属与默认值消解。完整测试如下:

// file: test/router/url-state.test.ts
// tier: L1
// verify: node --test test/router/url-state.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { buildUrl, classifyState, normalizePath, parseUrl, queryKey } from "../../src/router/url-state.ts";
import type { RouteDefinition } from "../../src/router/url-state.ts";

const routes: RouteDefinition[] = [
  { name: "order-detail", pattern: "/orders/:id", params: { id: "number" }, query: ["tab", "ref"] },
  { name: "order-new", pattern: "/orders/new" },
  { name: "search", pattern: "/search", query: ["q", "page", "sort"] },
  { name: "home", pattern: "/" },
];

test("解析动态参数,静态段优先且不受定义顺序影响", () => {
  const detail = parseUrl("/orders/42", routes);
  assert.equal(detail.ok, true);
  if (!detail.ok) return;
  assert.deepEqual(detail.match.params, { id: 42 });
  assert.equal(detail.canonical, "/orders/42");
  const literal = parseUrl("/orders/new", routes);
  assert.equal(literal.ok && literal.match.route.name, "order-new");
});

test("重复斜杠、末尾斜杠与根路径被归一化", () => {
  assert.equal(normalizePath("//orders///42/?tab=list"), "/orders/42");
  assert.equal(normalizePath(""), "/");
  assert.equal(parseUrl("//orders//42/", routes).ok, true);
});

test("参数类型与路径编码错误分别报 bad-param 与 bad-path", () => {
  const badParam = parseUrl("/orders/4x", routes);
  assert.equal(!badParam.ok && badParam.reason, "bad-param");
  const badPath = parseUrl("/orders/%E0%A4%A", routes);
  assert.equal(!badPath.ok && badPath.reason, "bad-path");
});

test("查询串按白名单归一化,重复键取最后一个并稳定排序", () => {
  const result = parseUrl("/orders/42?tab=list&ref=a&ref=b&utm=x", routes);
  assert.equal(result.ok, true);
  if (!result.ok) return;
  assert.deepEqual(result.match.query, { ref: "b", tab: "list" });
  assert.equal(result.canonical, "/orders/42?ref=b&tab=list");
});

test("敏感查询键被拒绝,而不是静默保留", () => {
  const result = parseUrl("/search?q=echo&token=abc", routes);
  assert.equal(!result.ok && result.reason, "bad-query");
  if (!result.ok) assert.match(result.detail, /token/);
});

test("buildUrl 省略内存/存储状态、拒绝敏感状态并去掉默认值", () => {
  const built = buildUrl(
    { name: "search", pattern: "/search", query: ["q", "page"] },
    {},
    { q: "echo", page: "1", draft: "x", scroll: "480", token: "t" },
    { page: "1" },
  );
  assert.equal(built.url, "/search?q=echo");
  assert.deepEqual(built.omitted, ["draft", "scroll"]);
  assert.deepEqual(built.refused, ["token"]);
});

test("状态四分类、未知路由与稳定比较", () => {
  assert.equal(classifyState("page"), "url");
  assert.equal(classifyState("modal"), "memory");
  assert.equal(classifyState("draft"), "storage");
  assert.equal(classifyState("auth.token"), "secret");
  assert.equal(classifyState("password"), "secret");
  const missing = parseUrl("/nope", routes);
  assert.equal(!missing.ok && missing.reason, "not-found");
  const absolute = parseUrl("https://echo.example/orders/42", routes);
  assert.equal(!absolute.ok && absolute.reason, "bad-path");
  assert.equal(queryKey({ b: "2", a: "1" }), queryKey({ a: "1", b: "2" }));
});

在实验工程根目录执行:

node --test test/router/url-state.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 解析动态参数,静态段优先且不受定义顺序影响 (1.250917ms)
✔ 重复斜杠、末尾斜杠与根路径被归一化 (0.611458ms)
✔ 参数类型与路径编码错误分别报 bad-param 与 bad-path (0.156042ms)
✔ 查询串按白名单归一化,重复键取最后一个并稳定排序 (0.491209ms)
✔ 敏感查询键被拒绝,而不是静默保留 (0.164875ms)
✔ buildUrl 省略内存/存储状态、拒绝敏感状态并去掉默认值 (0.097417ms)
✔ 状态四分类、未知路由与稳定比较 (0.111167ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 136.904708

一次把草稿写进分享链接的事故

某次做“分享当前订单”的功能,实现方式是把页面状态整体序列化进查询串,理由是“这样对方打开就能看到一模一样的页面”。上线三天后,有用户在群里转发的链接包含了收货地址草稿;更糟的是,链接里还有当时用于客服会话的临时令牌,有效期三十天。

复盘时发现三个问题叠加:状态没有分类,草稿与令牌被当成普通字段;JSON.stringify 之后直接 encodeURIComponent,没有任何敏感键过滤;分享按钮没有区分“分享资源”与“分享当前视图”,把整个页面对象都当成了可分享内容。根因不是编码函数写得差,而是没有先回答“这个状态属于谁”。修复分三步:先用状态分类把草稿移到本地存储、把令牌留在内存;再让 buildUrl 对敏感键返回 refused,调用方必须显式处理;最后把分享链接的生成收敛到一个函数,只允许路径参数与白名单查询键通过。修复后分享链接变短了,但用户反馈“打开后还是同一页”,因为真正需要共享的只有订单号与视图选择。

小结

URL 不是页面的附属品,而是可分享状态的投影。判断标准只有三条:别人打开是否需要看到它、刷新后是否必须保留、泄露后是否会造成伤害。前两条决定它进不进 URL,第三条决定它永远不能进。把判断写成 classifyState 与 buildUrl,比写进规范文档更可靠,因为代码会在每次生成链接时执行。

下一节讨论页面栈:URL 决定“现在在哪”,页面栈决定“怎么回去”,两者必须一起设计。

阅读导航:上一节:5.3 状态机的判据 · 下一节:6.2 页面栈与返回语义 。

常见错误与边界

  • 把整个页面对象序列化进 URL。草稿、滚动位置和令牌会一起泄露,链接也会长到无法分享。
  • 只做 encodeURIComponent 就认为安全。编码解决的是字符问题,不解决“该不该出现”的问题。
  • 忽略查询键顺序。?a=1&b=2 与 ?b=2&a=1 在字符串层面不同,缓存和埋点会被拆成两份。
  • 把默认值写进 URL。page=1 这类冗余参数会让规范 URL 失去唯一性。
  • 把参数错误当成 404。类型错误、编码损坏与路由不存在是三种不同的恢复路径。
  • 在 URL 里放短期令牌并假设“用户不会复制”。地址栏、历史记录、截图和日志都会复制它。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查