Wasp Queries 实战指南:用声明式方式实现只读数据查询与全栈类型安全
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
Wasp 的 Query(查询)机制让你无需手写 HTTP API、服务端路由与客户端缓存逻辑,即可从服务端安全地读取数据并在全栈范围内调用。本文基于仓库内 web/docs/data-model/operations/queries.md 官方文档展开,结合 examples/kitchen-sink 的真实示例与 @wasp.sh/spec 源码,完整讲解 Query 的声明、实现、调用、错误处理与类型安全,读完即可在 Wasp 应用中落地可复用的只读数据层。
Query 是什么:Wasp 中的只读数据操作
在 Wasp 的数据模型体系中,Entities 负责定义应用的数据结构与关系,而Operations(操作)负责与这些数据打交道。Operations 分为两类:
- Queries(查询):只读数据,不修改服务端状态;
- Actions(操作):修改或新增数据。
在 operations 总览文档 中,二者的分工被概括为一句话:Queries 用于读取数据,Actions 用于改变数据(更新既有记录或创建新记录)。
适合使用 Query 的场景非常典型:拉取一篇博客文章下的所有评论、获取点赞某个视频的用户列表、根据 ID 查询某个产品的详情——这些只读操作都是 Query 的完美用例。
:::tip 与 Action 的对比 Query 与 Action 在 API 上高度相似,Action 的指南同样适用于理解 Query。两者的核心差异在于:Query 只允许读取服务端状态,Action 可以(且通常应该)修改服务端状态。Wasp 依赖这一约定来进行前端缓存失效,因此遵守"读用 Query、写用 Action"的规范至关重要。详细差异见下文"Query 与 Action 的异同"一节。 :::
创建 Query 的两步流程
创建一个 Query 只需两步:
- 在 Wasp 文件中使用
queryspec 声明 Query; - 实现 Query 的 NodeJS 函数。
完成这两步后,Wasp 会自动生成对应的客户端与服务端调用代码,你可以在代码库的任意位置(客户端或服务端)使用该 Query。你无需自己构建 HTTP API、管理服务端请求处理,甚至无需关心客户端的响应处理和缓存——只需要专注于 Query 内部的业务逻辑,剩下的交给 Wasp。
声明 Query(Specifying Queries)
在main.wasp.ts中,通过query构造函数声明 Query。以下示例声明了两个 Query:一个用于获取全部任务,另一个根据筛选条件(如任务是否完成)获取任务:
import { app, query } from "@wasp.sh/spec" import { getAllTasks, getFilteredTasks } from "./src/queries" with { type: "ref" } export default app({ // ... spec: [ query(getAllTasks), query(getFilteredTasks), ], })注意导入语句末尾的with { type: "ref" }:根据 参考导入说明,这告诉 Wasp 把导入当作对应用代码的引用,而不会真正执行被导入的代码。更完整的参考导入语法说明见 web/docs/general/spec.md 中的 "reference imports" 一节。
此处你引用的是尚不存在的实现函数——这没关系。Wasp 的理念是先有高层概念(Wasp 文件中的 Query spec),再处理实现细节(JavaScript 中的 Query 实现)。
声明之后,Wasp 会从传给query的函数名推导出 Query 的名称:query(getFilteredTasks)会创建一个名为getFilteredTasks的 Query。随后发生两件重要的事:
- Wasp生成一个以 Query 命名的服务端 NodeJS 函数;
- Wasp生成一个以 Query 命名的客户端 JavaScript 函数(如
getFilteredTasks)。该函数接收一个可选的参数——一个包含任意可序列化数据的对象,Wasp 会通过网络发送该对象,并将其作为第一个位置参数传入 Query 的实现。
这种抽象之所以成立,是因为 Wasp 在服务端生成了一个 HTTP API 路由处理器,在其内部调用 Query 的 NodeJS 实现。生成的这两个同名函数,保证了整个应用(客户端与服务端)拥有一致的调用接口。
实现 Query(Implementing Queries in Node)
声明之后,需要在src/queries.{js,ts}中导出实现(Wasp 会从这里查找)。下面是getAllTasks与getFilteredTasks的完整实现:
JavaScript 版本(src/queries.js):
// our "database" const tasks = [ { id: 1, description: "Buy some eggs", isDone: true }, { id: 2, description: "Make an omelette", isDone: false }, { id: 3, description: "Eat breakfast", isDone: false }, ] // You don't need to use the arguments if you don't need them export const getAllTasks = () => { return tasks } // The 'args' object is something sent by the caller (most often from the client) export const getFilteredTasks = (args) => { const { isDone } = args return tasks.filter((task) => task.isDone === isDone) }TypeScript 版本(src/queries.ts):
import { type GetAllTasks, type GetFilteredTasks } from "wasp/server/operations" type Task = { id: number description: string isDone: boolean } // our "database" const tasks: Task[] = [ { id: 1, description: "Buy some eggs", isDone: true }, { id: 2, description: "Make an omelette", isDone: false }, { id: 3, description: "Eat breakfast", isDone: false }, ] // You don't need to use the arguments if you don't need them export const getAllTasks: GetAllTasks<void, Task[]> = () => { return tasks } // The 'args' object is something sent by the caller (most often from the client) export const getFilteredTasks: GetFilteredTasks< Pick<Task, "isDone">, Task[] > = (args) => { const { isDone } = args return tasks.filter((task) => task.isDone === isDone) }Payload 序列化约束(superjson):根据 操作文档附注,Wasp 底层使用 superjson 进行序列化。这意味着你不只限于发送和接收 JSON 载荷——bigint、Date、Map、Set以及Prisma.Decimal等 superjson 支持的额外类型都会由 Wasp 自动处理序列化与反序列化。在 TypeScript 中,只要用正确的自动生成类型标注 Operation,编译器就能保证 payload 是合法的(即 Wasp 知道如何序列化/反序列化它们)。
Query 的类型支持
Wasp 会根据 Wasp 文件中的 spec 自动生成GetAllTasks、GetFilteredTasks这类泛型类型,用来标注 Query 实现。这是可选的,但非常有用,因为正确标注后:
- TypeScript 会知道
context.entities对象必须包含Task实体; - TypeScript 会知道
context对象是否包含用户信息(取决于 Query 是否使用 auth)。
生成的类型接受两个可选类型参数:
Input——Query 函数接收的参数(payload)类型;Output——Query 函数的返回类型。
用上面的代码举例:getAllTasks不接收任何参数(输入类型为void),但返回任务列表(输出类型为Task[]);getFilteredTasks期望接收{ isDone: boolean }类型的对象(由Task实体类型派生)。如果省略两个类型参数,TypeScript 会推断最宽泛的类型(输入为never、输出为unknown)。如果你不希望 Query 接收或返回任何值,请使用void作为类型参数。
指定Input/Output完全是可选的,但强烈推荐,它能带来:
- 在实现内部获得参数与返回值的类型支持;
- 全栈类型安全(full-stack type safety)——客户端调用处的类型永远与服务端实现匹配。
用satisfies推断返回类型
如果不想显式写出 Query 的返回类型,可以用 TypeScript 的satisfies关键字让编译器自动推断:
const getFoo = (async (_args, context) => { const foos = await context.entities.Foo.findMany() return { foos, message: "Here are some foos!", queriedAt: new Date(), } }) satisfies GetFoo从这个片段中,TypeScript 可以推断出:context的正确类型,以及 Query 的返回类型是{ foos: Foo[], message: string, queriedAt: Date }。如果不需要context,甚至可以完全跳过类型标注与参数:
const getFoo = () => ({ name: "Foo", date: new Date() })使用 Query
在客户端调用 Query
在客户端,从wasp/client/operations导入 Query 并直接调用即可:
import { getAllTasks, getFilteredTasks } from "wasp/client/operations" // ... const allTasks = await getAllTasks() const doneTasks = await getFilteredTasks({ isDone: true })调用方式不因 Query 是否要求登录而改变——Wasp 会在后台自动认证已登录用户。
在 TypeScript 中,客户端代码会自动获得类型安全:
import { getAllTasks, getFilteredTasks } from "wasp/client/operations" // TypeScript automatically infers the return values and type-checks // the payloads. const allTasks = await getAllTasks() const doneTasks = await getFilteredTasks({ isDone: true })你只需要在服务端实现中指定 Query 的类型,客户端代码就会自动知道其 API payload 类型——这就是 Wasp 的自动全栈类型安全。
在服务端调用 Query
在服务端调用 Query 与客户端类似,但有两点不同:
- 从
wasp/server/operations而不是wasp/client/operations导入; - 对于需要认证的 Query,必须传入带有
user字段的context对象——context的其他部分(如 Entities)无需手动传入,会自动注入。
import { getAllTasks, getFilteredTasks } from "wasp/server/operations" const user = // Get an AuthUser object, e.g., from context.user in an operation. // ... const allTasks = await getAllTasks({ user }) const doneTasks = await getFilteredTasks({ isDone: true }, { user })TypeScript 版本同样会自动推断返回值并做 payload 类型检查。
用useQuery钩子实现响应式查询
在客户端使用 Query 时,可以用useQuery钩子让数据响应式。这个钩子随 Wasp 内置,是react-query的useQuery钩子的薄封装,唯一区别是你无需提供缓存 key——Wasp 会在底层自动处理。
下面是完整的组件示例(src/MainPage.jsx/src/MainPage.tsx):
import React from "react" import { useQuery, getAllTasks, getFilteredTasks } from "wasp/client/operations" const MainPage = () => { const { data: allTasks, error: error1 } = useQuery(getAllTasks) const { data: doneTasks, error: error2 } = useQuery(getFilteredTasks, { isDone: true, }) if (error1 !== null || error2 !== null) { return <div>There was an error</div> } return ( <div> <h2>All Tasks</h2> {allTasks && allTasks.length > 0 ? allTasks.map((task) => <Task key={task.id} {...task} />) : "No tasks"} <h2>Finished Tasks</h2> {doneTasks && doneTasks.length > 0 ? doneTasks.map((task) => <Task key={task.id} {...task} />) : "No finished tasks"} </div> ) } const Task = ({ description, isDone }: Task) => { return ( <div> <p> <strong>Description: </strong> {description} </p> <p> <strong>Is done: </strong> {isDone ? "Yes" : "No"} </p> </div> ) } export default MainPageTypeScript 版本中,你同样不需要手动标注 Query 的返回值类型——Wasp 会自动从后端实现推断。这正是"全栈类型安全"的含义:客户端上的类型永远与服务端一致。注意useQuery的第一个参数直接传入 Query 函数本身(如getAllTasks),第二个参数是传给 Query 的 payload 对象(如{ isDone: true })。
错误处理
出于安全考虑,Query 的 NodeJS 实现中抛出的所有异常,都会以 HTTP 状态码500发送给客户端,并且移除所有其他细节。默认隐藏错误细节,有助于避免通过网络意外泄露敏感信息。
如果确实想向客户端传递额外的错误信息,可以在实现中构造并抛出HttpError:
import { type GetAllTasks } from "wasp/server/operations" import { HttpError } from "wasp/server" export const getAllTasks: GetAllTasks = async (args, context) => { throw new HttpError( 403, // status code "You can't do this!", // message { foo: "bar" } // data ) }当状态码为4xx时,客户端会收到包含对应message和data字段的响应对象,并重新抛出包含这些字段的错误。为防止信息泄露,对于其他任何 HTTP 状态码,服务端都不会转发这些字段。
这一点在仓库示例中得到了实践:在 examples/kitchen-sink/src/features/operations/queries.ts 中,getTask在任务不存在时抛出HttpError(404),而当任务属于其他用户时也抛出HttpError(404)——代码注释明确说明,这是为了"隐藏被禁止访问的目标资源是否存在"(防范 IDOR 类漏洞的安全措施)。
在 Query 中使用 Entities
大多数情况下,Query 操作的资源是 Entities。要在 Query 中使用 Entity,需要在queryspec 中把它加入entities选项:
import { app, query } from "@wasp.sh/spec" import { getAllTasks, getFilteredTasks } from "./src/queries" with { type: "ref" } export default app({ // ... spec: [ query(getAllTasks, { entities: ["Task"] }), query(getFilteredTasks, { entities: ["Task"] }), ], })Wasp 会把指定的 Entity 注入 Query 的context参数,让你在实现中直接使用其 Prisma API:
import { type Task } from "wasp/entities" import { type GetAllTasks, type GetFilteredTasks } from "wasp/server/operations" export const getAllTasks: GetAllTasks<void, Task[]> = async (args, context) => { return context.entities.Task.findMany({}) } export const getFilteredTasks: GetFilteredTasks< Pick<Task, "isDone">, Task[] > = async (args, context) => { return context.entities.Task.findMany({ where: { isDone: args.isDone }, }) }context.entities.Task对象暴露的就是 Prisma 的 CRUD API(如findMany、findUnique、count等)。再次强调:标注 Query 是可选的,但能显著提升全栈类型安全。
关于
auth选项:queryspec 还支持auth配置。在 examples/kitchen-sink/src/features/operations/operations.wasp.ts 中可以看到query(getNumTasks, { entities: ["Task"], auth: false })的用法——它明确关闭了该 Query 的认证要求。这一选项在 spec 映射源码 中被解构出来,用于控制 Query 是否自动注入用户认证上下文。
Query 与 Action 的异同
Query 与 Action 是 Wasp 中两个紧密相关的概念,理解它们的区别至关重要:
- Action 可以(且通常应该)修改服务端状态,而 Query 只允许读取状态。Wasp 进行缓存失效时依赖你遵守这一约定,因此务必遵守。
- Action 不需要响应式,可以直接调用;但 Wasp 也提供了
useActionReact 钩子为 Action 增加额外行为(如乐观更新)。 actionspec 与queryspec 基本一致,唯一的区别在于 spec 的名字。
相关的缓存失效机制
由于 Wasp 使用react-query管理 Query 缓存,需要确保数据过期时失效缓存。手动失效(refetch、直接 invalidation)容易出错,因此 Wasp 提供了开箱即用的基于 Entity 的自动缓存失效:因为 Action 会修改状态而 Query 读取状态,所以每当一个使用某 Entity 的 Action 被执行时,Wasp 就会失效使用同一 Entity 的 Query 缓存。
例如,ActioncreateTask与 QuerygetTasks都使用 EntityTask,执行createTask可能使getTasks的缓存结果过期,Wasp 会将其失效并触发重新拉取。这意味着 Wasp 让 Query 保持"新鲜"而无需你操心缓存失效。该机制的详细说明见 web/docs/data-model/operations/actions.md 的 "Cache Invalidation" 一节。
如果这种自动失效不够精细(可能产生不必要的更新),你可以使用react-query提供的机制;Wasp 的useAction钩子目前是唯一原生支持的手动缓存机制(乐观更新)。在 TypeScript 中,还可以通过任意 Query 的queryCacheKey属性直接获取其内部缓存 key,以便使用react-query底层 API。
API 参考
声明 Query(Specifying Queries)
声明一个 Query 后,你可以在代码任意位置(服务端或客户端)导入并使用它。例如声明为getFoo的 Query,Wasp 会生成两个同名函数:
// Use it on the client import { getFoo } from "wasp/client/operations" // Use it on the server import { getFoo } from "wasp/server/operations"在 TypeScript 中,它还会生成一个可在服务端导入的类型:
import { type GetFoo } from "wasp/server/operations"queryspec 的所有支持选项可以在 spec 包源码 中查看:构造函数query(fn, config?)接收实现引用fn和可选配置config,返回{ kind: "query", fn, ...config };QueryConfig由entities、auth等可选字段组成。
实现 Query(Implementing Queries)
Query 的实现是一个接收两个参数的 NodeJS 函数(需要await时可以是async函数)。由于两个参数都是位置参数,参数名可以随意命名,但约定俗成用args和context:
args(类型取决于 Query)——调用 Query 时传入的数据对象(如筛选条件)。参阅上面的"使用 Query"一节了解如何传入。context(类型取决于 Query)——由 Wasp 传入的附加上下文对象,包含用户会话信息与实体信息。context.entities的用法见"在 Query 中使用 Entities",context.user的用法见 web/docs/auth/overview.md。
在 TypeScript 中,声明 Query 后 Wasp 会生成泛型类型:声明为getSomething的 Query 对应类型GetSomething,接收两个(可选)类型参数:
Input——args对象的类型(Query 的输入 payload),默认值为never;Output——Query 返回值的类型(Query 的输出 payload),默认值为unknown。
默认值被设计为尽可能宽松;如果希望 Query 不接收/不返回任何内容,请使用void作为类型参数。
完整示例
import { app, query } from "@wasp.sh/spec" import { getFoo } from "./src/queries" with { type: "ref" } export default app({ // ... spec: [ query(getFoo, { entities: ["Foo"] }), ], })上面的声明期望在src/queries.ts中找到命名导出getFoo。使用生成的类型GetFoo并指定输入输出:
import { type GetFoo } from "wasp/server/operations" type Foo = // ... export const getFoo: GetFoo<{ id: number }, Foo> = (args, context) => { // implementation };这里 Query 期望接收一个含id字段(类型number)的对象作为args,并返回类型为Foo的值。
useQuery钩子
Wasp 的useQuery钩子是react-queryuseQuery钩子的薄封装,关键区别是无需提供缓存 key——Wasp 在底层代劳。它接收三个参数:
queryFn(必填)——Wasp 根据 Wasp 文件中的queryspec 生成的客户端 Query 函数。queryFnArgs——希望传入 Query 的参数对象(payload),Query 的 NodeJS 实现会将其作为第一个位置参数接收。options——一个react-query的options对象,用于改变某个 Query 的默认行为。若要修改全局默认值,可以在 客户端配置函数 中进行设置。
仓库实战佐证:kitchen-sink 中的完整 Query 链路
examples/kitchen-sink 是仓库内覆盖最全面的示例应用,其 operations 模块展示了 Query 的完整落地形态:
- 声明(operations.wasp.ts):
getTasks、getNumTasks、getTask、getOldestTask四个 Query 均声明了{ entities: ["Task"] },其中getNumTasks额外设置了auth: false。 - 实现(queries.ts):
getTasks使用satisfies GetTasks<void>让 TS 推断返回值;getTask用GetTask<Pick<Task, "id">, Task>标注输入输出,并在实现中结合context.user做权限校验(IDOR 防护);getNumTasks演示了context.entities.Task.count()的用法。 - 客户端响应式使用(components/Todo.tsx):
const { data: tasks, isError, error: tasksError } = useQuery(getTasks)直接消费 Query 结果并处理错误状态。
这条从 Wasp 文件声明、NodeJS 实现到 React 组件消费的完整链路,正是本指南所有概念的落地验证。配套的 cacheInvalidation.test.ts 还覆盖了基于 Entity 的自动缓存失效行为,可以作为深入理解 Query 缓存语义的阅读材料。
【免费下载链接】waspThe batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex full-stack features like auth, background jobs, RPC, email sending, end-to-end type safety, single-command deployment, and more.项目地址: https://gitcode.com/GitHub_Trending/wa/wasp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考