Создание полнофункционального TypeScript-приложения с использованием tRPC и React

Автор Марио Зупан✏️

Возможно, вы уже знакомы с фреймворком удаленного вызова процедур gRPC. Учитывая сходство в названии, вы можете быть склонны полагать, что tRPC как-то связан с ним или делает то же самое или похожее. Однако это не так.

Хотя tRPC действительно является фреймворком удаленного вызова процедур, его цели и основа принципиально отличаются от gRPC. Основная цель tRPC – предоставить простой, безопасный для типов способ создания API для проектов на TypeScript и JavaScript с минимальными затратами.

В этой статье мы построим простое, полностековое TypeScript-приложение с использованием tRPC, которое будет безопасным с точки зрения типов как в коде, так и на границе API. Мы создадим небольшое приложение на кошачью тематику, чтобы показать, как настроить tRPC на бэкенде и как использовать созданный API во фронтенде React. Полный код этого примера вы можете найти на GitHub. Давайте приступим!

  • Изучение tRPC
  • Настройка tRPC
  • Настройка нашего бэкенда Express
    • Установка зависимостей
    • Бэкенд с Express
  • Тестирование нашего бэкенда Express
  • Создание нашего фронтенда React
  • Тестирование и возможности tRPC

Изучение tRPC

Если у вас есть приложение, использующее TypeScript как в бэкенде, так и во фронтенде, tRPC поможет вам настроить API таким образом, что вы понесете абсолютный минимум накладных расходов в плане зависимостей и сложности выполнения. Однако tRPC по-прежнему обеспечивает безопасность типов и все сопутствующие ей возможности, такие как автозавершение для всего API и ошибки при использовании API некорректным образом.

С практической точки зрения, вы можете рассматривать tRPC как очень легковесную альтернативу GraphQL. Однако tRPC не лишен своих ограничений. Во-первых, он ограничен TypeScript и JavaScript. Кроме того, API, который вы создаете, будет следовать модели tRPC, что означает, что это не будет REST API. Вы не сможете просто преобразовать REST API в tRPC и получить тот же API, что и раньше, но с включенными типами.

По сути, tRPC – это решение с включенными типами для всех ваших потребностей в API, но это также будет tRPC-API. Именно отсюда и происходит RPC в названии, коренным образом меняя принцип работы удаленных вызовов. tRPC может стать отличным решением для вас, если вам удобно использовать TypeScript на вашем API-шлюзе.

Настройка tRPC

