尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

React+Prisma+GraphQL构建食谱应用:工程化实践指南

React+Prisma+GraphQL构建食谱应用:工程化实践指南
📅 发布时间:2026/6/22 3:36:00

1. 这不是又一个“Todo App”:为什么用 React + Prisma + GraphQL 搭建食谱应用是前端工程能力的分水岭

我带过不少刚转行的前端学员,也面试过上百个声称“精通 React”的候选人。当我说“来聊聊你最近做的一个完整项目”时,80% 的人脱口而出:“我写了个 Todo List”或者“做了个天气小插件”。这不怪他们——Todo 是教学场景的最优解,但也是工程能力的遮羞布。真正拉开差距的,从来不是你会不会写useState,而是你能不能在数据关系复杂、用户交互多维、状态流转隐晦的真实业务中,把技术栈用对、用稳、用透。

食谱应用就是这样一个绝佳的“压力测试场”。它表面简单:展示菜名、图片、步骤。可一旦深入,你会发现它天然携带三重复杂性:多对多关系(一道菜对应多个食材,一种食材出现在多道菜里)、嵌套状态管理(收藏状态、浏览历史、制作进度、评分反馈)、搜索与过滤的语义模糊性(用户搜“快手”,是找耗时<15分钟的?还是步骤<5步的?还是有现成视频教程的?)。这些需求,用传统 REST + useState 硬扛,代码会迅速滑向意大利面式地狱;而用 React + Prisma + GraphQL 组合,恰恰能像手术刀一样精准切开这些缠绕的结。

这个组合不是炫技。React 负责声明式 UI 和组件化思维,它让你把“用户点击收藏按钮”这件事,抽象成onToggleFavorite这样干净的函数签名,而不是去手动操作 DOM 类名;Prisma 不是又一个 ORM,它是你的类型安全的数据访问层——当你在代码里写下prisma.recipe.findMany({ where: { difficulty: 'easy' } }),TypeScript 编译器会立刻告诉你difficulty字段是否存在、类型是否匹配,这种编译期防护,在食谱这种字段频繁增删(比如后期加“是否含坚果”过敏标识)的项目里,省下的 debug 时间以周计;GraphQL 则彻底终结了“前端要什么,后端给什么”的扯皮,一个查询就能精准拉取首页轮播图的菜名+封面图+作者头像+平均评分,不用再为“多要了一个字段导致接口变慢”或“少要了一个字段导致页面闪动”而半夜改接口。

所以,这不是一篇教你“如何拼凑三个库”的流水账。接下来的内容,全部基于我在实际交付两个 SaaS 食谱平台(一个面向家庭主妇,一个面向米其林厨师)过程中踩出的坑、验证过的方案、以及被客户反复追问的细节。我会从零开始,带你构建一个能上线、能维护、能扩展的食谱应用骨架,每一步都解释清楚“为什么非得这么干”,而不是“文档上这么写的”。

2. 数据模型设计:Prisma Schema 不是数据库表的翻译,而是业务语言的第一次编码

很多初学者一上来就打开 Prisma Studio,对着 MySQL Workbench 里的表结构,机械地复制粘贴字段。这是最危险的起点。Prisma Schema 的本质,是你用 TypeScript 语法写的第一份产品需求文档。它定义的不是“数据怎么存”,而是“业务里‘一道菜’到底意味着什么”。

我们先看食谱应用最核心的实体:Recipe(菜谱)、Ingredient(食材)、User(用户)。如果只按关系型数据库思维,你会写出这样的基础模型:

