当我们创建一个同时具有 Rest Api 和 Web 应用程序的 TypeScript 项目时,从长远来看,保持类型定义的简洁变得具有挑战性。

如果我们创建了一个 GraphQL Api,对话可能会改变,因为我们可以使用代码生成,但我们仍然必须在后端维护架构。

所以基本上,在这两个选项中,我们总是必须维护一个模式或某种类型的定义。

简介

这就是tRPC的用武之地,有了这个工具包,就可以只使用推理来创建一个完全类型安全的应用程序。当我们在后端进行小改动时,我们最终会在前端反映这些相同的更改。

先决条件

在继续之前,您需要:

  • 节点

  • 打字稿

  • Next.js

  • 顺风

  • NPM

此外,您还应具备这些技术的基本知识。

入门

项目设置

让我们设置 next.js 并导航到项目目录:

npx create-next-app@latest --ts grocery-list
cd grocery-list

进入全屏模式 退出全屏模式

tsconfig.json中,我们将添加一个路径别名,以便更轻松地使用相对路径:

// @/tsconfig.json
{
  "compilerOptions": {
    // ...
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "src/*"
      ],
    }
  },
  // ...
}

进入全屏模式 退出全屏模式

安装 Tailwind CSS:

npm install @fontsource/poppins
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

进入全屏模式 退出全屏模式

在文件tailwind.config.js中添加页面和组件文件夹的路径:

// @/tailwind.config.js
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}",
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

进入全屏模式 退出全屏模式

现在让我们将 Tailwind 指令添加到我们的globals.css中:

