Redwood 实战用 Cell 为博客文章渲染多条评论CommentsCell 完整实践【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood本篇教程对应 Redwood 官方教程第 6 章「Multiple Comments」以博客文章的多条评论展示为实战场景系统讲解如何用 Redwood 的Cell声明式地完成数据获取与展示从生成CommentsCell、编写QUERY与四种生命周期组件、用 Storybook mock 数据先行构建 UI到为CommentsCell与Article编写可维护的测试。读完你将掌握 Cell 的完整开发闭环——「先 UI、后 API」的前后端并行工作流以及测试中避免魔法数字、善用waitFor的工程经验。为什么评论列表需要一个独立的 Cell博客文章发布后必然会有大量评论几乎不会只有一条。当前博客首页只展示文章摘要用户需要进入文章详情页才能看到评论而详情页当前的查询只取回单篇文章的数据。要展示评论我们需要同时完成「取数据」和「展示数据」两件事——这正是Cell的用武之地。教程中特别用一个问答框解释了这里的设计取舍为什么不让博客文章页面的查询顺便把评论也查出来可以但 Cell 的设计哲学是让组件对自己的数据获取和展示全权负责可组合性。如果依赖博客文章页面去获取评论那么即将创建的Comments组件就必须依赖外部把评论数据传进来一旦在别处复用该组件就会在两处重复获取评论。让评论自己去拉数据组件才真正可复用、可组合。那刚做好的Comment组件为什么不自己取数据因为几乎不存在「单独展示一条评论」的场景评论永远是以列表形式挂在某篇文章下的。如果单条评论展示很常见完全可以把它改造成CommentCell让它自己拉取单条数据——但请权衡一篇文章有 50 条评论时就意味着要发出 50 个 GraphQL 请求。任何设计都有取舍。为什么不直接在一个CommentsCell里做完所有展示还要单独的Comment组件教程把步骤拆成小块以便循序渐进同时从小块组件拼装 UI 也更便于理解和维护。这正是 Redwood 教程一贯的「先简单后复杂」节奏。从源码结构看Redwood 的 Cell 机制在 packages/web/src/components/cell/createCell.tsx 中实现createCell接收QUERY、Loading、Failure、Empty、Success等导出把它们组装成一个真正可渲染的 React 组件。关于 Cell 的完整能力beforeQuery、afterQuery、isEmpty等生命周期钩子可参阅 Cells 文档。生成 CommentsCell 并接入 Storybook评论属于列表数据用 Redwood 的 Cell 生成器创建yarn rw g cell Comments执行后 Storybookyarn rw storybook的Cells文件夹下会出现一个CommentsCell并且它竟然直接显示了内容——因为生成器自动生成了CommentsCell.mock.{js,ts}。此时项目中还没有 Comment 的 Prisma 模型Redwood 便猜测模型至少会有一个id字段用id生成了 mock 数据。从 Cell 生成器源码 packages/cli/src/commands/generate/cell/cell.js 可以看到生成逻辑的关键细节COMPONENT_SUFFIX Cell组件统一落在web/src/components下生成器会通过isWordPluralizable与isPlural判断名称是单数还是复数复数名称如comments生成列表 Cell单数名称生成单个条目 Cell不规则的不可数词如equipment可显式加--list参数强制生成列表 Cell当 Schema 中找不到对应模型时如本例尚未创建Comment模型getIdType兜底返回Intmock 的 id 默认值取自[42, 43, 44]。更新 QUERY 与 Success让 Cell 渲染 Comment 组件打开CommentsCell主文件做两件事一是把QUERY补全为渲染Comment所需的所有字段name、body、createdAt二是让Success用前面章节创建的Comment组件渲染每条评论。JavaScript 版本web/src/components/CommentsCell/CommentsCell.jsimport Comment from src/components/Comment export const QUERY gql query CommentsQuery { comments { id name body createdAt } } export const Loading () divLoading.../div export const Empty () divEmpty/div export const Failure ({ error }) ( div style{{ color: red }}Error: {error.message}/div ) export const Success ({ comments }) { return ( {comments.map((comment) ( Comment key{comment.id} comment{comment} / ))} / ) }TypeScript 版本web/src/components/CommentsCell/CommentsCell.tsximport Comment from src/components/Comment import type { CommentsQuery } from types/graphql import type { CellSuccessProps, CellFailureProps } from redwoodjs/web export const QUERY gql query CommentsQuery { comments { id name body createdAt } } export const Loading () divLoading.../div export const Empty () divEmpty/div export const Failure ({ error }: CellFailureProps) ( div style{{ color: red }}Error: {error.message}/div ) export const Success ({ comments }: CellSuccessPropsCommentsQuery) { return ( {comments.map((comment) ( Comment key{comment.id} comment{comment} / ))} / ) }这里给每个Comment传了keyprop这是 React 在map遍历数组时的硬性要求。TypeScript 版本用到了 Redwood 内置的CellSuccessPropsCommentsQuery与CellFailureProps工具类型——CommentsQuery是types/graphql中根据 SDL 自动生成的类型。更新 mock认识 standard 标准数据此时切回 Storybook会看到Comment组件渲染了三次但没有数据显示。原因是我们没有为 mock 提供任何评论数据。更新CommentsCell.mock.{js,ts}JavaScriptweb/src/components/CommentsCell/CommentsCell.mock.jsexport const standard () ({ comments: [ { id: 1, name: Rob Cameron, body: First comment, createdAt: 2020-01-02T12:34:56Z, }, { id: 2, name: David Price, body: Second comment, createdAt: 2020-02-03T23:00:00Z, }, ], })TypeScriptweb/src/components/CommentsCell/CommentsCell.mock.tsexport const standard () ({ comments: [ { id: 1, name: Rob Cameron, body: First comment, createdAt: 2020-01-02T12:34:56Z, }, { id: 2, name: David Price, body: Second comment, createdAt: 2020-02-03T23:00:00Z, }, ], })这个standard是什么它是「默认的、标准的一份 mock 数据」——当你没有别的 mock 时就用它。作者本来想叫default但default在 JavaScript 里是保留字所以用了standard。同一份 mock 同时服务于 Jest 测试与 Storybook stories这正是 Redwood 生成器在nameCell.mock.js文件中把它们统一起来的原因。调整间距让 Cell 负责列表的整体布局两条评论紧挨在一起难以区分。既然CommentsCell负责渲染多条评论它就应该「统领」列表的展示方式包括评论之间的间距。给Success套一个容器export const Success ({ comments }) { return ( div classNamespace-y-8 {comments.map((comment) ( Comment comment{comment} key{comment.id} / ))} /div ) }space-y-8是 Tailwind 的一个实用类只在元素之间添加间距而不会在整组元素的上方或下方添加边距——这正是「列表内间距」而非「每个元素各自 margin」的语义也比给每个Comment单独加 top/bottom margin 更合理。集成到 Article摘要与全文的差异化展示现在把CommentsCell放进真正的文章展示页。Article组件同时被用于首页摘要列表和详情全文页通过summaryprop 区分两种形态。评论只应在全文页展示JavaScriptweb/src/components/Article/Article.jsimport { Link, routes } from redwoodjs/router import CommentsCell from src/components/CommentsCell 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 CommentsCell /} /article ) } export default ArticleTypeScriptweb/src/components/Article/Article.tsximport { Link, routes } from redwoodjs/router import CommentsCell from src/components/CommentsCell import type { Post } from types/graphql const truncate (text: string, length: number) { return text.substring(0, length) ... } interface Props { article: OmitPost, createdAt summary?: boolean } const Article ({ article, summary false }: Props) { 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 CommentsCell /} /article ) } export default Article逻辑很直白summary为true首页摘要时不渲染评论summary为false全文页时渲染CommentsCell。回到 Storybook 的Full与Summary两个 story 中验证全文形态有评论摘要形态没有。这时出现了另一个视觉问题评论紧贴在文章正文下方。加一个顶部间距{!summary ( div classNamemt-12 CommentsCell / /div )}深入理解Storybook 是如何「模拟」GraphQL 的你可能会疑惑CommentsCell里明明写了真实的QUERY在 Storybook 中为什么不会发起真的 GraphQL 请求Redwood 为 Storybook 做了增强如果你测试的组件本身不是 Cell比如Article但它内部渲染了一个 Cell比如CommentsCellStorybook 会自动 mock 掉 GraphQL并使用与该 Cell 配套的standardmock 数据。这让组件测试/预览完全不必依赖后端。同时如果你此时打开真实站点会看到评论区域报错——因为教程到目前为止只做了前端既没有在schema.prisma里创建Comment模型也没有生成对应的 SDL 和 servicecomments查询在真实 GraphQL 端并不存在。这是刻意为之的它恰好展示了 Storybook 工作流的最大价值——UI 功能可以完全脱离 api 侧独立开发。团队协作时web 侧团队可以专心做界面api 侧团队同时搭后端互不阻塞。补充说明Cell 在真实运行时才会执行查询。从 createCell.tsx 的实现看createNonSuspendingCell会在error时渲染Failure并把errorCode一并传入data存在且isEmpty判定为空时渲染Empty有数据则渲染Success请求进行中渲染LoadingbeforeQuery的默认实现把 props 当作 GraphQL 变量并设置fetchPolicy: cache-and-network。这些状态切换正是 Cell 在浏览器里「自动干活」的底层原理。测试为 CommentsCell 与 Article 补齐断言新增了CommentsCell、修改了Article分别该测什么、在哪里测测试 CommentsCellComment组件承担了大部分渲染工作这些已在Comment自己的测试中覆盖CommentsCell不需要重复。它真正独特的职责是有加载提示Loading有错误提示Failure有失败信息Failure的错误消息渲染成功时输出的评论数量与QUERY返回的数量一致每条评论渲染成什么交给Comment的测试去管生成器默认生成的CommentsCell.test.{js,tsx}已经以最基础的方式覆盖了每个状态——确保渲染时不抛异常JavaScriptweb/src/components/CommentsCell/CommentsCell.test.jsimport { render } from redwoodjs/testing/web import { Loading, Empty, Failure, Success } from ./CommentsCell import { standard } from ./CommentsCell.mock describe(CommentsCell, () { it(renders Loading successfully, () { expect(() { render(Loading /) }).not.toThrow() }) it(renders Empty successfully, async () { expect(() { render(Empty /) }).not.toThrow() }) it(renders Failure successfully, async () { expect(() { render(Failure error{new Error(Oh no)} /) }).not.toThrow() }) it(renders Success successfully, async () { expect(() { render(Success comments{standard().comments} /) }).not.toThrow() }) })别小看这套「不抛异常」的测试React 组件要么一切正常要么炸得粉碎。能通过就说明组件工作正常出问题测试就会失败——这正是我们想要的。在此基础上可以再进一步验证CommentsCell的行为符合预期。升级Success的测试断言传入的每条评论都被渲染出来检验方式是每条comment.body评论最核心的内容是否出现在屏幕上import { render, screen } from redwoodjs/testing/web import { Loading, Empty, Failure, Success } from ./CommentsCell import { standard } from ./CommentsCell.mock describe(CommentsCell, () { it(renders Loading successfully, () { expect(() { render(Loading /) }).not.toThrow() }) it(renders Empty successfully, async () { expect(() { render(Empty /) }).not.toThrow() }) it(renders Failure successfully, async () { expect(() { render(Failure error{new Error(Oh no)} /) }).not.toThrow() }) it(renders Success successfully, async () { const comments standard().comments render(Success comments{comments} /) comments.forEach((comment) { expect(screen.getByText(comment.body)).toBeInTheDocument() }) }) })关键点测试数据直接取自 mock就是 Storybook 用的同一份standard。即使以后在 mock 里新增评论来试验 Storybook 的不同效果这个测试依然成立。反之如果硬编码「正好有两条评论」这类断言几个月后一旦 mock 数据变化测试就会莫名失败。务必避免在测试中硬编码数据尤其是魔法数字。测试 Article我们对Article新增的功能是非摘要形态下展示评论。Article已有「全文渲染」和「摘要渲染」两个测试。测试一般主张「一个测试只测一件事」——测文章正文是否出现是一个测试测评论是否展示是另一个测试。如果测试描述里出现「and」比如renders a blog post and its comments那通常意味着该拆成两个测试了。新增两个测试覆盖新功能JavaScriptweb/src/components/Article/Article.test.jsimport { render, screen, waitFor } from redwoodjs/testing import { standard } from src/components/CommentsCell/CommentsCell.mock import Article from ./Article const ARTICLE { id: 1, title: First post, body: Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom., createdAt: new Date().toISOString(), } describe(Article, () { it(renders a blog post, () { render(Article article{ARTICLE} /) expect(screen.getByText(ARTICLE.title)).toBeInTheDocument() expect(screen.getByText(ARTICLE.body)).toBeInTheDocument() }) it(renders comments when displaying a full blog post, async () { const comment standard().comments[0] render(Article article{ARTICLE} /) await waitFor(() expect(screen.getByText(comment.body)).toBeInTheDocument() ) }) it(renders a summary of a blog post, () { render(Article article{ARTICLE} summary{true} /) expect(screen.getByText(ARTICLE.title)).toBeInTheDocument() expect( screen.getByText( Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Str... ) ).toBeInTheDocument() }) it(does not render comments when displaying a summary, async () { const comment standard().comments[0] render(Article article{ARTICLE} summary{true} /) await waitFor(() expect(screen.queryByText(comment.body)).not.toBeInTheDocument() ) }) })注意这里从另一个完全不同的组件导入 mocksrc/components/CommentsCell/CommentsCell.mock——这完全没问题mock 本质就是一份普通的导出数据。TypeScript 版本web/src/components/Article/Article.test.tsx与上面结构一致仅把ARTICLE的字段类型对应到接口id、title、body、createdAtdescribe/it断言逻辑完全相同。waitFor 的作用这里引入了新的测试函数waitFor()它会等待 GraphQL 查询这类异步操作完成后再检查渲染结果。因为Article渲染了CommentsCell需要等CommentsCell的Success组件真正渲染出来。为什么「摘要形态」的测试也需要waitFor摘要形态本不该渲染CommentsCell看似不用等待。但假如代码写错了、摘要形态也渲染了CommentsCell而测试没有等待——页面文字暂时不在页面上只是因为还在显示Loading组件测试会「假阳性」通过只有等待渲染完成才能看到真实的评论正文真的出现了测试才会正确地失败。等待是防御性写法确保断言判断的是最终渲染结果。小结与下一步至此评论列表的前端闭环已经完成用yarn rw g cell Comments生成CommentsCell它自动附带 mock 与测试骨架补全QUERY字段、让Success复用Comment组件用standardmock 在 Storybook 中先行预览与调试 UI将CommentsCell按summary标志位接入Article并用space-y-8、mt-12完善布局为CommentsCell的四种状态与Article的评论显隐行为补齐测试用waitFor处理异步渲染。整个过程没有写一行后端代码——这正是 Cell Storybook 工作流的精髓web 侧可以独立于 api 侧开发并测试完整 UI。教程中刻意留下的「后端空缺」没有 Comment 模型、SDL 和 service将在下一节补上先在schema.prisma中添加Comment模型参考 comments-schema.md 中的建模过程再生成 SDL 与 service。而 comment-form.md 已经准备好评论表单与createCommentmutation——接下来就让用户真正把评论写进数据库。如果你希望更系统地理解 Cell 的完整 APIbeforeQuery、isEmpty、afterQuery、多根查询等可以继续阅读 Cells 官方文档教程早期对 Cell 的基础讲解见 第 2 章 Cells。【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考