设计边界

Decurl 的定位很窄:为 URLSearchParams 提供一套显式的解析与序列化规则,以及执行这些规则的工具。

解析与序列化

URL search params 本质上是一段字符串数据。我们对它做的一切,都是在做解析和序列化。

类似的事情在很多场景都会发生:

  • TypeScript 源码本质是文本,会被 TypeScript Compiler 解析成 AST;AST 也可以再被打印成文本。
  • JavaScript value 可以序列化为 JSON 字符串,也可以从 JSON 字符串解析回 JavaScript value。
  • YYYY-MM-DD 字符串可以解析成 Date 对象,Date 对象也可以序列化成适合传输的字符串。
  • location.search 是 string,可以被解析成 URLSearchParams 实例;URLSearchParams 也可以重新序列化成 search string。

Decurl 针对 URL search params 做的是同一类事情:

  • decode:把 stringstring[] 或缺失值解析成业务值。
  • encode:把业务值序列化回 URLSearchParams。

Search Fields 是这套规则的静态定义;URLSearchParams codec 是这套规则的执行器。

跨字段不变式

FieldCodec 围绕一个逻辑字段设计。Search Fields 可以组合多个 FieldCodec,但每个字段仍然只根据自己的 raw input 完成 decode。

一个逻辑字段不一定只有一个 raw value。例如:

?time=start&time=end

这里的两个值属于同一个 URL key。multi FieldCodec 会一次得到完整的 string[],因此可以在自己的 decode 中排序、过滤,或者检查两个值能否组成有效范围。legacy alias 也是同一个逻辑字段的候选 key,不属于跨字段关系。

下面的形式则不同:

?startTime=...&endTime=...

startTimeendTime 是两个独立字段。它们都可以单独完成字符串解析,但“startTime 必须小于 endTime”只有在两个字段组合后才成立。这类规则属于跨字段不变式,FieldCodec 无法也不应该单独处理。

如果允许一个 FieldCodec 在 decode 时读取其他字段,会让字段行为依赖解析顺序和外部上下文,也会让字段复用、局部 patch 和类型推导变得不再明确。因此 Decurl 只保证字段级解析,不提供 schema 级的跨字段 validation 或自动修正。

推荐在 Search Fields decode 完成后,由业务代码处理组合语义:

const TimeRangeView = () => {
  const [values] = useSearchValues(searchFields);

  if (values.startTime >= values.endTime) {
    return <InvalidTimeRange />;
  }

  return (
    <Timeline
      startTime={values.startTime}
      endTime={values.endTime}
    />
  );
};

具体策略由页面决定:它可以展示错误、阻止请求、清除某个字段,或者修正成新的范围。正常交互也应该在写入前控制值的合法性;如果两个字段需要一起变化,可以通过一次 setValues({ startTime, endTime }) 提交,但外部 URL 仍然需要业务层检查。

Decode 不应该默认抛异常

URL search params 是用户可编辑、可分享、可遗留的字符串输入。Decode 遇到预期外字符串时,默认不应该把抛异常作为控制流,而应该返回 nullundefined,再交给 defaultValue 或页面 guard 处理。

分页参数是典型例子:

const [pagination, setPagination] = useSearchValues(
  pick(searchFields, ['page', 'pageSize']),
);

如果 URL 中出现 page=abc.123,页面通常不应该进入 ErrorBoundary。分页参数大多只是辅助状态,decode 失败后回退到默认页码,会比抛异常更符合用户预期。

核心参数则需要另一种处理方式:

const [id, setId] = useSearchValue(searchFields.id);

如果 id 是页面展示的核心参数,而 URL 中出现 id=abc.12343,页面确实可能无法正常展示。但这也不一定要通过 decode 抛异常来交给 ErrorBoundary。

更推荐在页面中显式 guard:

const PageView = () => {
  const [id] = useSearchValue(searchFields.id);

  if (!id) {
    return <ErrorPage description="缺少有效的资源 id" />;
  }

  return <Detail id={id} />;
};

这种方式更容易为不同页面定制错误说明、返回按钮、刷新按钮或其他辅助操作。

validation 库很适合“校验失败 -> 结构化错误”的表单、API payload 或服务端契约;而 URL search params 更常见的是“无效值当作缺失值”、“缺失值走默认值”、“核心参数缺失由页面 guard 展示专门 UI”。

当然,预期之外的异常仍然可能发生,例如开发者自己的 decode 函数有 bug,或调用了会 throw 的第三方 parser。Decurl 的理念不是“永远不可能 throw”,而是“URL 值不符合预期时,不应该默认用 throw 表达”。

Decode 保持同步

Decurl 的 URLSearchParams codec 目前不支持异步 decode。这也是有意的边界。

URL search params 中更适合存放核心基础参数,而不是异步派生结果。例如页面可能只把 id 放在 URL 中:

const [id, setId] = useSearchValue(searchFields.id);

而真正的业务实体可能包含更多字段:

type Instance = {
  id: number;
  startTime: number;
  endTime: number;
};

如果页面只需要实体中的时间范围,看起来可以把异步请求放进 decode pipeline:

const decode = pipe(
  trim,
  shape.integer,
  toNumber,
  async (id) => fetchTimeRange(id),
);

Decurl 不支持这种写法。异步解析通常会引入状态管理问题:loading 如何展示,error 如何处理,同一个 key 在页面多处使用时如何复用请求结果,以及它是否会和系统里已有的 swr、react-query 等数据请求模块重复。

更推荐把 URL 参数 decode 成基础值,再把异步数据交给专门的请求或缓存模块:

const PageView = () => {
  const [id] = useSearchValue(searchFields.id);

  const {
    data: instance,
    isLoading: instanceLoading,
    error: instanceError,
    refetch: refetchInstance,
  } = useQuery({
    queryKey: ['instance', id],
    queryFn: () => fetchInstance(id),
    enabled: Boolean(id),
  });

  if (instanceLoading) {
    return <LoadingPage />;
  }

  if (instanceError) {
    return (
      <ErrorPage error={instanceError} refetch={refetchInstance} />
    );
  }

  return (
    <TimeRangeView
      startTime={instance.startTime}
      endTime={instance.endTime}
    />
  );
};

字符串的解析和序列化天然是同步流程。把异步请求放进 decode 会让模块边界变得模糊,也会让 loading、error、缓存和重试等 UI 状态难以统一管理。异步状态更适合交给专门的模块处理。

为什么 decode first

写入 URL 往往不难:

searchParams.set('page', String(page));

真正困难的是读取:

const page = Number(searchParams.get('page'));

这段代码没有说明:

  • 空字符串是否有效。
  • 1e3 是否有效。
  • 0 是否有效。
  • 参数缺失时默认值是什么。
  • 旧 key 是否还需要兼容。

Decurl 把这些规则前置到 Search Fields 中,让 URL 进入业务层之前就被显式处理。

Decurl 不接管什么

Decurl 不尝试接管所有数据规则。它不负责:

  • 完整 validation DSL。
  • 表单错误树和字段级错误展示。
  • 路由匹配和页面生命周期。
  • 把所有业务状态都放进 URL。
  • 把 URL 当成数据库或全局 store。
  • 鼓励把复杂对象都塞进 search params。

如果状态不需要分享、刷新保留或浏览器历史记录,它未必适合进入 URL。Decurl 只希望让那些确实属于 URL search params 的状态,有明确、可推导、可复用的解析与序列化规则。