查询层模式Skill query-layer

查询层模式是一种在前端开发中使用的技能,它通过TanStack Query和WellCrafted工厂实现UI组件与服务层之间的反应式桥梁。核心功能包括错误转换、缓存管理、运行时依赖注入和乐观更新,以提高应用性能和用户体验。关键词:查询层、TanStack Query、错误处理、缓存、反应式编程、前端开发、数据管理。

前端开发 0 次安装 0 次浏览 更新于 3/20/2026

名称: 查询层模式 描述: 使用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);
		},
	}),
};

关键规则

  1. 始终在查询边界转换错误 - 切勿返回原始服务错误
  2. 使用.options(无括号) - 它是静态对象,包装在访问器函数中以供Svelte使用
  3. 切勿双重包装错误 - 每个错误只包装一次
  4. 服务是纯函数,查询注入设置 - 服务接受明确参数
  5. .ts文件中使用.execute() - createMutation需要组件上下文
  6. 乐观更新缓存 - 更好的用户体验

参考资料

  • apps/whispering/src/lib/query/README.md获取详细架构
  • services-layer技能了解服务如何实现
  • error-handling技能获取trySync/tryAsync模式