用 Redwood 方式构建组件:以评论组件为例的 Storybook 与测试全流程
2026/9/23 10:48:06 网站建设 项目流程
  • 后端
  • 前端
  • Web框架
  • 开发工具

【免费下载链接】redwood

RedwoodGraphQL

项目地址:https://gitcode.com/gh_mirrors/re/redwood
点击查看免费下载

导读

本文基于 Redwood 官方教程第五章"用 Redwood 的方式构建组件"(Building a Component the Redwood Way)展开,以给博客添加"评论"功能为实战场景,完整演示 Redwood 开发工作流的经典路径:先用yarn rw g component生成组件骨架,再借助 Storybook 以交互方式打磨组件外观与数据结构,最后用yarn rw test为组件编写行为级测试。读完本文,你将掌握 Redwood 中"生成 → 可视化开发 → 测试验证"的组件开发闭环,以及time标签 +datetime属性这类容易被忽略的机器可读性细节。


一、需求拆解:评论功能的两条主线

在开始动手之前,先明确我们要构建的功能。博客缺少"评论",目标很简单:让读者在文章下面留下他们"完全理性、有理有据"的评论。整个功能可以被拆成两大块:

  1. 评论表单与创建(Comment form and creation):用户输入并提交评论;
  2. 评论检索与展示(Comment retrieval and display):把已存在的评论取出来并渲染到页面上。

两条主线先后顺序没有强制要求。为了循序渐进,教程选择先做"取数 + 展示",再去做更复杂的"表单 + Service 创建"。当然,正如教程所说:这是 Redwood,连表单和 Service 也没有那么复杂。

这种"先展示、后写入"的顺序安排也符合组件开发的直觉:先把静态展示形态确定下来(数据结构、样式、测试),再接入数据写入,能显著降低心智负担。


二、用生成器创建 Comment 组件(Storybook 先行)

2.1 生成组件骨架

Redwood 的 CLI 提供了组件生成器,一条命令即可产出组件文件:

yarn rw g component Comment

执行后,Storybook 会自动刷新,并生成一个开箱即用的 "Generated" Comment story。

从仓库源码看,这条命令背后做了不少事情。component.js 中files()函数定义了生成器的产出逻辑:

  • 生成主组件文件:web/src/components/Comment/Comment.jsx(或.tsx);
  • 生成测试文件:web/src/components/Comment/Comment.test.jsx(或.test.tsx);
  • 生成 Story 文件:web/src/components/Comment/Comment.stories.jsx(或.stories.tsx)。

其中 TS 与 JS 版本会使用不同的模板:JS 版本的主组件内容由transformTSToJS从 TS 模板转换而来,而 Story 模板则分别有 stories.tsx.template 与stories.jsx.template两个独立文件(因为 TS 模板注释中含类型信息)。是否生成测试和 Story 文件,取决于命令选项options.storiesoptions.tests,默认都会生成。

主组件的初始模板(component.tsx.template)内容非常简单,仅仅渲染一个标题和提示文字:

const Comment = () => { return ( <div> <h2>{'Comment'}</h2> <p>{'Find me in web/src/components/Comment/Comment.tsx'}</p> </div> ) } export default Comment

生成的测试模板(test.tsx.template)也只做了最基础的事情——渲染组件不抛异常:

import { render } from '@redwoodjs/testing/web' import Comment from './Comment' describe('Comment', () => { it('renders successfully', () => { expect(() => { render(<Comment />) }).not.toThrow() }) })

这就是教程中所说"默认测试只是确保不抛错"的来源:这是生成器给所有组件的最低保障。

2.2 明确组件的数据契约

接下来要思考:我们希望用户提供什么、展示什么?最简方案是只收集姓名评论正文,再额外带上评论的创建时间。于是 Comment 组件需要接收一个包含三个属性的comment对象:

  • name:评论者姓名;
  • createdAt:评论创建时间;
  • body:评论正文。

JavaScript 版本直接解构 props:

const Comment = ({ comment }) => { return ( <div> <h2>{comment.name}</h2> <time dateTime={comment.createdAt}>{comment.createdAt}</time> <p>{comment.body}</p> </div> ) } export default Comment

TypeScript 版本则需要先定义一个临时的 Props 类型(教程特意注明:这只是临时类型,后续接入 GraphQL 生成类型后会替换):

// Just a temporary type. We'll replace this later interface Props { comment: { name: string createdAt: string body: string } } const Comment = ({ comment }: Props) => { return ( <div> <h2>{comment.name}</h2> <time dateTime={comment.createdAt}>{comment.createdAt}</time> <p>{comment.body}</p> </div> ) } export default Comment

