头图

抱歉Next.js,我现在已经爱上了Blitz

前言

大家好,我是倔强青铜三。是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞收藏关注一键三连!!!

为什么选择Blitz?

Blitz在Next.js的基础上更进一步

Next.js在处理React和构建方面做得非常出色。但除此之外,你需要自己想办法。

Blitz增加了所有缺失的特性和功能,让你能够构建一个完整的全栈Next.js应用,比如类型安全的API层、中间件和认证。

类型安全的数据层

Blitz推广了RPC作为REST或GraphQL API的替代方案。有了Blitz,你可以直接将服务器代码导入前端,因此你不需要从前端构建API和进行数据获取。在构建时,Blitz会自动插入一个RPC API调用,在服务器上运行服务器代码。本质上,Blitz将你的API抽象为编译步骤。

这对React应用开发来说是一个游戏规则改变者,因为它消除了传统React应用架构的一大部分。这意味着它更容易学习,开发速度更快,构建事物也更有趣!

话虽如此,你仍然可以通过REST或GraphQL像以前一样进行数据获取。Blitz不会以任何方式限制这一点。

认证

Blitz提供了会话管理,可以与任何身份提供者一起工作,包括自托管的电子邮件/密码和第三方服务。
认证是复杂且难以正确实现的事情。将其内置于Blitz中可以为你节省大量时间和潜在的安全漏洞。

在Next.js中构建具有认证功能的出色用户体验非常棘手和繁琐,但Blitz免费为你提供了一流的开发体验。

代码脚手架

通过以下两种主要方式减少你需要手写的代码量:

  1. 代码生成
  2. 代码脚手架

代码生成意味着一个库为你生成代码。例如,graphql-code-generator从GraphQL查询生成代码,Hasura从你的数据库模式生成整个GraphQL API。通常,代码生成没有完全自定义生成代码的方法。你完全依赖于库支持的内容。通常,你会遇到代码生成没有解决方案的边缘情况。而且你不能修复它,因为你不拥有代码。

代码脚手架意味着初始代码为你的项目搭建。从那时起,你对所有代码拥有完全的所有权,并且可以根据需要进行自定义。代码脚手架的缺点是你不会像第三方库的代码生成那样获得自动更新。但巨大的优点是你永远不会受到别人的设计选择的限制,这些选择你无法更改。

Blitz是代码脚手架的忠实粉丝。我们有一系列blitz generate命令,用于将代码搭建到你的项目中。对我们的代码脚手架来说,这仍然是早期阶段——我们还有很多强大的功能需要添加,包括添加你自己的自定义脚手架模板的能力。

配方

配方是将代码从npm上的MDX配方或git仓库搭建到你的项目中的单行命令。

示例:

  • blitz install tailwind - 一个命令安装并配置tailwind
  • blitz install chakra-ui - 一个命令安装并配置chakra
  • blitz install material-ui - 一个命令安装并配置material-ui

配方非常强大。它们可以更改项目的几乎所有内容,包括添加依赖项、更改代码、添加代码等。它们通过MDX编写,可以像React组件一样组合。

新应用开发

一个新的Next.js应用完全是裸骨的。因此,每次你开始一个Next.js项目时,你都需要花费数小时设置所有基础内容,如eslint、prettier、husky git钩子等。

一个新的Blitz应用为你节省了大量的时间,因为所有这些都已经为你预配置好了!当然,你总是可以在以后自定义它,但有一个工作的起点是很棒的。

路由清单

Next.js要求你手动输入页面位置。Blitz带有路由清单,所以你可以做到:

<Link href={Routes.ProductsPage({ productId: 123 })} />
// instead of
<Link href={`/products/${123}`} />

这提高了表达力,并简化了将页面移动到其他位置的过程。

环境配置

设置你的计算机

你需要安装 Node.js 16 或更新的版本。你可以通过在终端运行 node -v 来验证。如果你没有安装 Node 或者需要更新版本,我们推荐使用像 fnm 这样的节点版本管理器。这将允许你更改节点版本,甚至为每个项目设置不同的版本。

安装 Blitz

运行 yarn global add blitz 或者 npm install -g blitz

你可以通过在终端运行以下命令来检查Blitz是否已安装以及安装的版本:

blitz -v

如果Blitz已安装,你将看到安装的版本号。如果没有安装,你会得到一个错误,类似于“命令未找到:blitz”。