Мы начнем с создания папки в корне нашего проекта под названием server. В папке server мы создадим файл package.json следующим образом:

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@tsconfig/node14": "^1.0.1",
    "typescript": "^4.5"
  },
  "dependencies": {
    "@trpc/server": "^9.21.0",
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.13",
    "cors": "^2.8.5",
    "express": "^4.17.2",
    "zod": "^3.14.2"
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Мы также создадим файл tsconfig.json:

{
  "extends": "@tsconfig/node14/tsconfig.json",
  "compilerOptions": {
      "outDir": "build"
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, создадим исполняемый файл run.sh:

#!/bin/bash -e

./node_modules/.bin/tsc && node build/index.js
Войдите в полноэкранный режим Выйти из полноэкранного режима

Далее мы создаем папку src и включаем в нее файл index.ts. Наконец, мы выполним команду npm install в папке server. На этом мы закончили настройку бэкенда.

Для фронтенда мы будем использовать Create React App для установки приложения React с поддержкой TypeScript с помощью следующей команды в корне проекта:

npx create-react-app client --template typescript
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы также можем запустить npm install в папке client и запустить приложение с помощью npm start, чтобы убедиться, что все работает и настроено правильно. Далее мы реализуем бэкенд нашего приложения.

Настройка бэкенда Express

Установите зависимости

Как вы можете видеть выше в package.json части server нашего приложения, мы используем Express в качестве HTTP-сервера. Кроме того, мы добавляем TypeScript и зависимость trpc-server.

Кроме того, мы использовали библиотеку cors для добавления CORS к нашему API, что не совсем необходимо для данного примера, но является хорошей практикой. Мы также добавили Zod, библиотеку проверки схем с поддержкой TypeScript, которая часто используется в сочетании с tRPC. Однако вы можете использовать и другие библиотеки, такие как Yup или Superstruct. Для чего именно они нужны, мы увидим позже.

С зависимостями разобрались, давайте настроим наш базовый бэкенд Express с поддержкой tRPC.

Бэкэнд с Express

Начнем с определения маршрутизатора tRPC, который является очень важной частью всей этой инфраструктуры, позволяя нам соединить бэкенд и фронтенд с точки зрения безопасности типов и автозаполнения. Этот маршрутизатор должен находиться в собственном файле, например, router.ts, поскольку позже мы импортируем его в наше приложение React.

Внутри router.ts мы начнем с определения структуры данных для нашего доменного объекта, Cat:

let cats: Cat[] = [];

const Cat = z.object({
    id: z.number(),
    name: z.string(),
});
const Cats = z.array(Cat);

...

export type Cat = z.infer<typeof Cat>;
export type Cats = z.infer<typeof Cats>;
Вход в полноэкранный режим Выход из полноэкранного режима

Вы можете задаться вопросом, почему мы не создаем простые типы и объекты на JavaScript или TypeScript. Поскольку мы используем Zod для валидации схем в tRPC, нам также необходимо создавать эти доменные объекты с его помощью. Мы можем добавлять правила проверки с помощью Zod, например, максимальное количество символов для строки, проверку электронной почты и многое другое, сочетая проверку типов с фактической проверкой.

Мы также получаем автоматически создаваемые сообщения об ошибках, когда вводимые данные не соответствуют действительности. Однако эти ошибки могут быть полностью настроены. Если вы заинтересованы в валидации и обработке ошибок, ознакомьтесь с документацией для получения дополнительной информации.

После реализации нашего типа с помощью Zod мы можем вывести из него тип TypeScript с помощью z.infer. После этого мы экспортируем тип, чтобы использовать его в других частях приложения, например во фронтенде, а затем перейдем к созданию сердца приложения – маршрутизатора:

const trpcRouter = trpc.router()
    .query('get', {
        input: z.number(),
        output: Cat,
        async resolve(req) {
            const foundCat = cats.find((cat => cat.id === req.input));
            if (!foundCat) {
                throw new trpc.TRPCError({
                    code: 'BAD_REQUEST',
                    message: `could not find cat with id ${req.input}`,
                });
            }
            return foundCat;
        },
    })
    .query('list', {
        output: Cats,
        async resolve() {
            return cats;
        },
    })
Вход в полноэкранный режим Выход из полноэкранного режима

Мы можем создать маршрутизатор tRPC, вызвав метод router() и подключив к нему различные конечные точки. Мы также можем создать несколько маршрутизаторов и объединить их. В tRPC существует два типа процедур:

  • Запрос: Используется для получения данных. Например, GET.
  • Мутация: Используется для изменения данных. Используйте POST, PUT, PATCH, DELETE.

Во фрагменте кода выше мы создаем конечные точки query, одну для получения единичного объекта Cat по ID и одну для получения всех объектов Cat. tRPC также поддерживает концепцию infiniteQuery, которая принимает курсор и при необходимости может вернуть постраничный ответ с потенциально бесконечными данными.

Для конечной точки GET мы определяем input. Эта конечная точка будет, по сути, конечной точкой GET /get?input=123, возвращающей JSON нашего Cat на основе определения выше.

Мы можем определить несколько входов, если они нам нужны. В асинхронной функции resolve мы реализуем нашу фактическую бизнес-логику. В реальном приложении мы можем вызвать службу или уровень базы данных. Однако, поскольку мы просто сохраняем наши объекты Cat в памяти или в массиве, мы проверяем, есть ли у нас Cat с заданным ID, и если нет, мы выдаем ошибку. Если объект Cat найден, мы возвращаем его.

Конечная точка list еще проще, поскольку она не принимает никаких входных данных и возвращает только наш текущий список объектов Cat. Давайте посмотрим, как мы можем реализовать создание и удаление с помощью tRPC:

    .mutation('create', {
        input: z.object({ name: z.string().max(50) }),
        async resolve(req) {
            const newCat: Cat = { id: newId(), name: req.input.name };
            cats.push(newCat)
            return newCat
        }
    })
    .mutation('delete', {
        input: z.object({ id: z.number() }),
        output: z.string(),
        async resolve(req) {
            cats = cats.filter(cat => cat.id !== req.input.id);
            return "success"
        }
    });

function newId(): number {
    return Math.floor(Math.random() * 10000)
}

export type TRPCRouter = typeof trpcRouter;
export default trpcRouter;
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы видите, мы используем метод .mutation для создания новой мутации. В нем мы можем, опять же, определить input, который в данном случае будет объектом JSON. Обратите внимание на опцию валидации, которую мы предоставили для name.

В resolve мы создаем новую Cat от заданного имени со случайным ID. Проверяем функцию newId внизу и добавляем ее в наш список объектов Cat, возвращая новый Cat вызывающей стороне. В результате получится что-то вроде POST /create, ожидающего какого-то тела. Если мы используем application/json content-type, он вернет нам JSON и будет ожидать JSON.

В мутации delete мы ожидаем ID объекта Cat, фильтруем список объектов Cat по этому ID и обновляем список, возвращая пользователю сообщение об успехе. На самом деле ответы не похожи на то, что мы определили здесь. Скорее, они обернуты внутри ответа tRPC, как показано ниже:

{"id":null,"result":{"type":"data","data":"success"}}
Войти в полноэкранный режим Выйти из полноэкранного режима

Вот и все для нашего маршрутизатора; у нас есть все конечные точки, которые нам нужны. Теперь нам нужно соединить его с веб-приложением Express:

import express, { Application } from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import trpcRouter from './router';

const app: Application = express();

const createContext = ({}: trpcExpress.CreateExpressContextOptions) => ({})

app.use(express.json());
app.use(cors());
app.use(
    '/cat',
    trpcExpress.createExpressMiddleware({
        router: trpcRouter,
        createContext,
    }),
);

app.listen(8080, () => {
    console.log("Server running on port 8080");
});
Войти в полноэкранный режим Выход из полноэкранного режима

tRPC поставляется с адаптером для Express, поэтому мы просто создаем наше приложение Express и используем предоставленное промежуточное ПО tRPC внутри приложения. Мы можем определить подмаршрут, где должна использоваться эта конфигурация, маршрутизатор и контекст.

Функция контекста вызывается для каждого входящего запроса и передает его результат обработчикам. В функцию контекста можно добавить нужные данные контекста для каждого запроса, например, токен аутентификации или userId пользователя, который вошел в систему.

Если вы хотите узнать больше об авторизации с помощью tRPC, в документации есть раздел об этом.

Тестирование нашего бэкенда Express

Вот и все для приложения! Давайте быстро протестируем его, чтобы знать, что все работает правильно. Мы можем запустить приложение, выполнив файл ./run.sh, и отправить несколько HTTP-запросов с помощью cURL. Сначала создадим новый Cat:

curl -X POST "http://localhost:8080/cat/create" -d '{"name": "Minka" }' -H 'content-type: application/json'

{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем мы можем перечислить существующие объекты Cat:

curl "http://localhost:8080/cat/list"

{"id":null,"result":{"type":"data","data":[{"id":7216,"name":"Minka"}]}}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы также можем получить Cat по его ID:

curl "http://localhost:8080/cat/get?input=7216"

{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Войти в полноэкранный режим Выйти из полноэкранного режима

И, наконец, удалить Cat:

curl -X POST  "http://localhost:8080/cat/delete" -d '{"id": 7216}' -H 'content-type: application/json'

{"id":null,"result":{"type":"data","data":"success"}}

curl "http://localhost:8080/cat/list"

{"id":null,"result":{"type":"data","data":[]}}
Войти в полноэкранный режим Выйти из полноэкранного режима

Все работает, как и ожидалось. Теперь, когда бэкенд создан, давайте создадим наш фронтенд React.

Создание нашего React frontend

Сначала в папке src создадим папку cats, чтобы добавить некоторую структуру в наше приложение. Затем добавим некоторые дополнительные зависимости:

npm install --save @trpc/client @trpc/server @trpc/react react-query zod
Вход в полноэкранный режим Выход из полноэкранного режима

Нам нужен server для безопасности типов, client для минимальной логики, необходимой для вызовов API, zod, как уже упоминалось, для валидации схемы, trpc/react для более простой интеграции с React Query, и, наконец, React Query. Однако можно также использовать trpc/client самостоятельно, или полностью ванильно, что также описано в документации.

В этом примере, как и в официальных, мы будем использовать React Query, который добавляет API-взаимодействие в приложения React. Добавление React Query совершенно необязательно, и можно просто использовать ванильный клиент с фронтенд-фреймворком по вашему выбору, включая React, и интегрировать его именно так, как вы хотите. Давайте начнем с создания базовой структуры нашего приложения в App.tsx:

import { useState } from 'react';
import './App.css';
import type { TRPCRouter } from '../../server/src/router';
import { createReactQueryHooks } from '@trpc/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Create from './cats/Create';
import Detail from './cats/Detail';
import List from './cats/List';

const BACKEND_URL: string = "http://localhost:8080/cat";

export const trpc = createReactQueryHooks<TRPCRouter>();

function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() => trpc.createClient({ url: BACKEND_URL }));

  const [detailId, setDetailId] = useState(-1);

  const setDetail = (id: number) => {
    setDetailId(id);
  }

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <div className="App">
          <Create />
          <List setDetail={setDetail}/>
          { detailId > 0 ? <Detail id={detailId} /> : null }
        </div>
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default App;
Вход в полноэкранный режим Выход из полноэкранного режима

Распаковывать придется довольно много, поэтому начнем с самого начала. Мы создаем trpc с помощью помощника createReactQueryHooks из trpc/react, передавая ему TRPCRouter, который мы импортируем из нашего внутреннего приложения. Мы также экспортируем его для использования в остальной части нашего приложения.

По сути, это создает все привязки к нашему API. Далее мы создаем клиент React Query и tRPC-клиент для предоставления URL нашего бэкенда. Мы будем использовать этот клиент для выполнения запросов к API, а точнее, клиент, который будет использовать React Query.

В дополнение ко всей этой настройке мы также определяем переменную состояния для detailId, чтобы знать, какую деталь Cat показывать, если пользователь выберет любую.

Если вы посмотрите, что мы возвращаем из App, вы увидите, что наша фактическая разметка, div с классом App, вложена в два слоя. Эти слои находятся на внешней стороне, это провайдер tRPC, а внутри него – провайдер React Query.

Эти два компонента делают необходимые движущиеся части доступными для всего нашего приложения. Таким образом, мы можем использовать tRPC во всем нашем приложении, а вызовы запросов легко интегрируются в наше приложение React. Далее мы добавим в нашу разметку компоненты Create, List и Detail, которые будут включать всю нашу бизнес-логику.

Начнем с компонента Create, создав файл Create.css и Create.tsx в папке src/cats. В этом компоненте мы просто создадим форму и подключим ее к мутации create, которую мы реализовали на бэкенде. После создания нового Cat мы хотим повторно получить список объектов Cat, чтобы он всегда был актуальным. Мы можем реализовать это с помощью следующего кода:

import './Create.css';
import { ChangeEvent, useState } from 'react';
import { trpc } from '../App';

function Create() {
  const [text, setText] = useState("");
  const [error, setError] = useState("");

  const cats = trpc.useQuery(['list']);
  const createMutation = trpc.useMutation(['create'], {
    onSuccess: () => {
      cats.refetch();
    },
    onError: (data) => {
      setError(data.message);
    }
  });

  const updateText = (event: ChangeEvent<HTMLInputElement>) => {
    setText(event.target.value);
  };

  const handleCreate = async() => {
    createMutation.mutate({ name: text });
    setText("");
  };

  return (
    <div className="Create">
      {error && error}
      <h2>Create Cat</h2>
      <div>Name: <input type="text" onChange={updateText} value={text} /></div>
      <div><button onClick={handleCreate}>Create</button></div>
    </div>
  );
}

export default Create;
Войти в полноэкранный режим Выйти из полноэкранного режима

Давайте начнем с очень базовой, ванильной логики React. Мы создаем некоторое внутреннее состояние компонента для поля нашей формы и потенциальных ошибок, которые мы, возможно, захотим показать. Мы возвращаем простую форму, содержащую текстовое поле, связанное с нашим состоянием, а также кнопку для отправки.

Теперь давайте рассмотрим функцию handleCreate. Мы вызываем .mutate на createMutation, которую определяем над ней, и после этого сбрасываем поле text.

createMutation создается с помощью trpc.useMutation с нашей конечной точкой create. В вашей IDE или редакторе обратите внимание, что при наборе create внутри вызова useMutation вы получите предложения автозаполнения. Мы также получаем предложения в полезной нагрузке для вызова .mutate, предлагающие использовать поле name.

Внутри вызова .useMutation мы определяем, что должно произойти в случае успеха и ошибки. Если мы столкнулись с ошибкой, мы просто хотим отобразить ее, используя наше внутреннее состояние компонента. Если мы успешно создали Cat, мы хотим повторно получить данные для нашего списка объектов Cat. Для этого мы определяем вызов этой конечной точки с помощью trpc.useQuery с нашей конечной точкой list и вызываем ее внутри обработчика onSuccess.

Мы уже видим, как легко интегрировать наше приложение с API tRPC, а также как tRPC помогает нам во время разработки. Далее рассмотрим детальный вид, создав Detail.tsx и Detail.css в папке cats:

import './Detail.css';
import { trpc } from '../App';

function Detail(props: {
  id: number,
}) {
  const cat = trpc.useQuery(['get', props.id]);

  return (
    cat.data ? 
      <div className="Detail">
        <h2>Detail</h2>
        <div>{cat.data.id}</div>
        <div>{cat.data.name}</div>
      </div> : <div className="Detail"></div>
  );
}

export default Detail;
Войдите в полноэкранный режим Выход из полноэкранного режима

В компоненте выше, мы в основном просто используем .useQuery снова для определения конечной точки getCatById, предоставляя ID, который мы получаем из нашего корневого компонента через props. Если мы действительно получим данные, мы отобразим подробную информацию о Cat. Мы также можем использовать эффекты для получения данных. По сути, любой способ интеграции API в ваше приложение React будет отлично работать с tRPC и React Query.

Наконец, давайте реализуем наш компонент List, создав List.css и List.tsx в cats. В нашем списке объектов Cat мы будем отображать ID и имя Cat, а также ссылку для детального просмотра и ссылку для удаления:

import './List.css';
import { trpc } from '../App';
import type { Cat } from '../../../server/src/router';
import { useState } from 'react';

function List(props: {
  setDetail: (id: number) => void,
}) {
  const [error, setError] = useState("");
  const cats = trpc.useQuery(['list']);
  const deleteMutation = trpc.useMutation(['delete'], {
    onSuccess: () => {
      cats.refetch();
    },
    onError: (data) => {
      setError(data.message);
    }
  });

  const handleDelete = async(id: number) => {
    deleteMutation.mutate({ id })
  };

  const catRow = (cat: Cat) => {
    return (
      <div key={cat.id}>
        <span>{cat.id}</span>
        <span>{cat.name}</span>
        <span><a href="#" onClick={props.setDetail.bind(null, cat.id)}>detail</a></span>
        <span><a href="#" onClick={handleDelete.bind(null, cat.id)}>delete</a></span>
      </div>
    );
  };

  return (
    <div className="List">
      <h2>Cats</h2>
      <span>{error}</span>
      { cats.data && cats.data.map((cat) => {
        return catRow(cat);
      })}
    </div>
  );
}

export default List;
Вход в полноэкранный режим Выход из полноэкранного режима

Этот компонент в основном объединяет функциональность, которую мы использовали в двух предыдущих. Например, мы получаем список кошек с помощью useQuery на нашей конечной точке list, а также реализуем удаление объектов Cat с последующей повторной выборкой с помощью deleteMutation, указывая на нашу мутацию delete на бэкенде.

Кроме этого, все очень похоже. Мы передаем функцию setDetailId из App через props, чтобы мы могли установить кошку для показа деталей в Detail и создать обработчик для удаления кошки, который выполняет нашу мутацию.

Обратите внимание на все автодополнения, предоставляемые tRPC. Если вы введете что-то неправильно, например, имя конечной точки, вы получите ошибку, и фронтенд не запустится, пока ошибка не будет исправлена. Вот и все для нашего фронтенда, давайте протестируем его и посмотрим на tRPC в действии!

Тестирование и возможности tRPC

Во-первых, давайте запустим приложение с помощью npm start и посмотрим, как оно работает. После запуска приложения мы можем создавать новых котов, удалять их и просматривать страницу их подробностей, наблюдая за изменениями непосредственно в списке. Это не особенно красиво, но это работает!

Давайте посмотрим, как tRPC может помочь нам в процессе разработки. Допустим, мы хотим добавить поле age для наших кошек:

const Cat = z.object({
    id: z.number(),
    name: z.string(),
    age: z.number(),
});

...
    .mutation('create', {
        input: z.object({ name: z.string().max(50), age: z.number().min(1).max(30) }),
        async resolve(req) {
            const newCat: Cat = { id: newId(), name: req.input.name, age: req.input.age };
            cats.push(newCat)
            return newCat
        }
    })
...
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы добавляем поле в наш объект домена, и нам также нужно добавить его в конечную точку create. После того, как вы нажмете кнопку сохранения в коде бэкенда, вернитесь к коду фронтенда в ./client/src/cats/Create.tsx. Наш редактор показывает нам ошибку. Свойство age отсутствует в нашем вызове createMutation:

Если мы хотим добавить поле age в нашу мутацию сейчас, наш редактор предоставит нам автозаполнение с полной информацией о типе прямо из нашего измененного router.ts:

С моей точки зрения, в этом и заключается истинная сила tRPC. Хотя приятно иметь простой способ создания API как на фронтенде, так и на бэкенде, настоящим преимуществом является тот факт, что код фактически не будет собран, если я внесу разрушающее изменение на одной стороне, а не на другой.

Например, представьте себе огромную кодовую базу с несколькими командами, работающими над конечными точками API и элементами пользовательского интерфейса. Такая безопасность в плане совместимости с API при почти полном отсутствии накладных расходов для приложения просто замечательна.

Заключение

Надеюсь, эта статья показала вам, как tRPC может быть полезен в ситуациях, когда используется TypeScript как на фронтенде, так и на бэкенде. Мне нравится подход, не занимающий много места. С минимальным количеством или отсутствием дополнительных зависимостей вы можете сосредоточиться на корректности во время компиляции, а не на проверке во время выполнения.

Очевидно, что в некоторых случаях ограничение TypeScript может оказаться слишком сильным. Принцип, лежащий в основе tRPC, замечателен с точки зрения удобства для разработчиков. tRPC – это интересный проект, за которым я обязательно буду следить в будущем. Надеюсь, вам понравилась эта статья! Счастливого кодинга.


Много пишете на TypeScript? Посмотрите запись нашей недавней встречи по TypeScript, чтобы узнать о написании более читабельного кода.

TypeScript привносит безопасность типов в JavaScript. Между безопасностью типов и читабельностью кода может возникнуть противоречие. Посмотрите запись, чтобы узнать о некоторых новых возможностях TypeScript 4.4.

Оцените статью
Procodings.ru
Добавить комментарий