名称: 查询层模式 描述: 使用TanStack Query、错误转换和运行时依赖注入消费服务的查询层模式。适用于实现查询/突变、将服务错误转换为UI错误或添加反应式数据管理时使用。 元数据: 作者: epicenter 版本: ‘1.1’
查询层模式
查询层是UI组件和服务层之间的反应式桥梁。它使用TanStack Query和WellCrafted工厂包装纯服务函数,提供缓存、反应性和状态管理。
何时应用此技能
在以下情况下使用此模式:
- 创建消费服务的查询或突变
- 将服务层错误转换为用户界面错误类型
- 基于用户设置实现运行时服务选择
- 为即时UI反馈添加乐观缓存更新
- 理解双重接口模式(反应式与命令式)
核心架构
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ UI │ --> │ 查询层 │ --> │ 服务层 │
│ 组件 │ │ RPC/查询 │ │ (纯函数) │
└─────────────┘ └─────────────┘ └──────────────┘
↑ │
└────────────────────┘
反应式更新
查询层职责:
- 调用注入设置/配置的服务
- 将服务错误转换为用户界面错误类型以进行显示
- 管理TanStack Query缓存以进行乐观更新
- 提供双重接口:反应式(
.options)和命令式(.execute())
错误转换模式
关键点: 服务错误应在查询层边界转换为用户界面错误类型。
三层错误流
服务层 → 查询层 → UI层
TaggedError<'名称'> → UserFacingError → 吐司通知
(领域特定) (显示就绪) (显示)
标准错误转换
import { Err, Ok } from 'wellcrafted/result';
// 在查询层 - 将服务错误转换为用户界面错误
const { data, error } = await services.recorder.startRecording(params);
if (error) {
return Err({
title: '❌ 启动录制失败',
description: error.message,
action: { type: 'more-details', error },
});
}
return Ok(data);
真实世界示例
// 简单错误转换
enumerateDevices: defineQuery({
queryKey: recorderKeys.devices,
queryFn: async () => {
const { data, error } = await recorderService().enumerateDevices();
if (error) {
return Err({
title: '❌ 枚举设备失败',
description: error.message,
action: { type: 'more-details', error },
});
}
return Ok(data);
},
}),
// 当服务消息不足时的自定义描述
stopRecording: defineMutation({
mutationFn: async ({ toastId }) => {
const { data: blob, error } = await recorderService().stopRecording({ sendStatus });
if (error) {
return Err({
title: '❌ 停止录制失败',
description: error.message,
action: { type: 'more-details', error },
});
}
if (!recordingId) {
return Err({
title: '❌ 缺少录制ID',
description: '发生内部错误:录制ID未设置。',
});
}
return Ok({ blob, recordingId });
},
}),
反模式:双重包装
切勿包装已包装的错误:
// ❌ 错误:双重包装
if (error) {
const userError = Err({ title: '失败', description: error.message });
notify.error.execute({ id: nanoid(), ...userError.error }); // 不要扩展!
return userError;
}
// ✅ 正确:转换一次,直接使用
if (error) {
return Err({
title: '❌ 启动录制失败',
description: error.message,
});
}
// 在onError钩子中,错误已是用户界面类型
onError: (error) => notify.error.execute(error),
运行时依赖注入
查询层基于用户设置动态选择服务实现。
服务选择模式
// 来自transcription.ts - 在提供者之间切换
async function transcribeBlob(blob: Blob): Promise<Result<string, UserError>> {
const selectedService =
settings.value['transcription.selectedTranscriptionService'];
switch (selectedService) {
case 'OpenAI':
return await services.transcriptions.openai.transcribe(blob, {
apiKey: settings.value['apiKeys.openai'],
modelName: settings.value['transcription.openai.model'],
outputLanguage: settings.value['transcription.outputLanguage'],
prompt: settings.value['transcription.prompt'],
temperature: settings.value['transcription.temperature'],
});
case 'Groq':
return await services.transcriptions.groq.transcribe(blob, {
apiKey: settings.value['apiKeys.groq'],
modelName: settings.value['transcription.groq.model'],
outputLanguage: settings.value['transcription.outputLanguage'],
prompt: settings.value['transcription.prompt'],
temperature: settings.value['transcription.temperature'],
});
// ... 更多案例
default:
return Err({
title: '⚠️ 未选择转录服务',
description: '请在设置中选择转录服务。',
});
}
}
录制器服务选择
// 基于平台和设置的选择
export function recorderService() {
// 在浏览器中,始终使用navigator录制器
if (!window.__TAURI_INTERNALS__) return services.navigatorRecorder;
// 在桌面上,使用设置
const recorderMap = {
navigator: services.navigatorRecorder,
ffmpeg: desktopServices.ffmpegRecorder,
cpal: desktopServices.cpalRecorder,
};
return recorderMap[settings.value['recording.method']];
}
双重接口模式
每个查询/突变提供两种使用方式:
反应式接口:.options
在Svelte组件中使用以实现自动状态管理。在访问器函数内传递.options(静态对象):
<script lang="ts">
import { createQuery, createMutation } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
// 反应式查询 - 包装在访问器函数中,访问.options(无括号)
const recordings = createQuery(() => rpc.db.recordings.getAll.options);
// 反应式突变 - 相同模式
const deleteRecording = createMutation(
() => rpc.db.recordings.delete.options,
);
</script>
{#if recordings.isPending}
<Spinner />
{:else if recordings.error}
<Error message={recordings.error.description} />
{:else}
{#each recordings.data as recording}
<RecordingCard
{recording}
onDelete={() => deleteRecording.mutate(recording)}
/>
{/each}
{/if}
命令式接口:.execute() / .fetch()
在事件处理器和工作流中使用,无需反应式开销:
// 在事件处理器或工作流中
async function handleBulkDelete(recordings: Recording[]) {
for (const recording of recordings) {
const { error } = await rpc.db.recordings.delete.execute(recording);
if (error) {
notify.error.execute(error);
return;
}
}
notify.success.execute({ title: '所有录制删除成功' });
}
// 在顺序工作流中
async function stopAndTranscribe(toastId: string) {
const { data: blobData, error: stopError } =
await rpc.recorder.stopRecording.execute({ toastId });
if (stopError) {
notify.error.execute(stopError);
return;
}
// 继续转录...
}
何时使用每种接口
使用.options和createQuery/createMutation |
使用.execute()/.fetch() |
|---|---|
| 组件数据显示 | 事件处理器 |
| 需要加载旋转器 | 顺序工作流 |
| 希望自动重新获取 | 一次性操作 |
| 需要反应式状态 | 组件上下文外 |
| 缓存同步 | 性能关键路径 |
缓存管理
乐观更新模式
立即更新缓存,然后与服务器同步:
create: defineMutation({
mutationKey: ['db', 'recordings', 'create'] as const,
mutationFn: async (params: { recording: Recording; audio: Blob }) => {
const { error } = await services.db.recordings.create(params);
if (error) return Err(error);
// 乐观缓存更新 - UI即时更新
queryClient.setQueryData<Recording[]>(
dbKeys.recordings.all,
(oldData) => [...(oldData || []), params.recording],
);
queryClient.setQueryData<Recording>(
dbKeys.recordings.byId(params.recording.id),
params.recording,
);
// 失效以在后台重新获取新鲜数据
queryClient.invalidateQueries({ queryKey: dbKeys.recordings.all });
queryClient.invalidateQueries({ queryKey: dbKeys.recordings.latest });
return Ok(undefined);
},
}),
查询键模式
层级组织键以进行目标失效:
export const dbKeys = {
recordings: {
all: ['db', 'recordings'] as const,
latest: ['db', 'recordings', 'latest'] as const,
byId: (id: string) => ['db', 'recordings', id] as const,
},
transformations: {
all: ['db', 'transformations'] as const,
byId: (id: string) => ['db', 'transformations', id] as const,
},
};
查询定义示例
基础查询
export const db = {
recordings: {
getAll: defineQuery({
queryKey: dbKeys.recordings.all,
queryFn: () => services.db.recordings.getAll(),
}),
},
};
带初始数据的查询
getLatest: defineQuery({
queryKey: dbKeys.recordings.latest,
queryFn: () => services.db.recordings.getLatest(),
// 使用可用缓存数据
initialData: () =>
queryClient
.getQueryData<Recording[]>(dbKeys.recordings.all)
?.toSorted((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
)[0] ?? null,
initialDataUpdatedAt: () =>
queryClient.getQueryState(dbKeys.recordings.all)?.dataUpdatedAt,
}),
带访问器的参数化查询
getById: (id: Accessor<string>) =>
defineQuery({
queryKey: dbKeys.recordings.byId(id()),
queryFn: () => services.db.recordings.getById(id()),
initialData: () =>
queryClient
.getQueryData<Recording[]>(dbKeys.recordings.all)
?.find((r) => r.id === id()) ?? null,
}),
带回调的突变
startRecording: defineMutation({
mutationKey: recorderKeys.startRecording,
mutationFn: async ({ toastId }) => {
const { data, error } = await recorderService().startRecording(params, {
sendStatus: (options) => notify.loading.execute({ id: toastId, ...options }),
});
if (error) {
return Err({
title: '❌ 启动录制失败',
description: error.message,
action: { type: 'more-details', error },
});
}
return Ok(data);
},
// 突变完成后失效状态
onSettled: () => queryClient.invalidateQueries({ queryKey: recorderKeys.recorderState }),
}),
RPC命名空间
所有查询打包到统一的rpc命名空间:
// query/index.ts
export const rpc = {
db,
recorder,
transcription,
clipboard,
sound,
analytics,
notify,
// ... 所有功能模块
} as const;
// 在应用任何地方使用
import { rpc } from '$lib/query';
// 反应式(在组件中)
const query = createQuery(() => rpc.db.recordings.getAll.options);
// 命令式(在处理器/工作流中)
const { data, error } = await rpc.recorder.startRecording.execute({ toastId });
通知API示例
查询层可以协调多个服务:
export const notify = {
success: defineMutation({
mutationFn: async (options: NotifyOptions) => {
// 同时显示吐司和操作系统通知
services.toast.success(options);
await services.notification.show({ ...options, variant: 'success' });
return Ok(undefined);
},
}),
error: defineMutation({
mutationFn: async (error: UserError) => {
services.toast.error(error);
await services.notification.show({ ...error, variant: 'error' });
return Ok(undefined);
},
}),
loading: defineMutation({
mutationFn: async (options: LoadingOptions) => {
// 仅用于加载状态的吐司(无操作系统通知垃圾邮件)
services.toast.loading(options);
return Ok(undefined);
},
}),
};
关键规则
- 始终在查询边界转换错误 - 切勿返回原始服务错误
- 使用
.options(无括号) - 它是静态对象,包装在访问器函数中以供Svelte使用 - 切勿双重包装错误 - 每个错误只包装一次
- 服务是纯函数,查询注入设置 - 服务接受明确参数
- 在
.ts文件中使用.execute()-createMutation需要组件上下文 - 乐观更新缓存 - 更好的用户体验
参考资料
- 见
apps/whispering/src/lib/query/README.md获取详细架构 - 见
services-layer技能了解服务如何实现 - 见
error-handling技能获取trySync/tryAsync模式