ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RedwoodJS 评论表单实战:从表单组件、GraphQL Mutation 到 refetch 缓存更新的完整实现

RedwoodJS 评论表单实战:从表单组件、GraphQL Mutation 到 refetch 缓存更新的完整实现 后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载本教程源于 RedwoodJS 官方教程第 6 章version-2.x以博客评论功能为实战场景完整演示如何在 Redwood 应用中从零构建一个可提交的评论表单包括用redwoodjs/forms搭建带校验的表单、用useMutation提交 GraphQL mutation、用 Storybook 的mockGraphQLMutation模拟请求、通过 Apollo 的refetchQueries刷新 Cell 数据以及借助redwoodjs/web/toast弹出提交成功通知。读完本篇你将掌握 Redwood 表单、Cell、Service、GraphQL SDL 四层之间完整的数据流协作方式并学会用 Redwood Console 与 TDD 驱动 Service 层改造。一、生成组件并启动 Storybook首先使用 Redwood CLI 的生成器创建CommentForm组件yarn rw g component CommentForm生成器会在web/src/components/CommentForm/目录下创建组件文件、测试文件.test.js/tsx和 Storybook 故事文件.stories.js/tsx。接着启动 Storybookyarn rw storybook此时 Storybook 侧边栏会出现CommentForm条目组件虽尚未实现但骨架已就绪可以边写边看效果。CLI 生成器是所有 Redwood 实操的起点组件、Cell、Service、SDL 等都可以用它快速生成保持目录结构约定web/src/components/、api/src/services/、api/src/graphql/。二、用 redwoodjs/forms 搭建表单Redwood 的redwoodjs/forms包基于react-hook-form封装见 Form.tsx内部通过useForm与FormProvider提供表单上下文提供了Form、Label、TextField、TextAreaField、Submit、FormError、FieldError等一套声明式表单组件。先实现一个简单版本收集用户的name和body评论正文并为两个字段开启必填校验import { Form, Label, TextField, TextAreaField, Submit, } from redwoodjs/forms const CommentForm () { return ( div h3 classNamefont-light text-lg text-gray-600Leave a Comment/h3 Form classNamemt-4 w-full Label namename classNameblock text-sm text-gray-600 uppercase Name /Label TextField namename classNameblock w-full p-1 border rounded text-xs validation{{ required: true }} / Label namebody classNameblock mt-4 text-sm text-gray-600 uppercase Comment /Label TextAreaField namebody classNameblock w-full p-1 border rounded h-24 text-xs validation{{ required: true }} / Submit classNameblock mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50 Submit /Submit /Form /div ) } export default CommentFormTypeScript 版本仅文件扩展名与类型标注不同组件结构完全一致import { Form, Label, TextField, TextAreaField, Submit, } from redwoodjs/forms const CommentForm () { return ( div h3 classNamefont-light text-lg text-gray-600Leave a Comment/h3 Form classNamemt-4 w-full {/* 与 JS 版本相同的结构 */} Label namename classNameblock text-sm text-gray-600 uppercase Name /Label TextField namename classNameblock w-full p-1 border rounded text-xs validation{{ required: true }} / {/* ... body 字段与 Submit 按钮 ... */} /Form /div ) } export default CommentForm要点说明校验即声明式每个字段的validation属性直接透传给 react-hook-form 的注册规则required: true让空字段在提交时自动获得焦点并提示。布局交由父组件决定表单与输入框均设为 100% 宽度。组件本身不应决定自己的排版宽度TextField的className完全由使用方控制Tailwind 的block w-full p-1 border rounded等这样无论放在侧边栏还是文章底部都能自适应。Submit 的语义Submit.tsx 实际渲染一个button typesubmit配合Form的handleSubmit触发提交回调。此时在 Storybook 中可以直接尝试提交留空name或comment时对应字段会被标红并获得焦点两个字段都填上后点击Submit不会发生任何事——因为还没有接入提交逻辑。三、接入 useMutation 实现提交提交逻辑依赖第 5 章中已经实现并暴露到 GraphQL 的createCommentservice。我们需要三件事定义 mutation、调用useMutation、给Form加onSubmit处理器。同时由于createComment可能返回错误用FormError组件来展示错误信息import { Form, FormError, Label, TextField, TextAreaField, Submit, } from redwoodjs/forms import { useMutation } from redwoodjs/web const CREATE gql mutation CreateCommentMutation($input: CreateCommentInput!) { createComment(input: $input) { id name body createdAt } } const CommentForm () { const [createComment, { loading, error }] useMutation(CREATE) const onSubmit (input) { createComment({ variables: { input } }) } return ( div h3 classNamefont-light text-lg text-gray-600Leave a Comment/h3 Form classNamemt-4 w-full onSubmit{onSubmit} FormError error{error} titleClassNamefont-semibold wrapperClassNamebg-red-100 text-red-900 text-sm p-3 rounded / {/* ... 字段定义同前 ... */} Submit disabled{loading} classNameblock mt-4 bg-blue-500 text-white text-xs font-semibold uppercase tracking-wide rounded px-3 py-2 disabled:opacity-50 Submit /Submit /Form /div ) } export default CommentFormTypeScript 版本需要额外的类型标注——SubmitHandlerFormValues描述onSubmit接收的表单数据类型它来自redwoodjs/formsimport { Form, FormError, Label, TextField, TextAreaField, Submit, SubmitHandler, } from redwoodjs/forms import { useMutation } from redwoodjs/web const CREATE gql mutation CreateCommentMutation($input: CreateCommentInput!) { createComment(input: $input) { id name body createdAt } } interface FormValues { name: string comment: string } const CommentForm () { const [createComment, { loading, error }] useMutation(CREATE) const onSubmit: SubmitHandlerFormValues (input) { createComment({ variables: { input } }) } return ( // ... 与 JS 版本相同的 JSX ... ) } export default CommentForm逐项拆解useMutation来自redwoodjs/webRedwood 在 packages/web/src/apollo/index.tsx 中基于 Apollo Client 封装了useMutation返回[mutate, { loading, error, data }]与 Apollo 的 API 一致。onSubmit拿到的是纯字段值对象Form在内部通过formMethods.handleSubmit触发onSubmit见 Form.tsx所以回调里的input就是{ name, body }这样的数据直接作为 mutation 的variables.input传入。FormError的渲染规则FormError.tsx 在error为空时返回null存在 GraphQL 错误时展示其 message若错误码为BAD_USER_INPUTService 校验失败则显示 Errors prevented this form from being saved并把extensions.properties.messages中的逐字段错误渲染为列表存在网络错误时则展示网络层信息。提交期间禁用按钮disabled{loading}防止用户在请求进行中重复提交。在 Storybook 中模拟 Mutation此时直接提交表单浏览器控制台会报错——Storybook 会自动 mock GraphQL 查询query但不会自动 mock 变更mutation。需要在故事文件中用mockGraphQLMutation拦截并手动处理响应import CommentForm from ./CommentForm export const generated () { mockGraphQLMutation(CreateCommentMutation, (variables, { ctx }) { const id Math.floor(Math.random() * 1000) ctx.delay(1000) return { createComment: { id, name: variables.input.name, body: variables.input.body, createdAt: new Date().toISOString(), }, } }) return CommentForm / } export default { title: Components/CommentForm }TypeScript 故事文件仅后缀不同内容一致.stories.tsx。mockGraphQLMutation的实现位于 packages/testing/src/web/mockRequests.ts它基于MSWMock Service Worker注册一个 GraphQL mutation 拦截器调用签名是mockGraphQLMutation(operationName, dataFunction, responseEnhancer?)。其中第一个参数是要拦截的 mutation 名称CreateCommentMutation第二个参数可以是普通对象也可以是函数。传函数时拦截器会把req.variables和{ req, ctx }传给该函数见DataFunction类型与mockGraphQL内部对ctx的包装从而让你能读取提交的variables并利用ctx.delay()、ctx.errors()、ctx.status()等 MSW 上下文能力ctx.delay(1000)模拟一次 1 秒的服务器往返方便验证「提交期间 Submit 按钮被禁用、无法重复提交」这一交互。注册的 handler 会进入队列在 Storybook/Jest 的 MSW 实例启动后统一生效registerHandler→startMSW。尝试提交错误消失Submit 按钮在延迟的 1 秒内呈禁用态且点击无效。补充mockGraphQLMutation同系列的还有mockGraphQLQuery拦截查询、mockCurrentUser模拟__REDWOOD__AUTH_GET_CURRENT_USER查询全部定义在 mockRequests.ts 中是 Redwood Storybook 与测试环境中 mock GraphQL 请求的统一入口。四、把表单放进文章页组件职责划分评论表单应该放在哪里直觉上可以放进CommentsCell既然要展示评论列表那同时提供新增评论的表单似乎顺理成章。但这会带来一个设计问题如果CommentForm放在CommentsCell的Success组件里那么当评论为空时渲染的是Empty组件——表单不会出现用户将永远无法发表第一条评论把表单复制一份到Empty组件一旦开始复制代码就该停下来重新思考设计。更合理的划分是CommentsCell只负责检索并展示评论接受用户输入超出它的核心职责。于是把Article作为组装层把文章正文、评论表单、评论列表三部分拼在一起评论列表上方留一点边距import { Link, routes } from redwoodjs/router import CommentsCell from src/components/CommentsCell import CommentForm from src/components/CommentForm const truncate (text, length) { return text.substring(0, length) ... } const Article ({ article, summary false }) { return ( article header h2 classNametext-xl text-blue-700 font-semibold Link to{routes.article({ id: article.id })}{article.title}/Link /h2 /header div classNamemt-2 text-gray-900 font-light {summary ? truncate(article.body, 100) : article.body} /div {!summary ( div classNamemt-12 CommentForm / div classNamemt-12 CommentsCell / /div /div )} /article ) } export default ArticleTypeScript 版本额外引入import type { Post } from types/graphql用于标注article的类型生成类型由 Redwood 自动产出。注意summary为true列表页摘要模式时不渲染表单与评论仅详情页展示。Storybook 与真实站点中表单都显示正常。现在迎来真正的考验提交一条评论五、必修字段错误postId 从哪来点击提交后页面报错错误信息结尾是Field postId of required type Int! was not provided原因在 Prisma schema 中Comment通过postId字段关联Post即一篇 post 拥有多条 comment且该字段必填。当前 mutation 只传了name和bodyGraphQL 服务端自然拒绝请求。好在Article组件本来就有article对象它的id正是我们需要的postId。为什么 Storybook 的故事没有提前暴露这个问题因为我们在故事里手动 mock 了响应而 mock 无论输入什么都会返回正确结果。Mock 的价值在于免去整套 GraphQL 栈即可测试交互代价是模拟得多真就得写得多真。对本例这种一次性修复不值得为它再造一个专门模拟缺失postId的故事但如果组件会被复用、或被多位开发者频繁修改那就值得把 props 接口与预期返回仔细打磨。先给CommentForm传postIdprop// ... {!summary ( div classNamemt-12 CommentForm postId{article.id} / div classNamemt-12 CommentsCell / /div /div )}然后在表单组件里接收该 prop并把它合并进 mutation 的inputconst CommentForm ({ postId }) { const [createComment, { loading, error }] useMutation(CREATE) const onSubmit (input) { createComment({ variables: { input: { postId, ...input } } }) } return ( // ... ) }TypeScript 版本需要声明 props 接口interface Props { postId: number } const CommentForm ({ postId }: Props) { const [createComment, { loading, error }] useMutation(CREATE) const onSubmit: SubmitHandlerFormValues (input) { createComment({ variables: { input: { postId, ...input } } }) } return ( // ... ) }再次提交错误消失了——但刷新页面后评论才出现且输入框仍保留着刚才的名字和内容。这暴露了两个待优化点提交成功后应立即看到新评论数据实时性提交成功后表单应清空或隐藏交互体验。六、GraphQL 查询缓存用 refetchQueries 刷新 Cell第一个问题的本质是Apollo 客户端的缓存不知道新增评论会影响评论列表查询的结果。最简单可靠的解法是refetch重新查询告诉 Apollomutation 完成后重新执行展示评论的那个查询。CommentsCell中导出的QUERY常量就是那条查询。把它以别名导入CommentForm并传给useMutation的refetchQueries选项import { useMutation } from redwoodjs/web import { QUERY as CommentsQuery } from src/components/CommentsCell // ... const CommentForm ({ postId }) { const [createComment, { loading, error }] useMutation(CREATE, { refetchQueries: [{ query: CommentsQuery }], }) // ... }refetchQueries接收一个查询描述对象数组{ query: CommentsQuery }表示此 mutation 完成后重跑CommentsQuery。现在提交后新评论立即出现由于列表按时间正序排列新评论显示在末尾用户可能不易察觉所以还需要成功通知。七、提交成功反馈toast 隐藏表单用 React state 记录是否已提交提交成功后把整个表单隐藏并弹出感谢通知。Redwood 内置了react-hot-toast通过redwoodjs/web/toast导出见 packages/web/src/toast/index.ts直接 re-exportreact-hot-toast的toast与全部组件import { Form, FormError, Label, TextField, TextAreaField, Submit, } from redwoodjs/forms import { useMutation } from redwoodjs/web import { toast } from redwoodjs/web/toast import { QUERY as CommentsQuery } from src/components/CommentsCell import { useState } from react const CREATE gql mutation CreateCommentMutation($input: CreateCommentInput!) { createComment(input: $input) { id name body createdAt } } const CommentForm ({ postId }) { const [hasPosted, setHasPosted] useState(false) const [createComment, { loading, error }] useMutation(CREATE, { onCompleted: () { setHasPosted(true) toast.success(Thank you for your comment!) }, refetchQueries: [{ query: CommentsQuery }], }) const onSubmit (input) { createComment({ variables: { input: { postId, ...input } } }) } return ( div className{hasPosted ? hidden : } h3 classNamefont-light text-lg text-gray-600Leave a Comment/h3 Form classNamemt-4 w-full onSubmit{onSubmit} {/* ...字段与 Submit 同前... */} /Form /div ) } export default CommentForm这里的关键点onCompleted回调mutation 成功完成后触发此时setHasPosted(true)隐藏表单并调用toast.success(...)弹出通知hidden类用 CSS 直接隐藏表单与标题组件仍保持挂载而不是条件卸载简单且不丢状态Toaster组件toast 消息需要一个挂载点才能渲染出来。不能只放在CommentForm里——如果其他代码即使CommentForm未挂载也要发通知怎么办答案是把全局 UI 元素放在所有页面共有的布局里BlogLayout。import { Link, routes } from redwoodjs/router import { useAuth } from redwoodjs/auth import { Toaster } from redwoodjs/web/toast const BlogLayout ({ children }) { const { logOut, isAuthenticated, currentUser } useAuth() return ( Toaster / header classNamerelative flex justify-between items-center py-4 px-8 bg-blue-700 text-white {/* ...导航、登录/登出等... */} /header main classNamemax-w-4xl mx-auto p-12 bg-white shadow rounded-b {children} /main / ) } export default BlogLayoutTypeScript 版本需要声明children类型type BlogLayoutProps { children?: React.ReactNode }。现在提交评论表单消失、弹窗致谢、新评论立即出现在列表末尾体验完整闭环。八、重大事故所有文章显示相同的评论回到首页进入另一篇文章——所有文章的评论区居然一模一样这正是前几章埋下的伏笔comments()service 只是简单地返回全部评论完全没有按postId过滤。计算机只执行你告诉它的指令别无其他。接下来分别从前端和后端修复。九、Redwood Console在真实数据库上验证 Prisma 调用在动手改代码前最好先验证只查询某篇文章评论的 Prisma 语法是否可行而不必把组件、Cell、GraphQL、Service 全链路搭起来。这正是Redwood Console的用武之地。新开一个终端yarn rw console这是一个预置了 Redwood 内部对象最关键的是db即 Prisma Client的 Node 交互式控制台。先查看现有评论 db.comment.findMany() [ { id: 1, name: Rob, body: The first real comment!, postId: 1, createdAt: 2020-12-08T23:45:10.641Z }, { id: 2, name: Tom, body: Here is another comment, postId: 1, createdAt: 2020-12-08T23:46:10.641Z } ]输出取决于你数据库里的实际数据。试试带条件的查询 db.comment.findMany({ where: { postId: 1 }})因为当前只有一篇 post 的评论结果看起来一样。造一条属于第二篇文章的评论先拿到所有 post 的 id db.post.findMany({ select: { id: true } }) [ { id: 1 }, { id: 2 }, { id: 3 } ]再创建一条postId: 2的评论 db.comment.create({ data: { name: Peter, body: I also like leaving comments, postId: 2 } })分别按postId: 1和postId: 2查询可以看到返回各自对应的评论——语法验证通过。用Ctrl-C按两次或输入.exit退出控制台。为什么这里没有awaitdb的调用返回 Promise正常需要await才能立即拿到结果。Redwood Console 会自动帮你await省去每次手写的麻烦。十、TDD 改造 Service按 postId 过滤先跑一遍测试套件确认现状全绿。Redwood 的分层思想可以理解为自顶向下越靠近用户React 组件越上越靠近数据存储越下——Browser → React → GraphQL → Services → Database。业务逻辑放得越靠下越容易构建与维护Redwood 鼓励把业务逻辑放进 services紧贴数据、位于 GraphQL 接口之后。1. 先让测试失败打开 comments 服务的测试文件把postId传给comments()scenario(returns all comments, async (scenario) { const result await comments({ postId: scenario.comment.jane.postId }) expect(result.length).toEqual(Object.keys(scenario.comment).length) })此时 JS 版测试依然全绿JS 不在乎你突然多传一个参数TS 版则会直接报类型错误——这正是 TDD 想避免的先改实现再补测试。在 TDD 中应当先让测试失败再写让它通过的代码。什么断言在只返回部分评论后会变化期望返回的评论数量。看一下默认场景standard()场景文件comments.scenarios.js/tsdefineScenario定义了jane与john两条评论各自关联一篇新建的 post。既然每条评论属于不同 post那就只统计目标 post 名下的评论数而不是全库评论数import { comments, createComment } from ./comments import { db } from src/lib/db describe(comments, () { scenario(returns all comments, async (scenario) { const result await comments({ postId: scenario.comment.jane.postId }) const post await db.post.findUnique({ where: { id: scenario.comment.jane.postId }, include: { comments: true }, }) expect(result.length).toEqual(post.comments.length) }) // ... })测试逻辑变为先拿 service 返回的评论再直接从数据库取该 post 并include其评论最后断言两者数量一致。现在测试如预期失败FAIL api api/src/services/comments/comments.test.js • comments › returns all comments expect(received).toEqual(expected) // deep equality Expected: 1 Received: 2同时把测试名改得更准确scenario( returns all comments for a single post from the database, async (scenario) { // ...断言同前... } )2. 让测试通过更新 Service打开api/src/services/comments/comments.js让comments()接收postId并把它作为findMany的过滤条件export const comments ({ postId }) { return db.comment.findMany({ where: { postId } }) }TypeScript 版本使用 Prisma 的CommentWhereInput类型精确约束参数export const comments ({ postId, }: RequiredPickPrisma.CommentWhereInput, postId) { return db.comment.findMany({ where: { postId } }) }保存后测试重新通过。3. 更新 GraphQL SDL让 GraphQL 层知道comments查询需要必填的postId参数当前应用没有查看全站评论的入口所以要求它必须存在type Query { comments(postId: Int!): [Comment!]! skipAuth }skipAuth表示该查询无需认证即可访问与本教程目前未启用鉴权的设置一致。刷新开发环境下的真实页面评论区位置会报错——提示postId缺失这正是预期的中间状态。4. 更新 Cell传递 postIdCommentsCell需要拿到postId才能查询。像给CommentForm传postIdprop 一样在Article里给CommentsCell也传一份{!summary ( div classNamemt-12 CommentForm postId{article.id} / div classNamemt-12 CommentsCell postId{article.id} / /div /div )}然后让 Cell 的QUERY接收变量并把postId传给commentsexport const QUERY gql query CommentsQuery($postId: Int!) { comments(postId: $postId) { id name body createdAt } } 这个$postId从哪来Redwood 会自动把传给 Cell 组件的同名 prop 作为查询变量注入——所以CommentsCell postId{article.id}中的postId自动映射为查询里的$postId无需手动书写变量映射代码。现在切换不同文章只会看到各自关联的评论包括刚才用 Console 创建的那条也能为每篇文章单独发表评论了。5. 修好 refetch带上变量但很快会发现提交评论后它不再立即出现。原因正是前面对refetchQueries的配置——重跑CommentsQuery时必须带上它首次执行时的变量否则查询会因为缺少$postId而失败或拿不到正确数据const [createComment, { loading, error }] useMutation(CREATE, { onCompleted: () { setHasPosted(true) toast.success(Thank you for your comment!) }, refetchQueries: [{ query: CommentsQuery, variables: { postId } }], })至此评论功能完整闭环发表评论 → 弹窗致谢、表单隐藏 → 该文章的评论列表立即刷新出最新内容且不同文章之间互不串台。十一、关键源码索引Form.tsxredwoodjs/forms的Form实现基于 react-hook-form 的useForm/FormProvideronSubmit通过handleSubmit触发。FormError.tsx错误展示组件支持 GraphQL 错误、BAD_USER_INPUT服务端校验错误与网络错误三种来源。Submit.tsx渲染button typesubmit。mockRequests.tsmockGraphQLQuery/mockGraphQLMutation的实现基于 MSW 拦截请求支持ctx.delay、ctx.errors等响应修饰handler 先入队后随startMSW生效。index.tsredwoodjs/web/toast对 react-hot-toast 的 re-export。apollo/index.tsxredwoodjs/web对 ApollouseMutation/useQuery的封装接入点。十二、小结本教程走完了一条 Redwood 全栈功能的完整闭环表单层redwoodjs/forms的声明式组件 validation必填校验交互层useMutationonSubmitFormErrordisabled{loading}Mock 层Storybook 中mockGraphQLMutation配合ctx.delay模拟服务器往返数据层refetchQueries触发 Apollo 重跑 Cell 查询onCompletedtoast完成反馈分层修复Redwood Console 验证 Prisma 语法 → TDD 驱动 Service 加postId过滤 → SDL 声明必填参数 → Cell 透传变量 → refetch 补齐变量。过程中两次踩坑缺失postId的必填字段错误、refetch 忘记带变量恰恰展示了 Redwood 各层之间如何通过约定Cell 的 prop 自动映射为查询变量、Service 与 SDL 的自动接线协同工作也验证了业务逻辑下沉到 Service、组件只做展示与交互的架构收益。赞分享后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载相关推荐CANN/asc-devkit SIMT bfloat16转换函数\_\_bfloat162ull\_ru 产品支持情况 ! npu950 id1 Ascend 950PR/Ascend 950DT支持 ! end后端前端Web框架开发工具3大核心技术解密GeoAI如何重塑地理空间智能分析3大核心技术解密GeoAI如何重塑地理空间智能分析 GeoAI是一个专为地理空间数据设计的Python工具包通过集成先进的AI模型与地理信息系统让研究人员后端前端Web框架开发工具RedwoodJS 教程实战构建博客评论表单 CommentForm 的完整流程RedwoodJS 教程实战构建博客评论表单 CommentForm 的完整流程 本文以 RedwoodJS 官方教程第六章为主线完整演示如何在 Redwo后端前端Web框架开发工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进