/* @/src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

* {
    font-family: "Poppins";
  }

进入全屏模式 退出全屏模式

您可能已经注意到,我们所有的源代码,包括样式,都将位于/src文件夹中。

设置棱镜

首先让我们安装必要的依赖项:

npm install prisma

进入全屏模式 退出全屏模式

现在让我们初始化 prisma 设置:

npx prisma init

进入全屏模式 退出全屏模式

让我们将以下模式添加到我们的schema.prisma中:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model GroceryList {
  id      Int      @id @default(autoincrement())
  title   String
  checked Boolean? @default(false)
}

进入全屏模式 退出全屏模式

定义架构后,您可以运行我们的第一个迁移:

npx prisma migrate dev --name init

进入全屏模式 退出全屏模式

最后我们可以安装 prisma 客户端:

npm install @prisma/client

进入全屏模式 退出全屏模式

完成项目的基本配置后,我们可以继续下一步。

配置 tRPC

首先,让我们确保tsconfig.json启用了严格模式:

// @/tsconfig.json
{
  "compilerOptions": {
    // ...
    "strict": true
  },
  // ...
}

进入全屏模式 退出全屏模式

然后我们可以安装以下依赖项:

npm install @trpc/client @trpc/server @trpc/react @trpc/next zod react-query

进入全屏模式 退出全屏模式

安装依赖项后,我们可以创建/server文件夹,然后我们可以创建上下文。

上下文用于将上下文数据传递给所有路由器解析器。在我们的上下文中,我们将只传递我们的 prism 客户端实例。

// @/src/server/context.ts
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { PrismaClient } from "@prisma/client";

export async function createContext(opts?: trpcNext.CreateNextContextOptions) {
const prisma = new PrismaClient();

return { prisma };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;

进入全屏模式 退出全屏模式

创建上下文 (createContext()) 和从中推断出的数据类型 (Context) 后,我们可以继续定义我们的路由器,但在此之前,重要的是要记住:

  • 端点称为过程;

  • 一个过程可以有两种类型的操作(查询和变异);

  • 查询负责获取数据,而突变负责对数据进行更改(服务器端)。

考虑到这些要点,我们现在可以定义我们的路由器:

// @/src/server/router.ts
import * as trpc from "@trpc/server";
import { z } from "zod";

import { Context } from "./context";

export const serverRouter = trpc
  .router<Context>()
  .query("findAll", {
    resolve: async ({ ctx }) => {
      return await ctx.prisma.groceryList.findMany();
    },
  })
  .mutation("insertOne", {
    input: z.object({
      title: z.string(),
    }),
    resolve: async ({ input, ctx }) => {
      return await ctx.prisma.groceryList.create({
        data: { title: input.title },
      });
    },
  })
  .mutation("updateOne", {
    input: z.object({
      id: z.number(),
      title: z.string(),
      checked: z.boolean(),
    }),
    resolve: async ({ input, ctx }) => {
      const { id, ...rest } = input;

      return await ctx.prisma.groceryList.update({
        where: { id },
        data: { ...rest },
      });
    },
  })
  .mutation("deleteAll", {
    input: z.object({
      ids: z.number().array(),
    }),
    resolve: async ({ input, ctx }) => {
      const { ids } = input;

      return await ctx.prisma.groceryList.deleteMany({
        where: {
          id: { in: ids },
        },
      });
    },
  });

export type ServerRouter = typeof serverRouter;

进入全屏模式 退出全屏模式

根据前面的代码片段,您可能已经注意到以下内容:

  • 我们的上下文的数据类型在我们的路由器中被用作泛型,因此我们拥有类型化的上下文对象(以便访问我们的 prisma 实例);

  • 我们的后端一共四个程序;

  • 我们导出了我们的路由器 (serverRouter) 及其数据类型 (ServerRouter)。

配置好路由器后,我们需要从 Next.js 创建一个API 路由,我们将向其中添加我们的处理程序 api。在我们的处理程序 api 中,我们将传递我们的路由器和我们的上下文(在每个请求上调用)。

// @/src/pages/api/trpc/[trpc].ts
import * as trpcNext from "@trpc/server/adapters/next";

import { serverRouter } from "@/server/router";
import { createContext } from "@/server/context";

export default trpcNext.createNextApiHandler({
  router: serverRouter,
  createContext,
});

进入全屏模式 退出全屏模式

现在是时候配置_app.tsx文件了,如下所示:

// @/src/pages/_app.tsx
import "../styles/globals.css";
import "@fontsource/poppins";
import { withTRPC } from "@trpc/next";
import { AppType } from "next/dist/shared/lib/utils";
import type { ServerRouter } from "@/server/router";

const App: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

export default withTRPC<ServerRouter>({
  config({ ctx }) {
    const url = process.env.VERCEL_URL
      ? `https://${process.env.VERCEL_URL}/api/trpc`
      : "http://localhost:3000/api/trpc";

    return { url };
  },
  ssr: true,
})(App);

进入全屏模式 退出全屏模式

然后我们将创建 tRPC 钩子,我们将在createReactQueryHooks()函数上将路由器的数据类型添加为泛型,以便我们可以进行 api 调用:

// @/src/utils/trpc.ts
import type { ServerRouter } from "@/server/router";
import { createReactQueryHooks } from "@trpc/react";

export const trpc = createReactQueryHooks<ServerRouter>();

进入全屏模式 退出全屏模式

创建前端

首先让我们处理应用程序的组件,为了更简单,我将所有内容放在/components文件夹中的单个文件中。

从卡片开始,让我们创建卡片的容器、标题和内容:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

interface CardProps {
  children: React.ReactNode;
}

export const Card: NextPage<CardProps> = ({ children }) => {
  return (
    <div className="h-screen flex flex-col justify-center items-center bg-slate-100">
      {children}
    </div>
  );
};

export const CardContent: NextPage<CardProps> = ({ children }) => {
  return (
    <div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md">
      {children}
    </div>
  );
};

interface CardHeaderProps {
  title: string;
  listLength: number;
  clearAllFn?: () => void;
}

export const CardHeader: NextPage<CardHeaderProps> = ({
  title,
  listLength,
  clearAllFn,
}) => {
  return (
    <div className="flex flex-row items-center justify-between p-3 border-b border-slate-200">
      <div className="flex flex-row items-center justify-between">
        <h1 className="text-base font-medium tracking-wide text-gray-900 mr-2">
          {title}
        </h1>
        <span className="h-5 w-5 bg-blue-200 text-blue-600 flex items-center justify-center rounded-full text-xs">
          {listLength}
        </span>
      </div>
      <button
        className="text-sm font-medium text-gray-600 underline"
        type="button"
        onClick={clearAllFn}
      >
        Clear all
      </button>
    </div>
  );
};

// ...

进入全屏模式 退出全屏模式

现在我们已经创建了卡片,我们可以创建列表的组件:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

// ...

export const List: NextPage<CardProps> = ({ children }) => {
  return <div className="overflow-y-auto h-72">{children}</div>;
};

interface ListItemProps {
  item: GroceryList;
  onUpdate?: (item: GroceryList) => void;
}

const ListItemComponent: NextPage<ListItemProps> = ({ item, onUpdate }) => {
  return (
    <div className="h-12 border-b flex items-center justify-start px-3">
      <input
        type="checkbox"
        className="w-4 h-4 border-gray-300 rounded mr-4"
        defaultChecked={item.checked as boolean}
        onChange={() => onUpdate?.(item)}
      />
      <h2 className="text-gray-600 tracking-wide text-sm">{item.title}</h2>
    </div>
  );
};

export const ListItem = memo(ListItemComponent);

// ...

进入全屏模式 退出全屏模式

最后,只需创建我们的表单以将新元素添加到列表中:

// @/src/components/index.tsx
import React, { memo } from "react";
import type { NextPage } from "next";
import { GroceryList } from "@prisma/client";

// ...

interface CardFormProps {
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  submit: () => void;
}

export const CardForm: NextPage<CardFormProps> = ({
  value,
  onChange,
  submit,
}) => {
  return (
    <div className="bg-white w-5/6 md:w-4/6 lg:w-3/6 xl:w-2/6 rounded-lg drop-shadow-md mt-4">
      <div className="relative">
        <input
          className="w-full py-4 pl-3 pr-16 text-sm rounded-lg"
          type="text"
          placeholder="Grocery item name..."
          onChange={onChange}
          value={value}
        />
        <button
          className="absolute p-2 text-white -translate-y-1/2 bg-blue-600 rounded-full top-1/2 right-4"
          type="button"
          onClick={submit}
        >
          <svg
            className="w-4 h-4"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="M12 6v6m0 0v6m0-6h6m-6 0H6"
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

进入全屏模式 退出全屏模式

一切准备就绪,我们就可以开始在我们的主页上工作了。可以如下:

// @/src/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import { useCallback, useState } from "react";
import { trpc } from "@/utils/trpc";

import {
  Card,
  CardContent,
  CardForm,
  CardHeader,
  List,
  ListItem,
} from "../components/Card";
import { GroceryList } from "@prisma/client";

const Home: NextPage = () => {
  const [itemName, setItemName] = useState<string>("");

  const { data: list, refetch } = trpc.useQuery(["findAll"]);
  const insertMutation = trpc.useMutation(["insertOne"], {
    onSuccess: () => refetch(),
  });
  const deleteAllMutation = trpc.useMutation(["deleteAll"], {
    onSuccess: () => refetch(),
  });
  const updateOneMutation = trpc.useMutation(["updateOne"], {
    onSuccess: () => refetch(),
  });

  const insertOne = useCallback(() => {
    if (itemName === "") return;

    insertMutation.mutate({
      title: itemName,
    });

    setItemName("");
  }, [itemName, insertMutation]);

  const clearAll = useCallback(() => {
    if (list?.length) {
      deleteAllMutation.mutate({
        ids: list.map((item) => item.id),
      });
    }
  }, [list, deleteAllMutation]);

  const updateOne = useCallback(
    (item: GroceryList) => {
      updateOneMutation.mutate({
        ...item,
        checked: !item.checked,
      });
    },
    [updateOneMutation]
  );

  return (
    <>
      <Head>
        <title>Grocery List</title>
        <meta name="description" content="Visit www.mosano.eu" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <Card>
          <CardContent>
            <CardHeader
              title="Grocery List"
              listLength={list?.length ?? 0}
              clearAllFn={clearAll}
            />
            <List>
              {list?.map((item) => (
                <ListItem key={item.id} item={item} onUpdate={updateOne} />
              ))}
            </List>
          </CardContent>
          <CardForm
            value={itemName}
            onChange={(e) => setItemName(e.target.value)}
            submit={insertOne}
          />
        </Card>
      </main>
    </>
  );
};

export default Home;

进入全屏模式 退出全屏模式

经过本文中的所有这些步骤,预期的最终结果如下:

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--GNsE3DHj--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://res.cloudinary.com /dj5iihhqv/image/upload/v1654636896/Kapture_2022-06-07_at_22.19.07-min_vsi8p5.gif)

如果你只是想克隆项目并创建你自己的这个应用程序版本,你可以点击这个链接访问本文的存储库。

希望这篇文章对您有所帮助,我们下次再见。

Logo

React社区为您提供最前沿的新闻资讯和知识内容

更多推荐