- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
本指南基于 RedwoodJS 官方教程第六章 "Multiple Comments"(见 multiple-comments.md),完整演示如何为博客文章构建"评论列表"功能:从yarn rw g cell Comments生成 Cell,到用 Storybook 的standardmock 独立开发 UI,再到挂载进文章详情页并补齐 Jest 组件测试。读完你将掌握 RedwoodJS Cell 模式的完整工作流:数据获取与展示自治的设计动机、Cell 四种渲染状态的测试方法,以及waitFor处理异步渲染的实战细节——全程不依赖任何后端接口,仅凭 mock 数据即可完成整个前端功能。
为什么"评论列表"要用一个 Cell?
在教程当前阶段,博客首页只展示每篇文章的摘要(summary),用户需要进入文章详情页才能看到完整正文。因此评论列表显然应该出现在文章详情页上。但详情页目前的 GraphQL 查询只取了单个Post的数据,并没有评论。既然我们既要获取评论数据、又要展示评论,这正是 RedwoodJSCell的典型应用场景。
也许你会问:"能不能让文章页的查询把评论一起查出来?"
当然可以!但 Cell 的设计哲学在于让组件更加可组合:每个 Cell 自己负责数据获取与展示。如果让Article页面去获取评论,那么新的Comments组件就必须依赖"别人"把评论数据传进来;一旦这个组件在别处被复用,就不得不在两个地方重复获取评论。把数据获取内聚到组件自身,组件才能被随意搬移、复用而不破坏数据流。
还有两个常见追问,值得一并说清:
- 上一节做好的
Comment单条组件,为什么不让它自己取数据?因为几乎没有"单独展示一条评论"的场景——评论总是以"某篇文章下的全部评论"形式出现。如果你确实需要单条展示,完全可以把它改造成CommentCell让它自取数据。但要权衡:假设一篇文章有 50 条评论,每个CommentCell发一次 GraphQL 请求,页面就要发出 50 个请求。任何架构选择都有代价。 - 既然最终都要在
CommentsCell里展示,为什么还要单独做一个Comment组件?一方面教程希望循序渐进;另一方面,把 UI 拆成更小、更易推理的块,本来就更利于维护和团队协作。
生成 CommentsCell:yarn rw g cell Comments
在项目根目录执行:
yarn rw g cell CommentsStorybook 中立即多出一个Cells/CommentsCell,而且它真的显示了内容。这些内容从哪来的?来自生成的CommentsCell.mock.{js,ts}文件。此时项目里还没有Comment的 Prisma 模型,Redwood 的 cell 生成器便"猜测"你的模型至少会有一个id字段,并用它生成了 mock 数据。
从生成器源码可以印证这个"猜测"行为。查看 packages/cli/src/commands/generate/cell/cell.js:生成器会尝试通过getSchema()读取 Prisma 模型来确定idName/idType;读取失败(例如模型尚未创建)时,会吞掉错误并把idType默认设为Int,同时使用内置的mockIdValues = [42, 43, 44]生成三条假数据。这也解释了为什么 mock 模板 mockList.ts.template 里只有id字段。
另外注意生成器的几个细节(同一文件 cell.js):
Comments本身是复数,生成器会判定为列表型 Cell(shouldGenerateList),强制复数化并套用cellList模板,从而生成查询comments而非单条comment;- 操作名(operation name)会调用
uniqueOperationName保证全局唯一,你也可以通过--query参数强制指定(必须是唯一的); - 当项目已存在对应 SDL 时,生成器会顺带执行类型生成(
generateTypes());否则会提示先运行yarn rw g sdl。
查看列表型 Cell 的模板 cellList.tsx.template,默认生成的QUERY只查询id一个字段,Success也只是把每条数据JSON.stringify打印出来——这正是教程接下来要改造的起点。
让 CommentsCell 渲染 Comment 组件并补全查询字段
打开生成的CommentsCell,导入上一节创建的Comment组件,并把Comment渲染所需的name、body、createdAt字段全部补进QUERY:
JavaScript 版本(web/src/components/CommentsCell/CommentsCell.jsx)
import Comment from 'src/components/Comment' export const QUERY = gql` query CommentsQuery { comments { id name body createdAt } } ` export const Loading = () => <div>Loading...</div> export const Empty = () => <div>Empty</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.tsx)
import 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 = () => <div>Loading...</div> export const Empty = () => <div>Empty</div> export const Failure = ({ error }: CellFailureProps) => ( <div style={{ color: 'red' }}>Error: {error.message}</div> ) export const Success = ({ comments }: CellSuccessProps<CommentsQuery>) => { return ( <> {comments.map((comment) => ( <Comment key={comment.id} comment={comment} /> ))} </> ) }注意两点:
- 在
map遍历数组时为每个<Comment>传了key={comment.id},这是 React 渲染列表的要求; - TypeScript 版本使用
@redwoodjs/web导出的CellSuccessProps、CellFailureProps泛型,以及types/graphql中由 SDL 生成的CommentsQuery类型——目前后端 SDL 还不存在,类型生成会延后到后端就绪时完成。
回到 Storybook,你会看到Comment组件确实被渲染了 3 次,但因为 mock 数据里只有id,页面是"空的"。接下来补上真实的 mock 数据。
用 standard mock 填充演示数据
更新CommentsCell.mock.{js,ts},为comments提供两条完整数据:
JavaScript 版本(web/src/components/CommentsCell/CommentsCell.mock.js)
export 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', }, ], })TypeScript 版本(web/src/components/CommentsCell/CommentsCell.mock.ts)
export 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是什么?它是这个 Cell 的"标准默认 mock"。如果不在测试/Storybook 中做额外设置,就使用这份数据。取名standard而非default,是因为default在 JavaScript 中是保留字。
刷新 Storybook,两条评论就显示出来了。不过两条评论紧挨着、难以区分——既然CommentsCell负责绘制"多条评论",那么评论之间的间距也应由它统一管理。
用 space-y-8 统一评论列表的间距
给Success的外层容器加上 Tailwind 的space-y-8:
export const Success = ({ comments }) => { return ( <div className="space-y-8"> {comments.map((comment) => ( <Comment comment={comment} key={comment.id} /> ))} </div> ) }为什么用
space-y-8而不是给每条<Comment>单独加上下 margin?space-y-8只在元素之间插入间距,不会在整组元素的最上方和最下方多出空隙;而给每个Comment加mt-8/mb-8会在列表两端产生多余的空白。这是 Tailwind 布局的经典技巧。
把 CommentsCell 挂载到文章详情页
现在把CommentsCell放进实际的博客文章展示组件Article:
JavaScript 版本(web/src/components/Article/Article.jsx)
import { 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 className="text-xl text-blue-700 font-semibold"> <Link to={routes.article({ id: article.id })}>{article.title}</Link> </h2> </header> <div className="mt-2 text-gray-900 font-light"> {summary ? truncate(article.body, 100) : article.body} </div> {!summary && <CommentsCell />} </article> ) } export default ArticleTypeScript 版本(web/src/components/Article/Article.tsx)
import { 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: Omit<Post, 'createdAt'> summary?: boolean } const Article = ({ article, summary = false }: Props) => { return ( <article> <header> <h2 className="text-xl text-blue-700 font-semibold"> <Link to={routes.article({ id: article.id })}>{article.title}</Link> </h2> </header> <div className="mt-2 text-gray-900 font-light"> {summary ? truncate(article.body, 100) : article.body} </div> {!summary && <CommentsCell />} </article> ) } export default Article核心逻辑一行:如果不是摘要模式(summary为 false),就渲染CommentsCell。到 Storybook 里查看Article的Full和Summary两个 story,可以看到前者有评论、后者没有。
等等,Article本身不是 Cell,它内部渲染的CommentsCell真的会发出 GraphQL 请求吗?为什么在 Storybook 里能正常显示?
这得益于 RedwoodJS 为 Storybook 注入的能力:当被测试/展示的组件本身不是 Cell,却渲染了某个 Cell时,Storybook 会自动拦截 GraphQL 请求,并使用该 Cell 对应的standardmock。底层实现见 packages/storybook/src/mocks/StorybookProvider.tsx:MockingLoader通过import.meta.glob预加载所有*.mock.{js,ts}文件,随后启动 MSW(startMSW('browsers'))并注册请求处理器(setupRequestHandlers()),使每个 Cell 的standardmock 在渲染时即被命中。这也是教程强调"前端 UI 完全可以与后端并行开发"的技术基础。
评论挂进文章后,又暴露出一个视觉问题:评论紧贴在文章正文下方。再包一层mt-12拉开间距:
{!summary && ( <div className="mt-12"> <CommentsCell /> </div> )}(TypeScript 版本改动完全相同。)
为什么真实页面会报错?——后端尚未就绪
如果你此时访问真实站点,评论位置会报错。原因正如本节标题所说:我们从一开始就只做了CommentsCell,从未在schema.prisma中创建 Comment 模型,也没有创建 SDL 和 service。后端就绪后(添加模型、yarn rw prisma migrate dev、生成 SDL/service),这个错误会自然消失——相关操作正是教程下一节 comments-schema.md 的内容。
这个"先报错"的过程恰恰体现了 Storybook 的另一大收益:UI 开发可以与 api 侧完全隔离。web 团队可以在后端尚未动工时就把界面做完、测完,api 团队同时开发后端,互不阻塞。
测试:CommentsCell 与 Article
我们新增了CommentsCell、修改了Article,该测什么、在哪里测?
测试 CommentsCell
Comment组件本身承担了大部分渲染工作,它的功能已在Comment自己的测试里覆盖,无需在CommentsCell中重复。CommentsCell独有的职责是:
- 有加载中(Loading)提示;
- 有空数据(Empty)提示;
- 有失败(Failure)提示;
- 渲染成功时,输出与
QUERY返回数量一致的评论(具体渲染成什么样,交给Comment的测试)。
生成器默认生成的测试 test.js.template 已经覆盖了全部四种状态——虽然只是最基础的"不抛错"断言:
import { 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() }) })(TypeScript 版本除导入类型外完全相同。)千万别小看这套"冒烟测试":React 组件要么 100% 正常工作、要么直接炸掉,这种测试能保证"能渲染不抛错",失败时能立刻抓住问题。
在此基础上,我们还可以更进一步:更新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 中的每条评论做断言——mock 数据来自与 Storybook 同一份standard,将来往 mock 里加数据时测试自动覆盖。千万不要写死"正好有两条评论"之类的断言:当时能过,可一旦为了在 Storybook 里尝试不同形态而往 mock 里加数据,测试就挂了。避免在测试里硬编码数据,尤其是魔法数字(magic number),尽量从 mock 数据推导。
测试 Article
Article新增的行为是:非摘要模式下显示评论。关于"全篇"和"摘要"两种渲染,我们已经各有一个测试了。好的测试习惯是一个测试只验证一件事——如果测试描述里出现"and"(比如"renders a blog post and its comments"),多半该拆成两个测试。
为新增功能补两个测试:
import { 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() ) }) })(TypeScript 版本相同,仅涉及类型标注差异。)
这里有三个值得注意的实战要点:
- 跨组件导入 mock 完全合法:这里从
src/components/CommentsCell/CommentsCell.mock导入standard,而不是从Article自己的 mock 导入——mock 只是普通模块,随处可复用。 waitFor的用途:Article渲染了CommentsCell,后者内部要等 GraphQL(被 mock 拦截)返回后才会渲染Success。waitFor会等待这类异步操作完成后再执行断言。这正是@redwoodjs/testing提供waitFor的原因。- 摘要模式的测试也要
waitFor:摘要版Article本就不渲染CommentsCell,为什么还要等?设想一下:如果将来有人误把CommentsCell加进摘要版,而测试没有等待,就会出现假阳性——断言时评论还没渲染出来(还停留在Loading),于是"文本不在页面上"恰好通过。等待之后,评论 body 真的渲染出来,测试才会(正确地)失败。
深入理解:Cell 运行时的四种状态机
教程中的Loading/Empty/Failure/Success四个导出并不是魔法,而是由 Cell 运行时统一驱动的。查看 packages/web/src/components/cell/createCell.tsx 可以看清这套状态机:
- 查询返回
error且有Failure时渲染Failure,并额外传入errorCode(来自 GraphQL 扩展字段extensions.code);若未定义Failure则直接抛出错误; - 返回
data且经isEmpty判定为空、同时定义了Empty时渲染Empty; - 返回
data且非空时渲染Success,并把afterQuery(data)的结果与props、updating、queryResult一并传入; - 仍在加载时渲染
Loading; - 若出现"无 error、无 data、不在 loading"的异常状态(通常是缓存问题),则抛出明确错误,并提示"给查询的所有字段加上
id可能解决该问题"。
beforeQuery的默认实现还会设置fetchPolicy: 'cache-and-network'与notifyOnNetworkStatusChange: true(见 createCell.tsx),这也是 Cell 能在数据更新时自动刷新的底层原因。理解了这套状态机,你就能放心地在beforeQuery/afterQuery/isEmpty上做定制。
总结与下一步
至此,"多评论列表"的前端已完成:
- 用
yarn rw g cell Comments生成 Cell,理解生成器对复数/单数、mock 字段的推断逻辑; - 让
CommentsCell复用Comment组件并补全查询字段; - 用
standardmock 在 Storybook 中独立演示; - 用
space-y-8、mt-12打磨布局并挂载进Article; - 为
CommentsCell与Article补齐状态渲染与异步渲染测试。
接下来的自然步骤是补齐后端:在 schema.prisma 中添加Comment模型并建立与Post的关联,生成 SDL 与 service,让真实页面上的评论列表真正跑起来;随后再实现评论的创建表单(见 comment-form.md)。想继续深挖 Cell 机制,可以直接阅读 createCell.tsx、Storybook mock 预加载 与 MSW 请求拦截 的源码实现。
- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
相关推荐
Redwood 教程实战:用 Cell 构建博客评论列表(CommentsCell 的创建、Mock 与测试)
Redwood 教程实战:用 Cell 构建博客评论列表(CommentsCell 的创建、Mock 与测试) 本篇技术指南以 Redwood 官方教程「Mul
后端前端Web框架开发工具Redwood 教程:用 Cell 构建评论列表(CommentsCell)——从 Storybook 开发到组件测试全流程
Redwood 教程:用 Cell 构建评论列表(CommentsCell)——从 Storybook 开发到组件测试全流程 本篇教程是 Redwood 官方教
后端前端Web框架开发工具Redwood 实战:用 Cell 为博客文章渲染多条评论(CommentsCell 完整实践)
Redwood 实战:用 Cell 为博客文章渲染多条评论(CommentsCell 完整实践) 本篇教程对应 Redwood 官方教程第 6 章「Multip
后端前端Web框架开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考