创建一个新应用

在命令行中,cd进入你想要创建应用的文件夹,然后运行以下命令:

blitz new my-blitz-app

Blitz将在当前文件夹中创建一个my-blitz-app文件夹。系统会询问你希望你的新应用如何配置。对于本教程,请通过仅在被询问时按Enter键(你将创建一个带有TypeScript、npm和React Final Form的完整Blitz应用)来选择所有默认值。

让我们看看blitz new创建了什么:

my-blitz-app
├── src/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── SignupForm.tsx
│   │   ├── mutations/
│   │   │   ├── changePassword.ts
│   │   │   ├── forgotPassword.test.ts
│   │   │   ├── forgotPassword.ts
│   │   │   ├── login.ts
│   │   │   ├── logout.ts
│   │   │   ├── resetPassword.test.ts
│   │   │   ├── resetPassword.ts
│   │   │   └── signup.ts
│   │   └── validations.ts
│   ├── core/
│   │   ├── components/
│   │   │   ├── Form.tsx
│   │   │   └── LabeledTextField.tsx
│   │   └── layouts/
│   │       └── Layout.tsx
│   ├── users/
│   │   ├── hooks/
│   │   │   └── useCurrentUser.ts
│   │   └── queries/
│   │       └── getCurrentUser.ts
│   ├── pages/
│   │   ├── api/
│   │   │   └── rpc/
│   │   │       └── [[...blitz]].ts
│   │   ├── auth/
│   │   │   ├── forgot-password.tsx
│   │   │   ├── login.tsx
│   │   │   └── signup.tsx
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   ├── 404.tsx
│   │   └── index.tsx
│   ├── blitz-client.ts
│   └── blitz-server.ts
├── db/
│   ├── migrations/
│   ├── index.ts
│   ├── schema.prisma
│   └── seeds.ts
├── integrations/
├── mailers/
│   └── forgotPasswordMailer.ts
├── public/
│   ├── favicon.ico*
│   └── logo.png
├── test/
│   └── setup.ts
├── README.md
├── next.config.js
├── vitest.config.ts
├── package.json
├── tsconfig.json
├── types.d.ts
├── types.ts
└── yarn.lock

这些文件包括:

  • src/文件夹包含Blitz设置文件——blitz-client.tsblitz-server.ts。这也是你放置任何查询/突变或一些组件的地方。
  • src/pages/文件夹是主要的页面文件夹。你将把所有的页面和API路由放在这里。
  • src/core/文件夹是放置在整个应用中使用的组件、钩子等的主要位置。
  • db/是你的数据库配置所在的位置。如果你正在编写模型或检查迁移,这就是你要去的地方。
  • public/是一个文件夹,你将在这里放置任何静态资源。如果你有想要在应用中使用的图片、文件或视频,这就是你要放置它们的地方。
  • .npmrc.env等("点文件")是各种JavaScript工具的配置文件。
  • next.config.js是Blitz和Next.js的高级自定义配置。
  • tsconfig.json是我们推荐的TypeScript设置。

你可以在这里阅读更多关于文件结构的信息。

开发服务器

现在确保你已经在my-blitz-app文件夹中,如果还没有,请运行以下命令:

blitz dev

你将在命令行看到以下输出:

✔ Compiled
Loaded env from /private/tmp/my-blitz-app/.env
warn  - You have enabled experimental feature(s).
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use them at your own risk.

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled successfully

现在服务器正在运行,用你的网页浏览器访问localhost:3000。你将看到一个欢迎页面,上面有Blitz的logo。它起作用了!

注册为用户

Blitz应用已经设置好了用户注册和登录!那么让我们来试试。点击注册按钮。输入任何电子邮件和密码,然后点击创建账户。然后你将被重定向回首页,在那里你可以看到用户的idrole

如果你想,你也可以尝试注销然后再登录。或者点击登录页面上的忘记密码了?来尝试那个流程。

编写你的第一个页面

接下来让我们创建你的第一个页面。

打开文件pages/index.tsx,用这个替换Home组件的内容:

//...

const Home: BlitzPage = () => {
  return (
    <div>
      <h1>Hello, world!</h1>

      <Suspense fallback="Loading...">
        <UserInfo />
      </Suspense>
    </div>
  )
}

//...