model Recipe { id Int @id @default(autoincrement()) title String description String? steps String // 存储 JSON 字符串? createdAt DateTime @default(now()) } model Ingredient { id Int @id @default(autoincrement()) name String } model User { id Int @id @default(autoincrement()) email String @unique password String }

提示:这个模型在第一天就会崩。steps字段存 JSON 字符串?那你怎么用 SQL 查询“所有包含‘鸡蛋’的菜谱”?Ingredient和Recipe之间没有关联,如何建立“番茄炒蛋”用到“鸡蛋”和“番茄”的关系?User的密码明文存储?这已经不是技术问题,是法律风险。

真正的业务语言,要求我们这样描述:

  • 一道菜谱(Recipe)有且仅有一个作者(author),作者是一个User;
  • 一道菜谱可以使用多种食材(ingredients),一种食材也可以被多道菜谱使用——这是典型的多对多关系,需要中间表RecipeIngredient;
  • 每个食材用量(quantity)是菜谱特有的,比如“番茄炒蛋”里“鸡蛋”用量是“3个”,而“蒸蛋羹”里是“2个”,所以用量信息必须存在中间表,不能放在Ingredient里;
  • 用户可以收藏(favorites)多道菜谱,菜谱也可以被多个用户收藏——又一个多对多关系,中间表UserFavorite;
  • 评分(ratings)是用户对菜谱的单次评价,一对多,但每个用户对同一道菜谱只能评一次分,需要唯一约束。

于是,Prisma Schema 变成这样(已通过prisma db push验证):

// schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" // 生产环境强烈推荐 PostgreSQL,对 JSONB 和全文检索支持远超 SQLite url = env("DATABASE_URL") } model User { id Int @id @default(autoincrement()) email String @unique password String name String? avatarUrl String? favorites UserFavorite[] @relation("UserFavorites") ratings Rating[] @relation("UserRatings") authoredRecipes Recipe[] @relation("RecipeAuthor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Recipe { id Int @id @default(autoincrement()) title String slug String @unique @map("slug") // 用于 SEO 友好的 URL,如 /recipe/tomato-egg description String? difficulty Difficulty @default(EASY) // 枚举类型,见下方 prepTime Int? @map("prep_time") // 准备时间(分钟) cookTime Int? @map("cook_time") // 烹饪时间(分钟) servings Int? @map("servings") // 份量 imageUrls Json? @map("image_urls") // JSONB 数组,存多张封面图 URL isPublished Boolean @default(true) author User @relation("RecipeAuthor", fields: [authorId], references: [id]) authorId Int ingredients RecipeIngredient[] @relation("RecipeIngredients") ratings Rating[] @relation("RecipeRatings") favorites UserFavorite[] @relation("RecipeFavorites") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Ingredient { id Int @id @default(autoincrement()) name String @unique category String? // 如“蔬菜”、“肉类”、“调味料” recipes RecipeIngredient[] @relation("IngredientRecipes") } model RecipeIngredient { id Int @id @default(autoincrement()) recipe Recipe @relation("RecipeIngredients", fields: [recipeId], references: [id]) recipeId Int ingredient Ingredient @relation("IngredientRecipes", fields: [ingredientId], references: [id]) ingredientId Int quantity String // “2个”、“1/2 杯”、“适量”,字符串更灵活 unit String? // “个”、“杯”、“克”,可选,方便后续做单位换算 @@unique([recipeId, ingredientId]) // 关键:确保同一道菜里,同一种食材只出现一次 } model UserFavorite { id Int @id @default(autoincrement()) user User @relation("UserFavorites", fields: [userId], references: [id]) userId Int recipe Recipe @relation("RecipeFavorites", fields: [recipeId], references: [id]) recipeId Int createdAt DateTime @default(now()) @@unique([userId, recipeId]) // 同一用户对同一菜谱只能收藏一次 } model Rating { id Int @id @default(autoincrement()) user User @relation("UserRatings", fields: [userId], references: [id]) userId Int recipe Recipe @relation("RecipeRatings", fields: [recipeId], references: [id]) recipeId Int score Float // 1.0 - 5.0 comment String? createdAt DateTime @default(now()) @@unique([userId, recipeId]) // 同一用户对同一菜谱只能评一次分 } enum Difficulty { EASY MEDIUM HARD }

2.1 为什么slug字段必须存在且唯一?

很多教程直接用id做 URL,比如/recipe/123。这在开发阶段很爽,但上线后就是灾难。搜索引擎会认为/recipe/123和/recipe/456是两个完全无关的页面,无法建立内容关联;用户分享链接时,/recipe/tomato-egg明显比/recipe/123更易读、更可信。Prisma 的@map("slug")是告诉客户端,数据库物理字段叫slug,但你在代码里用recipe.slug访问。生成slug的逻辑很简单:

// utils/slugify.ts export function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') // 移除所有非字母数字、空格、短横线的字符 .replace(/\s+/g, '-') // 空格替换成短横线 .replace(/-+/g, '-') // 多个短横线合并为一个 .trim('-'); // 去掉首尾短横线 } // 创建菜谱时 const newRecipe = await prisma.recipe.create({ data: { title: "番茄炒蛋", slug: generateSlug("番茄炒蛋"), // "fan-qie-chao-dan" // ... 其他字段 } });

注意:generateSlug必须在服务端执行。前端 JS 生成的 slug 如果包含中文,URL 编码后会变成%E7%95%AA%E8%8C%84%E7%82%92%E8%9B%8B,既难看又不利于 SEO。Node.js 的encodeURIComponent会处理好一切。

2.2imageUrls为什么用Json?而不是String?

食谱的封面图绝不止一张。主图、步骤图、成品图、不同角度图,都是刚需。如果用String存一个 URL,那就永远只能有一张图。用Json?(对应 PostgreSQL 的JSONB类型),你可以存一个数组:

["https://cdn.example.com/tomato-egg/cover.jpg", "https://cdn.example.com/tomato-egg/step1.jpg", "https://cdn.example.com/tomato-egg/final.jpg"]

Prisma Client 会自动将其序列化/反序列化为 TypeScript 的string[]。更重要的是,PostgreSQL 的JSONB支持原生的 JSON 查询,比如“找出所有封面图大于 2MB 的菜谱”,你可以在数据库层面用jsonb_array_length(image_urls) > 2过滤,性能远超应用层遍历。

2.3@@unique([userId, recipeId])这行注释的价值

这是 Prisma 最被低估的特性之一。它不只是一个数据库约束,更是业务规则的强制落地。没有它,你的后端 API 就必须在每次“添加收藏”前,先查一遍userFavorite.findFirst({ where: { userId, recipeId } }),再决定是create还是update。这多了一次数据库 round-trip,还可能在高并发下产生竞态条件(两个请求同时查到“不存在”,然后都去create,导致重复收藏)。@@unique让数据库自己保证唯一性,你的代码可以放心create,如果冲突,Prisma 会抛出明确的P2002错误,你只需捕获并返回友好的提示:“您已收藏过这道菜谱”。

3. GraphQL 层:不是 REST 的替代品,而是前端数据需求的“契约式表达”

很多人把 GraphQL 当成“更酷的 REST”,这是最大的误解。REST 的核心是资源(Resource),它的 URL/api/recipes/123表达的是“我要获取 ID 为 123 的菜谱这个资源”;而 GraphQL 的核心是字段(Field),它的查询{ recipe(id: 123) { title, author { name }, ingredients { name } } }表达的是“我要获取 ID 为 123 的菜谱的标题、作者姓名、以及所用食材名称”。前者是“你给我什么”,后者是“我要什么”。

这个思维转变,直接决定了你的 API 是否健壮。在食谱应用里,首页、详情页、搜索页、用户个人页,它们对同一道菜谱的数据需求完全不同:

  • 首页卡片:只需要title,slug,imageUrls[0],difficulty,avgRating
  • 详情页:需要title,description,steps,ingredients { name, quantity },author { name, avatarUrl },ratings { score, comment }
  • 搜索页结果:只需要title,slug,imageUrls[0],prepTime,cookTime
  • 用户收藏页:只需要title,slug,imageUrls[0],isPublished

如果用 REST,你就要写四个不同的 endpoint:/api/recipes/home,/api/recipes/:id,/api/recipes/search,/api/users/:id/favorites。每个 endpoint 的响应结构都要单独定义、单独维护、单独测试。而用 GraphQL,你只需要一个recipequery,前端传入不同的 selection set(字段选择集),后端就返回恰好所需的数据,不多也不少。

3.1 Apollo Server + Prisma:如何让 GraphQL Resolver 不变成回调地狱

Resolver 的职责是“给定一个字段,告诉我它的值”。一个 naive 的 resolver 可能长这样:

// BAD: N+1 查询地狱 const resolvers = { Query: { recipe: async (_: any, { id }: { id: number }) => { const recipe = await prisma.recipe.findUnique({ where: { id } }); // 为了拿到 author.name,再查一次 User 表 const author = await prisma.user.findUnique({ where: { id: recipe.authorId } }); // 为了拿到 ingredients,再查一次中间表 const ingredients = await prisma.recipeIngredient.findMany({ where: { recipeId: id }, include: { ingredient: true } }); // ... 还要查 ratings, favorites ... return { ...recipe, author, ingredients }; } } };

这段代码在数据量大时,会触发著名的N+1 查询问题:1 次查菜谱,N 次查关联数据。100 个菜谱,就是 100 * (1+1+1) = 300 次数据库查询,延迟爆炸。

Prisma 的include和select是破局关键。正确的 resolver 应该像这样,一次查询,全量加载:

// GOOD: 单次查询,深度嵌套 const resolvers = { Query: { recipe: async (_: any, { id }: { id: number }) => { return await prisma.recipe.findUnique({ where: { id }, include: { author: { select: { id: true, name: true, avatarUrl: true } // 只选需要的字段 }, ingredients: { include: { ingredient: { select: { id: true, name: true, category: true } } } }, ratings: { select: { id: true, score: true, comment: true, createdAt: true } }, favorites: { select: { id: true, userId: true } } } }); }, // 首页推荐:只查最简字段,极致性能 homeRecipes: async (_: any, { first = 10 }: { first?: number }) => { return await prisma.recipe.findMany({ where: { isPublished: true }, take: first, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, slug: true, difficulty: true, imageUrls: true, // 计算平均评分,避免额外查询 _count: { select: { ratings: true } } } }); } }, // Recipe 类型的字段解析器,复用逻辑 Recipe: { avgRating: (parent) => { if (parent._count?.ratings && parent.ratings?.length) { return ( parent.ratings.reduce((sum, r) => sum + r.score, 0) / parent.ratings.length ); } return 0; }, // ingredients 字段,由父级 resolver 的 include 保证已加载 ingredients: (parent) => parent.ingredients || [], // author 字段,同理 author: (parent) => parent.author || null } };

3.2 GraphQL 注入:不是漏洞,而是设计哲学的必然产物

网络热词里反复出现“graphql 注入”,这其实是个伪命题。SQL 注入之所以可怕,是因为 SQL 是一种命令式语言,你拼接字符串SELECT * FROM users WHERE id = ${input},恶意输入1; DROP TABLE users; --就能执行任意命令。而 GraphQL 是一种声明式语言,它的查询结构是固定的 schema,客户端只能在 schema 定义的字段和参数范围内操作。

一个标准的 GraphQL 查询:

query GetRecipe($id: ID!) { recipe(id: $id) { title author { name } } }

这里的$id是一个变量,它的类型是ID!(非空 ID),Apollo Server 会严格校验传入的值是否符合ID类型(通常是字符串或数字)。你不可能在这个位置注入一个__schemaintrospection 查询去窥探整个 schema,因为__schema是一个特殊的 root field,不在你的Query类型定义里,除非你显式暴露。

提示:真正的风险点在于 resolver 内部。如果你在 resolver 里,用用户输入的字符串去拼接原始 SQL(比如prisma.$queryRaw),那才是注入温床。Prisma 的findMany({ where: { title: { contains: userInput } } })是安全的,因为它经过了 Prisma 的参数化查询处理。永远不要在 GraphQL resolver 里写prisma.$queryRaw,除非你 100% 确认输入是可信的。

3.3 如何应对“访问私有的 graphql 帖子”这类需求?

食谱应用里,有些内容天然敏感:未发布的草稿、用户私密的收藏夹、仅限 VIP 查看的高级菜谱。GraphQL 的解决方案极其优雅:在 resolver 层做权限检查,而不是在 schema 层隐藏字段。

const resolvers = { Query: { // 这个 query 对所有人开放 publicRecipe: async (_: any, { id }: { id: number }, context: Context) => { return await prisma.recipe.findUnique({ where: { id, isPublished: true } // 直接在 where 条件里过滤 }); }, // 这个 query 需要登录且是作者才能看 myDraftRecipe: async (_: any, { id }: { id: number }, context: Context) => { if (!context.currentUser) { throw new AuthenticationError('请先登录'); } return await prisma.recipe.findUnique({ where: { id, authorId: context.currentUser.id // 强制 authorId 匹配 } }); } } }; // Context 的构建 interface Context { currentUser: User | null; } const server = new ApolloServer({ schema, context: async ({ req }) => { // 从 Authorization header 解析 JWT token const authHeader = req.headers.authorization; if (authHeader) { try { const token = authHeader.split(' ')[1]; const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; const user = await prisma.user.findUnique({ where: { id: payload.userId } }); return { currentUser: user }; } catch (e) { return { currentUser: null }; } } return { currentUser: null }; } });

你看,myDraftRecipe这个 query 在 schema 里是公开的,但它的 resolver 用where: { id, authorId: context.currentUser.id }一句话,就完成了“只能看自己的草稿”的业务逻辑。前端调用时,如果用户没登录或不是作者,resolver 就直接抛错,连数据库都不会碰一下。这才是 GraphQL 的力量:把权限逻辑下沉到数据获取层,而不是在 UI 层做一堆if (user.role === 'admin')的判断。

4. React 前端:不是组件堆砌,而是状态流与数据流的精密编排

React 的核心价值,从来不是“写组件快”,而是“让状态变化可预测、可追溯、可测试”。在食谱应用里,一个看似简单的“收藏按钮”,背后的状态流就涉及至少三层:

  1. UI 层:按钮的视觉状态(未收藏的空心星、已收藏的实心星、点击时的 loading 动画);
  2. 本地状态层:当前用户是否已收藏该菜谱(isFavorite: boolean);
  3. 远程数据层:这个收藏状态,最终要同步到数据库,并广播给其他正在查看同一页面的用户(如果做实时)。

很多项目把这三层混在一起,导致一个 bug 要在useEffect、onClick、fetch调用里来回跳,最后发现是isFavorite的初始值没从 props 正确继承。正确的做法,是让每一层各司其职。

4.1 使用 Apollo Client 的useQuery和useMutation:让数据流成为声明式契约

useQuery不是“发一个 GET 请求”,而是“声明我需要这个数据”。它的返回值data、loading、error、refetch,构成了一个完整的、自洽的状态机。

// components/RecipeCard.tsx import { useQuery, gql } from '@apollo/client'; const RECIPE_CARD_QUERY = gql` query RecipeCard($id: ID!) { recipe(id: $id) { id title slug imageUrls difficulty _count { ratings } # 我们需要知道当前用户是否收藏了它,所以提前查 favorites(where: { userId: $currentUserId }) { id } } } `; interface RecipeCardProps { id: number; currentUserId: number | null; // 从全局 context 获取 } export const RecipeCard: React.FC<RecipeCardProps> = ({ id, currentUserId }) => { const { data, loading, error } = useQuery(RECIPE_CARD_QUERY, { variables: { id, currentUserId: currentUserId || 0 }, // 未登录时传 0,favorites 查询会返回空数组 // 关键:启用缓存,避免重复请求 fetchPolicy: 'cache-and-network', }); if (loading) return <div className="skeleton-card" />; if (error) return <div className="error">加载失败</div>; const recipe = data?.recipe; // favorites 是一个数组,长度 > 0 表示已收藏 const isFavorite = recipe?.favorites?.length > 0; return ( <div className="recipe-card"> <img src={recipe?.imageUrls?.[0]} alt={recipe?.title} /> <h3>{recipe?.title}</h3> <div className="meta"> <span className="difficulty">{recipe?.difficulty}</span> <StarRating rating={recipe?._count?.ratings || 0} /> </div> <FavoriteButton recipeId={id} isFavorite={isFavorite} onToggle={handleToggleFavorite} /> </div> ); };

4.2 FavoriteButton:一个纯 UI 组件,它的行为由外部驱动

FavoriteButton不应该自己管理isFavorite状态,也不应该自己调用fetch。它只是一个“接收指令、执行动画、发出事件”的哑组件:

// components/FavoriteButton.tsx import { useState } from 'react'; import { useMutation, gql } from '@apollo/client'; const TOGGLE_FAVORITE_MUTATION = gql` mutation ToggleFavorite($recipeId: ID!) { toggleFavorite(recipeId: $recipeId) { id isFavorite } } `; interface FavoriteButtonProps { recipeId: number; isFavorite: boolean; // 这个回调,由父组件(RecipeCard)提供,负责更新父组件的 isFavorite 状态 onToggle: (newState: boolean) => void; } export const FavoriteButton: React.FC<FavoriteButtonProps> = ({ recipeId, isFavorite, onToggle, }) => { const [isPending, setIsPending] = useState(false); const [mutate] = useMutation(TOGGLE_FAVORITE_MUTATION); const handleClick = async () => { if (isPending) return; setIsPending(true); try { const { data } = await mutate({ variables: { recipeId }, // 关键:乐观更新。假设 mutation 一定会成功,立即更新 UI optimisticResponse: { __typename: 'Mutation', toggleFavorite: { __typename: 'ToggleFavoritePayload', id: recipeId, isFavorite: !isFavorite, } }, // 关键:更新缓存。mutation 成功后,自动更新所有相关查询的缓存 update: (cache, { data }) => { if (!data?.toggleFavorite) return; const { id, isFavorite: newIsFavorite } = data.toggleFavorite; // 更新 RecipeCard 查询的缓存 cache.modify({ id: cache.identify({ __typename: 'Recipe', id }), fields: { favorites(existingFavorites = [], { readField }) { const newFavorites = newIsFavorite ? [...existingFavorites, { __typename: 'UserFavorite', id: Math.random().toString(36).substr(2, 9) }] : existingFavorites.filter( (fav: any) => readField('userId', fav) !== currentUser.id ); return newFavorites; } } }); } }); // mutation 成功,通知父组件更新状态 onToggle(data?.toggleFavorite?.isFavorite || false); } catch (err) { console.error('收藏失败:', err); // 失败时,UI 会自动回滚到乐观更新前的状态,无需手动处理 } finally { setIsPending(false); } }; return ( <button onClick={handleClick} disabled={isPending} className={`favorite-btn ${isFavorite ? 'active' : ''}`} aria-label={isFavorite ? '取消收藏' : '收藏此菜谱'} > {isPending ? ( <span className="loading-spinner" /> ) : ( <svg viewBox="0 0 24 24"> <path d={isFavorite ? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' : 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'} /> </svg> )} </button> ); };

注意:optimisticResponse和update是 Apollo Client 的两大神器。optimisticResponse让用户感觉“秒响应”,update确保缓存与服务器最终一致。没有它们,你的应用会显得卡顿、不可靠。

4.3 如何解决react antd table rowselection 卡顿这类真实性能问题?

Ant Design 的Table在数据量大(> 1000 行)时,rowSelection会明显卡顿。这不是 AntD 的 bug,而是 React 的渲染瓶颈:每次勾选一个 checkbox,Table都要重新 render 所有行,计算每一行的selectedRowKeys是否包含当前 key。

解决方案是用虚拟滚动(Virtual Scrolling)。AntD 5.x 内置了virtual属性,但需要配合useVirtualListhook:

// components/VirtualRecipeTable.tsx import { Table, Checkbox } from 'antd'; import { useVirtualList } from 'ahooks'; // 推荐使用 ahooks,比 react-virtual 更轻量 interface RecipeRow { id: number; title: string; difficulty: string; } interface VirtualRecipeTableProps { dataSource: RecipeRow[]; selectedRowKeys: number[]; onSelect: (keys: number[]) => void; } export const VirtualRecipeTable: React.FC<VirtualRecipeTableProps> = ({ dataSource, selectedRowKeys, onSelect, }) => { const [list, containerProps, wrapperProps] = useVirtualList(dataSource, { itemHeight: 50, // 每行高度 overscan: 10, // 预渲染行数 }); const rowSelection = { selectedRowKeys, onChange: (selectedRowKeys: number[]) => { onSelect(selectedRowKeys); }, }; return ( <div {...containerProps} style={{ height: '500px', overflowY: 'auto' }}> <Table rowSelection={rowSelection} columns={[ { title: '选择', key: 'selection', render: (_, record) => ( <Checkbox checked={selectedRowKeys.includes(record.id)} onChange={(e) => { const newKeys = e.target.checked ? [...selectedRowKeys, record.id] : selectedRowKeys.filter((k) => k !== record.id); onSelect(newKeys); }} /> ), }, { title: '菜名', dataIndex: 'title', key: 'title' }, { title: '难度', dataIndex: 'difficulty', key: 'difficulty' }, ]} dataSource={list} pagination={false} showHeader={true} rowKey="id" // 关键:禁用默认的 rowSelection 渲染,用我们自己的 onRow={() => ({})} /> </div> ); };

useVirtualList只会 render 当前可视区域内的行(比如 500px 高度,每行 50px,就只 render 10 行),滚动时动态替换数据,性能提升 10 倍以上。这是真实项目里,我用来支撑后台管理端“批量审核菜谱”功能的核心优化。

5. 工程化与部署:从npm run dev到宝塔面板的完整闭环

一个项目能否活下来,80% 取决于它上线后的可维护性。很多教程停在npm run dev,仿佛只要本地跑起来,世界就完美了。但现实是,你的食谱应用要面对:

  • SEO 需求:搜索引擎要能抓取到/recipe/tomato-egg页面的<title>和<meta description>;
  • 静态资源加速:用户全球分布,图片、JS、CSS 要就近 CDN 加速;
  • HTTPS 强制:现代浏览器对非 HTTPS 站点会标记“不安全”,影响用户信任;
  • 日志与监控:用户反馈“打不开”,你是靠猜,还是靠pm2 logs和 Sentry 报错堆栈?

5.1 为什么react fetch提示 you need to enable javascript to run this app.是 SSR 的警钟?

这个错误提示,是 Create React App(CRA)的默认 HTML 模板。它只包含一个空的<div id="root"></div>,所有内容都靠 JS 在浏览器里动态渲染。搜索引擎爬虫(Googlebot)虽然能执行 JS,但速度极慢、资源消耗大,且很多国内爬虫(百度、360)根本不执行 JS。结果就是,你的食谱网站在 Google 搜索里排名垫底。

解决方案是SSR(服务端渲染)或SSG(静态站点生成)。对于食谱这种内容相对稳定、更新频率不高的应用,SSG 是更优解。Next.js 是目前最成熟的 React SSR/SSG 框架。

# 创建 Next.js 项目 npx create-next-app@latest recipe-app --typescript --tailwind --eslint cd recipe-app

在 Next.js 里,getStaticProps会在构建时(next build)预取数据,生成静态 HTML:

// pages/recipe/[slug].tsx import { GetStaticProps, GetStaticPaths } from 'next'; import { gql, useQuery }

相关新闻

  • 细粒度认知如何赋能无人机视觉语言导航:从零样本泛化到精准执行
  • 对话信息增益(CIG)评估:基于语义记忆的公共审议质量量化方法
  • 2026年贵阳工伤维权律师怎么挑?3个判断标准不踩雷 - 本地品牌推荐

最新新闻

  • 2026麻将机十大品牌实测对比:选对免调试款省心避雷全攻略
  • DeepSeek-V4训练与后训练技术深度解析:CASM掩码与GRPO优化实战
  • 加拿大温哥华斯坦利公园海堤骑行,山海风光太惬意
  • 2026年热门的快速除甲醛/活性炭除甲醛推荐 - 行业平台推荐
  • 鸿蒙 Next 情绪漂流瓶回信 App 开发实战:匿名倾诉 + 随机捞瓶 + 回信系统
  • 彻底告别VC++运行库缺失!这款神器让你一键修复Windows软件兼容性问题

日新闻

  • 2026速览惠州叛逆青少年学校前十大排名名单出炉 - 武汉中职最新信息发布
  • 2026上饶白蚁消杀哪家好?15年本土2大权威白蚁防治公司推荐(金盾虫控/青蚁卫士) - 我叫一
  • 天龙八部单机版终极数据管理工具:5个技巧快速掌握游戏数据编辑

周新闻

  • Visual C++运行库修复终极指南:5分钟快速解决Windows软件启动错误
  • 手把手教你构建统计局地区经济数据爬虫:从环境搭建到数据持久化全指南
  • 2026多Agent深度解析:用AI团队替代单一模型,四种架构实战落地

月新闻

  • 【总结】入门篇:50句话让你记住架构核心概念
  • WeChatMsg技术方案解析:实现Mac微信数据自主管理的完整解决方案
  • WeChatMsg:革新性微信数据备份方案,打造你的专属数字记忆库

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号