Wasp 框架 Actions 完全指南:声明式后端操作、实体注入与 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
导读
本文是 Wasp 数据操作(Operations)体系的核心技术指南,围绕 version-0.19 版本文档 系统讲解Actions:如何通过action声明在main.wasp中定义写操作、如何在 Node.js 中实现其业务逻辑、如何在客户端与服务端统一调用,以及 Wasp 如何基于实体自动完成前端 Query 缓存失效、并通过useActionHook 实现乐观更新。读完本文,你将掌握在 Wasp 中编写"增改数据"类后端逻辑的完整实战方案,并理解其底层代码生成机制与全栈类型安全原理。
什么是 Actions
在 Wasp 中,数据操作(Operations)分为两类:Queries与Actions,二者共同构成了 Operations 概述 中提到的"围绕数据模型工作"的能力层:
- Queries:只读数据(如获取任务列表、查询用户信息)。
- Actions:修改与新增数据(如给博客文章添加评论、点赞视频、更新商品价格)。
Actions 与 Queries 的 API 几乎完全一致(官方文档明确提示:熟悉 Queries 的开发者可以跳过大部分内容,只读"Queries 与 Actions 的区别"一节)。二者协同工作,共同保证前端数据缓存始终新鲜:Actions 负责改变服务端状态,Queries 负责读取,而 Wasp 会在 Action 执行后自动使相关 Query 缓存失效。
在 Wasp 编译器内部,Queries 与 Actions 被统一建模为Operation类型,见 waspc/src/Wasp/AppSpec/Operation.hs:
data Operation = QueryOp String Query | ActionOp String Action这正是"两者在 API 层面几乎相同、仅在声明名称上不同"这一设计的底层来源。
使用 Actions 的两步工作流
Actions 在 Wasp 中声明、在 Node.js 中实现。Wasp 会在服务端上下文(server context)中运行 Actions,同时生成代码让你在任意位置(客户端或服务端)以相同接口调用它们:
- 在 Wasp 文件中使用
action声明 Action。 - 在 Node.js 中实现 Action 的业务逻辑。
完成后即可在代码的任何位置使用该 Action。你完全不需要关心:构建 HTTP API、管理服务端请求处理、处理客户端响应与缓存——只需专注业务逻辑,其余交给 Wasp。
从代码生成的角度看,服务端生成器会为每个 Action 产出一个"包装文件":导入用户实现、注入上下文后重新导出,见 waspc/src/Wasp/Generator/ServerGenerator/OperationsG.hs。这也是"其余交给 Wasp"的实现基础。
声明 Actions
在main.wasp中以action声明开始。例如声明两个 Action——一个用于创建任务,一个用于将任务标记为完成:
// ... action createTask { fn: import { createTask } from "@src/actions" } action markTaskAsDone { fn: import { markTaskAsDone } from "@src/actions" }注意:Wasp 中的
action声明与其 Node.js 实现的名称不必相同(fn字段指向具体实现),但为免混淆,官方示例统一保持同名。
Wasp 声明的名称与实现无关这一点,在仓库中有直接佐证:examples/kitchen-sink中声明的 Action 与实现保持同名(如 operations.wasp.ts 中的createTask、updateTaskIsDone、deleteCompletedTasks、toggleAllTasks),examples/waspello中的 cards.wasp.ts 则展示了可同时声明entities的写法。
小提示:你可能会发现上例中导入的 Action 实现尚不存在。无需担心,下一步就在
src/actions.{js,ts}中编写这些实现。官方建议遵循"先高层概念(Wasp 声明)、后实现细节(JS 实现)"的开发顺序。
声明完成后,Wasp 会自动做两件重要的事:
- 生成一个与服务端同名的 Node.js 函数(用于在服务端逻辑中调用);
- 生成一个与 Action 同名的客户端 JavaScript 函数(例如
markTaskAsDone)。该函数接收一个可选参数——包含任意可序列化数据的对象。Wasp 会将该对象通过网络发送,并作为第一个位置参数传入 Action 实现。这套抽象依赖 Wasp 在服务端生成的 HTTP API 路由处理器,它会在底层调用 Action 的 Node.js 实现。
生成这两个函数,保证了整个应用(客户端与服务端)拥有一致的调用接口。
在 Node 中实现 Actions
我们已经指示 Wasp 从src/actions.{js,ts}中寻找实现,因此需要在该文件中导出对应函数。以下是如何实现之前声明的createTask与markTaskAsDone:
// our "database" let nextId = 4 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 }, ] // 不需要参数时可以不使用它 export const createTask = (args) => { const newTask = { id: nextId, isDone: false, description: args.description, } nextId += 1 tasks.push(newTask) return newTask } // 'args' 对象由调用方(通常是客户端)发送 export const markTaskAsDone = (args) => { const task = tasks.find((task) => task.id === args.id) if (!task) { // 稍后会展示如何正确处理此类错误 return } task.isDone = true }TypeScript 版本(利用 Wasp 自动生成的泛型类型):
import { type CreateTask, type MarkTaskAsDone } from 'wasp/server/operations' type Task = { id: number description: string isDone: boolean } // our "database" let nextId = 4 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 }, ] // 不需要参数时可以不使用它 export const createTask: CreateTask<Pick<Task, 'description'>, Task> = ( args ) => { const newTask = { id: nextId, isDone: false, description: args.description, } nextId += 1 tasks.push(newTask) return newTask } // 'args' 对象由调用方(通常是客户端)发送 export const markTaskAsDone: MarkTaskAsDone<Pick<Task, 'id'>, void> = ( args ) => { const task = tasks.find((task) => task.id === args.id) if (!task) { // 稍后会展示如何正确处理此类错误 return } task.isDone = true }载荷约束(superjson)
Wasp 底层使用superjson进行序列化。这意味着你不局限于只能收发 JSON 载荷:
- Wasp 会自动处理 superjson 支持的所有数据类型 的序列化与反序列化(如
bigint、Date、Map、Set等),以及Prisma.Decimal; - 在 TypeScript 中,只要你使用正确的自动生成类型标注 Operations,编译器会确保载荷合法(即 Wasp 知道如何序列化/反序列化它们)。
Actions 的类型支持(TypeScript)
Wasp 会根据main.wasp中的声明自动生成类型CreateTask与MarkTaskAsDone:
CreateTask是基于createTask的 Action 声明自动生成的泛型类型;MarkTaskAsDone是基于markTaskAsDone的 Action 声明自动生成的泛型类型。
使用这些类型标注实现是可选的,但非常有用——它能让 Action 的context获得正确类型:TypeScript 会知道context.entities必须包含Task实体,也会根据 Action 是否使用 auth 判断context是否包含用户信息。
生成的类型是泛型,接受两个可选类型参数:
Input—— Action 函数接收的参数(载荷)。Output—— Action 函数的返回类型。
以上例说明:
createTask期望接收包含新任务描述的对象(输入类型为Pick<Task, 'description'>),并返回新任务(输出类型为Task);markTaskAsDone期望接收类型为Pick<Task, 'id'>的对象(派生自Task实体类型)。
如果不在乎输入输出类型,可以省略两个类型参数,TypeScript 会推断最宽泛的类型(输入为never,输出为unknown)。虽然完全可选,但官方强烈建议显式指定,因为可以带来:
- 实现内部对参数和返回值的类型支持;
- 全栈类型安全(在客户端调用时体现)。
提示:推断返回类型。如果不希望显式写出 Action 的返回类型,可以用
satisfies关键字让 TypeScript 自动推断:
const createFoo = (async (_args, context) => { const foo = await context.entities.Foo.create() return { newFoo: foo, message: "Here's your foo!", returnedAt: new Date(), } }) satisfies CreateFoo从上述代码中,TypeScript 能知道:context的正确类型,以及 Action 返回类型为{ newFoo: Foo, message: string, returnedAt: Date }。如果不需要context,可以连 Action 的类型和参数一起省略:
const createFoo = () => ({ name: 'Foo', date: new Date() })使用 Actions
在客户端使用 Actions
在客户端调用 Action,只需从wasp/client/operations导入并直接调用:
import { createTask, markTaskAsDone } from 'wasp/client/operations' // ... const newTask = await createTask({ description: 'Learn TypeScript' }) await markTaskAsDone({ id: 1 })TypeScript 版本会自动推断返回值并类型校验载荷:
import { createTask, markTaskAsDone } from 'wasp/client/operations' const newTask = await createTask({ description: 'Keep learning TypeScript' }) await markTaskAsDone({ id: 1 })Wasp 支持自动的全栈类型安全:只需在服务端定义中指定 Action 的类型,客户端代码便会自动获得其 API 载荷类型。
使用方式不依赖 Action 是否经过认证——Wasp 会在后台自动认证当前登录用户。
在客户端使用 Actions 时,最典型的场景是组件内部。以下是一个"标记任务完成"的组件:
import React from 'react' import { useQuery, getTask, markTaskAsDone } from 'wasp/client/operations' export const TaskPage = ({ id }) => { const { data: task } = useQuery(getTask, { id }) if (!task) { return <h1>"Loading"</h1> } const { description, isDone } = task return ( <div> <p> <strong>Description: </strong> {description} </p> <p> <strong>Is done: </strong> {isDone ? 'Yes' : 'No'} </p> {isDone || ( <button onClick={() => markTaskAsDone({ id })}>Mark as done.</button> )} </div> ) }TypeScript 版本与之几乎相同,只是为组件props标注类型:({ id }: { id: number })。
由于 Actions 不需要响应式(reactive),在组件内无需 Hook 即可直接使用。当然,Wasp 也提供了useActionHook 来增强 Action(详见后文 API Reference)。
在服务端使用 Actions
在服务端调用 Action 与客户端类似,只需两处不同:
- 从
wasp/server/operations而不是wasp/client/operations导入; - 对于需要认证的 Action,必须传入包含 user 的 context 对象。
import { createTask, markTaskAsDone } from 'wasp/server/operations' const user = // 获取 AuthUser 对象,例如来自 context.user const newTask = await createTask( { description: 'Learn TypeScript' }, { user }, ) await markTaskAsDone({ id: 1 }, { user })TypeScript 版本同样会自动推断返回值并校验载荷。关于context.user对象的使用(含hashedPassword字段会被剥离等安全细节),可参阅 auth 文档的 "Using the context.user object" 小节。
错误处理
出于安全考虑,Action 的 Node.js 实现中抛出的所有异常,都会以 HTTP 状态码500返回给客户端,且移除全部其他细节。默认隐藏错误细节,有助于避免通过网络意外泄露敏感信息。
如果你确实想向客户端传递额外的错误信息,可以在实现中构造并抛出适当的HttpError:
import { HttpError } from 'wasp/server' export const createTask = async (args, context) => { throw new HttpError( 403, // status code "You can't do this!", // message { foo: 'bar' } // data ) }TypeScript 版本:
import { type CreateTask } from 'wasp/server/operations' import { HttpError } from 'wasp/server' export const createTask: CreateTask = async (args, context) => { throw new HttpError( 403, // status code "You can't do this!", // message { foo: 'bar' } // data ) }仓库中的真实示例展示了HttpError在认证场景的典型用法:examples/kitchen-sink的 actions.ts 在context.user不存在时抛出HttpError(401),并将新任务与当前用户通过connect: { id: context.user.id }关联——这正是"每个操作必须检查context.user并决定如何处理"这一访问控制约定的落地实现。
在 Actions 中使用实体(Entities)
大多数情况下,Action 中操作的数据资源是 Entities。要在 Action 中使用实体,把它添加到 Wasp 的action声明中:
action createTask { fn: import { createTask } from "@src/actions", entities: [Task] } action markTaskAsDone { fn: import { markTaskAsDone } from "@src/actions", entities: [Task] }Wasp 会将指定实体注入 Action 的context参数,使你可以访问该实体的 Prisma API。同时,Wasp 通过检查每个 Action/Query 使用的实体来失效前端 Query 缓存(详见"缓存失效"一节)。
实现示例:
// 'args' 对象是调用方(通常是客户端)发送的载荷 export const createTask = async (args, context) => { const newTask = await context.entities.Task.create({ data: { description: args.description, isDone: false, }, }) return newTask } export const markTaskAsDone = async (args, context) => { await context.entities.Task.update({ where: { id: args.id }, data: { isDone: true }, }) }TypeScript 版本(标注 Action 类型仍可选,但能显著提升全栈类型安全):
import { type CreateTask, type MarkTaskAsDone } from 'wasp/server/operations' import { type Task } from 'wasp/entities' export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ( args, context ) => { const newTask = await context.entities.Task.create({ data: { description: args.description, isDone: false, }, }) return newTask } export const markTaskAsDone: MarkTaskAsDone<Pick<Task, 'id'>, void> = async ( args, context ) => { await context.entities.Task.update({ where: { id: args.id }, data: { isDone: true }, }) }context.entities.Task对象暴露的是 Prisma 的 CRUD API(对应prisma.task)。从 Action 声明的数据结构看,Action类型由fn :: ExtImport与entities :: Maybe [Ref Entity]两个字段构成,见 waspc/src/Wasp/AppSpec/Action.hs——这就是action声明支持的全部配置项。
缓存失效(Cache Invalidation)
Web 应用状态管理中最棘手的问题之一,是保证 Queries 返回的数据始终最新。由于 Wasp 使用react-query管理 Query,必须在数据过期时使 Query(更准确地说,是 react-query 管理的缓存结果)失效。
你可以通过 react-query 提供的多种机制手动失效缓存(如 refetch、直接 invalidation)。但手动缓存失效很快会变得复杂且容易出错,因此 Wasp 提供了一种更快捷高效的方案:基于实体的自动 Query 缓存失效。
由于 Actions 可以(且大多数时候确实会)修改状态,而 Queries 读取状态,因此Wasp 会在某个使用相同实体的 Action 执行后,使该 Query 的缓存失效。例如:若 ActioncreateTask与 QuerygetTasks都使用实体Task,则执行createTask后,getTasks的缓存结果可能过期——Wasp 会立即使其失效,触发getTasks从服务端重新拉取并更新数据。在实践中,这意味着无需考虑缓存失效,Wasp 就能让 Queries 保持"新鲜"。
当然,这种自动失效有时会显得浪费(某些更新可能并无必要),且只对实体生效。如果遇到这类问题,可以暂时使用 react-query 提供的机制,Wasp 未来版本会以更优雅的方式支持这些场景。
仓库佐证:失效的时序保证。
examples/kitchen-sink中的 cacheInvalidation.test.ts 专门针对 GitHub issue #3009 编写了回归测试:当 Action 声明entities: [X]且某个 Query 也依赖X时,Action 的 Promise 完成时 Query 缓存必须已经反映更新(即失效触发的 refetch 必须在await someAction()返回前完成)。测试在await createTask({ description: "after" })之后同步断言getTasks缓存已包含新任务,验证了"Action 执行后相关 Query 立即刷新"这一契约。
如果你希望在执行 Action 后乐观地(optimistically)设置缓存值,可以使用乐观更新机制,通过 Wasp 的 useAction Hook 配置。这是目前 Wasp 原生支持的唯一手动缓存失效机制;其他场景可以始终依赖 react-query。
Queries 与 Actions 的区别
Actions 与 Queries 是 Wasp 中两个紧密相关的概念。它们看似执行类似任务,但 Wasp 对二者的处理方式不同,各自代表不同的语义。核心区别如下:
- Actions 可以(且通常应该)修改服务端状态,而 Queries 只允许读取。Wasp 在执行缓存失效时依赖你遵守这一约定,因此务必遵循。
- Actions 不需要响应式,可以直接调用。不过 Wasp 提供了
useActionReact Hook,用于为 Action 附加额外行为(如乐观更新)。 action声明与query声明几乎完全一致,唯一区别在于声明的名称。从编译器源码看,二者都走 OperationsG.hs 的同一套genOperations管线(genQueries <++> genActions),只是模板文件不同(_query.ts与_action.ts)。
API Reference
在 Wasp 中声明 Actions
action声明支持以下字段:
fn: ExtImport(必填)Action 的 Node.js 实现的导入语句。
entities: [Entity]希望在 Action 中使用的实体列表。使用方式见"在 Actions 中使用实体"一节。
示例
声明 Action:
action createFoo { fn: import { createFoo } from "@src/actions" entities: [Foo] }之后便可在代码的任何位置(服务端或客户端)导入并使用它:
// 在客户端使用 import { createFoo } from 'wasp/client/operations' // 在服务端使用 import { createFoo } from 'wasp/server/operations'TypeScript 还可以在服务端导入对应的类型:
import { type CreateFoo } from 'wasp/server/operations'实现 Actions
Action 的实现是一个接收两个参数的 Node.js 函数(如需使用await关键字可以写成async函数)。由于两个参数都是位置参数,你可以随意命名,但官方约定为args与context:
args(类型取决于 Action)包含调用 Action 时传入的数据的对象(例如过滤条件)。参见"使用 Actions"中的示例了解如何传递该对象。
context(类型取决于 Action)由Wasp 注入 Action 的附加上下文对象,包含用户会话信息以及实体信息。参见"在 Actions 中使用实体"了解
context对象的entities字段用法,或 auth 文档 了解user对象用法。
TypeScript 类型支持:声明 Action 后,Wasp 会生成一个可用于定义实现的泛型类型。对于声明为createSomething的 Action,生成的类型名为CreateSomething:
import { type CreateSomething } from 'wasp/server/operations'它接受两个(可选)类型参数:
Input——args对象的类型(Action 的输入载荷),默认值为never。Output—— Action 返回值的类型(Action 的输出载荷),默认值为unknown。
默认值的设计初衷是让类型签名尽可能宽松。如果不想让 Action 接收/返回任何内容,请使用void作为类型参数。
示例
以下声明:
action createFoo { fn: import { createFoo } from "@src/actions" entities: [Foo] }期望从src/actions.js文件中找到命名导出createFoo:
export const createFoo = (args, context) => { // implementation }TypeScript 版本使用生成类型CreateFoo并通过类型参数指定输入输出:
import { type CreateFoo } from 'wasp/server/operations' type Foo = // ... export const createFoo: CreateFoo<{ bar: string }, Foo> = (args, context) => { // implementation }此例中,Action 期望接收一个含bar: string字段的对象(即args的类型),并返回类型为Foo的值(必须与 Action 实际返回值匹配)。
useActionHook 与乐观更新
阅读本章前,请先理解 Queries 与缓存失效的工作原理。
在组件中使用 Actions 时,可以借助 Wasp 内置的useActionHook 增强它们。该 Hook 用于"装饰" Wasp Actions——返回一个 API 与原始 Action 一致的函数,同时在底层做额外的事情(取决于你的配置)。
useAction接收两个参数:
actionFn(必填)要增强的 Wasp Action(即 Wasp 根据 Action 声明生成的客户端 Action 函数)。
actionOptions一个配置对象,用于指定要附加到 Action 的额外功能。虽然技术上可选,但不传它就没有使用
useAction的意义(与直接调用 Action 无异)。支持以下字段:optimisticUpdates一个对象数组,每个对象定义对 Query 缓存执行的乐观更新。定义乐观更新必须指定以下属性:
getQuerySpecifier(必填)返回 Query specifier 的函数(specifier 是用于定位要更新的 Query 的值)。Query specifier 是一个指定 query 函数及其参数的数组。例如,要为
useQuery(fetchFilteredTasks, { isDone: true })使用的 Query 做乐观更新,getQuerySpecifier需要返回数组[fetchFilteredTasks, { isDone: true }]。Wasp 会将传入被装饰 Action 的参数转发给该函数(你可以利用新增/变更项的属性来定位 Query)。updateQuery(必填)执行乐观更新的函数,应返回缓存的期望状态。Wasp 会用以下参数调用它:
item—— 传入被装饰 Action 的参数;oldData—— specifier 标识的 Query 当前缓存值。
注意:
updateQuery函数必须是纯函数——返回getQuerySpecifier定位的期望缓存值,且不得产生任何副作用。同时请确保只更新受当前 Action 影响的 Query 缓存(Wasp 目前无法校验这一点)。最后,updateQuery的实现应能正确处理任何oldData状态(例如不要依赖数组位置)。如果在乐观更新期间需要做其他事情,可以直接使用 react-query 的底层 API(见"高级用法")。
以下是配置markTaskAsDoneAction(将任务的isDone切换为完成)执行乐观更新的示例:
import React from 'react' import { useQuery, useAction, getTask, markTaskAsDone, } from 'wasp/client/operations' const TaskPage = ({ id }) => { const { data: task } = useQuery(getTask, { id }) const markTaskAsDoneOptimistically = useAction(markTaskAsDone, { optimisticUpdates: [ { getQuerySpecifier: ({ id }) => [getTask, { id }], updateQuery: (_payload, oldData) => ({ ...oldData, isDone: true }), }, ], }) if (!task) { return <h1>"Loading"</h1> } const { description, isDone } = task return ( <div> <p> <strong>Description: </strong> {description} </p> <p> <strong>Is done: </strong> {isDone ? 'Yes' : 'No'} </p> {isDone || ( <button onClick={() => markTaskAsDoneOptimistically({ id })}> Mark as done. </button> )} </div> ) } export default TaskPageTypeScript 版本使用OptimisticUpdateDefinition类型提供类型检查:
import React from 'react' import { useQuery, useAction, type OptimisticUpdateDefinition, getTask, markTaskAsDone, } from 'wasp/client/operations' type TaskPayload = Pick<Task, 'id'> const TaskPage = ({ id }: { id: number }) => { const { data: task } = useQuery(getTask, { id }) // TypeScript 会自动类型校验载荷类型 const markTaskAsDoneOptimistically = useAction(markTaskAsDone, { optimisticUpdates: [ { getQuerySpecifier: ({ id }) => [getTask, { id }], updateQuery: (_payload, oldData) => ({ ...oldData, isDone: true }), } as OptimisticUpdateDefinition<TaskPayload, Task>, ], }) if (!task) { return <h1>"Loading"</h1> } const { description, isDone } = task return ( <div> <p> <strong>Description: </strong> {description} </p> <p> <strong>Is done: </strong> {isDone ? 'Yes' : 'No'} </p> {isDone || ( <button onClick={() => markTaskAsDoneOptimistically({ id })}> Mark as done. </button> )} </div> ) } export default TaskPage仓库实例:
examples/kitchen-sink的 Todo.tsx 中,useAction(updateTaskIsDone, ...)的updateQuery处理了oldData === undefined(缓存为空)的分支——这正是官方文档强调"应正确处理任何oldData状态"的实际写法。
高级用法
useActionHook 目前仅支持乐观更新,Wasp 未来版本会带来更多特性。Wasp 的乐观更新 API 刻意保持小巧,专注于更新 Query 缓存(这是最常见的用例)。如果你需要更灵活或更高控制级别的 API,可以放弃 Wasp 的useActionHook,改用 react-query 的useMutationHook 直接操作其底层 API。
如果决定直接使用 react-query 的 API,你需要访问 Query 缓存键。Wasp 内部使用该键但对开发者做了抽象,你可以通过访问任意 Query 上的queryCacheKey属性轻松获得它:
import { getTasks } from 'wasp/client/operations' const queryKey = getTasks.queryCacheKey小结
Actions 是 Wasp 数据操作体系中负责"写"的半壁江山:声明一个action、实现一个 Node.js 函数,即可同时获得服务端 HTTP 路由、客户端调用函数、类型安全与自动缓存失效。理解其与 Queries 的分工、实体注入机制以及useAction的乐观更新能力,是构建数据驱动型 Wasp 应用的关键。想继续深入,可阅读 Queries 文档、Entities 文档 与 Operations 概述,并在 examples/kitchen-sink 与 examples/waspello 中查看完整的可运行示例。
【免费下载链接】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),仅供参考