保存文件,你应该会在浏览器中看到页面更新。你可以随心所欲地自定义这个页面。当你准备好了,继续下一节。

数据库设置

好消息,已经为你设置了一个SQLite数据库!你可以在终端运行blitz prisma studio来打开一个Web界面,在那里你可以看到数据库中的数据。

请注意,当你开始你的第一个真正的项目时,你可能想要使用一个更可扩展的数据库,比如PostgreSQL,以避免将来更换数据库的痛苦。更多信息,请参见数据库概览。现在,我们将继续使用默认的SQLite数据库。

为我们的模型生成代码

Blitz提供了一个名为generate的便捷CLI命令,用于生成样板代码。我们将使用generate来创建两个模型:QuestionChoice。一个Question包含问题文本和一系列选择。一个Choice包含选择文本、投票计数和相关联的问题。Blitz将自动为两个模型生成id、创建时间戳和最后更新时间戳。

首先,我们将生成与Question模型相关的所有内容:

blitz generate all question text:string

当系统提示时,按Enter运行prisma migrate,这将使用新模型更新你的数据库模式。它会要求你输入一个名称,所以输入类似"add question"的东西。

CREATE    src/pages/questions/[questionId].tsx
CREATE    src/pages/questions/[questionId]/edit.tsx
CREATE    src/pages/questions/index.tsx
CREATE    src/pages/questions/new.tsx
CREATE    src/questions/components/QuestionForm.tsx
CREATE    src/questions/queries/getQuestion.ts
CREATE    src/questions/queries/getQuestions.ts
CREATE    src/questions/mutations/createQuestion.ts
CREATE    src/questions/mutations/deleteQuestion.ts
CREATE    src/questions/mutations/updateQuestion.ts

✔ Model 'Question' created in schema.prisma:

>
> model Question {
>   id        Int      @id @default(autoincrement())
>   createdAt DateTime @default(now())
>   updatedAt DateTime @updatedAt
>   text      String
> }
>

✔ Run 'prisma migrate dev' to update your database? (Y/n) · true
Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"

✔ Enter a name for the new migration: … add question
The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20210722070215_add_question/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (4.0.0) to ./node_modules/@prisma/client in 187ms

generate命令使用all类型生成模型和查询、突变和页面文件。查看Blitz generate页面以获取可用类型选项列表。

接下来,我们将生成Choice模型及其对应的查询和突变。

这次我们将传递一个resource类型,因为我们不需要为Choice模型生成页面:

blitz generate resource choice text votes:int:default=0 belongsTo:question

如果你遇到错误,请运行blitz prisma format

注意,这不需要数据库迁移,因为我们还没有将Choice字段添加到Question模型中。因此,当提示运行迁移时,我们选择false

CREATE    src/choices/queries/getChoice.ts
CREATE    src/choices/queries/getChoices.ts
CREATE    src/choices/mutations/createChoice.ts
CREATE    src/choices/mutations/deleteChoice.ts
CREATE    src/choices/mutations/updateChoice.ts

✔ Model for 'choice' created in schema.prisma:

> model Choice {
>   id         Int      @default(autoincrement()) @id
>   createdAt  DateTime @default(now())
>   updatedAt  DateTime @updatedAt
>   text       String
>   votes      Int      @default(0)
>   question   Question @relation(fields: [questionId], references: [id])
>   questionId Int
> }

? Run 'prisma migrate dev' to update your database? (Y/n) › false

最后,让我们更新Question模型,使其具有返回到Choice的关系。

打开db/schema.prisma并在Question模型中添加choices Choice[]

model Question {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  text      String
+ choices   Choice[]
}

现在我们可以运行迁移来更新我们的数据库:

blitz prisma migrate dev

再次,输入迁移的名称,比如"add choice":

Environment variables loaded from .env
Prisma schema loaded from db/schema.prisma
Datasource "db": SQLite database "db.sqlite" at "file:./db.sqlite"

✔ Name of migration … add choice
The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20210412175528_add_choice/
    └─ migration.sql

Your database is now in sync with your schema.

现在我们的数据库已经准备好了,也生成了Prisma客户端。让我们继续使用Prisma客户端!

为我们的模型属性更新生成的代码

信息

在再次运行应用程序之前,我们需要自定义一些已生成的代码。最终,这些修复将不再需要——但目前,我们需要解决一些未解决的问题。

