订单页的七种状态
「回声」的订单详情页同时承载七种状态:订单号、来源渠道、当前 Tab、筛选条件、滚动位置、未提交的备注草稿、登录令牌。产品经理希望用户把链接分享出去后,对方打开能看到同一页;工程师则要回答:这七种状态里,哪几种必须写入 URL?先给边界:可分享、可刷新、可被外部应用引用的状态进 URL;只影响本机观感的状态留在内存;跨会话要恢复的草稿留在本地存储;任何凭证类状态永远不进 URL,也不进日志。这个划分不是洁癖,而是由 URL 的三个事实决定的:它会被复制到聊天窗口,会进入浏览器历史,会被 CDN 与日志系统原样记录。
哪些状态该进 URL
| 状态 | 例子 | 归属 | 理由 |
|---|---|---|---|
| 资源标识 | id=42 | URL 路径 | 分享后必须指向同一条订单 |
| 来源渠道 | ref=push | URL 查询串 | 分享后保留进入路径 |
| 视图选择 | tab=logistics | URL 查询串 | 刷新与分享后保持同一视图 |
| 排序与分页 | page=3&sort=amount | URL 查询串 | 可回退、可收藏、可比较 |
| 输入草稿 | 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 里放短期令牌并假设“用户不会复制”。地址栏、历史记录、截图和日志都会复制它。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。