如何用TypeScript构建类型安全的全栈式应用(附代码)

170 阅读8分钟

简单的基于CRUD的模块,是任何企业的共同要求,应该是简单的构建和维护。Remult是一个全面的框架,它允许开发人员只使用TypeScript代码来构建全栈式、类型安全的应用程序。

本文将介绍Remult的基本概念,并将展示如何使用Remult来简化和加快你的网络应用程序开发过程!

在本指南中,我们将创建一个简单的预订表单,我们将把表单提交存储在MongoDB集合中。我们将使用React构建UI,然后用Spectre.css添加样式。

了解Remult框架

Remult是一个CRUD框架,使用TypeScript实体进行CRUD操作。它还提供了一个类型安全的API客户端和一个用于后端数据库操作的ORM。

这个框架抽象化并减少了你的应用程序中的模板代码。它使使用TypeScript构建全栈应用程序变得容易,也使开发人员能够与其他框架(如Express.js和Angular)集成。

Remult是一个中间地带。它不强迫你以某种方式工作;相反,它为你的项目提供了许多选项。

用Remult设置React项目

让我们先用Create React App创建一个React项目,并选择TypeScript模板:

> npx create-react-app remult-react-booking-app --template typescript
> cd remult-react-booking-app

接下来,我们将安装所需的依赖项:

> npm i axios express remult dotenv
> npm i -D @types/express ts-node-dev concurrently

在上面的代码中,我们使用的是concurrently 包。这个包是必需的,因为我们将从React项目的根部同时提供客户端和服务器代码。

现在,为服务器创建一个tsconfig 文件,像这样:

// tsconfig.server.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "emitDecoratorMetadata": true
  }
}

然后,在主tsconfig.json 文件中,添加experimentalDecorators 选项以启用装饰器的使用:

// tsconfig.json

{
  "compilerOptions": {
    // ...
    "experimentalDecorators": true
  },
}

更新package.json 文件,像这样:

// package.json

{
  "proxy": "http://localhost:3002",
  // ...
  "scripts": {
  // ...
    "start:dev": "concurrently -k -n \"SERVER,WEB\" -c \"bgBlue.bold,bgGreen.bold\" \"ts-node-dev -P tsconfig.server.json src/server/\" \"react-scripts start\""
  },
}

在这里,我们添加了proxy 选项,让webpack开发服务器知道当应用程序在本地环境中运行时要代理3000到3002端口的API请求。我们还添加了一个npm脚本来同时启动前端和API开发服务器。

初始化remultExpress 中间件

现在,让我们在Create React App创建的src 文件夹内创建一个server 文件夹,并创建一个api.ts 文件,将初始化remultExpress 中间件:

// src/server/api.ts

import { remultExpress } from "remult/remult-express";

export const api = remultExpress();

接下来,为服务器创建一个.env 文件,并指定API端口号:

// src/server/.env

API_PORT=3002

接下来,创建一个index.ts 文件,作为服务器的根文件,初始化express ,加载环境变量,并注册remultExpress 中间件:

// src/server/index.ts

import { config } from "dotenv";
config({ path: __dirname + "/.env" });

import express from "express";
import { api } from "./api";

const app = express();
app.use(api);

app.listen(process.env.API_PORT || 3002, () => console.log("Server started"));

在前端初始化Remult

我们将使用React应用程序中的全局Remult 对象,通过axios HTTP客户端与API服务器通信:

// src/common.ts

import axios from "axios";
import { Remult } from "remult";

export const remult = new Remult(axios);

在这一点上,主要的项目设置已经完成,准备在本地服务器上运行。

使用下面的命令:

> npm run start:dev

添加数据库连接

在本指南中,我们将使用MongoDB来存储我们的表单提交。要为Remult设置MongoDB连接池,请使用remultExpress 中间件的dataProvider 选项。

首先,你必须在你的项目中安装mongodb ,作为一个依赖项,像这样:

> npm i mongodb

dataProvider 选项可以接受一个async() 函数,该函数连接到 MongoDB 并返回MongoDataProvider 对象,该对象充当 Remult 的连接器:

// src/server/api.ts

import { MongoDataProvider } from "remult/remult-mongo";