生成的页面内容当前不使用你在生成期间定义的实际模型属性。很快就会使用,但与此同时,让我们修复生成的页面。

问题页面

转到src/pages/questions/index.tsx。注意,为你生成了一个QuestionsList组件:

// src/pages/questions/index.tsx

export const QuestionsList = () => {
  const router = useRouter()
  const page = Number(router.query.page) || 0
  const [{ questions, hasMore }, { isPreviousData }] = usePaginatedQuery(
    getQuestions,
    {
      orderBy: { id: "asc" },
      skip: ITEMS_PER_PAGE * page,
      take: ITEMS_PER_PAGE,
    }
  )

  const goToPreviousPage = () =>
    router.push({ query: { page: page - 1 } })

  const goToNextPage = () => {
    if (!isPreviousData && hasMore) {
      router.push({ query: { page: page + 1 } })
    }
  }

  return (
    <div>
      <ul>
        {questions.map((question) => (
          <li key={question.id}>
            <Link
              href={Routes.ShowQuestionPage({ questionId: question.id })}
            >
              <a>{question.name}</a>
            </Link>
          </li>
         ))}
      </ul>

      <button disabled={page === 0} onClick={goToPreviousPage}>
        Previous
      </button>
      <button
        disabled={isPreviousData || !hasMore}
        onClick={goToNextPage}
      >
        Next
      </button>
    </div>
  )
}

这不会起作用!记住,我们上面创建的Question模型没有任何name字段。要修复这个问题,将question.name替换为question.text

// src/pages/questions/index.tsx

const QuestionsList = () => {
  const router = useRouter()
  const page = Number(router.query.page) || 0
  const [{questions, hasMore}, {isPreviousData}] = usePaginatedQuery(
    getQuestions, {
      orderBy: {id: "asc"},
      skip: ITEMS_PER_PAGE * page,
      take: ITEMS_PER_PAGE,
    },
  )
  const goToPreviousPage = () => router.push({query: {page: page - 1}})
  const goToNextPage = () => {
    if (!isPreviousData && hasMore) {
      router.push({query: {page: page + 1}})
    }
  }
  return (
    <div>
      <ul>
        {questions.map((question) => (
          <li key={question.id}>
            <Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
-              <a>{question.name}</a>
+              <a>{question.text}</a>
            </Link>
          </li>
         ))}
      </ul>
      <button disabled={page === 0} onClick={goToPreviousPage}>
        Previous
      </button>
      <button disabled={isPreviousData || !hasMore} onClick={goToNextPage}>
        Next
      </button>
    </div>
  )
}

接下来,让我们对src/questions/components/QuestionForm.tsx应用类似的修复。在表单提交中,将LabeledTextFieldname替换为"text"

export function QuestionForm<S extends z.ZodType<any, any>>(
  props: FormProps<S>,
) {
  return (
    <Form<S> {...props}>
-     <LabeledTextField name="name" label="Name" placeholder="Name" />
+     <LabeledTextField name="text" label="Text" placeholder="Text" />
    </Form>
  )
}

创建问题变更

src/questions/mutations/createQuestion.ts中,我们需要更新CreateQuestion zod验证模式以使用text而不是name

// src/questions/mutations/createQuestion.ts

const CreateQuestion = z
  .object({
-   name: z.string(),
+   text: z.string(),
  })
// ...

更新问题变更

src/questions/mutations/updateQuestion.ts中,我们需要更新UpdateQuestion zod验证模式以使用text而不是name

// src/questions/mutations/updateQuestion.ts

const UpdateQuestion = z
  .object({
    id: z.number(),
-   name: z.string(),
+   text: z.string(),
  })

删除问题变更

Prisma尚不支持"级联删除"。在这个教程的背景下,这意味着它当前不会在删除Question时删除Choice数据。我们需要临时增强生成的deleteQuestion突变以手动执行此操作。打开你的文本编辑器中的src/questions/mutations/deleteQuestion.ts并在函数体的顶部添加以下内容:

await db.choice.deleteMany({ where: { questionId: id } })

最终结果应该是这样的:

// src/questions/mutations/deleteQuestion.ts

export default resolver.pipe(
  resolver.zod(DeleteQuestion),
  resolver.authorize(),
  async ({id}) => {
+   await db.choice.deleteMany({where: {questionId: id}})
    const question = await db.question.deleteMany({where: {id}})
    return question
  },
)

