Skip to content

策略概览 ​

策略(strategy)是驱动一个 SnailMethod 的小状态机:它替你管理 loading、data、 error,替你处理取消与并发,并把状态暴露成当前框架真正能渲染的句柄。装饰器回答「怎么描述一个 请求」,策略回答「怎么在界面里使用一个请求」。

ts
import { useRequest } from "@snail-js/api/strategies";
import { userApi } from "./service";

const user = useRequest(userApi.getUser);

await user.send("1");   // → 载荷
user.data.value;        // → 同一个载荷
user.loading.value;     // false

一个入口,不 import 任何框架 ​

策略只有一个入口 —— @snail-js/api/strategies —— 而它不 import 任何框架。框架由 server 决定:

ts
import { Server, SnailServer } from "@snail-js/api";
import { VueRef } from "@snail-js/api/adapter/vue";

@Server({ baseURL: "/api", stateAdapter: VueRef })
class BackEnd extends SnailServer {}

const { data } = useRequest(userApi.getUser);   // data 是 Vue Ref

每个 hook 从它拿到的 method 上读该 server 的 stateAdapter,所以同一份导入清单在 Vue、React 与 无框架的应用里完全一致。旧版的三个入口(/plain、/react)已经不存在:它们唯一的差别是在 导入时往一个进程级注册表里安装适配器,那既是 import 副作用,也让同一个进程里的两个 server 无法使用不同的框架。

适配器导入返回的句柄是什么读取时会订阅吗
SnailAdapter(默认)@snail-js/api普通 { value } 盒子不会 —— 值照常更新,只是不触发渲染
VueRef@snail-js/api/adapter/vueVue ref()会 —— Vue 的渲染副作用追踪 .value 的读取
ReactState@snail-js/api/adapter/react可订阅盒子需要 bind() / useBind(useSyncExternalStore)

也可以不用 server 声明,给单个 hook 显式传 adapter(见 SnailStateAdapter):它只覆盖这个 hook 自己的状态,永远不改 method.meta。解析顺序是 options.adapter → 所属 server 的 stateAdapter → SnailAdapter。三种适配器的完整说明见框架适配器。

共享的状态形状 ​

ts
interface StrategyState<TData> {
  readonly loading: SnailStateRef<boolean>;
  readonly data: SnailStateRef<TData | undefined>;
  readonly error: SnailStateRef<unknown>;
  readonly code: SnailStateRef<number | string | undefined>;
  readonly message: SnailStateRef<string | undefined>;

  abort(): void;
  update(patch: StrategyStatePatch<TData>): void;
  bind(): StrategyBoundState<TData>;

  onSuccess(callback: (data: TData) => void): () => void;
  onError(callback: (error: unknown) => void): () => void;
  onFinish(callback: () => void): () => void;
}
成员类型说明
loadingSnailStateRef<boolean>从一次 send() 开始到它落定
dataSnailStateRef<TData | undefined>最近一次成功发送的载荷
errorSnailStateRef<unknown>最近一次发送的失败;取消永远不写这里
codeSnailStateRef<number | string | undefined>最近一次发送的业务 / HTTP code,失败路径也会尽力填上
messageSnailStateRef<string | undefined>最近一次发送的业务消息
abort()() => void中止在飞的请求(各 hook 对「中止」的定义略有不同,见每个 hook 自己的参考页)
update(patch)(patch) => void直接改写状态,例如乐观更新及其回滚
bind()() => StrategyBoundState<TData>解出当前值,并在适配器支持时订阅当前组件
onSuccess / onError / onFinish(cb) => () => void事件订阅,返回取消订阅函数
ts
interface StrategyStatePatch<TData> {
  data?: TData;
  loading?: boolean;
  error?: unknown;
  code?: number | string;
  message?: string;
}

interface StrategyBoundState<TData> {
  loading: boolean;
  data: TData | undefined;
  error: unknown;
  code: number | string | undefined;
  message: string | undefined;
}

update() 按存在性而不是真值判断:update({ error: undefined }) 是调用方清除失败的方式, update({ loading: false }) 也不能被跳过。

abort() 与事件订阅都存在于每一个 hook 上 —— 包括 useFetcher({ withState: false })。 一个「没有状态」的 fetcher 仍然要能回答「中止」和「结束了吗」,所以五个句柄总是被创建;惰性 创建只会把句柄交给 UI 一个「第一次请求之后才出现」的时机。

句柄:为什么 VUE 不需要仪式,React 需要 bind() ​