export const api = remultExpress({
  dataProvider: async () => {
    const client = new MongoClient(process.env.MONGO_URL || "");
    await client.connect();
    console.log("Database connected");
    return new MongoDataProvider(client.db("remult-booking"), client);
  },
});

用Remult实体生成API端点

实体被Remult用于生成API端点、API查询和数据库命令。entity ,作为模型类用于前端和后端代码。

我们将需要两个实体,以便定义预订对象和每天的可用时段。

src 内创建一个shared 文件夹,它将包括前端和后端共享的代码。然后,在shared 文件夹中创建另一个子文件夹用于存储实体,并创建实体类文件:Booking.entity.tsSlot.entity.ts

要创建一个实体,定义一个具有所需属性的类,并使用@Entity 装饰器。@Entity 装饰器接受一个用于确定API路线的基本参数,默认的数据库集合或表名,以及一个用于定义实体相关属性和操作的选项参数。

在本指南中,Slot 实体可以被定义如下:

// src/shared/entities/Slot.entity.ts

import { Entity, Fields, IdEntity } from "remult";

@Entity("slots")
export class Slot extends IdEntity {
  @Fields.string()
  startTime: String;

  @Fields.string()
  endTime: String;
}

@Fields.string 装饰器定义了一个类型为String 的实体数据字段。这个装饰器也被用来描述字段相关的属性,如验证规则和操作:

// src/shared/entities/Booking.entity.ts

import { Entity, Fields, IdEntity, Validators } from "remult";

@Entity("bookings", {
  allowApiCrud: true
})
export class Booking extends IdEntity {
  @Fields.string({
    validate: Validators.required,
  })
  name: String;

  @Fields.string({
    validate: Validators.required,
  })
  email: String;

  @Fields.string({ validate: Validators.required })
  description: String;

  @Fields.string({
    validate: Validators.required,
  })
  date: String;

  @Fields.string({
    validate: Validators.required,
  })
  slotId: string;
}

现在这两个实体都被定义了,让我们把它们添加到remultExpress 中间件的entities 属性。我们还可以使用initApi 属性将初始数据种到槽集合中:

// src/server/api.ts

import { Slot } from "../shared/entities/Slot.entity";
import { Booking } from "../shared/entities/Booking.entity";

export const api = remultExpress({
  entities: [Slot, Booking],
  initApi: async (remult) => {
    const slotRepo = remult.repo(Slot);
    const shouldAddAvailablSlots = (await slotRepo.count()) === 0;

    if (shouldAddAvailablSlots) {
      const availableSlots = [10, 11, 12, 13, 14, 15, 16, 17].map((time) => ({
        startTime: `${time}:00`,
        endTime: `${time}:45`,
      }));

      await slotRepo.insert(availableSlots);
    }
  },
  dataProvider: async () => {
    // ...
  },
});

构建和样式化前台

让我们开始在应用程序的前端工作,建立表单用户界面:

Book Appointment Form

首先,用以下代码替换src/App.tsx 文件中的默认模板代码:

// src/App.tsx

import "./App.css";
import { BookingForm } from "./components/BookingForm";

function App() {
  return (
    <div className="App">
      <header className="hero hero-sm bg-primary ">
        <div className="hero-body text-center">
          <div className="container grid-md">
            <h1>Book an appointment</h1>
          </div>
        </div>
      </header>
      <BookingForm />
    </div>
  );
}

export default App;

现在,让我们添加Spectre.css库,以使用户界面看起来更美观:

> npm i spectre.css

你可以参考下面的代码,了解BookingForm 组件:

// src/components/BookingForm.tsx

import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { remult } from "../common";
import { Booking } from "../shared/entities/Booking.entity";
import { Slot } from "../shared/entities/Slot.entity";

const bookingRepo = remult.repo(Booking);

export const BookingForm = () => {
  const {
    register,
    handleSubmit,
    setValue,
    watch,
    setError,
    clearErrors,
    reset,
    formState: { errors },
  } = useForm();

  const [availableDates, setAvailableDates] = useState<string[]>([]);
  const [availableSlots, setAvailableSlots] = useState<Slot[]>([]);

  const [isSubmitting, setSubmitting] = useState<boolean>(false);

  const bookingDate = watch("date");

  const onSubmit = async (values: Record<string, any>) => {
    try {
      setSubmitting(true);
      const data = await bookingRepo.save(values);
      console.log({ data });
      reset();
    } catch (error: any) {
      setError("formError", {
        message: error?.message,
      });
    } finally {
      setSubmitting(false);
    }
  };

  // JSX code
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <>...</>
    </form>
  );
};