这个突变现在将在删除问题之前删除与问题相关联的选择。

更新选择变更

src/choices/mutations/updateChoice.ts中,我们需要更新UpdateChoice zod验证模式以使用text而不是name

// src/choices/mutations/updateChoice.ts

const UpdateChoice = z
  .object({
    id: z.number(),
-   name: z.string(),
+   text: z.string(),
  })

移除不必要的文件

我们的脚手架为我们创建了一个不再需要的突变文件。为了使yarn tscgit push成功,你需要删除src/choices/mutations/createChoice.ts(未使用)或更新CreateChoice zod模式以包含所需字段。

现在尝试创建、更新和删除问题

太好了!现在确保你停止应用程序,用blitz dev再次启动它,然后访问localhost:3000/questions。尝试创建问题,编辑和删除它们。

将选择添加到问题表单

你到目前为止做得很好!接下来我们将为我们的问题表单添加选择。打开你的编辑器中的src/questions/components/QuestionForm.tsx

添加三个更多的<LabeledTextField>组件作为选择。

export function QuestionForm<S extends z.ZodType<any, any>>(
  props: FormProps<S>,
) {
  return (
    <Form<S> {...props}>
      <LabeledTextField name="text" label="Text" placeholder="Text" />
+     <LabeledTextField name="choices.0.text" label="选择 1" />
+     <LabeledTextField name="choices.1.text" label="选择 2" />
+     <LabeledTextField name="choices.2.text" label="选择 3" />
    </Form>
  )
}

现在打开src/questions/mutations/createQuestion.ts并更新zod模式,以便突变接受选择数据。之后,我们需要导出CreateQuestion zod模式,因为我们将在下一步中使用它为QuestionForm创建验证模式。

// src/questions/mutations/createQuestion.ts

+ export const CreateQuestion = z
    .object({
      text: z.string(),
+     choices: z.array(z.object({text: z.string()})),
    })
export default resolver.pipe(
  resolver.zod(CreateQuestion),
  resolver.authorize(),
  async (input) => {
-   const question = await db.question.create({data: input})
+   const question = await db.question.create({
+     data: {
+       ...input,
+       choices: {create: input.choices},
+     },
+   })
    return question
  },
)

接下来,我们将创建一个单独的文件来存储我们的QuestionForm的验证模式。在src/questions文件夹中创建一个名为validations.ts的新文件,并将CreateQuestion变量从./mutations/createQuestion.ts移动到新的validations.ts文件中。然后,在src/questions/mutations/createQuestion.ts中从../validations导入CreateQuestion

// src/questions/validations.ts

+ import * as z from 'zod';
+ export const CreateQuestion = z.object({
+     text: z.string(),
+     choices: z.array(z.object({ text: z.string() }))
+ });
// src/questions/mutations/createQuestion.ts

import { resolver } from '@blitzjs/rpc';
import db from 'db';
- import { z } from 'zod';
+ import { CreateQuestion } from '../validations';
- const CreateQuestion = z.object({
-     text: z.string(),
-     choices: z.array(z.object({ text: z.string() }))
- });
export default resolver.pipe(resolver.zod(CreateQuestion), resolver.authorize(), async (input) => {
    // TODO: in multi-tenant app, you must add validation to ensure correct tenant
    const question = await db.question.create({
        data: {
            ...input,
            choices: { create: input.choices }
        }
    });

    return question;
});
我们创建一个共享的validations.ts文件,因为我们不能从查询(或突变)文件中导入任何东西到客户端,除了查询本身。你可以在查询使用突变使用中了解更多原因。

现在打开src/pages/questions/new.tsx,从src/questions/validations.ts导入CreateQuestion并将其设置为QuestionForm的模式。同时,我们需要将{{text: "", choices: []}}设置为QuestionForminitialValues

// src/pages/questions/new.tsx

+ import {CreateQuestion} from "src/questions/validations"

      <QuestionForm
        submitText="创建问题"
