Appearance
2.3 请求 Client 与 Mock 场景
首页按阶段取列表,详情页按 ID 取一条,“我的记录”按已保存 ID 恢复多条。让三个页面读取同一份 Mock 数组,会把查询规则和测试场景散进 page。
本节把边界收在这些文件:
text
apps/web/src/api/recommendation-client.ts
apps/web/src/api/recommendation-client.test.ts
apps/web/src/api/mock-recommendation-client.ts
apps/web/src/api/recommendation-client-provider.ts
apps/web/src/api/recommendation-client-provider.test.ts
apps/web/src/composables/recommendation-request.ts
apps/web/src/composables/recommendation-request.test.ts
docs/api-contract.md三个查询就是 page 的完整依赖
ts
export interface RecommendationClient {
list(stageId: StageId): Promise<readonly Recommendation[]>;
get(id: RecommendationId): Promise<Recommendation | undefined>;
listByIds(ids: readonly RecommendationId[]): Promise<readonly Recommendation[]>;
}- 首页切换 stage 后调用
list(stageId); - 详情页先用
isRecommendationId(query.id)收窄,再调用get(id); - “我的记录”把收藏和历史 ID 交给
listByIds(ids),不请求完整目录。
五个 RecommendationId 是封闭 contract。Client 不能接受服务端临时增加的第六个 ID,再让它一路流进 store 或 route。
Page 只 import recommendation-client-provider.ts:
ts
export function createAppRecommendationClient(
scenario: unknown = "success",
): RecommendationClient {
return createMockRecommendationClient({
scenario: parseRecommendationScenario(scenario),
});
}Provider 是替换 adapter 的单点。页面不 import Mock adapter,也不 import data/recommendations.ts。
Runtime parser 拒绝看似合法的对象
return value as Recommendation[] 没有验证运行时输入。parseRecommendationList(value: unknown) 必须检查:
- recommendation ID 非空且属于固定五项;同一响应不能有重复 ID;
stageIds非空且只包含四个StageId;- 七个字段都是对象自己的 data property,不能来自 prototype 或 getter;
- 外层和
stageIds都是 dense array,稀疏 index 直接失败; - descriptor trap 抛错、getter、异常 Proxy 都归一为
invalid-response; - 成功结果、每个 item 和嵌套
stageIds都冻结。
关键读取不使用普通属性访问:
ts
function readOwnDataProperty(object: object, key: PropertyKey): unknown {
if (!Object.hasOwn(object, key)) {
return invalidResponse();
}
const descriptor = Object.getOwnPropertyDescriptor(object, key);
if (!descriptor || !Object.hasOwn(descriptor, "value")) {
return invalidResponse();
}
return descriptor.value;
}数组也逐个读取 own index。Array(1) 的 length 是 1,但 index 0 不存在,不能被 .map() 静默跳过。
Review fixture 应包含 blank/unknown ID、重复 ID、继承字段、getter、sparse array 和会抛错的 Proxy。失败时修 parser,不能把 fixture 换成普通 object 或删 assertion。
Mock 负责四种可重复场景
ts
export type RecommendationScenario = "success" | "empty" | "slow" | "retry";| Scenario | Adapter 行为 |
|---|---|
success | 返回对应 list / get / listByIds 的已解析结果 |
empty | 返回冻结空数组或 undefined |
slow | 默认等待 800 ms 后返回 |
retry | 第一次请求抛 network,后续请求成功 |
未知 scenario 回 success。listByIds 对输入 ID 按首次出现顺序去重,再返回对应内容。
Delay 在 test 中注入为 0。Retry 使用 adapter closure 保存“是否应失败”,不会把次数状态放到 page。
共享 controller 挡住 stale result
首页、详情和“我的记录”共用 useRecommendationRequest<T>()。每次 load 增加 token:
ts
const token = ++requestToken;
status.value = "loading";
errorMessage.value = "";
const nextData = await loader();
if (token !== requestToken) return;
data.value = nextData;
status.value = "success";Catch 分支也检查 token。旧请求晚到的 success 或 error 都不能覆盖最新结果。retry() 复用最近一次 loader;新 load 立刻清理旧错误文案。
Controller 把 network、server、timeout、invalid-response 映射成固定页面文案。阶段 2 没有 HTTP 请求,也没有 timeout 计时器。阶段 3 可以在 provider 后替换 transport adapter,保留这三个 client 方法和 page 调用形状。
用三组定向测试批准边界
bash
npm run test:web -- \
recommendation-client.test.ts \
recommendation-client-provider.test.ts \
recommendation-request.test.ts
npm run typecheck:webFresh 结果:
text
src/api/recommendation-client.test.ts (45 tests)
src/api/recommendation-client-provider.test.ts (2 tests)
src/composables/recommendation-request.test.ts (9 tests)
Test Files 3 passed (3)
Tests 56 passed (56)
vue-tsc --noEmit: exit 045 项守 parser、固定 ID、Mock scenario 与三个 client 方法;2 项守 provider 的 scenario 注入;9 项守 stale success、stale error、retry 最近 loader、loading 清理旧错误和安全文案。Page import Mock 或 data 时,即使这 56 项全绿,依赖边界仍然不合格。
下一节:2.4 页面状态与浏览器验收。