注意这里 TypeScript 的类型信息是手工声明的内联结构。在 Redwood 中,这类类型最终往往会被替换为由 SDL(Schema Definition Language)与 Cell 生成出的类型,以实现端到端的类型安全——这也是教程中"Just a temporary type"注释的含义。

2.3 修复 Story:补上缺失的 props

保存文件后,Storybook 会立刻报错——因为 Story 仍然按"无 props"渲染组件,而组件现在要求comment对象。需要更新 Story 文件,传入一个符合数据契约的示例对象:

import Comment from './Comment' export const generated = () => { return ( <Comment comment={{ name: 'Rob Cameron', body: 'This is the first comment!', createdAt: '2020-01-01T12:34:56Z' }} /> ) } export default { title: 'Components/Comment', component: Comment, }

TypeScript 版本完全一致:

import Comment from './Comment' export const generated = () => { return ( <Comment comment={{ name: 'Rob Cameron', body: 'This is the first comment!', createdAt: '2020-01-01T12:34:56Z' }} /> ) } export default { title: 'Components/Comment', component: Comment, }

保存后 Storybook 重新加载,组件即可正常渲染。

:::info 关于日期格式的一个重要提示 时间值最终会以ISO8601 格式(如2020-01-01T12:34:56Z)从 GraphQL 返回,因此 Story 中必须提供一个该格式的示例值。这保证 Story 与真实运行时数据形态一致,避免"开发环境好好的、一接真数据就崩"的落差。 :::


三、让组件像"成品":样式与日期格式化

基础展示没有问题后,为组件加上一点样式和日期转换,让它成为一个"设计完成的组件"。

我们新增一个formattedDate工具函数:把 ISO8601 字符串解析为Date对象,提取出日期、月份(长名称,如 "January")和年份,拼成"2 January 2020"这样易读的格式;同时保留<time>标签的dateTime属性用于承载机器可读的原始时间戳。

JavaScript 版本:

const formattedDate = (datetime) => { const parsedDate = new Date(datetime) const month = parsedDate.toLocaleString('default', { month: 'long' }) return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}` } const Comment = ({ comment }) => { return ( <div className="bg-gray-200 p-8 rounded-lg"> <header className="flex justify-between"> <h2 className="font-semibold text-gray-700">{comment.name}</h2> <time className="text-xs text-gray-500" dateTime={comment.createdAt}> {formattedDate(comment.createdAt)} </time> </header> <p className="text-sm mt-2">{comment.body}</p> </div> ) } export default Comment

TypeScript 版本的关键差异在于入参类型:formattedDate接收的参数类型声明为ConstructorParameters<typeof Date>[0],即Date构造函数第一个参数的类型(string | number | Date),这样既能接受 ISO8601 字符串,又保持类型安全:

const formattedDate = (datetime: ConstructorParameters<typeof Date>[0]) => { const parsedDate = new Date(datetime) const month = parsedDate.toLocaleString('default', { month: 'long' }) return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}` } // Just a temporary type. We'll replace this later interface Props { comment: { name: string createdAt: string body: string } } const Comment = ({ comment }: Props) => { return ( <div className="bg-gray-200 p-8 rounded-lg"> <header className="flex justify-between"> <h2 className="font-semibold text-gray-700">{comment.name}</h2> <time className="text-xs text-gray-500" dateTime={comment.createdAt}> {formattedDate(comment.createdAt)} </time> </header> <p className="text-sm mt-2">{comment.body}</p> </div> ) } export default Comment

样式上用的是 Tailwind 工具类:外层卡片bg-gray-200 p-8 rounded-lg,头部用 flex 让姓名左对齐、时间右对齐,正文用小号文字。这样组件就从一个"裸数据展示器"变成了一个有完整视觉形态的 UI 元素,此时再回到 Storybook 中可以看到最终效果。


四、用测试锁定组件行为

样式和展示都正确了,接下来用测试确认组件"确实按预期工作"。测试要点:验证作者姓名、评论正文、以及评论发布日期的展示——并且要同时验证用户可读的格式化文本机器可读的datetime属性

测试代码如下:

import { render, screen } from '@redwoodjs/testing' import Comment from './Comment' describe('Comment', () => { it('renders successfully', () => { const comment = { name: 'John Doe', body: 'This is my comment', createdAt: '2020-01-02T12:34:56Z', } render(<Comment comment={comment} />) expect(screen.getByText(comment.name)).toBeInTheDocument() expect(screen.getByText(comment.body)).toBeInTheDocument() const dateExpect = screen.getByText('2 January 2020') expect(dateExpect).toBeInTheDocument() expect(dateExpect.nodeName).toEqual('TIME') expect(dateExpect).toHaveAttribute('datetime', comment.createdAt) }) })