这里,我们使用react-hook-form 库来管理表单状态和输入值。

为了在bookings 集合中保存提交的值,我们需要为Booking 实体创建一个资源库对象:

const bookingRepo = remult.repo(Booking);

Remult资源库对象提供了对实体进行CRUD操作的方法。在这种情况下,我们使用save() 存储库方法将数据插入集合中:

await bookingRepo.save(values);

添加仅有后台的方法

有时,你可能想创建带有额外逻辑的自定义API,比如发送电子邮件、执行多个数据库操作或完成其他连续的任务。

多个数据库操作必须只在后端执行,因为在前端有各种实体级功能可能会影响应用程序的性能。

在Remult中实现只在后端的方法的一种方法是创建一个控制器类,并使用@BackendMethod 装饰器。

对于我们项目的预订表格,让我们创建两个后端方法。第一个方法,getAvailableDates() ,将获得未来五个可用的工作日。第二个方法,getAvailableSlots() ,将按日期获得可用的预订时段:

// src/shared/controllers/Booking.controller.ts

import { BackendMethod, Remult } from "remult";
import { Booking } from "../entities/Booking.entity";
import { Slot } from "../entities/Slot.entity";
import { addWeekDays, formattedDate } from "../utils/date";

export class BookingsController {
  @BackendMethod({ allowed: true })
  static async getAvailableDates() {
    const addDates = (date: Date, count = 0) =>
      formattedDate(addWeekDays(date, count));

    return Array.from({ length: 5 }).map((v, idx) => addDates(new Date(), idx));
  }

  @BackendMethod({ allowed: true })
  static async getAvailableSlots(date: string, remult?: Remult) {
    if (!remult) return [];
    const unavailableSlotIds = (
      await remult.repo(Booking).find({ where: { date } })
    ).map((booking) => booking.slotId);

    const availableSlots = await remult
      .repo(Slot)
      .find({ where: { id: { $ne: unavailableSlotIds } } });

    return availableSlots;
  }
}

@BackendMethod 装饰器中的allowed 属性定义了请求的用户是否可以访问API。在这种情况下,它是真的,因为我们希望API是公开的。

你可以有授权规则来控制allowed 属性的值。后端方法也可以访问remult 对象,以便执行DB操作。

要使用后端方法,你不需要手动调用任何API。只要在你的前台代码中导入控制器,然后像其他模块一样直接调用这些方法。

在内部,Remult使用你初始化Remult时在前端代码中定义的HTTP客户端为你进行API调用。这样一来,你就可以保证API的类型安全,而且更容易维护:

// src/components/BookingForm.tsx

import { BookingsController } from "../shared/controllers/Booking.controller";

export const BookingForm = () => {
   // ...
  useEffect(() => {
    BookingsController.getAvailableDates().then(setAvailableDates);
  }, []);

  useEffect(() => {
    if (!availableDates.length) return;
    setValue("date", availableDates[0]);
    BookingsController.getAvailableSlots(availableDates[0]).then(
      setAvailableSlots
    );
  }, [availableDates]);

  useEffect(() => {
    BookingsController.getAvailableSlots(bookingDate).then(setAvailableSlots);
  }, [bookingDate]);

  useEffect(() => {
    setValue("slotId", availableSlots[0]?.id);
  }, [availableSlots]);
 // ...
}

如下图所示,日期可用的下拉表单字段现在是默认预填的:

Date and Available Slots Filled

Available Slots Dropdown

如果我们试图用不完整的值提交表单,在Booking 实体中添加的验证规则将失败并返回一个错误:

Name Field Error

要查看本文的完整代码,请看GitHub repo

总结

Remult是一个伟大的框架,它允许你快速而轻松地构建类型安全的全栈应用程序。它简单明了的语法使Remult成为任何想要开始类型安全编程的开发者的完美工具。你可以查看官方文档,了解本指南中所涉及的方法的更深入的解释。

那么你还在等什么呢?今天就来试试Remult吧