-       // * 使用zod模式进行表单验证
-       //  - 提示:将突变的模式提取到共享的`validations.ts`文件中,然后导入并在这里使用
-       //  schema={createQuestion}
-       // initialValues={{ }}
+       schema={CreateQuestion}
+       initialValues={{text: "", choices: []}}
        onSubmit={async (values) => {
          try {
            const question = await createQuestionMutation(values)
            router.push(Routes.ShowQuestionPage({ questionId: question.id }))
          } catch (error) {
            console.error(error)
            return {
              [FORM_ERROR]: error.toString(),
            }
          }
        }}
      />

试试吧

现在你可以去localhost:3000/questions/new创建一个新问题和选择!

列出选择

休息一下。回到浏览器中的localhost:3000/questions,看看你创建的所有问题。我们在这里列出这些问题的选择怎么样?首先,我们需要自定义问题查询。在Prisma中,你需要手动让客户端知道你想要查询嵌套关系。修改你的getQuestion.tsgetQuestions.ts文件如下:

// src/questions/queries/getQuestion.ts

const GetQuestion = z.object({
  // 这接受类型为undefined,但在运行时是必须的
  id: z.number().optional().refine(Boolean, "Required"),
})

export default resolver.pipe(
  resolver.zod(GetQuestion),
  resolver.authorize(),
  async ({id}) => {
-   const question = await db.question.findFirst({where: {id}})
+   const question = await db.question.findFirst({
+     where: {id},
+     include: {choices: true},
+   })
    if (!question) throw new NotFoundError()
    return question
  },
)
// src/questions/queries/getQuestions.ts

interface GetQuestionsInput
  extends Pick<
    Prisma.QuestionFindManyArgs,
    "where" | "orderBy" | "skip" | "take"
  > {}
export default resolver.pipe(
  resolver.authorize(),
  async ({where, orderBy, skip = 0, take = 100}: GetQuestionsInput) => {
    const {items: questions, hasMore, nextPage, count} = await paginate({
      skip,
      take,
      count: () => db.question.count({where}),
      query: (paginateArgs) =>
        db.question.findMany({
          ...paginateArgs,
          where,
          orderBy,
+         include: {choices: true},
         }),
    })
    return {
      questions,
      nextPage,
      hasMore,
      count,
    }
  },
)

现在回到我们的主要问题页面(src/pages/questions/index.tsx)在你的编辑器中,我们可以列出每个问题的选择。并在QuestionsList中添加以下代码:

// src/pages/questions/index.tsx

// ...
{
  questions.map((question) => (
    <li key={question.id}>
      <Link href={Routes.ShowQuestionPage({ questionId: question.id })}>
        <a>{question.text}</a>
      </Link>
+     <ul>
+       {question.choices.map((choice) => (
+         <li key={choice.id}>
+           {choice.text} - {choice.votes} votes
+         </li>
+        ))}
+     </ul>
    </li>
  ))
}
// ...

重新启动你的应用——停止开发服务器,再次运行yarn devnpm devpnpm dev。现在检查浏览器中的/questions

让人们对问题进行投票

打开src/pages/questions/[questionId].tsx在你的编辑器中。首先,我们将稍微改进这个页面。

  1. <title>Question {question.id}</title>替换为<title>{question.text}</title>
  2. <h1>Question {question.id}</h1>替换为<h1>{question.text}</h1>
  3. 删除pre元素,并复制我们之前写的问题选择列表:
<ul>
  {question.choices.map((choice) => (
    <li key={choice.id}>
      {choice.text} - {choice.votes} votes
    </li>
   ))}
</ul>

如果你回到浏览器,你的页面现在应该看起来像这样!

Screenshot

现在是时候添加投票了!

首先我们需要打开src/choices/mutations/updateChoice.ts,更新zod模式,并添加一个投票增加。

const UpdateChoice = z
  .object({
    id: z.number(),
-   text: z.string(),
  })

export default resolver.pipe(
  resolver.zod(UpdateChoice),
  resolver.authorize(),
  async ({id, ...data}) => {
-   const choice = await db.choice.update({where: {id}, data})
+   const choice = await db.choice.update({
+     where: {id},
+     data: {votes: {increment: 1}},
+   })
    return choice
  },
)

现在回到src/pages/questions/[questionId].tsx并进行以下更改:

在我们的li中,添加一个button

<li key={choice.id}>
  {choice.text} - {choice.votes} votes
  <button>Vote</button>
</li>

然后,导入我们更新的updateChoice突变,并在我们的页面中创建一个handleVote函数:

// src/pages/questions/[questionId].tsx
+import updateChoice from "src/choices/mutations/updateChoice"
//...

const Question = () => {
  const router = useRouter()
  const questionId = useParam("questionId", "number")
  const [deleteQuestionMutation] = useMutation(deleteQuestion)
  const [question] = useQuery(getQuestion, {id: questionId})
+ const [updateChoiceMutation] = useMutation(updateChoice)
+ 
+ const handleVote = async (id: number) => {
+   try {
+     await updateChoiceMutation({id})
+     refetch()
+   } catch (error) {
+     alert("Error updating choice " + JSON.stringify(error, null, 2))
+   }
+ }
  return (

然后我们需要更新问题useQuery调用来返回我们在handleVote中使用的refetch函数:

// src/pages/questions/[questionId].tsx

//...
- const [question] = useQuery(getQuestion, {id: questionId})
+ const [question, {refetch}] = useQuery(getQuestion, {id: questionId})
//...

最后,我们将告诉我们的新button调用该函数!

<button onClick={() => handleVote(choice.id)}>Vote</button>

最终的Question组件现在应该看起来像这样:

export const Question = () => {
  const router = useRouter()
  const questionId = useParam("questionId", "number")
  const [deleteQuestionMutation] = useMutation(deleteQuestion)
  const [question, { refetch }] = useQuery(getQuestion, {
    id: questionId,
  })
  const [updateChoiceMutation] = useMutation(updateChoice)

  const handleVote = async (id: number) => {
    try {
      await updateChoiceMutation({ id })
      refetch()
    } catch (error) {
      alert("Error updating choice " + JSON.stringify(error, null, 2))
    }
  }

  return (
    <>
      <Head>
        <title>Question {question.id}</title>
      </Head>

      <div>
        <h1>{question.text}</h1>
        <ul>
          {question.choices.map((choice) => (
            <li key={choice.id}>
              {choice.text} - {choice.votes} votes
              <button onClick={() => handleVote(choice.id)}>Vote</button>
            </li>
          ))}
        </ul>

        <Link href={Routes.EditQuestionPage({ questionId: question.id })}>
          <a>Edit</a>
        </Link>

        <button
          type="button"
          onClick={async () => {
            if (window.confirm("This will be deleted")) {
              await deleteQuestionMutation({ id: question.id })
              router.push(Routes.QuestionsPage())
            }
          }}
          style={{ marginLeft: "0.5rem" }}
        >
          Delete
        </button>
      </div>
    </>
  )
}

编辑问题的选项

如果你点击一个现有问题的编辑按钮,你会看到它使用的表单与创建问题时相同。所以我们只需要更新我们的突变。

打开src/questions/mutations/updateQuestion.ts并进行以下更改:

// src/questions/mutations/updateQuestion.ts
import {resolver} from "blitz"
import db from "db"
import * as z from "zod"

const UpdateQuestion = z
  .object({
    id: z.number(),
    text: z.string(),
+   choices: z.array(
+     z.object({id: z.number().optional(), text: z.string()}),
+   ),
  })
export default resolver.pipe(
  resolver.zod(UpdateQuestion),
  resolver.authorize(),
  async ({id, ...data}) => {
-   const question = await db.question.update({where: {id}, data})
+   const question = await db.question.update({
+     where: {id},
+     data: {
+       ...data,
+       choices: {
+         upsert: data.choices.map((choice) => ({
+           // 看起来像是一个prisma的错误,
+           // 因为 `|| 0` 不应该是必需的
+           where: {id: choice.id || 0},
+           create: {text: choice.text},
+           update: {text: choice.text},
+         })),
+       },
+     },
+     include: {
+       choices: true,
+     },
+   })
    return question
  },
)

upsert是一个特殊操作,意味着“如果这个项目存在,更新它。否则创建它”。这非常适合我们的情况,因为我们在创建问题时并没有要求用户添加三个选择。所以如果用户后来通过编辑问题添加了另一个选择,那么它将在这里被创建。

结论

🥳 恭喜!你创建了你自己的Blitz应用!现在你已经完成了这个教程,你可以尝试:

  • 添加样式
  • 显示更多关于投票的统计信息
最后感谢阅读!欢迎关注我,微信公众号倔强青铜三。欢迎点赞收藏关注,一键三连!!!

倔强青铜三
23 声望0 粉丝