TypeScript 版本相同,仅扩展名与导入路径不同(.test.tsx):

import { render, screen } from '@redwoodjs/testing' import Comment from './Comment' describe('Comment', () => { it('renders successfully', () => { const comment = { name: 'John Doe', body: 'This is my comment', createdAt: '2020-01-02T12:34:56Z', } render(<Comment comment={comment} />) expect(screen.getByText(comment.name)).toBeInTheDocument() expect(screen.getByText(comment.body)).toBeInTheDocument() const dateExpect = screen.getByText('2 January 2020') expect(dateExpect).toBeInTheDocument() expect(dateExpect.nodeName).toEqual('TIME') expect(dateExpect).toHaveAttribute('datetime', comment.createdAt) }) })

这段测试值得逐条拆解:

  1. expect(screen.getByText(comment.name)).toBeInTheDocument():验证姓名文本被渲染;
  2. expect(screen.getByText(comment.body)).toBeInTheDocument():验证正文文本被渲染;
  3. screen.getByText('2 January 2020'):验证格式化后的日期文本(这与前文中测试文章截断文本的思路一致);
  4. expect(dateExpect.nodeName).toEqual('TIME'):验证包裹该文本的元素确实是<time>标签;
  5. expect(dateExpect).toHaveAttribute('datetime', comment.createdAt):验证datetime属性携带原始时间戳。

第 4、5 条断言看起来像是"过度测试",但这是有明确目的的:datetime属性的存在意义是提供机器可读的时间戳,浏览器(理论上)可以据此做诸如自动转换本地时区、注入日历提醒等能力。断言这两点,就是确保我们不会在日后的重构中无意间破坏这种"机器可读性"。它和测试"截断文本"在理念上一脉相承——你关心什么行为,就锁定什么行为。

测试所用的renderscreen等 API 全部来自@redwoodjs/testing。在仓库源码中,packages/testing/src/web/index.ts 通过export * from '@testing-library/react'重新导出了 React Testing Library 的全部能力,Redwood 的测试生态因此与社区标准工具链完全兼容。

运行测试

如果测试还没有在另一个终端窗口中运行,现在启动:

yarn rw test

Redwood 的测试 runner 会以 watch 模式运行,保存测试文件后自动重跑。

:::info 如果改了日期格式化逻辑,测试会不会跟着坏?

会的——正如改动截断长度就要同步修改截断文本的断言一样。一个可选的替代方案是:把日期格式化逻辑抽成一个可从组件导出的独立函数,然后在测试中导入该函数来生成期望值。这样当你改动格式化公式时,测试因为与组件共享同一个函数而自动保持通过,无需手工同步两处逻辑。

从组件中导出纯函数并在测试中复用的做法,在 Redwood 中非常常见——它既保证了测试与实现的同步性,也让格式化这类纯逻辑可以被独立单元测试。 :::


五、小结:Redwood 的组件开发闭环

回顾本章的完整流程,可以提炼出 Redwood 推荐的组件开发模式:

  1. 生成yarn rw g component Comment一键生成组件、测试、Story 三件套(源码见 component.js);
  2. 定义数据契约:明确组件接收的 props(本例为namecreatedAtbody),TS 项目同步声明类型;
  3. Storybook 可视化迭代:为 Story 提供符合契约的示例数据,在浏览器中即时调整样式与展示逻辑;
  4. 测试锁定行为:不止断言"渲染不报错",而是锁定真实业务行为——文本内容、元素语义(<time>)、机器可读属性(datetime);
  5. 运行yarn rw test持续验证。

这套"生成器 + Storybook + 测试"的组合拳贯穿 Redwood 的日常开发。本章只完成了评论功能的"展示"半边;接下来将进入更复杂的另一半——评论表单的创建与 Service 层的写入逻辑。那时,Comment组件积累的展示与测试基础会直接复用,这也正是"用 Redwood 的方式"渐进式构建功能的精髓所在。

  • 后端
  • 前端
  • Web框架
  • 开发工具

【免费下载链接】redwood

RedwoodGraphQL

项目地址:https://gitcode.com/gh_mirrors/re/redwood
点击查看免费下载

相关推荐

上一篇:终极指南:ggml混合精度训练中FP16与FP32的最佳实践
下一篇:终极指南:co代码规范——编写优雅异步代码的10条黄金准则

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询