ts
interface SnailStateRef<T = unknown> {
  value: T;             // Vue 的 Ref<T> 在结构上就满足它
}

句柄刻意保持最小:任何带可变 value 属性的东西都算。Vue 的 ref() 天生就是 { value: T }, 所以 Vue 适配器几乎是纯恒等映射 —— create 直接返回 ref,read / write 摸 .value。Vue 的 渲染副作用自己追踪 .value 的读取,因此写入就够了,Vue 侧不需要 bind(),也不需要任何 订阅实现。

React 没有这种追踪,组件只在有人显式通知时重渲染,所以:

ts
function bindRef<T>(adapter: SnailStateAdapter, ref: SnailStateRef<T>): T {
  return adapter.useBind?.(ref) ?? adapter.read(ref);
}
适配器useBindbind() 的行为
VueRef未实现读取 .value(渲染副作用自己追踪)
ReactStateuseSyncExternalStore订阅当前组件并返回快照
SnailAdapter未实现读取 .value,只是不触发渲染
tsx
// React:bind() 是让组件重渲染的那一步
import { ReactState } from "@snail-js/api/adapter/react";

const user = useRequest(userApi.getUser, { adapter: ReactState });
const { data, loading } = user.bind();

?? 而不是真值判断是有意的:合法的 false / 0 / "" 不能被第二次读取替换掉。

一个方法,多次发送 ​

每个 hook 内部都用 MethodHolder 持有唯一一个 SnailMethod,第一次 send() 的参数被捕获, 之后复用:

ts
interface MethodHolder<TData = unknown> {
  readonly instance: SnailRequest<TData> | undefined;
  readonly pending: boolean;
  resolve(args: readonly unknown[]): SnailRequest<TData>;
  abort(): void;
}

这不是优化,而是正确性要求:核心在构建 SnailMethod 时创建的五个 meta 句柄每个方法只创建 一次(插件生命周期),再调用一次 method(...args) 会构造第二个 上下文和第二套 ref —— UI 会一直渲染第一个,冻结在那里。send("2") 仍然会用新参数覆盖本次请求, 只是不换实例。

副作用是:一个实例意味着同一时刻只有一个请求。SnailMethod 每次 send() 都会重置自己的 上下文,所以在第一次还在飞的时候发起第二次 send(),会让第一次读到一个属于第二次的上下文。 要并发就先 abort(),或者用 useWatcher / useAutoRequest —— 它们收敛突发调用正是为了这个。

公共选项 ​

ts
interface SnailStrategyCommonOptions {
  immediate?: boolean;             // 默认 false
  adapter?: SnailStateAdapter;     // 默认取所属 server 的 stateAdapter
  onSuccess?: (data: unknown) => void;
  onError?: (error: unknown) => void;
  onFinish?: () => void;
}

SnailStrategyCommonOptions 从包根 @snail-js/api 导出。

选项默认说明
immediatefalse创建时就发一次。useRequest / useFetcher / useWatcher 用空参数发送;useAutoRequest 的 immediate 等价于 start()
adapter所属 server 的 stateAdapter(再兜底 SnailAdapter)只覆盖这个 hook 自己的句柄,不影响 method.meta
onSuccess / onError / onFinish未设置与 state.onSuccess(...) 注册的监听器走同一条路径,所以两种写法行为一致

onError 不会为取消触发:取消是预期控制流(abort()、策略丢弃过期响应),不是失败。上报 它只会让每次用户主动中止都弹一个错误提示。

有哪些 hook ​

Hook一句话参考
useRequest一个方法 + 状态句柄,手动 send()参考
useWatcher被监视的值变化时重发,带 debounce / throttle参考
useFetcher无视图的请求:预取、SSR、静默刷新参考
usePagination分页 / 无限滚动参考
useAutoRequest轮询 + 焦点 / 重连 / 可见性刷新参考
useRetriableRequest指数退避重试,返回尝试次数参考
useUploader有界并发的文件上传与进度参考
useTokenAuthBearer token + 单飞刷新(返回的是插件)参考
useSSE把 SSE 端点消费成响应式状态参考
useDownload服务端签发 URL、浏览器执行下载参考

每一个 hook 都有一页自己的参考:用途、真实默认值、返回表、完整示例与边界。首页只列「有哪些」, 不再把十个签名堆在一起。

还有两个底层导出,插件作者与自定义策略会用到:createStrategyState(自己拼一个状态机)以及 MethodHolder / SnailRequest / StrategyMethod 这组类型。

相关 ​

基于 MIT 许可发布