返回资讯中心
外部精选
软件工程
#开发者实时流

通过模拟 API 制作产品演示(以 tRPC 为例)

来源标题:Creating product demos by mocking your API (feat. tRPC)

作者回顾自己在售前岗位制作交互式产品演示的经历,指出过去需要搭建带真实数据库的完整 Web 应用,如今单页应用只需后端提供 API 即可。他提出将后端 API 请求与 React 组件隔离,并在请求处加入开关以硬编码演示内容,类似 Storybook 但覆盖整个登录后应用。

Hacker News 最新mortensonsam2026/11/15 · 今天18 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

我最早的一份软件工程工作,说来也怪,是在销售部门。我做的是售前,算是销售推介中的技术支撑角色,负责搭建交互式演示,用来向潜在客户展示我们的产品。我以前常开玩笑说,我就是那个替能说会道的销售磨好刀、让他上门推销的人。

虽然这些演示的复杂程度各不相同,但每一个都需要投入大量精力才能搭起来。我们卖的是内容管理系统,每个演示都得是一个能跑的 Web 应用,还得配一个真实的数据库,这样才能“现场”演示编辑内容。

那还是 PHP 时代,HTML 在服务端渲染。如今我们已经走了很远,大多数产品都做成了单页应用(SPA),后端只提供一个 API,供 React 之类的框架在客户端消费。既然光是浏览网站已经不再需要服务器了,做演示理应容易得多!

我最初的想法是把后端 API 请求与 React 组件严格隔离开,然后在每个发起 API 请求的地方放一个“开关”,让它硬编码返回演示数据,而不是去请求后端。有点像 Storybook,只不过覆盖整个登录后的体验。这个尝试惨不忍睹——我在深层嵌套的组件里散落着各种 API 请求,而且整体上并没有按“有状态”和“无状态”的方式来对待组件,无法支持那种只从 props 消费响应的做法。

在放弃成为优秀 React 开发者的梦想之后,我决定换个思路,在更高的层级上 mock 整个 API,而组件渲染方式完全不动——从组件的角度看,这些 API 请求就是真实的。

我用的是 tRPC,这是一个 TypeScript 库,利用类型推断,让你能快速定义后端路由供客户端(也就是你的前端)使用,而不用“手动”编写请求/响应类型或 schema。tRPC 里有个概念叫 link,是可以在客户端或服务端修改请求和响应的中间件。借助 link,你可以识别出某个请求是在演示环境中发出的,然后返回一些硬编码的响应数据,阻止真实请求打到后端。

下面是一个简化的实现示例:

写完这个之后,我有了一个不错的原型验证,但接下来的负担是要为后端每一个 API 定义 mock(我的后端有 100 多个)。这看起来很不划算,因为在我的项目里,演示只在查看标识符/slug 为“demo”的特定组织/租户时才可见——那些与组织无关的路由(比如用户登录)没有理由被 mock。

为了满足这个设计,DemoRoutes 类型演变成了:

// 组织相关 HTTP 请求的形状
// (注意:在真实产品里我用的是 UUID,不是 slug)
type OrganizationInputShape = {
 slug: string;
};

// 用来识别那些返回 "{}"、不需要 mock 的路由。
type EmptyShape = {
 [key: string]: never;
};

// 组织相关路由名称的字符串联合类型。
type OrganizationRoute = {
 [T in keyof RouterInput]:
 RouterInput[T] extends OrganizationInputShape
 ? T
 : never;
}[keyof RouterInput];

// 看起来有点邪门,但要求为返回响应的组织相关路由
// 提供定义,并可选地允许为其他组织路由提供定义。
type DemoRoutes = {
 [ T in OrganizationRoute as RouterOutput[T] extends EmptyShape
 ? never
 : T
 ]: DemoDataGenerator<T>;
} & {
 [ T in OrganizationRoute as RouterOutput[T] extends EmptyShape
 ? T
 : never
 ]?: DemoDataGenerator<T>;
};

这意味着我只需要 mock 查看演示时会被命中的路由——在我的项目里,这把路由数量减少了一半左右!

为了适配新的类型,中间件现在必须处理三种情况:路由有 mock、路由是组织相关且返回空对象、或者应该“放行”到真实后端。

完成最初这批必需的 mock 之后,我发现演示感觉有点死板,因为它是只读的。大多数表单点“提交”什么都不会发生,却弹出一个“表单已保存”的 toast,作为用户会觉得有点困惑。

于是我着手让 mock 数据显得更真实,通过把更新写入内存(局部变量)来在演示中引入可变性。处理这些写入有很多方式,下面是一个简单的实现:

// 假设服务端有这些路由:
const appRouter = router({
 listPosts: publicProcedure
 .input(
 z.object({
 slug: z.string().nonempty(),
 }),
 )
 .query(async ({ input }) => {
 // (一些数据库代码设置 posts)
 return posts as { id: string, text: string }[]
 }),
 createPost: publicProcedure
 .input(
 z.object({
 slug: z.string().nonempty(),
 text: z.string().nonempty(),
 }),
 )
 .mutation(async ({ input }) => {
 // (一些数据库代码插入一条 post)
 return { postId: id }
 }),
});

// 我们可以定义一个内存中的 Map,用来做 post 的 CRUD 操作。
// 它可以(也应该)包含一些初始数据。
//
// 注意值的类型是从某个路由的响应推断出来的——这感觉有点怪,
// 但如果很多路由共享响应形状,用起来其实很顺手。
const postsStore = new Map<string, RouterOutput["listPosts"][number]>(
 [
 "I am a default, hardcoded post",
 "And so am I!",
 ].map(
 text => {
 const id = crypto.randomUUID()
 return [id, { id, text }]
 )
 )
);

export const demoRoutes: DemoRoutes = {
 // mock 列表查询很简单
 listPosts: () => ([...postsStore.values()]),
 // 修改数据也不算太糟
 createPost: ({ text }) => {
 const id = crypto.randomUUID();
 postsStore.set(id, { id, text });
 return { postId: id };
 },
};

如你所见,演示想做多复杂,这玩意儿就能变得多复杂,简直是个无底洞!不过我觉得,如果你想避开那种“恐怖谷”效应——即演示真实得让人发毛——静态的只读演示数据也完全够用来展示产品了。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →